tibet-ping 0.1.0__tar.gz
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.
- tibet_ping-0.1.0/.gitignore +8 -0
- tibet_ping-0.1.0/PKG-INFO +106 -0
- tibet_ping-0.1.0/README.md +78 -0
- tibet_ping-0.1.0/pyproject.toml +50 -0
- tibet_ping-0.1.0/src/tibet_ping/__init__.py +82 -0
- tibet_ping-0.1.0/src/tibet_ping/airlock.py +176 -0
- tibet_ping-0.1.0/src/tibet_ping/beacon.py +179 -0
- tibet_ping-0.1.0/src/tibet_ping/handler.py +164 -0
- tibet_ping-0.1.0/src/tibet_ping/node.py +229 -0
- tibet_ping-0.1.0/src/tibet_ping/nonce.py +97 -0
- tibet_ping-0.1.0/src/tibet_ping/proto.py +241 -0
- tibet_ping-0.1.0/src/tibet_ping/topology.py +179 -0
- tibet_ping-0.1.0/src/tibet_ping/vouch.py +138 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tibet-ping
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Intent-based discovery and communication protocol with TIBET provenance. ICMP replacement with identity, trust, and context.
|
|
5
|
+
Project-URL: Homepage, https://humotica.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Humotica/tibet-ping
|
|
7
|
+
Author-email: "J. van de Meent" <jasper@humotica.com>, "R. AI" <root_idd@humotica.nl>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: airlock,beacon,bootstrap,discovery,hub,intent,iot,jis,mesh,ping,pod,provenance,station,tibet,trust,vouching
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: System :: Networking
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: tibet-core>=0.2.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
25
|
+
Provides-Extra: overlay
|
|
26
|
+
Requires-Dist: tibet-overlay>=0.1.1; extra == 'overlay'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# tibet-ping
|
|
30
|
+
|
|
31
|
+
**Intent-based discovery and communication protocol with TIBET provenance.**
|
|
32
|
+
|
|
33
|
+
ICMP ping is dumb: "are you there?" → "yes". No identity, no intent, no trust.
|
|
34
|
+
|
|
35
|
+
tibet-ping replaces this with a TIBET token as handshake. Every ping carries identity (JIS), intent, context, and purpose. Responses are trust-gated through Airlock zones.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **PingPacket** — TIBET-backed ping with identity, intent, context, purpose
|
|
40
|
+
- **NonceTracker** — Replay protection (30-second time window)
|
|
41
|
+
- **Airlock** — Trust-gated access (GROEN/GEEL/ROOD zones) with rules engine
|
|
42
|
+
- **Vouching** — Trust delegation for device groups (solves HITL scaling)
|
|
43
|
+
- **Topology** — Hub/Hubby/Pod/Station network modeling
|
|
44
|
+
- **Beacon** — Airgapped bootstrap for new devices (chicken-and-egg solved)
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from tibet_ping import PingNode, PingDecision
|
|
50
|
+
|
|
51
|
+
# Create nodes
|
|
52
|
+
hub = PingNode("jis:home:hub")
|
|
53
|
+
sensor = PingNode("jis:home:sensor_temp")
|
|
54
|
+
|
|
55
|
+
# Hub trusts sensor
|
|
56
|
+
hub.set_trust("jis:home:sensor_temp", 0.9)
|
|
57
|
+
|
|
58
|
+
# Sensor pings hub
|
|
59
|
+
packet = sensor.ping(
|
|
60
|
+
target="jis:home:hub",
|
|
61
|
+
intent="temperature.report",
|
|
62
|
+
purpose="Periodic temperature reading",
|
|
63
|
+
payload={"celsius": 21.5},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Hub receives and processes
|
|
67
|
+
response = hub.receive(packet)
|
|
68
|
+
assert response.decision == PingDecision.ACCEPT
|
|
69
|
+
assert response.airlock_zone == "GROEN"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Airlock Zones
|
|
73
|
+
|
|
74
|
+
| Zone | Trust | Action |
|
|
75
|
+
|------|-------|--------|
|
|
76
|
+
| **GROEN** | >= 0.7 | Auto-allow |
|
|
77
|
+
| **GEEL** | 0.3 - 0.7 | Pending (rules or HITL) |
|
|
78
|
+
| **ROOD** | < 0.3 | Silent drop |
|
|
79
|
+
|
|
80
|
+
## Vouching (HITL Scaling)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# Hub vouches for 50 sensors at once
|
|
84
|
+
hub.vouch(
|
|
85
|
+
vouched_dids=["jis:home:s1", "jis:home:s2", ...],
|
|
86
|
+
my_trust=0.9,
|
|
87
|
+
vouch_factor=0.7, # Vouched trust = 0.9 * 0.7 = 0.63
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Beacon Bootstrap
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# New device broadcasts beacon (no secrets!)
|
|
95
|
+
beacon = new_device.broadcast_beacon(
|
|
96
|
+
capabilities=["temperature"],
|
|
97
|
+
device_type="sensor",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Hub auto-vouches or escalates to HITL
|
|
101
|
+
response = hub.handle_beacon(beacon)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT — Humotica AI Lab 2025-2026
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# tibet-ping
|
|
2
|
+
|
|
3
|
+
**Intent-based discovery and communication protocol with TIBET provenance.**
|
|
4
|
+
|
|
5
|
+
ICMP ping is dumb: "are you there?" → "yes". No identity, no intent, no trust.
|
|
6
|
+
|
|
7
|
+
tibet-ping replaces this with a TIBET token as handshake. Every ping carries identity (JIS), intent, context, and purpose. Responses are trust-gated through Airlock zones.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **PingPacket** — TIBET-backed ping with identity, intent, context, purpose
|
|
12
|
+
- **NonceTracker** — Replay protection (30-second time window)
|
|
13
|
+
- **Airlock** — Trust-gated access (GROEN/GEEL/ROOD zones) with rules engine
|
|
14
|
+
- **Vouching** — Trust delegation for device groups (solves HITL scaling)
|
|
15
|
+
- **Topology** — Hub/Hubby/Pod/Station network modeling
|
|
16
|
+
- **Beacon** — Airgapped bootstrap for new devices (chicken-and-egg solved)
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from tibet_ping import PingNode, PingDecision
|
|
22
|
+
|
|
23
|
+
# Create nodes
|
|
24
|
+
hub = PingNode("jis:home:hub")
|
|
25
|
+
sensor = PingNode("jis:home:sensor_temp")
|
|
26
|
+
|
|
27
|
+
# Hub trusts sensor
|
|
28
|
+
hub.set_trust("jis:home:sensor_temp", 0.9)
|
|
29
|
+
|
|
30
|
+
# Sensor pings hub
|
|
31
|
+
packet = sensor.ping(
|
|
32
|
+
target="jis:home:hub",
|
|
33
|
+
intent="temperature.report",
|
|
34
|
+
purpose="Periodic temperature reading",
|
|
35
|
+
payload={"celsius": 21.5},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Hub receives and processes
|
|
39
|
+
response = hub.receive(packet)
|
|
40
|
+
assert response.decision == PingDecision.ACCEPT
|
|
41
|
+
assert response.airlock_zone == "GROEN"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Airlock Zones
|
|
45
|
+
|
|
46
|
+
| Zone | Trust | Action |
|
|
47
|
+
|------|-------|--------|
|
|
48
|
+
| **GROEN** | >= 0.7 | Auto-allow |
|
|
49
|
+
| **GEEL** | 0.3 - 0.7 | Pending (rules or HITL) |
|
|
50
|
+
| **ROOD** | < 0.3 | Silent drop |
|
|
51
|
+
|
|
52
|
+
## Vouching (HITL Scaling)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
# Hub vouches for 50 sensors at once
|
|
56
|
+
hub.vouch(
|
|
57
|
+
vouched_dids=["jis:home:s1", "jis:home:s2", ...],
|
|
58
|
+
my_trust=0.9,
|
|
59
|
+
vouch_factor=0.7, # Vouched trust = 0.9 * 0.7 = 0.63
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Beacon Bootstrap
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
# New device broadcasts beacon (no secrets!)
|
|
67
|
+
beacon = new_device.broadcast_beacon(
|
|
68
|
+
capabilities=["temperature"],
|
|
69
|
+
device_type="sensor",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Hub auto-vouches or escalates to HITL
|
|
73
|
+
response = hub.handle_beacon(beacon)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT — Humotica AI Lab 2025-2026
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tibet-ping"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Intent-based discovery and communication protocol with TIBET provenance. ICMP replacement with identity, trust, and context."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "J. van de Meent", email = "jasper@humotica.com"},
|
|
14
|
+
{name = "R. AI", email = "root_idd@humotica.nl"},
|
|
15
|
+
]
|
|
16
|
+
keywords = [
|
|
17
|
+
"tibet", "jis", "ping", "discovery", "intent", "iot",
|
|
18
|
+
"mesh", "provenance", "trust", "airlock", "vouching",
|
|
19
|
+
"beacon", "bootstrap", "hub", "pod", "station"
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 3 - Alpha",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Intended Audience :: System Administrators",
|
|
25
|
+
"Intended Audience :: Telecommunications Industry",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Topic :: System :: Networking",
|
|
32
|
+
"Topic :: Security",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"tibet-core>=0.2.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
overlay = ["tibet-overlay>=0.1.1"]
|
|
40
|
+
dev = ["pytest>=7.0"]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://humotica.com"
|
|
44
|
+
Repository = "https://github.com/Humotica/tibet-ping"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["/src"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/tibet_ping"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tibet-ping: Intent-Based Discovery & Communication Protocol.
|
|
3
|
+
|
|
4
|
+
ICMP ping is dumb: "are you there?" → "yes". No identity, no intent, no trust.
|
|
5
|
+
tibet-ping replaces this with a TIBET token as handshake.
|
|
6
|
+
|
|
7
|
+
Every ping carries:
|
|
8
|
+
Identity — JIS DID (who are you)
|
|
9
|
+
Intent — what do you want
|
|
10
|
+
Context — pod, station, network
|
|
11
|
+
Purpose — why are you pinging
|
|
12
|
+
|
|
13
|
+
Responses are trust-gated through Airlock (GROEN/GEEL/ROOD).
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
from tibet_ping import PingNode
|
|
18
|
+
|
|
19
|
+
# Create a node
|
|
20
|
+
hub = PingNode("jis:home:hub")
|
|
21
|
+
sensor = PingNode("jis:home:sensor_temp")
|
|
22
|
+
|
|
23
|
+
# Hub trusts sensor
|
|
24
|
+
hub.set_trust("jis:home:sensor_temp", 0.9)
|
|
25
|
+
|
|
26
|
+
# Sensor pings hub
|
|
27
|
+
packet = sensor.ping(
|
|
28
|
+
target="jis:home:hub",
|
|
29
|
+
intent="temperature.report",
|
|
30
|
+
purpose="Periodic temperature reading",
|
|
31
|
+
payload={"celsius": 21.5},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Hub receives and processes
|
|
35
|
+
response = hub.receive(packet)
|
|
36
|
+
print(response.decision) # PingDecision.ACCEPT
|
|
37
|
+
print(response.airlock_zone) # "GROEN"
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from .proto import PingPacket, PingResponse, PingType, Priority, RoutingMode, PingDecision
|
|
41
|
+
from .nonce import NonceTracker
|
|
42
|
+
from .airlock import Airlock, AirlockRule, AirlockZone, PendingPing
|
|
43
|
+
from .vouch import Vouch, VouchRegistry
|
|
44
|
+
from .topology import TopologyManager, Pod, Station, NodeRole
|
|
45
|
+
from .beacon import Beacon, BeaconHandler, BeaconResponse
|
|
46
|
+
from .handler import PingHandler
|
|
47
|
+
from .node import PingNode
|
|
48
|
+
|
|
49
|
+
__version__ = "0.1.0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# Node (main entry point)
|
|
53
|
+
"PingNode",
|
|
54
|
+
# Proto
|
|
55
|
+
"PingPacket",
|
|
56
|
+
"PingResponse",
|
|
57
|
+
"PingType",
|
|
58
|
+
"Priority",
|
|
59
|
+
"RoutingMode",
|
|
60
|
+
"PingDecision",
|
|
61
|
+
# Nonce
|
|
62
|
+
"NonceTracker",
|
|
63
|
+
# Airlock
|
|
64
|
+
"Airlock",
|
|
65
|
+
"AirlockRule",
|
|
66
|
+
"AirlockZone",
|
|
67
|
+
"PendingPing",
|
|
68
|
+
# Vouch
|
|
69
|
+
"Vouch",
|
|
70
|
+
"VouchRegistry",
|
|
71
|
+
# Topology
|
|
72
|
+
"TopologyManager",
|
|
73
|
+
"Pod",
|
|
74
|
+
"Station",
|
|
75
|
+
"NodeRole",
|
|
76
|
+
# Beacon
|
|
77
|
+
"Beacon",
|
|
78
|
+
"BeaconHandler",
|
|
79
|
+
"BeaconResponse",
|
|
80
|
+
# Handler
|
|
81
|
+
"PingHandler",
|
|
82
|
+
]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trust-gated access control for incoming pings.
|
|
3
|
+
|
|
4
|
+
Three zones:
|
|
5
|
+
GROEN — Known + trusted → auto-allow
|
|
6
|
+
GEEL — Unknown → pending (rules or HITL)
|
|
7
|
+
ROOD — Untrusted → silent drop (no info leak)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .proto import PingDecision, PingPacket
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AirlockZone(Enum):
|
|
18
|
+
"""Three-zone trust model."""
|
|
19
|
+
GROEN = "GROEN"
|
|
20
|
+
GEEL = "GEEL"
|
|
21
|
+
ROOD = "ROOD"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AirlockRule:
|
|
26
|
+
"""
|
|
27
|
+
Pattern-based auto-decision rule.
|
|
28
|
+
|
|
29
|
+
Patterns support simple glob: "*" matches everything,
|
|
30
|
+
"prefix*" matches start, "*suffix" matches end.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
{"source_did": "jis:home:*", "intent": "temperature.*"} → GROEN
|
|
34
|
+
{"intent": "door.unlock"} → GEEL (force HITL)
|
|
35
|
+
"""
|
|
36
|
+
rule_id: str
|
|
37
|
+
name: str
|
|
38
|
+
pattern: Dict[str, str]
|
|
39
|
+
decision: PingDecision
|
|
40
|
+
zone: AirlockZone
|
|
41
|
+
priority: int = 50 # Higher = checked first
|
|
42
|
+
|
|
43
|
+
def matches(self, packet: PingPacket, sender_trust: float) -> bool:
|
|
44
|
+
"""Check if packet matches this rule's pattern."""
|
|
45
|
+
for key, pattern in self.pattern.items():
|
|
46
|
+
if key == "source_did":
|
|
47
|
+
if not _glob_match(packet.source_did, pattern):
|
|
48
|
+
return False
|
|
49
|
+
elif key == "intent":
|
|
50
|
+
if not _glob_match(packet.intent, pattern):
|
|
51
|
+
return False
|
|
52
|
+
elif key == "pod_id":
|
|
53
|
+
if packet.pod_id != pattern:
|
|
54
|
+
return False
|
|
55
|
+
elif key == "ping_type":
|
|
56
|
+
if packet.ping_type.value != pattern:
|
|
57
|
+
return False
|
|
58
|
+
elif key == "min_trust":
|
|
59
|
+
if sender_trust < float(pattern):
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _glob_match(value: str, pattern: str) -> bool:
|
|
65
|
+
"""Simple glob: *, prefix*, *suffix, exact."""
|
|
66
|
+
if pattern == "*":
|
|
67
|
+
return True
|
|
68
|
+
if pattern.startswith("*") and pattern.endswith("*") and len(pattern) > 2:
|
|
69
|
+
return pattern[1:-1] in value
|
|
70
|
+
if pattern.endswith("*"):
|
|
71
|
+
return value.startswith(pattern[:-1])
|
|
72
|
+
if pattern.startswith("*"):
|
|
73
|
+
return value.endswith(pattern[1:])
|
|
74
|
+
return value == pattern
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PendingPing:
|
|
79
|
+
"""Ping awaiting HITL decision."""
|
|
80
|
+
packet: PingPacket
|
|
81
|
+
sender_trust: float
|
|
82
|
+
reason: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Airlock:
|
|
86
|
+
"""
|
|
87
|
+
Trust-gated access control.
|
|
88
|
+
|
|
89
|
+
Rules are checked first (highest priority wins).
|
|
90
|
+
Then trust thresholds determine zone.
|
|
91
|
+
GEEL pings go to pending queue and trigger on_hitl_needed callback.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
trust_threshold_groen: float = 0.7,
|
|
97
|
+
trust_threshold_rood: float = 0.3,
|
|
98
|
+
on_hitl_needed: Optional[Callable[[PendingPing], None]] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.trust_threshold_groen = trust_threshold_groen
|
|
101
|
+
self.trust_threshold_rood = trust_threshold_rood
|
|
102
|
+
self.on_hitl_needed = on_hitl_needed
|
|
103
|
+
self._rules: List[AirlockRule] = []
|
|
104
|
+
self.pending: Dict[str, PendingPing] = {}
|
|
105
|
+
|
|
106
|
+
def add_rule(self, rule: AirlockRule) -> None:
|
|
107
|
+
"""Add a rule (auto-sorted by priority, highest first)."""
|
|
108
|
+
self._rules.append(rule)
|
|
109
|
+
self._rules.sort(key=lambda r: r.priority, reverse=True)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def rules(self) -> List[AirlockRule]:
|
|
113
|
+
return list(self._rules)
|
|
114
|
+
|
|
115
|
+
def gate(
|
|
116
|
+
self, packet: PingPacket, sender_trust: float
|
|
117
|
+
) -> Tuple[AirlockZone, Optional[AirlockRule]]:
|
|
118
|
+
"""
|
|
119
|
+
Determine zone for a packet.
|
|
120
|
+
Returns (zone, matched_rule_or_None).
|
|
121
|
+
"""
|
|
122
|
+
# Rules first
|
|
123
|
+
for rule in self._rules:
|
|
124
|
+
if rule.matches(packet, sender_trust):
|
|
125
|
+
return (rule.zone, rule)
|
|
126
|
+
|
|
127
|
+
# Trust thresholds
|
|
128
|
+
if sender_trust >= self.trust_threshold_groen:
|
|
129
|
+
return (AirlockZone.GROEN, None)
|
|
130
|
+
if sender_trust < self.trust_threshold_rood:
|
|
131
|
+
return (AirlockZone.ROOD, None)
|
|
132
|
+
|
|
133
|
+
return (AirlockZone.GEEL, None)
|
|
134
|
+
|
|
135
|
+
def process(self, packet: PingPacket, sender_trust: float) -> PingDecision:
|
|
136
|
+
"""
|
|
137
|
+
Full processing: gate → decision → pending queue if GEEL.
|
|
138
|
+
"""
|
|
139
|
+
zone, rule = self.gate(packet, sender_trust)
|
|
140
|
+
|
|
141
|
+
if zone == AirlockZone.GROEN:
|
|
142
|
+
return PingDecision.ACCEPT
|
|
143
|
+
if zone == AirlockZone.ROOD:
|
|
144
|
+
return PingDecision.REJECT
|
|
145
|
+
|
|
146
|
+
# GEEL: add to pending
|
|
147
|
+
pending = PendingPing(
|
|
148
|
+
packet=packet,
|
|
149
|
+
sender_trust=sender_trust,
|
|
150
|
+
reason=f"Trust {sender_trust:.2f} in GEEL zone"
|
|
151
|
+
+ (f" (rule: {rule.name})" if rule else ""),
|
|
152
|
+
)
|
|
153
|
+
self.pending[packet.packet_id] = pending
|
|
154
|
+
|
|
155
|
+
if self.on_hitl_needed:
|
|
156
|
+
self.on_hitl_needed(pending)
|
|
157
|
+
|
|
158
|
+
return PingDecision.PENDING
|
|
159
|
+
|
|
160
|
+
def approve_pending(self, packet_id: str) -> bool:
|
|
161
|
+
"""HITL approves a pending ping."""
|
|
162
|
+
return self.pending.pop(packet_id, None) is not None
|
|
163
|
+
|
|
164
|
+
def reject_pending(self, packet_id: str) -> bool:
|
|
165
|
+
"""HITL rejects a pending ping."""
|
|
166
|
+
return self.pending.pop(packet_id, None) is not None
|
|
167
|
+
|
|
168
|
+
def stats(self) -> dict:
|
|
169
|
+
return {
|
|
170
|
+
"rules": len(self._rules),
|
|
171
|
+
"pending_count": len(self.pending),
|
|
172
|
+
"thresholds": {
|
|
173
|
+
"groen": self.trust_threshold_groen,
|
|
174
|
+
"rood": self.trust_threshold_rood,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bootstrap beacon for new devices (airgapped, local-only).
|
|
3
|
+
|
|
4
|
+
Solves the chicken-and-egg problem: new device knows nobody.
|
|
5
|
+
Beacon broadcasts on local network. Hub can auto-vouch or escalate to HITL.
|
|
6
|
+
|
|
7
|
+
NO secrets in beacons — assume adversarial broadcast medium.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Callable, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Beacon:
|
|
19
|
+
"""
|
|
20
|
+
Bootstrap beacon broadcast by a new device.
|
|
21
|
+
|
|
22
|
+
Contains only public information:
|
|
23
|
+
- JIS DID
|
|
24
|
+
- Capabilities
|
|
25
|
+
- Requested pod
|
|
26
|
+
- Public key HASH (not the key itself!)
|
|
27
|
+
"""
|
|
28
|
+
beacon_id: str
|
|
29
|
+
source_did: str
|
|
30
|
+
capabilities: List[str] = field(default_factory=list)
|
|
31
|
+
requested_pod: Optional[str] = None
|
|
32
|
+
device_type: str = "generic" # sensor, actuator, gateway, controller
|
|
33
|
+
public_key_hash: str = ""
|
|
34
|
+
timestamp: str = field(
|
|
35
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def is_fresh(self, max_age_seconds: int = 300) -> bool:
|
|
39
|
+
"""Check if beacon is recent enough (default 5 minutes)."""
|
|
40
|
+
now = datetime.now(timezone.utc)
|
|
41
|
+
try:
|
|
42
|
+
ts = self.timestamp.replace("Z", "+00:00")
|
|
43
|
+
beacon_time = datetime.fromisoformat(ts)
|
|
44
|
+
if beacon_time.tzinfo is None:
|
|
45
|
+
beacon_time = beacon_time.replace(tzinfo=timezone.utc)
|
|
46
|
+
age = (now - beacon_time).total_seconds()
|
|
47
|
+
return age <= max_age_seconds
|
|
48
|
+
except (ValueError, OSError):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"beacon_id": self.beacon_id,
|
|
54
|
+
"source_did": self.source_did,
|
|
55
|
+
"capabilities": self.capabilities,
|
|
56
|
+
"requested_pod": self.requested_pod,
|
|
57
|
+
"device_type": self.device_type,
|
|
58
|
+
"public_key_hash": self.public_key_hash,
|
|
59
|
+
"timestamp": self.timestamp,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def create(
|
|
64
|
+
cls,
|
|
65
|
+
source_did: str,
|
|
66
|
+
capabilities: Optional[List[str]] = None,
|
|
67
|
+
requested_pod: Optional[str] = None,
|
|
68
|
+
device_type: str = "generic",
|
|
69
|
+
public_key_hash: str = "",
|
|
70
|
+
) -> "Beacon":
|
|
71
|
+
"""Factory method with auto-generated beacon_id."""
|
|
72
|
+
return cls(
|
|
73
|
+
beacon_id=f"beacon_{secrets.token_hex(8)}",
|
|
74
|
+
source_did=source_did,
|
|
75
|
+
capabilities=capabilities or [],
|
|
76
|
+
requested_pod=requested_pod,
|
|
77
|
+
device_type=device_type,
|
|
78
|
+
public_key_hash=public_key_hash,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class BeaconResponse:
|
|
84
|
+
"""Hub response to a beacon."""
|
|
85
|
+
response_id: str
|
|
86
|
+
in_response_to: str # beacon_id
|
|
87
|
+
hub_did: str
|
|
88
|
+
decision: str # "auto_vouched", "hitl_pending", "rejected"
|
|
89
|
+
vouch_id: Optional[str] = None
|
|
90
|
+
assigned_pod: Optional[str] = None
|
|
91
|
+
message: str = ""
|
|
92
|
+
timestamp: str = field(
|
|
93
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class BeaconHandler:
|
|
98
|
+
"""
|
|
99
|
+
Handle beacon broadcasts for bootstrapping.
|
|
100
|
+
|
|
101
|
+
auto_vouch_rules: list of dicts with matching conditions.
|
|
102
|
+
Each rule can match on: device_type, required_capabilities, source_pattern.
|
|
103
|
+
If matched, the beacon is auto-vouched into the specified pod.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
auto_vouch_rules: Optional[List[dict]] = None,
|
|
109
|
+
on_hitl_needed: Optional[Callable[["Beacon"], None]] = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
self.auto_vouch_rules = auto_vouch_rules or []
|
|
112
|
+
self.on_hitl_needed = on_hitl_needed
|
|
113
|
+
self._recent: Dict[str, Beacon] = {}
|
|
114
|
+
|
|
115
|
+
def handle_beacon(self, beacon: Beacon, hub_did: str) -> BeaconResponse:
|
|
116
|
+
"""
|
|
117
|
+
Process an incoming beacon.
|
|
118
|
+
|
|
119
|
+
1. Check freshness
|
|
120
|
+
2. Try auto-vouch rules
|
|
121
|
+
3. Escalate to HITL if no rule matches
|
|
122
|
+
"""
|
|
123
|
+
resp_id = f"resp_{beacon.beacon_id}"
|
|
124
|
+
|
|
125
|
+
if not beacon.is_fresh():
|
|
126
|
+
return BeaconResponse(
|
|
127
|
+
response_id=resp_id,
|
|
128
|
+
in_response_to=beacon.beacon_id,
|
|
129
|
+
hub_did=hub_did,
|
|
130
|
+
decision="rejected",
|
|
131
|
+
message="Beacon too old",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Track recent beacons
|
|
135
|
+
self._recent[beacon.beacon_id] = beacon
|
|
136
|
+
|
|
137
|
+
# Try auto-vouch rules
|
|
138
|
+
for rule in self.auto_vouch_rules:
|
|
139
|
+
if self._matches_rule(beacon, rule):
|
|
140
|
+
vouch_id = f"vouch_{secrets.token_hex(8)}"
|
|
141
|
+
return BeaconResponse(
|
|
142
|
+
response_id=resp_id,
|
|
143
|
+
in_response_to=beacon.beacon_id,
|
|
144
|
+
hub_did=hub_did,
|
|
145
|
+
decision="auto_vouched",
|
|
146
|
+
vouch_id=vouch_id,
|
|
147
|
+
assigned_pod=rule.get("pod_id"),
|
|
148
|
+
message=f"Auto-vouched via rule: {rule.get('name', 'unnamed')}",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# No rule matched → HITL
|
|
152
|
+
if self.on_hitl_needed:
|
|
153
|
+
self.on_hitl_needed(beacon)
|
|
154
|
+
|
|
155
|
+
return BeaconResponse(
|
|
156
|
+
response_id=resp_id,
|
|
157
|
+
in_response_to=beacon.beacon_id,
|
|
158
|
+
hub_did=hub_did,
|
|
159
|
+
decision="hitl_pending",
|
|
160
|
+
message="Awaiting HITL approval",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _matches_rule(beacon: Beacon, rule: dict) -> bool:
|
|
165
|
+
"""Check if beacon matches an auto-vouch rule."""
|
|
166
|
+
if "device_type" in rule:
|
|
167
|
+
if beacon.device_type != rule["device_type"]:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
if "required_capabilities" in rule:
|
|
171
|
+
required = set(rule["required_capabilities"])
|
|
172
|
+
if not required.issubset(set(beacon.capabilities)):
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
if "source_pattern" in rule:
|
|
176
|
+
if not re.match(rule["source_pattern"], beacon.source_did):
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
return True
|