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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
node/keystore/enclave.py
ADDED
|
@@ -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}"
|