182 lines
9.7 KiB
Python
182 lines
9.7 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import base64
|
|
import ssl
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
sys.path.insert(0, str(ROOT / "Brain" / "src"))
|
|
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
from jsonschema import Draft202012Validator
|
|
|
|
from yovision_brain.integration.machine_identity import (
|
|
KeyRecord,
|
|
MachineIdentityError,
|
|
Registry,
|
|
ReplayStore,
|
|
Signer,
|
|
TransportPolicy,
|
|
Verifier,
|
|
load_registry,
|
|
bearer_token,
|
|
)
|
|
|
|
|
|
class ContractFilesTest(unittest.TestCase):
|
|
def test_closed_claim_and_registry_schemas(self) -> None:
|
|
claims = json.loads((ROOT / "contracts/machine-identity/v1/machine-token.schema.json").read_text(encoding="utf-8"))
|
|
registry = json.loads((ROOT / "contracts/machine-identity/v1/principal-registry.schema.json").read_text(encoding="utf-8"))
|
|
transport = json.loads((ROOT / "contracts/transport/v1/transport-policy.schema.json").read_text(encoding="utf-8"))
|
|
Draft202012Validator.check_schema(claims)
|
|
Draft202012Validator.check_schema(registry)
|
|
Draft202012Validator.check_schema(transport)
|
|
self.assertFalse(claims["additionalProperties"])
|
|
self.assertEqual(claims["properties"]["ver"]["const"], "yovision.machine-identity/v1")
|
|
self.assertEqual(claims["properties"]["scope"]["items"]["enum"], [
|
|
"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"
|
|
])
|
|
self.assertFalse(registry["additionalProperties"])
|
|
self.assertEqual(transport["properties"]["verify_certificate"]["const"], True)
|
|
self.assertEqual(transport["properties"]["verify_hostname"]["const"], True)
|
|
|
|
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
|
|
claims_object = json.loads(base64.urlsafe_b64decode(vector["token"].split(".")[1] + "=="))
|
|
Draft202012Validator(claims).validate(claims_object)
|
|
Draft202012Validator(transport).validate({
|
|
"version": "yovision.transport/v1", "tls_min_version": "1.2", "verify_certificate": True,
|
|
"verify_hostname": True, "connect_timeout_ms": 1000, "response_header_timeout_ms": 1000,
|
|
"request_timeout_ms": 5000, "max_request_bytes": 1048576,
|
|
})
|
|
|
|
def test_brain_dependency_is_frozen(self) -> None:
|
|
pyproject = (ROOT / "Brain/pyproject.toml").read_text(encoding="utf-8")
|
|
self.assertIn('dependencies = ["cryptography==50.0.1"]', pyproject)
|
|
|
|
def test_contract_documents_fail_closed(self) -> None:
|
|
identity = (ROOT / "contracts/machine-identity/v1/README.md").read_text(encoding="utf-8")
|
|
transport = (ROOT / "contracts/transport/v1/README.md").read_text(encoding="utf-8")
|
|
for required in ("300 seconds", "30 seconds", "24 hours", "jti", "revoked", "browser token"):
|
|
self.assertIn(required, identity)
|
|
for required in ("TLS 1.2", "hostname verification", "query-string credentials", "disables the connector"):
|
|
self.assertIn(required, transport)
|
|
|
|
def test_python_verifies_cross_language_vector(self) -> None:
|
|
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
|
|
raw_key = base64.urlsafe_b64decode(vector["public_key_base64url"] + "=")
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
registry = Registry([KeyRecord("yv:brain:vector", "brain-vector-0001", Ed25519PublicKey.from_public_bytes(raw_key), vector["audience"], frozenset({vector["required_scope"]}))])
|
|
verifier = Verifier(registry, ReplayStore(), clock=lambda: vector["now"])
|
|
claims = verifier.verify(vector["token"], vector["audience"], vector["required_scope"], vector["method"], vector["path"], base64.b64decode(vector["body_base64"]))
|
|
self.assertEqual(claims.iss, "yv:brain:vector")
|
|
|
|
|
|
class BrainAdapterTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.now = 1_800_000_000
|
|
self.private = Ed25519PrivateKey.generate()
|
|
self.record = KeyRecord(
|
|
principal="yv:brain:node-a",
|
|
key_id="brain-key-0001",
|
|
public_key=self.private.public_key(),
|
|
audience="yovision-sense",
|
|
scopes=frozenset({"runtime-status:write"}),
|
|
)
|
|
self.registry = Registry([self.record])
|
|
self.signer = Signer("yv:brain:node-a", "brain-key-0001", self.private, clock=lambda: self.now)
|
|
|
|
def mint(self, body: bytes = b"{}") -> str:
|
|
return self.signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", body)
|
|
|
|
def verify(self, token: str, body: bytes = b"{}", **changes: str):
|
|
verifier = Verifier(self.registry, changes.pop("replay", ReplayStore()), clock=lambda: int(changes.pop("now", self.now)))
|
|
return verifier.verify(
|
|
token,
|
|
changes.pop("audience", "yovision-sense"),
|
|
changes.pop("scope", "runtime-status:write"),
|
|
changes.pop("method", "POST"),
|
|
changes.pop("path", "/machine/v1/runtime-status"),
|
|
body,
|
|
)
|
|
|
|
def assert_code(self, code: str, callback) -> None:
|
|
with self.assertRaises(MachineIdentityError) as caught:
|
|
callback()
|
|
self.assertEqual(caught.exception.code, code)
|
|
self.assertEqual(str(caught.exception), code)
|
|
|
|
def test_valid_token_and_replay_rejection(self) -> None:
|
|
token = self.mint()
|
|
replay = ReplayStore()
|
|
first = Verifier(self.registry, replay, clock=lambda: self.now)
|
|
claims = first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
|
self.assertEqual(claims.iss, "yv:brain:node-a")
|
|
self.assert_code("machine_token_replayed", lambda: first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}"))
|
|
|
|
def test_bearer_token_has_no_cookie_or_query_fallback(self) -> None:
|
|
self.assertEqual(bearer_token("Bearer compact.token.value"), "compact.token.value")
|
|
for value in ("", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"):
|
|
self.assert_code("machine_token_missing", lambda value=value: bearer_token(value))
|
|
|
|
def test_wrong_audience_scope_body_and_expiry(self) -> None:
|
|
self.assert_code("machine_audience_denied", lambda: self.verify(self.mint(), audience="yovision-bell"))
|
|
self.assert_code("machine_scope_denied", lambda: self.verify(self.mint(), scope="events:ingest"))
|
|
self.assert_code("machine_token_invalid", lambda: self.verify(self.mint(), body=b"changed"))
|
|
self.assert_code("machine_token_expired", lambda: self.verify(self.mint(), now=str(self.now + 361)))
|
|
|
|
def test_tampering_revocation_and_rotation(self) -> None:
|
|
token = self.mint()
|
|
parts = token.split(".")
|
|
tampered = f"{parts[0]}.{parts[1][:-1]}A.{parts[2]}"
|
|
self.assert_code("machine_token_invalid", lambda: self.verify(tampered))
|
|
self.assertTrue(self.registry.revoke("brain-key-0001"))
|
|
self.assert_code("machine_identity_revoked", lambda: self.verify(self.mint()))
|
|
|
|
new_private = Ed25519PrivateKey.generate()
|
|
overlap = Registry([
|
|
self.record,
|
|
KeyRecord("yv:brain:node-a", "brain-key-0002", new_private.public_key(), "yovision-sense", frozenset({"runtime-status:write"})),
|
|
])
|
|
new_signer = Signer("yv:brain:node-a", "brain-key-0002", new_private, clock=lambda: self.now)
|
|
verifier = Verifier(overlap, ReplayStore(), clock=lambda: self.now)
|
|
verifier.verify(self.mint(), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
|
verifier.verify(new_signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", b"{}"), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
|
|
|
def test_transport_policy_requires_verified_tls(self) -> None:
|
|
policy = TransportPolicy("1.2", True, True, 1000, 1000, 2000, 1024)
|
|
context = policy.ssl_context()
|
|
self.assertGreaterEqual(context.minimum_version, ssl.TLSVersion.TLSv1_2)
|
|
self.assertTrue(context.check_hostname)
|
|
self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
|
|
with self.assertRaises(ValueError):
|
|
TransportPolicy("1.1", True, True, 1000, 1000, 2000, 1024).validate()
|
|
with self.assertRaises(ValueError):
|
|
TransportPolicy("1.2", True, False, 1000, 1000, 2000, 1024).validate()
|
|
|
|
def test_loads_external_public_registry_and_rejects_wrong_audience(self) -> None:
|
|
from cryptography.hazmat.primitives import serialization
|
|
raw_public = self.private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
document = {
|
|
"version": "yovision.machine-principal-registry/v1",
|
|
"audience": "yovision-sense",
|
|
"principals": [{
|
|
"principal_id": "yv:brain:node-a", "enabled": True,
|
|
"keys": [{"kid": "brain-key-0001", "public_key_base64url": base64.urlsafe_b64encode(raw_public).rstrip(b"=").decode(), "status": "active", "scopes": ["runtime-status:write"]}],
|
|
}],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "principals.json"
|
|
path.write_text(json.dumps(document), encoding="utf-8")
|
|
registry = load_registry(path, "yovision-sense")
|
|
self.assertIsNotNone(registry.lookup("brain-key-0001"))
|
|
with self.assertRaises(ValueError):
|
|
load_registry(path, "yovision-bell")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|