ajar-connector 0.5.3__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.
- ajar_connector-0.5.3/PKG-INFO +82 -0
- ajar_connector-0.5.3/README.md +56 -0
- ajar_connector-0.5.3/ajar_connector/__init__.py +36 -0
- ajar_connector-0.5.3/ajar_connector/builder.py +169 -0
- ajar_connector-0.5.3/ajar_connector/canonical.py +15 -0
- ajar_connector-0.5.3/ajar_connector/connector.py +44 -0
- ajar_connector-0.5.3/ajar_connector/event_pb2.py +40 -0
- ajar_connector-0.5.3/ajar_connector/profile.py +49 -0
- ajar_connector-0.5.3/ajar_connector/seal.py +92 -0
- ajar_connector-0.5.3/ajar_connector.egg-info/PKG-INFO +82 -0
- ajar_connector-0.5.3/ajar_connector.egg-info/SOURCES.txt +15 -0
- ajar_connector-0.5.3/ajar_connector.egg-info/dependency_links.txt +1 -0
- ajar_connector-0.5.3/ajar_connector.egg-info/requires.txt +8 -0
- ajar_connector-0.5.3/ajar_connector.egg-info/top_level.txt +1 -0
- ajar_connector-0.5.3/pyproject.toml +47 -0
- ajar_connector-0.5.3/setup.cfg +4 -0
- ajar_connector-0.5.3/tests/test_sdk.py +105 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ajar-connector
|
|
3
|
+
Version: 0.5.3
|
|
4
|
+
Summary: Python SDK for building byte-compatible connectors to the Ajar integration plane.
|
|
5
|
+
Author: Ajar connectors contributors
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/promaka/ajar-connectors
|
|
8
|
+
Project-URL: Documentation, https://github.com/promaka/ajar-connectors/blob/main/docs/embedding-python.md
|
|
9
|
+
Project-URL: Wire contract, https://github.com/promaka/ajar-connectors/blob/main/docs/wire-contract-v1.md
|
|
10
|
+
Project-URL: Source, https://github.com/promaka/ajar-connectors
|
|
11
|
+
Project-URL: Changelog, https://github.com/promaka/ajar-connectors/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: ajar,connector,defence,interoperability,protobuf,ed25519
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: protobuf<8,>=7.35
|
|
21
|
+
Requires-Dist: cryptography>=42
|
|
22
|
+
Provides-Extra: examples
|
|
23
|
+
Requires-Dist: nats-py>=2.6; extra == "examples"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
|
|
27
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
28
|
+
# ajar-connector (Python)
|
|
29
|
+
|
|
30
|
+
The Python SDK for building byte-compatible connectors to Ajar. Types are
|
|
31
|
+
generated from the same vendored `event.proto` as the Rust/Go/C++ SDKs, so this
|
|
32
|
+
SDK reproduces the **same** golden vectors.
|
|
33
|
+
|
|
34
|
+
## Try the template in 10 seconds
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd python
|
|
38
|
+
pip install -e . # or: pip install protobuf cryptography
|
|
39
|
+
echo '{"lat":26.4,"lon":50.9,"alt_m":11000,"quality":0.9}' \
|
|
40
|
+
| PYTHONPATH=. python examples/connector_template.py --dry-run
|
|
41
|
+
# -> 019e... -> ajar.ingest.demo-connector (196 sealed bytes) [dry-run]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You just built and signed a canonical Ajar event. To make it yours, edit the one
|
|
45
|
+
function marked `EDIT` in [examples/connector_template.py](examples/connector_template.py).
|
|
46
|
+
|
|
47
|
+
## Use it in code
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ajar_connector import EventBuilder, canonical_bytes, seal, SigningKey
|
|
51
|
+
|
|
52
|
+
event = (
|
|
53
|
+
EventBuilder("acme-radar-1", "mim:aircraft")
|
|
54
|
+
.new_id().now()
|
|
55
|
+
.location(26.4, 50.9, 11000.0)
|
|
56
|
+
.confidence(0.94)
|
|
57
|
+
.build()
|
|
58
|
+
)
|
|
59
|
+
sealed = seal(canonical_bytes(event), SigningKey.from_seed(my_seed)) # 64-byte sig ++ canonical
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Run the checks
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd python
|
|
66
|
+
python -m pytest # unit tests
|
|
67
|
+
PYTHONPATH=. python conformance/golden_vectors.py # the byte-compat gate
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Streaming example
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install -e ".[examples]" # adds nats-py
|
|
74
|
+
PYTHONPATH=. python examples/synthetic_radar.py # publish to a local Core
|
|
75
|
+
PYTHONPATH=. python examples/synthetic_radar.py --dry-run --ticks 3 # no infra
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See the full [onboarding guide](../ONBOARDING.md) for data flow, deployment
|
|
79
|
+
topology, key generation, and troubleshooting.
|
|
80
|
+
|
|
81
|
+
> The conformance gate and `--dry-run` need only `protobuf` + `cryptography`.
|
|
82
|
+
> `nats-py` is an `examples` extra, kept out of the SDK's core dependencies.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
# ajar-connector (Python)
|
|
3
|
+
|
|
4
|
+
The Python SDK for building byte-compatible connectors to Ajar. Types are
|
|
5
|
+
generated from the same vendored `event.proto` as the Rust/Go/C++ SDKs, so this
|
|
6
|
+
SDK reproduces the **same** golden vectors.
|
|
7
|
+
|
|
8
|
+
## Try the template in 10 seconds
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
cd python
|
|
12
|
+
pip install -e . # or: pip install protobuf cryptography
|
|
13
|
+
echo '{"lat":26.4,"lon":50.9,"alt_m":11000,"quality":0.9}' \
|
|
14
|
+
| PYTHONPATH=. python examples/connector_template.py --dry-run
|
|
15
|
+
# -> 019e... -> ajar.ingest.demo-connector (196 sealed bytes) [dry-run]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
You just built and signed a canonical Ajar event. To make it yours, edit the one
|
|
19
|
+
function marked `EDIT` in [examples/connector_template.py](examples/connector_template.py).
|
|
20
|
+
|
|
21
|
+
## Use it in code
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from ajar_connector import EventBuilder, canonical_bytes, seal, SigningKey
|
|
25
|
+
|
|
26
|
+
event = (
|
|
27
|
+
EventBuilder("acme-radar-1", "mim:aircraft")
|
|
28
|
+
.new_id().now()
|
|
29
|
+
.location(26.4, 50.9, 11000.0)
|
|
30
|
+
.confidence(0.94)
|
|
31
|
+
.build()
|
|
32
|
+
)
|
|
33
|
+
sealed = seal(canonical_bytes(event), SigningKey.from_seed(my_seed)) # 64-byte sig ++ canonical
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Run the checks
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd python
|
|
40
|
+
python -m pytest # unit tests
|
|
41
|
+
PYTHONPATH=. python conformance/golden_vectors.py # the byte-compat gate
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Streaming example
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e ".[examples]" # adds nats-py
|
|
48
|
+
PYTHONPATH=. python examples/synthetic_radar.py # publish to a local Core
|
|
49
|
+
PYTHONPATH=. python examples/synthetic_radar.py --dry-run --ticks 3 # no infra
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
See the full [onboarding guide](../ONBOARDING.md) for data flow, deployment
|
|
53
|
+
topology, key generation, and troubleshooting.
|
|
54
|
+
|
|
55
|
+
> The conformance gate and `--dry-run` need only `protobuf` + `cryptography`.
|
|
56
|
+
> `nats-py` is an `examples` extra, kept out of the SDK's core dependencies.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""ajar-connector: the Python SDK for building byte-compatible connectors to the
|
|
3
|
+
Ajar integration plane.
|
|
4
|
+
|
|
5
|
+
Types are generated from the same vendored ``event.proto`` as the Rust, Go, and
|
|
6
|
+
C++ SDKs; canonical bytes are the deterministic protobuf encoding that gets
|
|
7
|
+
hashed and signed. The shared ``vendor/contract/vectors.json`` proves this
|
|
8
|
+
SDK's output is byte-identical to the others.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .builder import MAX_ATTRIBUTES, MAX_METADATA, MAX_POLICY_TAGS, BuildError, EventBuilder
|
|
12
|
+
from .canonical import canonical_bytes
|
|
13
|
+
from .connector import Connector, OutboundProfile
|
|
14
|
+
from .event_pb2 import Attribute, Event, GeoPoint
|
|
15
|
+
from .profile import ConnectorProfile
|
|
16
|
+
from .seal import SEAL_SIGNATURE_LEN, SealVerificationError, SigningKey, seal, verify
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Attribute",
|
|
20
|
+
"BuildError",
|
|
21
|
+
"Connector",
|
|
22
|
+
"ConnectorProfile",
|
|
23
|
+
"Event",
|
|
24
|
+
"EventBuilder",
|
|
25
|
+
"GeoPoint",
|
|
26
|
+
"MAX_ATTRIBUTES",
|
|
27
|
+
"MAX_METADATA",
|
|
28
|
+
"MAX_POLICY_TAGS",
|
|
29
|
+
"OutboundProfile",
|
|
30
|
+
"SEAL_SIGNATURE_LEN",
|
|
31
|
+
"SealVerificationError",
|
|
32
|
+
"SigningKey",
|
|
33
|
+
"canonical_bytes",
|
|
34
|
+
"seal",
|
|
35
|
+
"verify",
|
|
36
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Ergonomic, fail-closed construction of canonical Events."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .event_pb2 import Event
|
|
9
|
+
|
|
10
|
+
MAX_ATTRIBUTES = 128
|
|
11
|
+
MAX_METADATA = 128
|
|
12
|
+
MAX_POLICY_TAGS = 64
|
|
13
|
+
_SCHEMA_VERSION = "v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuildError(Exception):
|
|
17
|
+
"""Raised when a canonical invariant or required field is violated."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _uuid7() -> str:
|
|
21
|
+
"""A UUIDv7 string (48-bit ms timestamp + random), dependency-free."""
|
|
22
|
+
b = bytearray(os.urandom(16))
|
|
23
|
+
ms = int(time.time() * 1000)
|
|
24
|
+
b[0] = (ms >> 40) & 0xFF
|
|
25
|
+
b[1] = (ms >> 32) & 0xFF
|
|
26
|
+
b[2] = (ms >> 24) & 0xFF
|
|
27
|
+
b[3] = (ms >> 16) & 0xFF
|
|
28
|
+
b[4] = (ms >> 8) & 0xFF
|
|
29
|
+
b[5] = ms & 0xFF
|
|
30
|
+
b[6] = (b[6] & 0x0F) | 0x70 # version 7
|
|
31
|
+
b[8] = (b[8] & 0x3F) | 0x80 # RFC 4122 variant
|
|
32
|
+
h = b.hex()
|
|
33
|
+
return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_namespaced(entity_type: str) -> bool:
|
|
37
|
+
"""True if entity_type is mim:<type> or x:<vendor>:<type>."""
|
|
38
|
+
if entity_type.startswith("mim:"):
|
|
39
|
+
rest = entity_type[4:]
|
|
40
|
+
return bool(rest) and ":" not in rest
|
|
41
|
+
if entity_type.startswith("x:"):
|
|
42
|
+
parts = entity_type[2:].split(":")
|
|
43
|
+
return len(parts) == 2 and all(parts)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EventBuilder:
|
|
48
|
+
"""Builds a canonical Event. Auto-sorts attributes, rejects duplicate keys,
|
|
49
|
+
enforces required fields and limits, offers UUIDv7 / RFC 3339 helpers.
|
|
50
|
+
``received_at`` is intentionally not exposed — Ajar stamps its own clock.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, source_id: str, entity_type: str) -> None:
|
|
54
|
+
self._source_id = source_id
|
|
55
|
+
self._entity_type = entity_type
|
|
56
|
+
self._id: str | None = None
|
|
57
|
+
self._timestamp: str | None = None
|
|
58
|
+
self._location: tuple[float, float, float] | None = None
|
|
59
|
+
self._payload = b""
|
|
60
|
+
self._policy_tags: list[str] = []
|
|
61
|
+
self._confidence = 0.0
|
|
62
|
+
self._attributes: list[tuple[str, str]] = []
|
|
63
|
+
self._metadata: list[tuple[str, str]] = []
|
|
64
|
+
self._strict_entity_namespace = True
|
|
65
|
+
|
|
66
|
+
def id(self, value: str) -> "EventBuilder":
|
|
67
|
+
self._id = value
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def new_id(self) -> "EventBuilder":
|
|
71
|
+
self._id = _uuid7()
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def timestamp(self, ts: str) -> "EventBuilder":
|
|
75
|
+
self._timestamp = ts
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def now(self) -> "EventBuilder":
|
|
79
|
+
self._timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def location(self, latitude: float, longitude: float, altitude_m: float) -> "EventBuilder":
|
|
83
|
+
self._location = (latitude, longitude, altitude_m)
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def payload(self, data: bytes) -> "EventBuilder":
|
|
87
|
+
self._payload = data
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def policy_tag(self, tag: str) -> "EventBuilder":
|
|
91
|
+
self._policy_tags.append(tag)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def confidence(self, confidence: float) -> "EventBuilder":
|
|
95
|
+
self._confidence = confidence
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def attribute(self, key: str, value: str) -> "EventBuilder":
|
|
99
|
+
self._attributes.append((key, value))
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def metadata(self, key: str, value: str) -> "EventBuilder":
|
|
103
|
+
"""Add one ungoverned passthrough metadata entry (ADR-0030): a
|
|
104
|
+
vendor-specific field carried and audited opaquely, but not
|
|
105
|
+
type-validated or correlated (e.g. a source's native identifier).
|
|
106
|
+
Order does not matter — ``build`` sorts by key and rejects duplicates.
|
|
107
|
+
"""
|
|
108
|
+
self._metadata.append((key, value))
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def allow_unnamespaced_entity_type(self) -> "EventBuilder":
|
|
112
|
+
self._strict_entity_namespace = False
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def build(self) -> Event:
|
|
116
|
+
"""Validate invariants and produce the canonical Event."""
|
|
117
|
+
if not self._id:
|
|
118
|
+
raise BuildError("missing required field: id")
|
|
119
|
+
if not self._timestamp:
|
|
120
|
+
raise BuildError("missing required field: timestamp")
|
|
121
|
+
if not self._source_id:
|
|
122
|
+
raise BuildError("missing required field: source_id")
|
|
123
|
+
if not self._entity_type:
|
|
124
|
+
raise BuildError("missing required field: entity_type")
|
|
125
|
+
if self._strict_entity_namespace and not _is_namespaced(self._entity_type):
|
|
126
|
+
raise BuildError(
|
|
127
|
+
f"entity_type {self._entity_type!r} is not namespaced as "
|
|
128
|
+
"mim:<type> or x:<vendor>:<type>"
|
|
129
|
+
)
|
|
130
|
+
if not (0.0 <= self._confidence <= 1.0):
|
|
131
|
+
raise BuildError(f"confidence {self._confidence} is outside [0.0, 1.0]")
|
|
132
|
+
if len(self._policy_tags) > MAX_POLICY_TAGS:
|
|
133
|
+
raise BuildError(f"too many policy tags: {len(self._policy_tags)} (max {MAX_POLICY_TAGS})")
|
|
134
|
+
if len(self._attributes) > MAX_ATTRIBUTES:
|
|
135
|
+
raise BuildError(f"too many attributes: {len(self._attributes)} (max {MAX_ATTRIBUTES})")
|
|
136
|
+
if len(self._metadata) > MAX_METADATA:
|
|
137
|
+
raise BuildError(f"too many metadata entries: {len(self._metadata)} (max {MAX_METADATA})")
|
|
138
|
+
|
|
139
|
+
# Canonical rule: attributes sorted by key, unique.
|
|
140
|
+
attrs = sorted(self._attributes, key=lambda kv: kv[0])
|
|
141
|
+
for i in range(1, len(attrs)):
|
|
142
|
+
if attrs[i][0] == attrs[i - 1][0]:
|
|
143
|
+
raise BuildError(f"duplicate attribute key: {attrs[i][0]!r}")
|
|
144
|
+
|
|
145
|
+
# Metadata follows the identical canonical rule: sorted by key, unique.
|
|
146
|
+
meta = sorted(self._metadata, key=lambda kv: kv[0])
|
|
147
|
+
for i in range(1, len(meta)):
|
|
148
|
+
if meta[i][0] == meta[i - 1][0]:
|
|
149
|
+
raise BuildError(f"duplicate metadata key: {meta[i][0]!r}")
|
|
150
|
+
|
|
151
|
+
event = Event()
|
|
152
|
+
event.schema_version = _SCHEMA_VERSION
|
|
153
|
+
event.id = self._id
|
|
154
|
+
event.source_id = self._source_id
|
|
155
|
+
event.entity_type = self._entity_type
|
|
156
|
+
event.timestamp = self._timestamp
|
|
157
|
+
# received_at intentionally left empty — Ajar stamps its own clock.
|
|
158
|
+
if self._location is not None:
|
|
159
|
+
event.location.latitude = self._location[0]
|
|
160
|
+
event.location.longitude = self._location[1]
|
|
161
|
+
event.location.altitude_m = self._location[2]
|
|
162
|
+
event.payload = self._payload
|
|
163
|
+
event.policy_tags.extend(self._policy_tags)
|
|
164
|
+
event.confidence = self._confidence
|
|
165
|
+
for key, value in attrs:
|
|
166
|
+
event.attributes.add(key=key, value=value)
|
|
167
|
+
for key, value in meta:
|
|
168
|
+
event.metadata.add(key=key, value=value)
|
|
169
|
+
return event
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Canonical byte encoding of an Event."""
|
|
3
|
+
|
|
4
|
+
from .event_pb2 import Event
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def canonical_bytes(event: Event) -> bytes:
|
|
8
|
+
"""Return the canonical protobuf encoding of ``event``.
|
|
9
|
+
|
|
10
|
+
``deterministic=True`` gives ascending tag order with proto3 defaults
|
|
11
|
+
omitted and map keys sorted (the contract has no maps) — the same wire
|
|
12
|
+
shape prost (Rust), Go, libprotobuf, and nanopb produce. The caller owns
|
|
13
|
+
the attribute invariant (sorted by key, unique); EventBuilder guarantees it.
|
|
14
|
+
"""
|
|
15
|
+
return event.SerializeToString(deterministic=True)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Connector interfaces: inbound normalization and outbound rendering."""
|
|
3
|
+
|
|
4
|
+
import abc
|
|
5
|
+
|
|
6
|
+
from .event_pb2 import Event
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Connector(abc.ABC):
|
|
10
|
+
"""An inbound connector: native bytes -> canonical Event.
|
|
11
|
+
|
|
12
|
+
Implementations parse ``native`` (CoT XML, a CSV row, a vendor frame, ...)
|
|
13
|
+
and return a fully-populated event, ideally via EventBuilder.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abc.abstractmethod
|
|
17
|
+
def normalize(self, native: bytes) -> Event:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OutboundProfile(abc.ABC):
|
|
22
|
+
"""An outbound profile: render a governed Event into a target format.
|
|
23
|
+
|
|
24
|
+
Its honesty is the modeled/lossy field declaration; round-trip conformance
|
|
25
|
+
(canonical -> target -> canonical) checks the claim.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
def target(self) -> str: ...
|
|
30
|
+
|
|
31
|
+
@abc.abstractmethod
|
|
32
|
+
def slug(self) -> str: ...
|
|
33
|
+
|
|
34
|
+
@abc.abstractmethod
|
|
35
|
+
def version(self) -> str: ...
|
|
36
|
+
|
|
37
|
+
@abc.abstractmethod
|
|
38
|
+
def modeled_fields(self) -> list[str]: ...
|
|
39
|
+
|
|
40
|
+
@abc.abstractmethod
|
|
41
|
+
def lossy_fields(self) -> list[str]: ...
|
|
42
|
+
|
|
43
|
+
@abc.abstractmethod
|
|
44
|
+
def render(self, event: Event) -> bytes: ...
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: event.proto
|
|
5
|
+
# Protobuf Python Version: 7.35.0
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
7,
|
|
15
|
+
35,
|
|
16
|
+
0,
|
|
17
|
+
'',
|
|
18
|
+
'event.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\rajar.event.v1\"\xba\x02\n\x05\x45vent\x12\x16\n\x0eschema_version\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\t\x12\x11\n\tsource_id\x18\x03 \x01(\t\x12\x13\n\x0b\x65ntity_type\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\t\x12\x13\n\x0breceived_at\x18\n \x01(\t\x12)\n\x08location\x18\x06 \x01(\x0b\x32\x17.ajar.event.v1.GeoPoint\x12\x0f\n\x07payload\x18\x07 \x01(\x0c\x12\x13\n\x0bpolicy_tags\x18\x08 \x03(\t\x12\x12\n\nconfidence\x18\t \x01(\x01\x12,\n\nattributes\x18\x0b \x03(\x0b\x32\x18.ajar.event.v1.Attribute\x12*\n\x08metadata\x18\x0c \x03(\x0b\x32\x18.ajar.event.v1.Attribute\"\'\n\tAttribute\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"C\n\x08GeoPoint\x12\x10\n\x08latitude\x18\x01 \x01(\x01\x12\x11\n\tlongitude\x18\x02 \x01(\x01\x12\x12\n\naltitude_m\x18\x03 \x01(\x01\x62\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_EVENT']._serialized_start=31
|
|
35
|
+
_globals['_EVENT']._serialized_end=345
|
|
36
|
+
_globals['_ATTRIBUTE']._serialized_start=347
|
|
37
|
+
_globals['_ATTRIBUTE']._serialized_end=386
|
|
38
|
+
_globals['_GEOPOINT']._serialized_start=388
|
|
39
|
+
_globals['_GEOPOINT']._serialized_end=455
|
|
40
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Connector profile: the self-declaration a connector publishes."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConnectorProfile:
|
|
8
|
+
"""A connector's published profile. Serializes deterministically (verifying
|
|
9
|
+
key as lowercase hex); byte-identical to the Rust/Go/C++ profile JSON.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, source_id: str, verifying_key: bytes) -> None:
|
|
13
|
+
self.source_id = source_id
|
|
14
|
+
self.allowed_entity_types: list[str] = []
|
|
15
|
+
self.max_payload_bytes = 64 * 1024
|
|
16
|
+
self.rate_capacity = 100
|
|
17
|
+
self.rate_refill_per_sec = 10.0
|
|
18
|
+
self.verifying_key = verifying_key
|
|
19
|
+
|
|
20
|
+
def allow_entity_type(self, entity_type: str) -> "ConnectorProfile":
|
|
21
|
+
self.allowed_entity_types.append(entity_type)
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def with_max_payload_bytes(self, max_bytes: int) -> "ConnectorProfile":
|
|
25
|
+
self.max_payload_bytes = max_bytes
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def rate_limit(self, capacity: int, refill_per_sec: float) -> "ConnectorProfile":
|
|
29
|
+
self.rate_capacity = capacity
|
|
30
|
+
self.rate_refill_per_sec = refill_per_sec
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def _wire(self) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
"source_id": self.source_id,
|
|
36
|
+
"allowed_entity_types": self.allowed_entity_types,
|
|
37
|
+
"max_payload_bytes": self.max_payload_bytes,
|
|
38
|
+
"rate_capacity": self.rate_capacity,
|
|
39
|
+
"rate_refill_per_sec": self.rate_refill_per_sec,
|
|
40
|
+
"verifying_key_hex": self.verifying_key.hex(),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def to_json(self) -> str:
|
|
44
|
+
"""Compact JSON."""
|
|
45
|
+
return json.dumps(self._wire(), separators=(",", ":"))
|
|
46
|
+
|
|
47
|
+
def to_json_pretty(self) -> str:
|
|
48
|
+
"""Indented JSON."""
|
|
49
|
+
return json.dumps(self._wire(), indent=2)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The seal envelope: detached Ed25519 signature prefixed to canonical bytes."""
|
|
3
|
+
|
|
4
|
+
from cryptography.exceptions import InvalidSignature
|
|
5
|
+
from cryptography.hazmat.primitives import serialization
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
7
|
+
Ed25519PrivateKey,
|
|
8
|
+
Ed25519PublicKey,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
SEAL_SIGNATURE_LEN = 64
|
|
12
|
+
"""Length in bytes of the Ed25519 signature prefix on a sealed envelope."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SigningKey:
|
|
16
|
+
"""An Ed25519 signing key.
|
|
17
|
+
|
|
18
|
+
Each production connector holds its own; Ajar registers the matching
|
|
19
|
+
verifying key in the connector's profile. The golden-vector seed (32 x
|
|
20
|
+
0x47) is a published TEST seed — never sign production events with it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, key: Ed25519PrivateKey) -> None:
|
|
24
|
+
self._key = key
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_seed(cls, seed: bytes) -> "SigningKey":
|
|
28
|
+
"""Derive a key from a 32-byte seed."""
|
|
29
|
+
if len(seed) != 32:
|
|
30
|
+
raise ValueError(f"signing seed must be 32 bytes, got {len(seed)}")
|
|
31
|
+
return cls(Ed25519PrivateKey.from_private_bytes(seed))
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def verifying_key(self) -> bytes:
|
|
35
|
+
"""The 32-byte Ed25519 public (verifying) key."""
|
|
36
|
+
return self._key.public_key().public_bytes(
|
|
37
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def sign(self, data: bytes) -> bytes:
|
|
41
|
+
"""Return the 64-byte detached signature over ``data``."""
|
|
42
|
+
return self._key.sign(data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def seal(canonical: bytes, key: SigningKey) -> bytes:
|
|
46
|
+
"""Seal canonical bytes: ``sign(key, canonical) ++ canonical``.
|
|
47
|
+
|
|
48
|
+
The verifier splits at :data:`SEAL_SIGNATURE_LEN`, checks the signature over
|
|
49
|
+
the remainder with the connector's registered verifying key, and recovers
|
|
50
|
+
the canonical event from the suffix.
|
|
51
|
+
"""
|
|
52
|
+
return key.sign(canonical) + canonical
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SealVerificationError(Exception):
|
|
56
|
+
"""A sealed envelope was not accepted."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify(sealed: bytes, verifying_key: bytes) -> bytes:
|
|
60
|
+
"""Verify a sealed envelope and return the canonical bytes it carries.
|
|
61
|
+
|
|
62
|
+
The inverse of :func:`seal`, and the whole trust model in one call: it
|
|
63
|
+
answers whether these exact bytes were sealed by the holder of
|
|
64
|
+
``verifying_key`` and are unaltered since. A recipient holding a
|
|
65
|
+
connector's registered verifying key can establish provenance without the
|
|
66
|
+
connector, the broker or Ajar Core being present or trusted.
|
|
67
|
+
|
|
68
|
+
``verifying_key`` is the 32-byte raw Ed25519 public key, as published in the
|
|
69
|
+
connector's profile.
|
|
70
|
+
|
|
71
|
+
Raises :class:`SealVerificationError` if the envelope is too short, the key
|
|
72
|
+
is not a valid Ed25519 public key, or the signature does not verify.
|
|
73
|
+
"""
|
|
74
|
+
if len(verifying_key) != 32:
|
|
75
|
+
raise SealVerificationError(
|
|
76
|
+
f"verifying key must be 32 bytes, got {len(verifying_key)}"
|
|
77
|
+
)
|
|
78
|
+
if len(sealed) < SEAL_SIGNATURE_LEN:
|
|
79
|
+
raise SealVerificationError(
|
|
80
|
+
f"sealed envelope is {len(sealed)} bytes, shorter than the "
|
|
81
|
+
f"{SEAL_SIGNATURE_LEN}-byte signature"
|
|
82
|
+
)
|
|
83
|
+
signature, canonical = sealed[:SEAL_SIGNATURE_LEN], sealed[SEAL_SIGNATURE_LEN:]
|
|
84
|
+
try:
|
|
85
|
+
Ed25519PublicKey.from_public_bytes(verifying_key).verify(signature, canonical)
|
|
86
|
+
except InvalidSignature as exc:
|
|
87
|
+
raise SealVerificationError(
|
|
88
|
+
"signature did not verify under the given key"
|
|
89
|
+
) from exc
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
raise SealVerificationError(f"invalid verifying key: {exc}") from exc
|
|
92
|
+
return canonical
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ajar-connector
|
|
3
|
+
Version: 0.5.3
|
|
4
|
+
Summary: Python SDK for building byte-compatible connectors to the Ajar integration plane.
|
|
5
|
+
Author: Ajar connectors contributors
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/promaka/ajar-connectors
|
|
8
|
+
Project-URL: Documentation, https://github.com/promaka/ajar-connectors/blob/main/docs/embedding-python.md
|
|
9
|
+
Project-URL: Wire contract, https://github.com/promaka/ajar-connectors/blob/main/docs/wire-contract-v1.md
|
|
10
|
+
Project-URL: Source, https://github.com/promaka/ajar-connectors
|
|
11
|
+
Project-URL: Changelog, https://github.com/promaka/ajar-connectors/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: ajar,connector,defence,interoperability,protobuf,ed25519
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: protobuf<8,>=7.35
|
|
21
|
+
Requires-Dist: cryptography>=42
|
|
22
|
+
Provides-Extra: examples
|
|
23
|
+
Requires-Dist: nats-py>=2.6; extra == "examples"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
|
|
27
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
28
|
+
# ajar-connector (Python)
|
|
29
|
+
|
|
30
|
+
The Python SDK for building byte-compatible connectors to Ajar. Types are
|
|
31
|
+
generated from the same vendored `event.proto` as the Rust/Go/C++ SDKs, so this
|
|
32
|
+
SDK reproduces the **same** golden vectors.
|
|
33
|
+
|
|
34
|
+
## Try the template in 10 seconds
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd python
|
|
38
|
+
pip install -e . # or: pip install protobuf cryptography
|
|
39
|
+
echo '{"lat":26.4,"lon":50.9,"alt_m":11000,"quality":0.9}' \
|
|
40
|
+
| PYTHONPATH=. python examples/connector_template.py --dry-run
|
|
41
|
+
# -> 019e... -> ajar.ingest.demo-connector (196 sealed bytes) [dry-run]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You just built and signed a canonical Ajar event. To make it yours, edit the one
|
|
45
|
+
function marked `EDIT` in [examples/connector_template.py](examples/connector_template.py).
|
|
46
|
+
|
|
47
|
+
## Use it in code
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ajar_connector import EventBuilder, canonical_bytes, seal, SigningKey
|
|
51
|
+
|
|
52
|
+
event = (
|
|
53
|
+
EventBuilder("acme-radar-1", "mim:aircraft")
|
|
54
|
+
.new_id().now()
|
|
55
|
+
.location(26.4, 50.9, 11000.0)
|
|
56
|
+
.confidence(0.94)
|
|
57
|
+
.build()
|
|
58
|
+
)
|
|
59
|
+
sealed = seal(canonical_bytes(event), SigningKey.from_seed(my_seed)) # 64-byte sig ++ canonical
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Run the checks
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd python
|
|
66
|
+
python -m pytest # unit tests
|
|
67
|
+
PYTHONPATH=. python conformance/golden_vectors.py # the byte-compat gate
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Streaming example
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install -e ".[examples]" # adds nats-py
|
|
74
|
+
PYTHONPATH=. python examples/synthetic_radar.py # publish to a local Core
|
|
75
|
+
PYTHONPATH=. python examples/synthetic_radar.py --dry-run --ticks 3 # no infra
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See the full [onboarding guide](../ONBOARDING.md) for data flow, deployment
|
|
79
|
+
topology, key generation, and troubleshooting.
|
|
80
|
+
|
|
81
|
+
> The conformance gate and `--dry-run` need only `protobuf` + `cryptography`.
|
|
82
|
+
> `nats-py` is an `examples` extra, kept out of the SDK's core dependencies.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ajar_connector/__init__.py
|
|
4
|
+
ajar_connector/builder.py
|
|
5
|
+
ajar_connector/canonical.py
|
|
6
|
+
ajar_connector/connector.py
|
|
7
|
+
ajar_connector/event_pb2.py
|
|
8
|
+
ajar_connector/profile.py
|
|
9
|
+
ajar_connector/seal.py
|
|
10
|
+
ajar_connector.egg-info/PKG-INFO
|
|
11
|
+
ajar_connector.egg-info/SOURCES.txt
|
|
12
|
+
ajar_connector.egg-info/dependency_links.txt
|
|
13
|
+
ajar_connector.egg-info/requires.txt
|
|
14
|
+
ajar_connector.egg-info/top_level.txt
|
|
15
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ajar_connector
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["setuptools>=61"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "ajar-connector"
|
|
8
|
+
version = "0.5.3"
|
|
9
|
+
description = "Python SDK for building byte-compatible connectors to the Ajar integration plane."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
license = "Apache-2.0"
|
|
13
|
+
authors = [{ name = "Ajar connectors contributors" }]
|
|
14
|
+
keywords = ["ajar", "connector", "defence", "interoperability", "protobuf", "ed25519"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
"Topic :: System :: Networking",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
"protobuf>=7.35,<8",
|
|
25
|
+
"cryptography>=42",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/promaka/ajar-connectors"
|
|
30
|
+
Documentation = "https://github.com/promaka/ajar-connectors/blob/main/docs/embedding-python.md"
|
|
31
|
+
"Wire contract" = "https://github.com/promaka/ajar-connectors/blob/main/docs/wire-contract-v1.md"
|
|
32
|
+
Source = "https://github.com/promaka/ajar-connectors"
|
|
33
|
+
Changelog = "https://github.com/promaka/ajar-connectors/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
# Transport for the example connectors — kept OUT of the SDK's core deps.
|
|
37
|
+
examples = ["nats-py>=2.6"]
|
|
38
|
+
dev = ["pytest>=8"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["."]
|
|
42
|
+
include = ["ajar_connector*"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
# Make ajar_connector importable from the repo without an install step.
|
|
46
|
+
pythonpath = ["."]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Unit tests for the builder, seal, and profile (run with pytest)."""
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from ajar_connector import (
|
|
7
|
+
SEAL_SIGNATURE_LEN,
|
|
8
|
+
BuildError,
|
|
9
|
+
canonical_bytes,
|
|
10
|
+
ConnectorProfile,
|
|
11
|
+
EventBuilder,
|
|
12
|
+
SigningKey,
|
|
13
|
+
seal,
|
|
14
|
+
SealVerificationError,
|
|
15
|
+
verify,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_builder_sorts_attributes_and_sets_defaults():
|
|
20
|
+
e = (
|
|
21
|
+
EventBuilder("sensor-1", "mim:drone")
|
|
22
|
+
.new_id()
|
|
23
|
+
.now()
|
|
24
|
+
.attribute("speed", "110")
|
|
25
|
+
.attribute("heading", "225")
|
|
26
|
+
.build()
|
|
27
|
+
)
|
|
28
|
+
assert e.schema_version == "v1"
|
|
29
|
+
assert e.received_at == ""
|
|
30
|
+
assert [a.key for a in e.attributes] == ["heading", "speed"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_builder_rejects_duplicate_attribute_keys():
|
|
34
|
+
with pytest.raises(BuildError, match="duplicate attribute key"):
|
|
35
|
+
EventBuilder("sensor-1", "mim:drone").new_id().now().attribute(
|
|
36
|
+
"heading", "225"
|
|
37
|
+
).attribute("heading", "226").build()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_builder_requires_id_and_timestamp():
|
|
41
|
+
with pytest.raises(BuildError, match="missing required field: id"):
|
|
42
|
+
EventBuilder("sensor-1", "mim:drone").build()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_builder_rejects_out_of_range_confidence():
|
|
46
|
+
with pytest.raises(BuildError, match="confidence"):
|
|
47
|
+
EventBuilder("sensor-1", "mim:drone").new_id().now().confidence(1.5).build()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_builder_enforces_namespacing_with_opt_out():
|
|
51
|
+
with pytest.raises(BuildError, match="not namespaced"):
|
|
52
|
+
EventBuilder("sensor-1", "drone").new_id().now().build()
|
|
53
|
+
# opt-out builds fine
|
|
54
|
+
EventBuilder("sensor-1", "drone").new_id().now().allow_unnamespaced_entity_type().build()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_seal_layout():
|
|
58
|
+
key = SigningKey.from_seed(bytes([0x47]) * 32)
|
|
59
|
+
canonical = b"hello ajar"
|
|
60
|
+
sealed = seal(canonical, key)
|
|
61
|
+
assert len(sealed) == SEAL_SIGNATURE_LEN + len(canonical)
|
|
62
|
+
assert sealed[SEAL_SIGNATURE_LEN:] == canonical
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_profile_json_serializes_verifying_key_as_hex():
|
|
66
|
+
vk = SigningKey.from_seed(bytes([0x47]) * 32).verifying_key
|
|
67
|
+
profile = (
|
|
68
|
+
ConnectorProfile("sensor-123", vk)
|
|
69
|
+
.allow_entity_type("mim:drone")
|
|
70
|
+
.rate_limit(50, 5.0)
|
|
71
|
+
)
|
|
72
|
+
js = profile.to_json()
|
|
73
|
+
assert '"source_id":"sensor-123"' in js
|
|
74
|
+
assert "mim:drone" in js
|
|
75
|
+
assert vk.hex() in js
|
|
76
|
+
assert '"rate_refill_per_sec":5.0' in js # float keeps the .0, like the other SDKs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_verify_round_trips_and_returns_canonical_bytes():
|
|
80
|
+
key = SigningKey.from_seed(bytes([0x47] * 32))
|
|
81
|
+
event = EventBuilder("acme-radar-1", "mim:aircraft").new_id().now().build()
|
|
82
|
+
canonical = canonical_bytes(event)
|
|
83
|
+
assert verify(seal(canonical, key), key.verifying_key) == canonical
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_verify_rejects_tampering_wrong_key_and_truncation():
|
|
87
|
+
key = SigningKey.from_seed(bytes([0x47] * 32))
|
|
88
|
+
other = SigningKey.from_seed(bytes([0x11] * 32))
|
|
89
|
+
sealed = seal(canonical_bytes(
|
|
90
|
+
EventBuilder("acme-radar-1", "mim:aircraft").new_id().now().build()
|
|
91
|
+
), key)
|
|
92
|
+
|
|
93
|
+
tampered = bytearray(sealed)
|
|
94
|
+
tampered[-1] ^= 0x01
|
|
95
|
+
with pytest.raises(SealVerificationError):
|
|
96
|
+
verify(bytes(tampered), key.verifying_key)
|
|
97
|
+
|
|
98
|
+
with pytest.raises(SealVerificationError):
|
|
99
|
+
verify(sealed, other.verifying_key)
|
|
100
|
+
|
|
101
|
+
with pytest.raises(SealVerificationError):
|
|
102
|
+
verify(sealed[: SEAL_SIGNATURE_LEN - 1], key.verifying_key)
|
|
103
|
+
|
|
104
|
+
with pytest.raises(SealVerificationError):
|
|
105
|
+
verify(sealed, b"too-short-key")
|