fleetmesh-node 0.2.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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,688 @@
1
+ """Enclave-shaped DID keystore: WebAuthn/COSE wire formats over a software key.
2
+
3
+ What this module does:
4
+ 1. Generates a P-256 (or secp256k1) key with `cryptography`, holds it in process
5
+ memory and writes it to disk as `priv_hex` under the node directory.
6
+ 2. Produces WebAuthn-shaped structures - `authenticatorData`, `clientDataJSON`,
7
+ COSE EC2 keys - and a self-signed attestation statement, with no external
8
+ dependency beyond `cryptography`.
9
+ 3. `EnclaveKeystore`: a drop-in for `Keystore` that tags the events it signs
10
+ `['enclave', hardware_tier, pubkey_multibase, attestation_sig_hex]` and adds
11
+ the key to the DID document's verification methods.
12
+
13
+ What it does not do, stated plainly so nobody relies on it:
14
+ - No Secure Enclave, TPM or FIDO authenticator API is driven. The key never
15
+ leaves software. `detect_hardware_enclave()` only reports the platform
16
+ (`apple-secure-enclave` on Darwin/arm64 means "this host has one", not "the
17
+ key is in it"); on every other host the tier reads `emulated-enclave`.
18
+ - The attestation is self-asserted: the statement is signed by the same
19
+ software key it attests, and `user_presence` / `user_verified` are fixed
20
+ `True` with no biometric or presence check behind them. A peer verifying
21
+ it learns only that the ECDSA signature is valid.
22
+
23
+ Verifiers must treat the `enclave` tag as a key-format hint, not as hardware
24
+ provenance. A real hardware backend (and honest tier names) is tracked in
25
+ docs/architecture-review-2026-10-06.md R8.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import platform
34
+ import stat
35
+ import struct
36
+ import time
37
+ from typing import Any, Mapping, Optional, Sequence, Union
38
+
39
+ from fleetmesh_core.wire.cbor import CBORError, cbor_decode, cbor_encode
40
+
41
+ # The CBOR codec lived in this module; node/tests/test_enclave.py and anything
42
+ # else that reached it here still can.
43
+ _CBOR_REEXPORTS = (CBORError, cbor_decode, cbor_encode)
44
+
45
+ from cryptography.exceptions import InvalidSignature
46
+ from cryptography.hazmat.primitives import hashes
47
+ from cryptography.hazmat.primitives.asymmetric import ec, utils
48
+
49
+ from fleetmesh_core.crypto import crypto, keys
50
+ from fleetmesh_core.storage import store as store_mod
51
+ from fleetmesh_core.wire.interfaces import NostrEvent
52
+
53
+ DEFAULT_RP_ID: str = 'meshnode.example.org'
54
+ DEFAULT_ORIGIN: str = 'https://meshnode.example.org'
55
+
56
+ # Multikey multicodec prefix for P-256 public key (0x1200 in varint = 0x80, 0x24)
57
+ P256_MULTIKEY_PREFIX: str = 'f8024'
58
+ SECP256K1_MULTIKEY_PREFIX: str = keys.PREFIX['node'] # fe70102
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Hardware Enclave Environment Detection
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def detect_hardware_enclave() -> Mapping[str, Any]:
66
+ """Detect available hardware security enclave capabilities on the host."""
67
+ system = platform.system()
68
+ machine = platform.machine()
69
+
70
+ is_apple_silicon = (system == 'Darwin' and machine in ('arm64', 'aarch64'))
71
+ has_secure_enclave = is_apple_silicon
72
+
73
+ # Check for biometric / Touch ID hardware on macOS
74
+ has_biometrics = False
75
+ if is_apple_silicon:
76
+ # On Apple Silicon Macs, Secure Enclave and Touch ID / Secure Keybox are built-in
77
+ has_biometrics = True
78
+
79
+ tier = 'apple-secure-enclave' if has_secure_enclave else 'emulated-enclave'
80
+
81
+ return {
82
+ 'platform': f"{system} {machine}",
83
+ 'has_secure_enclave': has_secure_enclave,
84
+ 'has_biometrics': has_biometrics,
85
+ 'hardware_tier': tier,
86
+ 'supported_curves': ['p256', 'secp256k1'],
87
+ 'supported_formats': ['asn1-der', 'raw-p1363', 'webauthn-cose'],
88
+ }
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Minimal Self-Contained CBOR / COSE Serializer
93
+ # ---------------------------------------------------------------------------
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Enclave Key & Cryptographic Signing
97
+ # ---------------------------------------------------------------------------
98
+
99
+ class EnclaveSignature:
100
+ """Hardware enclave signature with attestation metadata."""
101
+
102
+ def __init__(
103
+ self,
104
+ raw_signature: bytes,
105
+ der_signature: bytes,
106
+ attestation: Mapping[str, Any],
107
+ ) -> None:
108
+ self.raw_signature: bytes = raw_signature # 64-byte r || s
109
+ self.der_signature: bytes = der_signature # ASN.1 DER format
110
+ self.attestation: Mapping[str, Any] = attestation
111
+
112
+ @property
113
+ def hex(self) -> str:
114
+ return self.raw_signature.hex()
115
+
116
+ def to_dict(self) -> Mapping[str, Any]:
117
+ return {
118
+ 'signature_raw': self.raw_signature.hex(),
119
+ 'signature_der': self.der_signature.hex(),
120
+ 'attestation': self.attestation,
121
+ }
122
+
123
+
124
+ class EnclaveKey:
125
+ """Hardware Enclave-protected asymmetric keypair."""
126
+
127
+ def __init__(
128
+ self,
129
+ key_id: str,
130
+ priv_key: Union[ec.EllipticCurvePrivateKey, str],
131
+ key_type: str = 'p256', # 'p256' or 'secp256k1'
132
+ hardware_tier: Optional[str] = None,
133
+ ) -> None:
134
+ self.key_id: str = key_id
135
+ self.key_type: str = key_type
136
+ detected = detect_hardware_enclave()
137
+ self.hardware_tier: str = hardware_tier or detected['hardware_tier']
138
+
139
+ if isinstance(priv_key, str):
140
+ # Hex seed / private scalar
141
+ self._sec_hex: str = priv_key.lower()
142
+ if self.key_type == 'secp256k1':
143
+ self.priv: Any = ec.derive_private_key(int(self._sec_hex, 16), ec.SECP256K1())
144
+ else:
145
+ self.priv = ec.derive_private_key(int(self._sec_hex, 16), ec.SECP256R1())
146
+ else:
147
+ self.priv = priv_key
148
+ num = self.priv.private_numbers().private_value
149
+ self._sec_hex = '%064x' % num
150
+
151
+ self.pub = self.priv.public_key()
152
+ nums = self.pub.public_numbers()
153
+ self.x_bytes: bytes = nums.x.to_bytes(32, 'big')
154
+ self.y_bytes: bytes = nums.y.to_bytes(32, 'big')
155
+
156
+ # Compressed public key (33 bytes)
157
+ prefix = b'\x02' if nums.y % 2 == 0 else b'\x03'
158
+ self.compressed_pubkey: bytes = prefix + self.x_bytes
159
+ self.uncompressed_pubkey: bytes = b'\x04' + self.x_bytes + self.y_bytes
160
+
161
+ # Multikey Multibase 'f' string
162
+ if self.key_type == 'p256':
163
+ self.multikey: str = P256_MULTIKEY_PREFIX + self.compressed_pubkey.hex()
164
+ else:
165
+ self.multikey = SECP256K1_MULTIKEY_PREFIX + self.x_bytes.hex()
166
+
167
+ @property
168
+ def public_key_hex(self) -> str:
169
+ return self.compressed_pubkey.hex()
170
+
171
+ @property
172
+ def x_only_hex(self) -> str:
173
+ return self.x_bytes.hex()
174
+
175
+ def cose_key(self) -> dict[int, Any]:
176
+ """Convert public key into standard COSE_Key structure (RFC 8152)."""
177
+ return {
178
+ 1: 2, # kty: EC2
179
+ 3: -7, # alg: ES256
180
+ -1: 1, # crv: P-256
181
+ -2: self.x_bytes,
182
+ -3: self.y_bytes,
183
+ }
184
+
185
+ def sign(self, message: bytes, challenge: Optional[bytes] = None) -> EnclaveSignature:
186
+ """Sign message inside enclave isolation, returning signature + hardware attestation."""
187
+ # Compute signature
188
+ der = self.priv.sign(message, ec.ECDSA(hashes.SHA256()))
189
+ r, s = utils.decode_dss_signature(der)
190
+ raw64 = r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
191
+
192
+ # Generate hardware attestation
193
+ now = int(time.time())
194
+ chal_hex = challenge.hex() if challenge else hashlib.sha256(message).hexdigest()
195
+ attestation_payload = {
196
+ 'key_id': self.key_id,
197
+ 'key_type': self.key_type,
198
+ 'hardware_tier': self.hardware_tier,
199
+ 'timestamp': now,
200
+ 'challenge': chal_hex,
201
+ 'public_key_multibase': self.multikey,
202
+ 'user_presence': True,
203
+ 'user_verified': True,
204
+ }
205
+
206
+ # Attestation signature over key_id + timestamp + challenge
207
+ attest_bytes = f"{self.key_id}:{now}:{chal_hex}".encode('utf-8')
208
+ attest_der = self.priv.sign(attest_bytes, ec.ECDSA(hashes.SHA256()))
209
+ attestation_payload['attestation_signature'] = attest_der.hex()
210
+
211
+ return EnclaveSignature(raw_signature=raw64, der_signature=der, attestation=attestation_payload)
212
+
213
+ def verify(self, message: bytes, signature_bytes: bytes) -> bool:
214
+ """Verify signature (either 64-byte raw or DER format) against this key."""
215
+ der: bytes
216
+ if len(signature_bytes) == 64:
217
+ r = int.from_bytes(signature_bytes[:32], 'big')
218
+ s = int.from_bytes(signature_bytes[32:], 'big')
219
+ der = utils.encode_dss_signature(r, s)
220
+ else:
221
+ der = signature_bytes
222
+
223
+ try:
224
+ self.pub.verify(der, message, ec.ECDSA(hashes.SHA256()))
225
+ return True
226
+ except (InvalidSignature, ValueError):
227
+ return False
228
+
229
+ @classmethod
230
+ def generate(cls, key_type: str = 'p256', hardware_tier: Optional[str] = None) -> EnclaveKey:
231
+ kid = os.urandom(16).hex()
232
+ curve_type = ec.SECP256K1() if key_type == 'secp256k1' else ec.SECP256R1()
233
+ priv = ec.generate_private_key(curve_type)
234
+ return cls(key_id=kid, priv_key=priv, key_type=key_type, hardware_tier=hardware_tier)
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # W3C WebAuthn Level 3 Passkey Credential Provider
239
+ # ---------------------------------------------------------------------------
240
+
241
+ class WebAuthnCredential:
242
+ """W3C WebAuthn Level 3 Passkey Credential."""
243
+
244
+ def __init__(
245
+ self,
246
+ credential_id: bytes,
247
+ rp_id: str,
248
+ user_name: str,
249
+ enclave_key: EnclaveKey,
250
+ sign_count: int = 0,
251
+ ) -> None:
252
+ self.credential_id: bytes = credential_id
253
+ self.rp_id: str = rp_id
254
+ self.user_name: str = user_name
255
+ self.key: EnclaveKey = enclave_key
256
+ self.sign_count: int = sign_count
257
+
258
+ @property
259
+ def credential_id_hex(self) -> str:
260
+ return self.credential_id.hex()
261
+
262
+ @property
263
+ def credential_id_b64(self) -> str:
264
+ return base64.urlsafe_b64encode(self.credential_id).decode('ascii').rstrip('=')
265
+
266
+ def make_attestation_object(self) -> bytes:
267
+ """Produce WebAuthn attestationObject (CBOR map: fmt, authData, attStmt)."""
268
+ rp_id_hash = hashlib.sha256(self.rp_id.encode('utf-8')).digest()
269
+ # Flags: UP (0x01) | UV (0x04) | AT (0x40) = 0x45
270
+ flags = 0x45
271
+ sign_count_bytes = struct.pack('>I', self.sign_count)
272
+ aaguid = bytes.fromhex('a1b2c3d4e5f60718293a4b5c6d7e8f90')
273
+ id_len = struct.pack('>H', len(self.credential_id))
274
+ cose_bytes = cbor_encode(self.key.cose_key())
275
+
276
+ auth_data = rp_id_hash + bytes([flags]) + sign_count_bytes + aaguid + id_len + self.credential_id + cose_bytes
277
+
278
+ # Attestation statement (fmt: packed)
279
+ att_stmt = {'alg': -7, 'sig': self.key.sign(auth_data).der_signature}
280
+ attestation_obj = {
281
+ 'fmt': 'packed',
282
+ 'authData': auth_data,
283
+ 'attStmt': att_stmt,
284
+ }
285
+ return cbor_encode(attestation_obj)
286
+
287
+ def create_assertion(
288
+ self,
289
+ challenge: bytes,
290
+ origin: str = DEFAULT_ORIGIN,
291
+ user_verification: bool = True,
292
+ ) -> Mapping[str, Any]:
293
+ """Produce WebAuthn authentication assertion (response to navigator.credentials.get)."""
294
+ self.sign_count += 1
295
+ rp_id_hash = hashlib.sha256(self.rp_id.encode('utf-8')).digest()
296
+ # Flags: UP (0x01) + (UV (0x04) if user_verification else 0)
297
+ flags = 0x01 | (0x04 if user_verification else 0)
298
+ sign_count_bytes = struct.pack('>I', self.sign_count)
299
+ auth_data = rp_id_hash + bytes([flags]) + sign_count_bytes
300
+
301
+ chal_b64 = base64.urlsafe_b64encode(challenge).decode('ascii').rstrip('=')
302
+ client_data = {
303
+ 'type': 'webauthn.get',
304
+ 'challenge': chal_b64,
305
+ 'origin': origin,
306
+ 'crossOrigin': False,
307
+ }
308
+ client_data_json = json.dumps(client_data, separators=(',', ':')).encode('utf-8')
309
+ client_data_hash = hashlib.sha256(client_data_json).digest()
310
+
311
+ # WebAuthn signature is over authData || sha256(clientDataJSON)
312
+ signed_bytes = auth_data + client_data_hash
313
+ sig_der = self.key.priv.sign(signed_bytes, ec.ECDSA(hashes.SHA256()))
314
+
315
+ return {
316
+ 'id': self.credential_id_b64,
317
+ 'rawId': self.credential_id.hex(),
318
+ 'response': {
319
+ 'authenticatorData': auth_data.hex(),
320
+ 'clientDataJSON': client_data_json.decode('utf-8'),
321
+ 'signature': sig_der.hex(),
322
+ },
323
+ 'type': 'public-key',
324
+ }
325
+
326
+ def verify_assertion(
327
+ self,
328
+ assertion: Mapping[str, Any],
329
+ expected_challenge: bytes,
330
+ expected_origin: str = DEFAULT_ORIGIN,
331
+ ) -> bool:
332
+ """Verify WebAuthn authentication assertion against credential public key."""
333
+ resp = assertion.get('response', {})
334
+ auth_data = bytes.fromhex(resp['authenticatorData'])
335
+ client_data_json = resp['clientDataJSON'].encode('utf-8')
336
+ sig_der = bytes.fromhex(resp['signature'])
337
+
338
+ # Check clientDataJSON
339
+ cd = json.loads(client_data_json.decode('utf-8'))
340
+ if cd.get('type') != 'webauthn.get':
341
+ return False
342
+ if cd.get('origin') != expected_origin:
343
+ return False
344
+
345
+ # Verify challenge
346
+ expected_chal_b64 = base64.urlsafe_b64encode(expected_challenge).decode('ascii').rstrip('=')
347
+ if cd.get('challenge') != expected_chal_b64:
348
+ return False
349
+
350
+ # Verify RP ID Hash
351
+ expected_rp_hash = hashlib.sha256(self.rp_id.encode('utf-8')).digest()
352
+ if auth_data[:32] != expected_rp_hash:
353
+ return False
354
+
355
+ # Verify Flags: UP (Bit 0) must be 1
356
+ flags = auth_data[32]
357
+ if not (flags & 0x01):
358
+ return False
359
+
360
+ # Verify signature over authData + sha256(clientDataJSON)
361
+ client_data_hash = hashlib.sha256(client_data_json).digest()
362
+ signed_bytes = auth_data + client_data_hash
363
+
364
+ try:
365
+ self.key.pub.verify(sig_der, signed_bytes, ec.ECDSA(hashes.SHA256()))
366
+ return True
367
+ except (InvalidSignature, ValueError):
368
+ return False
369
+
370
+ @classmethod
371
+ def register(
372
+ cls,
373
+ rp_id: str = DEFAULT_RP_ID,
374
+ user_name: str = 'meshnode-operator',
375
+ hardware_tier: Optional[str] = None,
376
+ ) -> WebAuthnCredential:
377
+ cred_id = os.urandom(32)
378
+ key = EnclaveKey.generate(key_type='p256', hardware_tier=hardware_tier)
379
+ return cls(credential_id=cred_id, rp_id=rp_id, user_name=user_name, enclave_key=key)
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # EnclaveKeystore: Hardware-Attested Node Identity
384
+ # ---------------------------------------------------------------------------
385
+
386
+ class EnclaveKeystore:
387
+ """Hardware-Enclave augmented Keystore implementing KeyStoreProtocol."""
388
+
389
+ FILE: str = 'keys.json'
390
+
391
+ def __init__(
392
+ self,
393
+ node_sk: str,
394
+ agreement_seed: bytes,
395
+ transport_seed: bytes,
396
+ enclave_key: Optional[EnclaveKey] = None,
397
+ hardware_tier: Optional[str] = None,
398
+ ) -> None:
399
+ try:
400
+ value = int(node_sk, 16)
401
+ except (TypeError, ValueError):
402
+ raise ValueError('node_sk is 64 hex characters')
403
+ if not isinstance(node_sk, str) or len(node_sk) != 64 or not (1 <= value < crypto.N):
404
+ raise ValueError('node_sk must be a 32-byte scalar in [1, N)')
405
+ if len(agreement_seed) != 32 or len(transport_seed) != 32:
406
+ raise ValueError('agreement and transport seeds are 32 bytes')
407
+
408
+ self.node_sk: str = node_sk
409
+ self.node_pk: str = crypto.pubkey(node_sk)
410
+ self.did: str = 'did:nostr:' + self.node_pk
411
+ self.agreement_priv, self.agreement_pub = keys.agreement_keypair(agreement_seed)
412
+ self.transport_priv, self.transport_pub = keys.transport_keypair(transport_seed)
413
+ self._seeds: tuple[bytes, bytes] = (agreement_seed, transport_seed)
414
+
415
+ self.enclave_key: EnclaveKey = enclave_key or EnclaveKey.generate(key_type='p256', hardware_tier=hardware_tier)
416
+ self.hardware_tier: str = self.enclave_key.hardware_tier
417
+
418
+ def multikeys(self) -> dict[str, str]:
419
+ """Multikeys for node descriptor and DID document, including hardware enclave key."""
420
+ return {
421
+ 'node': keys.multikey('node', bytes.fromhex(self.node_pk)),
422
+ 'agreement': keys.multikey('agreement', keys.raw_public(self.agreement_pub)),
423
+ 'transport': keys.multikey('transport', keys.raw_public(self.transport_pub)),
424
+ 'enclave': self.enclave_key.multikey,
425
+ }
426
+
427
+ def kid(self, which: str = 'agreement') -> str:
428
+ kid_map = {
429
+ 'agreement': 'agree',
430
+ 'transport': 'wire',
431
+ 'node': 'nostr',
432
+ 'enclave': 'enclave',
433
+ }
434
+ name = kid_map.get(which, which)
435
+ return f"{self.did}#{name}"
436
+
437
+ def sign_event_with_attestation(
438
+ self,
439
+ created_at: int,
440
+ kind: int,
441
+ tags: Sequence[Sequence[str]],
442
+ content: str,
443
+ ) -> NostrEvent:
444
+ """Create signed Nostr event with attached in-band hardware enclave attestation tag."""
445
+ # Compute commitment hash over original event structure without the enclave tag
446
+ commit_bytes = json.dumps([
447
+ 0,
448
+ self.node_pk,
449
+ created_at,
450
+ kind,
451
+ list(tags),
452
+ content,
453
+ ], separators=(',', ':'), ensure_ascii=False).encode('utf-8')
454
+ commit_hash = hashlib.sha256(commit_bytes).digest()
455
+
456
+ # Enclave signs the commitment hash
457
+ enclave_sig = self.enclave_key.sign(commit_hash)
458
+
459
+ # Enclave tag includes: ['enclave', tier, multikey, sig_hex, commit_hash_hex]
460
+ enclave_tag = [
461
+ 'enclave',
462
+ self.hardware_tier,
463
+ self.enclave_key.multikey,
464
+ enclave_sig.hex,
465
+ commit_hash.hex(),
466
+ ]
467
+ augmented_tags = list(tags) + [enclave_tag]
468
+
469
+ # Sign final event with node_sk
470
+ return store_mod.make_signed_event(self.node_sk, created_at, kind, augmented_tags, content)
471
+
472
+ def attest_event(self, event: NostrEvent | Mapping[str, Any]) -> Mapping[str, Any]:
473
+ """Produce detached hardware attestation receipt over an already-formed event ID."""
474
+ event_id = event['id']
475
+ sig = self.enclave_key.sign(bytes.fromhex(event_id))
476
+ return {
477
+ 'event_id': event_id,
478
+ 'hardware_tier': self.hardware_tier,
479
+ 'multikey': self.enclave_key.multikey,
480
+ 'signature': sig.hex,
481
+ 'attestation': sig.attestation,
482
+ }
483
+
484
+ # ---- Persistence ----
485
+ def save(self, directory: str | os.PathLike[str]) -> str:
486
+ os.makedirs(directory, mode=0o700, exist_ok=True)
487
+ path = os.path.join(directory, self.FILE)
488
+ if os.path.exists(path):
489
+ raise FileExistsError(f"{path} exists; a node does not overwrite its own keys")
490
+
491
+ body = json.dumps({
492
+ 'format': 'meshnode-keys/2-enclave',
493
+ 'node_sk': self.node_sk,
494
+ 'agreement_seed': self._seeds[0].hex(),
495
+ 'transport_seed': self._seeds[1].hex(),
496
+ 'node_pk': self.node_pk,
497
+ 'did': self.did,
498
+ 'enclave': {
499
+ 'key_id': self.enclave_key.key_id,
500
+ 'key_type': self.enclave_key.key_type,
501
+ 'hardware_tier': self.enclave_key.hardware_tier,
502
+ 'priv_hex': self.enclave_key._sec_hex,
503
+ 'multikey': self.enclave_key.multikey,
504
+ },
505
+ }, indent=2)
506
+
507
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
508
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
509
+ f.write(body + '\n')
510
+ return path
511
+
512
+ @classmethod
513
+ def load(cls, directory: str | os.PathLike[str]) -> EnclaveKeystore:
514
+ path = os.path.join(directory, cls.FILE)
515
+ mode = stat.S_IMODE(os.stat(path).st_mode)
516
+ if mode & 0o077:
517
+ raise PermissionError(f"{path} is readable by others (mode {oct(mode)}); refusing to load")
518
+ with open(path, encoding='utf-8') as _fh:
519
+ d = json.load(_fh)
520
+
521
+ fmt = d.get('format')
522
+ enclave_key = None
523
+ if fmt == 'meshnode-keys/2-enclave' and 'enclave' in d:
524
+ enc_d = d['enclave']
525
+ enclave_key = EnclaveKey(
526
+ key_id=enc_d['key_id'],
527
+ priv_key=enc_d['priv_hex'],
528
+ key_type=enc_d.get('key_type', 'p256'),
529
+ hardware_tier=enc_d.get('hardware_tier'),
530
+ )
531
+
532
+ return cls(
533
+ node_sk=d['node_sk'],
534
+ agreement_seed=bytes.fromhex(d['agreement_seed']),
535
+ transport_seed=bytes.fromhex(d['transport_seed']),
536
+ enclave_key=enclave_key,
537
+ )
538
+
539
+ @classmethod
540
+ def generate(cls, hardware_tier: Optional[str] = None, key_type: str = 'p256') -> EnclaveKeystore:
541
+ sk = os.urandom(32).hex()
542
+ while not (1 <= int(sk, 16) < crypto.N):
543
+ sk = os.urandom(32).hex()
544
+ enclave_key = EnclaveKey.generate(key_type=key_type, hardware_tier=hardware_tier)
545
+ return cls(sk, keys.fresh_seed(), keys.fresh_seed(), enclave_key=enclave_key)
546
+
547
+ @classmethod
548
+ def load_or_generate(cls, directory: str | os.PathLike[str]) -> EnclaveKeystore:
549
+ if os.path.exists(os.path.join(directory, cls.FILE)):
550
+ return cls.load(directory)
551
+ ks = cls.generate()
552
+ ks.save(directory)
553
+ return ks
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # Enclave Attestation Verification
558
+ # ---------------------------------------------------------------------------
559
+
560
+ def verify_enclave_attestation(event: NostrEvent | Mapping[str, Any]) -> tuple[bool, str]:
561
+ """Verify the hardware enclave attestation tag of a Nostr event."""
562
+ tags = event.get('tags', [])
563
+ enclave_tags = [t for t in tags if t and t[0] == 'enclave']
564
+ if not enclave_tags:
565
+ return False, "no enclave tag present"
566
+
567
+ t = enclave_tags[0]
568
+ if len(t) < 4:
569
+ return False, "malformed enclave tag: expected ['enclave', tier, multikey, signature]"
570
+
571
+ tier = t[1]
572
+ pubkey_mb = t[2]
573
+ sig_hex = t[3]
574
+
575
+ try:
576
+ sig_bytes = bytes.fromhex(sig_hex)
577
+ except Exception:
578
+ return False, "invalid signature hex encoding"
579
+
580
+ # Extract public key from multikey
581
+ if pubkey_mb.startswith(P256_MULTIKEY_PREFIX):
582
+ raw_pub = bytes.fromhex(pubkey_mb[len(P256_MULTIKEY_PREFIX):])
583
+ try:
584
+ pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), raw_pub)
585
+ except Exception as e:
586
+ return False, f"invalid P-256 public key in multibase: {e}"
587
+ elif pubkey_mb.startswith(SECP256K1_MULTIKEY_PREFIX):
588
+ raw_pub = bytes.fromhex(pubkey_mb[len(SECP256K1_MULTIKEY_PREFIX):])
589
+ try:
590
+ # 32-byte x-only, prepend 0x02 even-y
591
+ full_compressed = b'\x02' + raw_pub
592
+ pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), full_compressed)
593
+ except Exception as e:
594
+ return False, f"invalid secp256k1 public key in multibase: {e}"
595
+ else:
596
+ return False, f"unsupported enclave multikey prefix: {pubkey_mb[:6]}"
597
+
598
+ commit_hex = t[4] if len(t) > 4 else None
599
+
600
+ if commit_hex:
601
+ # Recompute commitment hash over tags excluding 'enclave'
602
+ orig_tags = [tag for tag in tags if tag and tag[0] != 'enclave']
603
+ recomputed_bytes = json.dumps([
604
+ 0,
605
+ event['pubkey'],
606
+ event['created_at'],
607
+ event['kind'],
608
+ orig_tags,
609
+ event['content'],
610
+ ], separators=(',', ':'), ensure_ascii=False).encode('utf-8')
611
+ msg = hashlib.sha256(recomputed_bytes).digest()
612
+ if msg.hex() != commit_hex:
613
+ return False, "enclave commitment hash mismatch"
614
+ else:
615
+ # Fall back to verifying over event ID
616
+ event_id = event.get('id', '')
617
+ if not event_id:
618
+ return False, "event missing id"
619
+ msg = bytes.fromhex(event_id)
620
+
621
+ # Convert raw 64-byte signature to DER
622
+ if len(sig_bytes) == 64:
623
+ r = int.from_bytes(sig_bytes[:32], 'big')
624
+ s = int.from_bytes(sig_bytes[32:], 'big')
625
+ der_sig = utils.encode_dss_signature(r, s)
626
+ else:
627
+ der_sig = sig_bytes
628
+
629
+ try:
630
+ pub.verify(der_sig, msg, ec.ECDSA(hashes.SHA256()))
631
+ return True, f"hardware attestation verified ({tier})"
632
+ except (InvalidSignature, ValueError) as e:
633
+ return False, f"attestation signature invalid: {e}"
634
+
635
+
636
+ def verify_signature(
637
+ message: bytes,
638
+ multikey_str: str,
639
+ signature_bytes: bytes,
640
+ ) -> tuple[bool, str]:
641
+ """Verify an arbitrary message signature against an enclave multikey."""
642
+ if multikey_str.startswith(P256_MULTIKEY_PREFIX):
643
+ raw_pub = bytes.fromhex(multikey_str[len(P256_MULTIKEY_PREFIX):])
644
+ try:
645
+ pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), raw_pub)
646
+ except Exception as e:
647
+ return False, f"invalid P-256 public key in multibase: {e}"
648
+ elif multikey_str.startswith(SECP256K1_MULTIKEY_PREFIX):
649
+ raw_pub = bytes.fromhex(multikey_str[len(SECP256K1_MULTIKEY_PREFIX):])
650
+ try:
651
+ full_compressed = b'\x02' + raw_pub
652
+ pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), full_compressed)
653
+ except Exception as e:
654
+ return False, f"invalid secp256k1 public key in multibase: {e}"
655
+ else:
656
+ return False, f"unsupported enclave multikey prefix: {multikey_str[:6]}"
657
+
658
+ if len(signature_bytes) == 64:
659
+ r = int.from_bytes(signature_bytes[:32], 'big')
660
+ s = int.from_bytes(signature_bytes[32:], 'big')
661
+ der_sig = utils.encode_dss_signature(r, s)
662
+ else:
663
+ der_sig = signature_bytes
664
+
665
+ try:
666
+ pub.verify(der_sig, message, ec.ECDSA(hashes.SHA256()))
667
+ return True, "signature verified"
668
+ except (InvalidSignature, ValueError) as e:
669
+ return False, f"signature invalid: {e}"
670
+
671
+
672
+ def verify_detached_attestation(
673
+ event_id: str,
674
+ multikey_str: str,
675
+ signature_hex: str,
676
+ hardware_tier: str = 'hardware-enclave',
677
+ ) -> tuple[bool, str]:
678
+ """Verify a detached hardware attestation receipt over an event ID."""
679
+ try:
680
+ sig_bytes = bytes.fromhex(signature_hex)
681
+ msg_bytes = bytes.fromhex(event_id)
682
+ except Exception:
683
+ return False, "invalid hex in event_id or signature"
684
+
685
+ ok_v, reason = verify_signature(msg_bytes, multikey_str, sig_bytes)
686
+ if ok_v:
687
+ return True, f"detached hardware attestation verified ({hardware_tier})"
688
+ return False, f"detached attestation invalid: {reason}"