cultmesh-py/rs authority route verification #18
9 changed files with 1379 additions and 1 deletions
|
|
@ -228,6 +228,17 @@ prediction = session.predict(note_doc, "input:python:move", {"body": "predicted
|
|||
unsubscribe()
|
||||
```
|
||||
|
||||
## Authority route verification
|
||||
|
||||
`cultmesh_py.authority` ports the C# reference
|
||||
(`src/GameCult.Mesh/CultMeshAuthorityProof.cs`): `AuthorityTrustPolicy.validate`
|
||||
judges an Odin-signed ECDSA P-256 route (channel protection, trusted root,
|
||||
validity window, IEEE P1363 signature over the canonical bytes) and
|
||||
`verify_session_proof` checks the provider nonce proof. Refusals raise
|
||||
`AuthorityRouteRefusal` with the C# message text. It needs `cryptography`.
|
||||
`tests/test_authority_route_vectors.py` judges
|
||||
`contracts/cultmesh/authority-route-vectors*.json`.
|
||||
|
||||
## Verify
|
||||
|
||||
`cultmesh_py.verify` checks the public exports and `py.typed` markers of all
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"]
|
||||
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"]
|
||||
[project.scripts]
|
||||
cultmesh-py-daemon = "cultmesh_py.daemon:main"
|
||||
cultmesh-py-verify = "cultmesh_py.verify:main"
|
||||
|
|
|
|||
338
packages/cultmesh-py/src/cultmesh_py/authority.py
Normal file
338
packages/cultmesh-py/src/cultmesh_py/authority.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
"""CultMesh authority route verification.
|
||||
|
||||
Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of the
|
||||
route constructors in `CultMeshVerse.cs`. Rule for rule, in the same order, the
|
||||
same input yields the same refusal text as `CultMeshAuthorityTrustPolicy.Verify`:
|
||||
unsigned, channel protection, root lookup, validity window, signature.
|
||||
|
||||
Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||
|
||||
ROUTE_DOMAIN = "gamecult.cultmesh.route-certificate.v1"
|
||||
SESSION_DOMAIN = "gamecult.cultmesh.session-proof.v1"
|
||||
|
||||
AUTHENTICATED_REMOTE = "authenticated-remote"
|
||||
LOCAL_DEVELOPMENT = "local-development"
|
||||
|
||||
# .NET `char.IsWhiteSpace`: Unicode Zs, Zl, Zp, U+0009-U+000D and U+0085.
|
||||
# `str.strip()` is a different set (it also strips U+001C-U+001F).
|
||||
_CSHARP_WHITESPACE = (
|
||||
"\u0009\u000a\u000b\u000c\u000d \u0085 "
|
||||
" "
|
||||
"
"
|
||||
)
|
||||
|
||||
|
||||
def trim_csharp(value: str) -> str:
|
||||
return value.strip(_CSHARP_WHITESPACE)
|
||||
|
||||
|
||||
def is_blank_csharp(value: str | None) -> bool:
|
||||
return value is None or trim_csharp(value) == ""
|
||||
|
||||
|
||||
def _require(value: str | None, name: str) -> str:
|
||||
if is_blank_csharp(value):
|
||||
raise ValueError(f"Value must be non-empty. (Parameter '{name}')")
|
||||
return trim_csharp(value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class AuthorityRouteRefusal(Exception):
|
||||
"""A route or proof the consumer's trust policy refuses.
|
||||
|
||||
Mirrors `CultMeshSessionException` carrying a `CultMeshSessionFailure` with
|
||||
reason `Authentication`: `reason` is always "authentication".
|
||||
"""
|
||||
|
||||
reason = "authentication"
|
||||
|
||||
def __init__(self, message: str, endpoint: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.endpoint = endpoint
|
||||
|
||||
|
||||
def _b64_strict(value: str) -> bytes:
|
||||
# `Convert.FromBase64String`: standard alphabet, padded, whitespace ignored.
|
||||
stripped = re.sub(r"[ \t\r\n]", "", value)
|
||||
return base64.b64decode(stripped, validate=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P256PublicKey:
|
||||
key_id: str
|
||||
x: str
|
||||
y: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "key_id", _require(self.key_id, "keyId"))
|
||||
for name in ("x", "y"):
|
||||
value = _require(getattr(self, name), name)
|
||||
try:
|
||||
_b64_strict(value)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise ValueError(f"Coordinate must be base64. (Parameter '{name}')") from error
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def verify(self, payload: bytes, signature_base64: str) -> bool:
|
||||
"""IEEE P1363 (r || s) ECDSA-SHA256 verification; False on any malformation."""
|
||||
try:
|
||||
x, y = _b64_strict(self.x), _b64_strict(self.y)
|
||||
signature = _b64_strict(signature_base64)
|
||||
if len(x) != 32 or len(y) != 32 or len(signature) != 64:
|
||||
return False
|
||||
key = ec.EllipticCurvePublicNumbers(
|
||||
int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1()
|
||||
).public_key()
|
||||
der = encode_dss_signature(
|
||||
int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
|
||||
)
|
||||
key.verify(der, payload, ec.ECDSA(hashes.SHA256()))
|
||||
return True
|
||||
except (InvalidSignature, ValueError, binascii.Error):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteCertificate:
|
||||
provider_key: P256PublicKey
|
||||
odin_key_id: str
|
||||
issued_at_unix_milliseconds: int
|
||||
expires_at_unix_milliseconds: int
|
||||
signature: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if is_blank_csharp(self.odin_key_id):
|
||||
raise ValueError("Odin key identity is required. (Parameter 'odinKeyId')")
|
||||
object.__setattr__(self, "odin_key_id", trim_csharp(self.odin_key_id))
|
||||
if self.issued_at_unix_milliseconds < 0:
|
||||
raise ValueError("issuedAtUnixMilliseconds must not be negative.")
|
||||
if self.expires_at_unix_milliseconds <= self.issued_at_unix_milliseconds:
|
||||
raise ValueError("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.")
|
||||
object.__setattr__(self, "signature", trim_csharp(self.signature or ""))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityRoute:
|
||||
"""`CultMeshAuthorityRoute`: construction cleans every field as the C# does."""
|
||||
|
||||
authority_runtime_id: str
|
||||
endpoint: str
|
||||
protocol_ids: Sequence[str] = ()
|
||||
priority: int = 0
|
||||
generation: str | None = None
|
||||
certificate: RouteCertificate | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
runtime_id = _require(self.authority_runtime_id, "authorityRuntimeId")
|
||||
endpoint = _require(self.endpoint, "endpoint")
|
||||
if self.priority < 0:
|
||||
raise ValueError("priority must not be negative.")
|
||||
object.__setattr__(self, "authority_runtime_id", runtime_id)
|
||||
object.__setattr__(self, "endpoint", endpoint)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"protocol_ids",
|
||||
tuple(
|
||||
sorted(
|
||||
{trim_csharp(v) for v in self.protocol_ids if not is_blank_csharp(v)},
|
||||
key=lambda v: v.encode("utf-16-be"), # StringComparer.Ordinal is UTF-16 code-unit order
|
||||
)
|
||||
),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"generation",
|
||||
f"{runtime_id}@{endpoint}" if is_blank_csharp(self.generation) else trim_csharp(self.generation), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionOpen:
|
||||
"""The fields of `CultMeshSessionOpenMessage` the session proof covers."""
|
||||
|
||||
client_nonce: str | None = None
|
||||
message_id: str | None = None
|
||||
source_runtime_id: str | None = None
|
||||
verse_id: str | None = None
|
||||
authority_runtime_id: str | None = None
|
||||
protocol_id: str | None = None
|
||||
route_generation: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionAccepted:
|
||||
client_nonce: str | None
|
||||
provider_key_id: str | None
|
||||
provider_signature: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedRoute:
|
||||
route: AuthorityRoute
|
||||
provider_key: P256PublicKey | None
|
||||
is_local_development: bool
|
||||
|
||||
|
||||
def canonical_fields(*values: str | None) -> bytes:
|
||||
out = bytearray()
|
||||
for value in values:
|
||||
encoded = (value or "").encode("utf-8")
|
||||
out += struct.pack(">I", len(encoded)) + encoded
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def canonical_route(verse_id: str, route: AuthorityRoute) -> bytes:
|
||||
certificate = route.certificate
|
||||
if certificate is None:
|
||||
raise ValueError("Route certificate is required.")
|
||||
return canonical_fields(
|
||||
ROUTE_DOMAIN,
|
||||
verse_id,
|
||||
route.authority_runtime_id,
|
||||
route.endpoint,
|
||||
"\u001f".join(route.protocol_ids), # already ordinally sorted; Python sorts by code point
|
||||
str(route.priority),
|
||||
route.generation,
|
||||
certificate.provider_key.key_id,
|
||||
certificate.provider_key.x,
|
||||
certificate.provider_key.y,
|
||||
certificate.odin_key_id,
|
||||
str(certificate.issued_at_unix_milliseconds),
|
||||
str(certificate.expires_at_unix_milliseconds),
|
||||
)
|
||||
|
||||
|
||||
def canonical_session(request: SessionOpen, endpoint: str) -> bytes:
|
||||
return canonical_fields(
|
||||
SESSION_DOMAIN,
|
||||
request.client_nonce,
|
||||
request.message_id,
|
||||
request.source_runtime_id,
|
||||
request.verse_id,
|
||||
request.authority_runtime_id,
|
||||
request.protocol_id,
|
||||
endpoint,
|
||||
request.route_generation,
|
||||
)
|
||||
|
||||
|
||||
# `System.Uri` refuses spellings WHATWG parsers repair; a lenient parse could call
|
||||
# a refused endpoint loopback or protected. Same shape rule as the TypeScript port.
|
||||
_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+\-.]*://([^/?#]+)")
|
||||
|
||||
|
||||
def _split_endpoint(value: str) -> tuple[str, str] | None:
|
||||
"""Return (scheme, lowercase host) or None where System.Uri would refuse."""
|
||||
raw = re.sub(r"^[\x00-\x20]+|[\x00-\x20]+$", "", value)
|
||||
if "\\" in raw:
|
||||
return None
|
||||
shape = _AUTHORITY.match(raw)
|
||||
if not shape:
|
||||
return None
|
||||
authority = shape.group(1)
|
||||
after_user = authority[authority.rfind("@") + 1 :]
|
||||
if after_user.startswith("["):
|
||||
host = after_user[: after_user.find("]") + 1]
|
||||
else:
|
||||
host = after_user.split(":")[0]
|
||||
if not host or re.search(r"[^!-~]", host) or host.endswith("."):
|
||||
return None
|
||||
return raw.split("://", 1)[0].lower(), host.strip("[]").lower()
|
||||
|
||||
|
||||
def is_loopback_endpoint(endpoint: str) -> bool:
|
||||
parts = _split_endpoint(endpoint)
|
||||
if parts is None:
|
||||
return False
|
||||
host = parts[1]
|
||||
return host in ("localhost", "loopback", "::1", "::ffff:127.0.0.1") or bool(
|
||||
re.fullmatch(r"127\.\d{1,3}\.\d{1,3}\.\d{1,3}", host)
|
||||
)
|
||||
|
||||
|
||||
def is_protected_endpoint(endpoint: str) -> bool:
|
||||
parts = _split_endpoint(endpoint)
|
||||
return parts is not None and (parts[0] in ("wss", "https") or "quic" in parts[0])
|
||||
|
||||
|
||||
class AuthorityTrustPolicy:
|
||||
"""Consumer-owned policy. Fails closed unless a route is valid under it."""
|
||||
|
||||
def __init__(self, mode: str, odin_roots: Iterable[P256PublicKey] = ()) -> None:
|
||||
if mode not in (AUTHENTICATED_REMOTE, LOCAL_DEVELOPMENT):
|
||||
raise ValueError(f"Unknown trust mode '{mode}'.")
|
||||
self.mode = mode
|
||||
self._roots: dict[str, P256PublicKey] = {}
|
||||
for root in odin_roots:
|
||||
if root.key_id in self._roots:
|
||||
raise ValueError(f"Odin root key id '{root.key_id}' is listed more than once in the trust policy.")
|
||||
self._roots[root.key_id] = root
|
||||
|
||||
def validate(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> None:
|
||||
self.verify(verse_id, route, now_unix_milliseconds)
|
||||
|
||||
def verify(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> VerifiedRoute:
|
||||
local = self.mode == LOCAL_DEVELOPMENT and is_loopback_endpoint(route.endpoint)
|
||||
|
||||
def refuse(message: str) -> AuthorityRouteRefusal:
|
||||
return AuthorityRouteRefusal(message, route.endpoint)
|
||||
|
||||
certificate = route.certificate
|
||||
if certificate is None or is_blank_csharp(certificate.signature):
|
||||
if local:
|
||||
return VerifiedRoute(route, None, True)
|
||||
raise refuse("Remote CultMesh routes require an Odin-signed authority certificate.")
|
||||
if not is_protected_endpoint(route.endpoint) and not local:
|
||||
raise refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.")
|
||||
root = self._roots.get(certificate.odin_key_id)
|
||||
if root is None:
|
||||
raise refuse(f"Odin key '{certificate.odin_key_id}' is not trusted by this consumer.")
|
||||
if not (certificate.issued_at_unix_milliseconds <= now_unix_milliseconds < certificate.expires_at_unix_milliseconds):
|
||||
raise refuse("The Odin route certificate is not currently valid.")
|
||||
try:
|
||||
signature = _b64_strict(certificate.signature)
|
||||
except (binascii.Error, ValueError):
|
||||
raise refuse("The Odin route signature is not base64.") from None
|
||||
if len(signature) != 64:
|
||||
raise refuse("The Odin route signature is not IEEE P1363 P-256.")
|
||||
if not root.verify(canonical_route(verse_id, route), certificate.signature):
|
||||
raise refuse("The Odin route certificate signature is invalid.")
|
||||
return VerifiedRoute(route, certificate.provider_key, False)
|
||||
|
||||
|
||||
def verify_session_proof(
|
||||
request: SessionOpen,
|
||||
response: SessionAccepted,
|
||||
verse_id: str,
|
||||
route: AuthorityRoute,
|
||||
trust: AuthorityTrustPolicy,
|
||||
now_unix_milliseconds: int,
|
||||
) -> bool:
|
||||
"""Verifies the Odin route certificate (raising) and the provider nonce proof."""
|
||||
verified = trust.verify(verse_id, route, now_unix_milliseconds)
|
||||
if verified.is_local_development:
|
||||
return True
|
||||
key = verified.provider_key
|
||||
if (
|
||||
key is None
|
||||
or response.provider_key_id != key.key_id
|
||||
or response.client_nonce != request.client_nonce
|
||||
or is_blank_csharp(response.provider_signature)
|
||||
):
|
||||
return False
|
||||
return key.verify(canonical_session(request, route.endpoint), response.provider_signature) # type: ignore[arg-type]
|
||||
217
packages/cultmesh-py/tests/test_authority_route_vectors.py
Normal file
217
packages/cultmesh-py/tests/test_authority_route_vectors.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Judges `contracts/cultmesh/authority-route-vectors*.json` in Python.
|
||||
|
||||
`authority-route-vectors.json` is written by the C# reference (signatures are
|
||||
captured C# bytes); the `.ts-signed.json` file is signed by TypeScript over awkward
|
||||
inputs. Each negative case fails when the matching check is removed from
|
||||
`cultmesh_py.authority`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
from cultmesh_py.authority import (
|
||||
AUTHENTICATED_REMOTE,
|
||||
LOCAL_DEVELOPMENT,
|
||||
AuthorityRoute,
|
||||
AuthorityRouteRefusal,
|
||||
AuthorityTrustPolicy,
|
||||
P256PublicKey,
|
||||
RouteCertificate,
|
||||
SessionAccepted,
|
||||
SessionOpen,
|
||||
canonical_route,
|
||||
verify_session_proof,
|
||||
)
|
||||
|
||||
CONTRACTS = Path(__file__).resolve().parents[3] / "contracts" / "cultmesh"
|
||||
|
||||
|
||||
def _key(element: dict) -> P256PublicKey:
|
||||
return P256PublicKey(element["keyId"], element["x"], element["y"])
|
||||
|
||||
|
||||
class Vector:
|
||||
def __init__(self, name: str) -> None:
|
||||
doc = json.loads((CONTRACTS / name).read_text(encoding="utf-8"))
|
||||
self.now = doc["nowUnixMilliseconds"]
|
||||
self.root = _key(doc["odinRoot"])
|
||||
r = doc["route"]
|
||||
self.verse_id = r["verseId"]
|
||||
c = r["certificate"]
|
||||
self.certificate = RouteCertificate(
|
||||
_key(c["providerKey"]), c["odinKeyId"], c["issuedAtUnixMilliseconds"],
|
||||
c["expiresAtUnixMilliseconds"], c["signature"],
|
||||
)
|
||||
self.route = AuthorityRoute(
|
||||
r["authorityRuntimeId"], r["endpoint"], r["protocolIds"], r["priority"],
|
||||
r["generation"], self.certificate,
|
||||
)
|
||||
p = doc["sessionProof"]
|
||||
q = p["request"]
|
||||
self.request = SessionOpen(
|
||||
q["clientNonce"], q["messageId"], q["sourceRuntimeId"], q["verseId"],
|
||||
q["authorityRuntimeId"], q["protocolId"], q["routeGeneration"],
|
||||
)
|
||||
self.accepted = SessionAccepted(q["clientNonce"], p["providerKeyId"], p["providerSignature"])
|
||||
self.trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [self.root])
|
||||
|
||||
def with_certificate(self, **changes) -> AuthorityRoute:
|
||||
return replace(self.route, certificate=replace(self.certificate, **changes))
|
||||
|
||||
|
||||
def refusal(test: unittest.TestCase, fn) -> str:
|
||||
with test.assertRaises(AuthorityRouteRefusal) as raised:
|
||||
fn()
|
||||
test.assertEqual("authentication", raised.exception.reason)
|
||||
return raised.exception.message
|
||||
|
||||
|
||||
class AuthorityRouteVectorTests(unittest.TestCase):
|
||||
def test_cs_written_vector_verifies_route_and_session_proof(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
v.trust.validate(v.verse_id, v.route, v.now)
|
||||
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
|
||||
def test_ts_signed_vector_verifies_and_tamper_is_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.ts-signed.json")
|
||||
v.trust.validate(v.verse_id, v.route, v.now)
|
||||
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
tampered = replace(v.route, priority=v.route.priority + 1)
|
||||
self.assertEqual(
|
||||
"The Odin route certificate signature is invalid.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, tampered, v.now)),
|
||||
)
|
||||
|
||||
def test_canonical_route_is_length_prefixed_and_sorted(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
data = canonical_route(v.verse_id, v.route)
|
||||
self.assertTrue(data.startswith(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"))
|
||||
self.assertIn(b"cultmesh.content.v1\x1fcultmesh.documents.v1", data)
|
||||
|
||||
def test_tampered_payload_fields_are_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
invalid = "The Odin route certificate signature is invalid."
|
||||
for tampered, verse in (
|
||||
(replace(v.route, priority=8), v.verse_id),
|
||||
(replace(v.route, endpoint="wss://evil.example/mesh"), v.verse_id),
|
||||
(replace(v.route, protocol_ids=("cultmesh.documents.v1",)), v.verse_id),
|
||||
(replace(v.route, generation="other"), v.verse_id),
|
||||
(v.with_certificate(expires_at_unix_milliseconds=v.certificate.expires_at_unix_milliseconds + 1), v.verse_id),
|
||||
(v.route, "other-verse"),
|
||||
):
|
||||
self.assertEqual(invalid, refusal(self, lambda: v.trust.validate(verse, tampered, v.now)))
|
||||
|
||||
def test_wrong_root_key_is_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
numbers = ec.generate_private_key(ec.SECP256R1()).public_key().public_numbers()
|
||||
impostor = P256PublicKey(
|
||||
v.root.key_id,
|
||||
base64.b64encode(numbers.x.to_bytes(32, "big")).decode(),
|
||||
base64.b64encode(numbers.y.to_bytes(32, "big")).decode(),
|
||||
)
|
||||
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [impostor])
|
||||
self.assertEqual(
|
||||
"The Odin route certificate signature is invalid.",
|
||||
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
|
||||
)
|
||||
|
||||
def test_untrusted_odin_key_id_is_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [replace(v.root, key_id="someone-else")])
|
||||
self.assertEqual(
|
||||
"Odin key 'odin-vector' is not trusted by this consumer.",
|
||||
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
|
||||
)
|
||||
|
||||
def test_expired_and_not_yet_valid_are_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
window = "The Odin route certificate is not currently valid."
|
||||
expires = v.certificate.expires_at_unix_milliseconds
|
||||
issued = v.certificate.issued_at_unix_milliseconds
|
||||
v.trust.validate(v.verse_id, v.route, expires - 1)
|
||||
v.trust.validate(v.verse_id, v.route, issued)
|
||||
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, expires)))
|
||||
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, issued - 1)))
|
||||
|
||||
def test_unsigned_and_unprotected_routes_are_refused(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
unsigned = replace(v.route, certificate=None)
|
||||
self.assertEqual(
|
||||
"Remote CultMesh routes require an Odin-signed authority certificate.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, unsigned, v.now)),
|
||||
)
|
||||
blank = v.with_certificate(signature=" ")
|
||||
self.assertEqual(
|
||||
"Remote CultMesh routes require an Odin-signed authority certificate.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, blank, v.now)),
|
||||
)
|
||||
plain = replace(v.route, endpoint="ws://provider.example/mesh")
|
||||
self.assertEqual(
|
||||
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, plain, v.now)),
|
||||
)
|
||||
|
||||
def test_local_development_admits_only_unsigned_loopback(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
dev = AuthorityTrustPolicy(LOCAL_DEVELOPMENT, [])
|
||||
loop = AuthorityRoute("a", "ws://127.0.0.1:9/mesh")
|
||||
dev.validate(v.verse_id, loop, v.now)
|
||||
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, loop, dev, v.now))
|
||||
for endpoint in ("ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"):
|
||||
refusal(self, lambda e=endpoint: dev.validate(v.verse_id, AuthorityRoute("a", e), v.now))
|
||||
authenticated = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [])
|
||||
refusal(self, lambda: authenticated.validate(v.verse_id, loop, v.now))
|
||||
|
||||
def test_malformed_signatures_get_their_own_refusals(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
not_base64 = v.with_certificate(signature="!!not base64!!")
|
||||
self.assertEqual(
|
||||
"The Odin route signature is not base64.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, not_base64, v.now)),
|
||||
)
|
||||
short = v.with_certificate(signature=base64.b64encode(b"x" * 63).decode())
|
||||
self.assertEqual(
|
||||
"The Odin route signature is not IEEE P1363 P-256.",
|
||||
refusal(self, lambda: v.trust.validate(v.verse_id, short, v.now)),
|
||||
)
|
||||
|
||||
def test_high_s_signature_verifies_as_it_does_in_dotnet(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
|
||||
raw = base64.b64decode(v.certificate.signature)
|
||||
s = int.from_bytes(raw[32:], "big")
|
||||
flipped = raw[:32] + (order - s).to_bytes(32, "big")
|
||||
high = v.with_certificate(signature=base64.b64encode(flipped).decode())
|
||||
v.trust.validate(v.verse_id, high, v.now)
|
||||
|
||||
def test_session_proof_rejects_replay_wrong_key_and_forgery(self) -> None:
|
||||
v = Vector("authority-route-vectors.json")
|
||||
replayed = replace(v.request, client_nonce="replayed")
|
||||
accepted = replace(v.accepted, client_nonce="replayed")
|
||||
self.assertFalse(verify_session_proof(replayed, accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
self.assertFalse(verify_session_proof(
|
||||
v.request, replace(v.accepted, provider_key_id="other"), v.verse_id, v.route, v.trust, v.now))
|
||||
self.assertFalse(verify_session_proof(
|
||||
v.request, replace(v.accepted, client_nonce="other"), v.verse_id, v.route, v.trust, v.now))
|
||||
self.assertFalse(verify_session_proof(
|
||||
v.request, replace(v.accepted, provider_signature=""), v.verse_id, v.route, v.trust, v.now))
|
||||
raw = bytearray(base64.b64decode(v.accepted.provider_signature))
|
||||
raw[0] ^= 1
|
||||
forged = replace(v.accepted, provider_signature=base64.b64encode(bytes(raw)).decode())
|
||||
self.assertFalse(verify_session_proof(v.request, forged, v.verse_id, v.route, v.trust, v.now))
|
||||
moved = replace(v.request, protocol_id="cultmesh.content.v1")
|
||||
self.assertFalse(verify_session_proof(moved, v.accepted, v.verse_id, v.route, v.trust, v.now))
|
||||
# An expired route fails closed even with a good proof.
|
||||
refusal(self, lambda: verify_session_proof(
|
||||
v.request, v.accepted, v.verse_id, v.route, v.trust, v.certificate.expires_at_unix_milliseconds))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
123
packages/cultmesh-rs/Cargo.lock
generated
123
packages/cultmesh-rs/Cargo.lock
generated
|
|
@ -67,6 +67,12 @@ version = "1.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
|
@ -161,6 +167,18 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
|
|
@ -214,11 +232,14 @@ name = "cultmesh-rs"
|
|||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"cultcache-rs",
|
||||
"cultnet-rs",
|
||||
"p256",
|
||||
"pretty_assertions",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
|
|
@ -298,10 +319,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
|
|
@ -326,6 +362,25 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -342,6 +397,16 @@ version = "2.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
|
|
@ -396,6 +461,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -442,6 +508,17 @@ dependencies = [
|
|||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
|
|
@ -555,6 +632,18 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -602,6 +691,15 @@ dependencies = [
|
|||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
|
|
@ -689,6 +787,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
|
|
@ -747,6 +855,20 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
|
@ -840,6 +962,7 @@ version = "2.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ path = "src/lib.rs"
|
|||
anyhow = "1"
|
||||
cultcache-rs = { path = "../cultcache-rs" }
|
||||
cultnet-rs = { path = "../cultnet-rs" }
|
||||
base64 = "0.22"
|
||||
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
rmp-serde = "1"
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
424
packages/cultmesh-rs/src/authority.rs
Normal file
424
packages/cultmesh-rs/src/authority.rs
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
//! CultMesh authority route verification.
|
||||
//!
|
||||
//! Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of
|
||||
//! the route constructors in `CultMeshVerse.cs`. Rule for rule, in the same
|
||||
//! order, the same input yields the same refusal text as
|
||||
//! `CultMeshAuthorityTrustPolicy.Verify`: unsigned, channel protection, root
|
||||
//! lookup, validity window, signature.
|
||||
//!
|
||||
//! Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
|
||||
|
||||
use base64::alphabet::STANDARD as STANDARD_ALPHABET;
|
||||
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
|
||||
use base64::Engine;
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
const ROUTE_DOMAIN: &str = "gamecult.cultmesh.route-certificate.v1";
|
||||
const SESSION_DOMAIN: &str = "gamecult.cultmesh.session-proof.v1";
|
||||
|
||||
// `Convert.FromBase64String`: standard alphabet, padded, tolerant of trailing bits.
|
||||
const DOTNET_BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||
&STANDARD_ALPHABET,
|
||||
GeneralPurposeConfig::new()
|
||||
.with_decode_padding_mode(DecodePaddingMode::RequireCanonical)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
fn base64_decode(value: &str) -> Option<Vec<u8>> {
|
||||
let stripped: String = value.chars().filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n')).collect();
|
||||
DOTNET_BASE64.decode(stripped).ok()
|
||||
}
|
||||
|
||||
/// .NET `char.IsWhiteSpace` is the Unicode White_Space property, as is `char::is_whitespace`.
|
||||
fn trim(value: &str) -> &str {
|
||||
value.trim_matches(char::is_whitespace)
|
||||
}
|
||||
|
||||
fn is_blank(value: &str) -> bool {
|
||||
trim(value).is_empty()
|
||||
}
|
||||
|
||||
fn require(value: &str, name: &str) -> Result<String, String> {
|
||||
if is_blank(value) {
|
||||
Err(format!("Value must be non-empty. (Parameter '{name}')"))
|
||||
} else {
|
||||
Ok(trim(value).to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// A route or proof the consumer's trust policy refuses. Mirrors
|
||||
/// `CultMeshSessionFailure` with reason `Authentication`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorityRouteRefusal {
|
||||
pub message: String,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
impl AuthorityRouteRefusal {
|
||||
pub const REASON: &'static str = "authentication";
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthorityRouteRefusal {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthorityRouteRefusal {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct P256PublicKey {
|
||||
pub key_id: String,
|
||||
pub x: String,
|
||||
pub y: String,
|
||||
}
|
||||
|
||||
impl P256PublicKey {
|
||||
pub fn new(key_id: &str, x: &str, y: &str) -> Result<Self, String> {
|
||||
let key = Self {
|
||||
key_id: require(key_id, "keyId")?,
|
||||
x: require(x, "x")?,
|
||||
y: require(y, "y")?,
|
||||
};
|
||||
for (name, value) in [("x", &key.x), ("y", &key.y)] {
|
||||
if base64_decode(value).is_none() {
|
||||
return Err(format!("Coordinate must be base64. (Parameter '{name}')"));
|
||||
}
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// IEEE P1363 (r || s) ECDSA-SHA256 verification; false on any malformation.
|
||||
pub fn verify(&self, payload: &[u8], signature_base64: &str) -> bool {
|
||||
let (Some(x), Some(y), Some(signature)) = (
|
||||
base64_decode(&self.x),
|
||||
base64_decode(&self.y),
|
||||
base64_decode(signature_base64),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if x.len() != 32 || y.len() != 32 || signature.len() != 64 {
|
||||
return false;
|
||||
}
|
||||
let mut sec1 = Vec::with_capacity(65);
|
||||
sec1.push(4);
|
||||
sec1.extend_from_slice(&x);
|
||||
sec1.extend_from_slice(&y);
|
||||
let (Ok(key), Ok(signature)) = (
|
||||
VerifyingKey::from_sec1_bytes(&sec1),
|
||||
Signature::from_slice(&signature),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
key.verify(payload, &signature).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RouteCertificate {
|
||||
pub provider_key: P256PublicKey,
|
||||
pub odin_key_id: String,
|
||||
pub issued_at_unix_milliseconds: i64,
|
||||
pub expires_at_unix_milliseconds: i64,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
impl RouteCertificate {
|
||||
pub fn new(
|
||||
provider_key: P256PublicKey,
|
||||
odin_key_id: &str,
|
||||
issued_at_unix_milliseconds: i64,
|
||||
expires_at_unix_milliseconds: i64,
|
||||
signature: &str,
|
||||
) -> Result<Self, String> {
|
||||
if is_blank(odin_key_id) {
|
||||
return Err("Odin key identity is required. (Parameter 'odinKeyId')".into());
|
||||
}
|
||||
if issued_at_unix_milliseconds < 0 {
|
||||
return Err("issuedAtUnixMilliseconds must not be negative.".into());
|
||||
}
|
||||
if expires_at_unix_milliseconds <= issued_at_unix_milliseconds {
|
||||
return Err("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.".into());
|
||||
}
|
||||
Ok(Self {
|
||||
provider_key,
|
||||
odin_key_id: trim(odin_key_id).to_owned(),
|
||||
issued_at_unix_milliseconds,
|
||||
expires_at_unix_milliseconds,
|
||||
signature: trim(signature).to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `CultMeshAuthorityRoute`: construction cleans every field as the C# does.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorityRoute {
|
||||
pub authority_runtime_id: String,
|
||||
pub endpoint: String,
|
||||
pub protocol_ids: Vec<String>,
|
||||
pub priority: i32,
|
||||
pub generation: String,
|
||||
pub certificate: Option<RouteCertificate>,
|
||||
}
|
||||
|
||||
impl AuthorityRoute {
|
||||
pub fn new(
|
||||
authority_runtime_id: &str,
|
||||
endpoint: &str,
|
||||
protocol_ids: &[&str],
|
||||
priority: i32,
|
||||
generation: Option<&str>,
|
||||
certificate: Option<RouteCertificate>,
|
||||
) -> Result<Self, String> {
|
||||
let authority_runtime_id = require(authority_runtime_id, "authorityRuntimeId")?;
|
||||
let endpoint = require(endpoint, "endpoint")?;
|
||||
if priority < 0 {
|
||||
return Err("priority must not be negative.".into());
|
||||
}
|
||||
let mut protocols: Vec<String> = protocol_ids
|
||||
.iter()
|
||||
.filter(|value| !is_blank(value))
|
||||
.map(|value| trim(value).to_owned())
|
||||
.collect();
|
||||
// StringComparer.Ordinal is UTF-16 code-unit order.
|
||||
protocols.sort_by_key(|value| value.encode_utf16().collect::<Vec<u16>>());
|
||||
protocols.dedup();
|
||||
let generation = match generation {
|
||||
Some(value) if !is_blank(value) => trim(value).to_owned(),
|
||||
_ => format!("{authority_runtime_id}@{endpoint}"),
|
||||
};
|
||||
Ok(Self {
|
||||
authority_runtime_id,
|
||||
endpoint,
|
||||
protocol_ids: protocols,
|
||||
priority,
|
||||
generation,
|
||||
certificate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of `CultMeshSessionOpenMessage` the session proof covers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SessionOpen {
|
||||
pub client_nonce: String,
|
||||
pub message_id: String,
|
||||
pub source_runtime_id: String,
|
||||
pub verse_id: String,
|
||||
pub authority_runtime_id: String,
|
||||
pub protocol_id: String,
|
||||
pub route_generation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SessionAccepted {
|
||||
pub client_nonce: String,
|
||||
pub provider_key_id: String,
|
||||
pub provider_signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TrustMode {
|
||||
AuthenticatedRemote,
|
||||
LocalDevelopment,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VerifiedRoute {
|
||||
pub route: AuthorityRoute,
|
||||
pub provider_key: Option<P256PublicKey>,
|
||||
pub is_local_development: bool,
|
||||
}
|
||||
|
||||
pub fn canonical_fields(values: &[&str]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for value in values {
|
||||
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(value.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn canonical_route(verse_id: &str, route: &AuthorityRoute) -> Result<Vec<u8>, String> {
|
||||
let certificate = route.certificate.as_ref().ok_or("Route certificate is required.")?;
|
||||
let protocols = route.protocol_ids.join("\u{1f}");
|
||||
let priority = route.priority.to_string();
|
||||
let issued = certificate.issued_at_unix_milliseconds.to_string();
|
||||
let expires = certificate.expires_at_unix_milliseconds.to_string();
|
||||
Ok(canonical_fields(&[
|
||||
ROUTE_DOMAIN,
|
||||
verse_id,
|
||||
&route.authority_runtime_id,
|
||||
&route.endpoint,
|
||||
&protocols,
|
||||
&priority,
|
||||
&route.generation,
|
||||
&certificate.provider_key.key_id,
|
||||
&certificate.provider_key.x,
|
||||
&certificate.provider_key.y,
|
||||
&certificate.odin_key_id,
|
||||
&issued,
|
||||
&expires,
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn canonical_session(request: &SessionOpen, endpoint: &str) -> Vec<u8> {
|
||||
canonical_fields(&[
|
||||
SESSION_DOMAIN,
|
||||
&request.client_nonce,
|
||||
&request.message_id,
|
||||
&request.source_runtime_id,
|
||||
&request.verse_id,
|
||||
&request.authority_runtime_id,
|
||||
&request.protocol_id,
|
||||
endpoint,
|
||||
&request.route_generation,
|
||||
])
|
||||
}
|
||||
|
||||
/// `(scheme, lowercase host)`, or `None` where `System.Uri` refuses the spelling.
|
||||
/// A lenient parse could call a refused endpoint loopback or protected, so the
|
||||
/// shape rule is the same strict one the TypeScript port applies.
|
||||
fn split_endpoint(value: &str) -> Option<(String, String)> {
|
||||
let raw = value.trim_matches(|c: char| c <= ' ');
|
||||
if raw.contains('\\') {
|
||||
return None;
|
||||
}
|
||||
let (scheme, rest) = raw.split_once("://")?;
|
||||
let mut scheme_chars = scheme.chars();
|
||||
if !scheme_chars.next()?.is_ascii_alphabetic()
|
||||
|| !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let after_user = &authority[authority.rfind('@').map_or(0, |i| i + 1)..];
|
||||
let host = if after_user.starts_with('[') {
|
||||
&after_user[..after_user.find(']').map_or(after_user.len(), |i| i + 1)]
|
||||
} else {
|
||||
after_user.split(':').next().unwrap_or("")
|
||||
};
|
||||
if host.is_empty() || host.chars().any(|c| !('\u{21}'..='\u{7e}').contains(&c)) || host.ends_with('.') {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
scheme.to_ascii_lowercase(),
|
||||
host.trim_matches(['[', ']']).to_ascii_lowercase(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn is_loopback_endpoint(endpoint: &str) -> bool {
|
||||
let Some((_, host)) = split_endpoint(endpoint) else {
|
||||
return false;
|
||||
};
|
||||
let octets: Vec<&str> = host.split('.').collect();
|
||||
matches!(host.as_str(), "localhost" | "loopback" | "::1" | "::ffff:127.0.0.1")
|
||||
|| (octets.len() == 4
|
||||
&& octets[0] == "127"
|
||||
&& octets[1..].iter().all(|o| (1..=3).contains(&o.len()) && o.bytes().all(|b| b.is_ascii_digit())))
|
||||
}
|
||||
|
||||
pub fn is_protected_endpoint(endpoint: &str) -> bool {
|
||||
split_endpoint(endpoint)
|
||||
.is_some_and(|(scheme, _)| scheme == "wss" || scheme == "https" || scheme.contains("quic"))
|
||||
}
|
||||
|
||||
/// Consumer-owned policy. Fails closed unless a route is valid under it.
|
||||
pub struct AuthorityTrustPolicy {
|
||||
mode: TrustMode,
|
||||
roots: BTreeMap<String, P256PublicKey>,
|
||||
}
|
||||
|
||||
impl AuthorityTrustPolicy {
|
||||
pub fn new(mode: TrustMode, odin_roots: Vec<P256PublicKey>) -> Result<Self, String> {
|
||||
let mut roots = BTreeMap::new();
|
||||
for root in odin_roots {
|
||||
let key_id = root.key_id.clone();
|
||||
if roots.insert(key_id.clone(), root).is_some() {
|
||||
return Err(format!(
|
||||
"Odin root key id '{key_id}' is listed more than once in the trust policy."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self { mode, roots })
|
||||
}
|
||||
|
||||
pub fn validate(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<(), AuthorityRouteRefusal> {
|
||||
self.verify(verse_id, route, now_unix_milliseconds).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn verify(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<VerifiedRoute, AuthorityRouteRefusal> {
|
||||
let refuse = |message: &str| AuthorityRouteRefusal {
|
||||
message: message.to_owned(),
|
||||
endpoint: route.endpoint.clone(),
|
||||
};
|
||||
let local = self.mode == TrustMode::LocalDevelopment && is_loopback_endpoint(&route.endpoint);
|
||||
let certificate = match &route.certificate {
|
||||
Some(certificate) if !is_blank(&certificate.signature) => certificate,
|
||||
_ if local => {
|
||||
return Ok(VerifiedRoute { route: route.clone(), provider_key: None, is_local_development: true });
|
||||
}
|
||||
_ => return Err(refuse("Remote CultMesh routes require an Odin-signed authority certificate.")),
|
||||
};
|
||||
if !is_protected_endpoint(&route.endpoint) && !local {
|
||||
return Err(refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection."));
|
||||
}
|
||||
let Some(root) = self.roots.get(&certificate.odin_key_id) else {
|
||||
return Err(refuse(&format!(
|
||||
"Odin key '{}' is not trusted by this consumer.",
|
||||
certificate.odin_key_id
|
||||
)));
|
||||
};
|
||||
if now_unix_milliseconds < certificate.issued_at_unix_milliseconds
|
||||
|| now_unix_milliseconds >= certificate.expires_at_unix_milliseconds
|
||||
{
|
||||
return Err(refuse("The Odin route certificate is not currently valid."));
|
||||
}
|
||||
let Some(signature) = base64_decode(&certificate.signature) else {
|
||||
return Err(refuse("The Odin route signature is not base64."));
|
||||
};
|
||||
if signature.len() != 64 {
|
||||
return Err(refuse("The Odin route signature is not IEEE P1363 P-256."));
|
||||
}
|
||||
let canonical = canonical_route(verse_id, route).map_err(|message| refuse(&message))?;
|
||||
if !root.verify(&canonical, &certificate.signature) {
|
||||
return Err(refuse("The Odin route certificate signature is invalid."));
|
||||
}
|
||||
Ok(VerifiedRoute {
|
||||
route: route.clone(),
|
||||
provider_key: Some(certificate.provider_key.clone()),
|
||||
is_local_development: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the Odin route certificate (refusing) and the provider nonce proof.
|
||||
pub fn verify_session_proof(
|
||||
request: &SessionOpen,
|
||||
response: &SessionAccepted,
|
||||
verse_id: &str,
|
||||
route: &AuthorityRoute,
|
||||
trust: &AuthorityTrustPolicy,
|
||||
now_unix_milliseconds: i64,
|
||||
) -> Result<bool, AuthorityRouteRefusal> {
|
||||
let verified = trust.verify(verse_id, route, now_unix_milliseconds)?;
|
||||
if verified.is_local_development {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(key) = verified.provider_key else {
|
||||
return Ok(false);
|
||||
};
|
||||
if response.provider_key_id != key.key_id
|
||||
|| response.client_nonce != request.client_nonce
|
||||
|| is_blank(&response.provider_signature)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(key.verify(&canonical_session(request, &route.endpoint), &response.provider_signature))
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ use std::time::Duration;
|
|||
use std::time::Instant;
|
||||
|
||||
mod rudp_document_server;
|
||||
pub mod authority;
|
||||
|
||||
pub use rudp_document_server::*;
|
||||
|
||||
|
|
|
|||
261
packages/cultmesh-rs/tests/authority_route_vectors.rs
Normal file
261
packages/cultmesh-rs/tests/authority_route_vectors.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
//! Judges `contracts/cultmesh/authority-route-vectors*.json` in Rust.
|
||||
//!
|
||||
//! `authority-route-vectors.json` is written by the C# reference (its signatures
|
||||
//! are captured C# bytes); the `.ts-signed.json` file is signed by TypeScript
|
||||
//! over awkward inputs. Each negative case fails when the matching check is
|
||||
//! removed from `cultmesh_rs::authority`.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use cultmesh_rs::authority::*;
|
||||
use p256::ecdsa::Signature;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct Vector {
|
||||
now: i64,
|
||||
root: P256PublicKey,
|
||||
verse_id: String,
|
||||
route: AuthorityRoute,
|
||||
request: SessionOpen,
|
||||
accepted: SessionAccepted,
|
||||
trust: AuthorityTrustPolicy,
|
||||
}
|
||||
|
||||
fn s<'a>(value: &'a Value, key: &str) -> &'a str {
|
||||
value[key].as_str().unwrap_or_else(|| panic!("missing string {key}"))
|
||||
}
|
||||
|
||||
fn key(value: &Value) -> P256PublicKey {
|
||||
P256PublicKey::new(s(value, "keyId"), s(value, "x"), s(value, "y")).unwrap()
|
||||
}
|
||||
|
||||
fn load(name: &str) -> Vector {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../contracts/cultmesh")
|
||||
.join(name);
|
||||
let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||
let r = &doc["route"];
|
||||
let c = &r["certificate"];
|
||||
let certificate = RouteCertificate::new(
|
||||
key(&c["providerKey"]),
|
||||
s(c, "odinKeyId"),
|
||||
c["issuedAtUnixMilliseconds"].as_i64().unwrap(),
|
||||
c["expiresAtUnixMilliseconds"].as_i64().unwrap(),
|
||||
s(c, "signature"),
|
||||
)
|
||||
.unwrap();
|
||||
let protocols: Vec<&str> = r["protocolIds"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
let route = AuthorityRoute::new(
|
||||
s(r, "authorityRuntimeId"),
|
||||
s(r, "endpoint"),
|
||||
&protocols,
|
||||
r["priority"].as_i64().unwrap() as i32,
|
||||
Some(s(r, "generation")),
|
||||
Some(certificate),
|
||||
)
|
||||
.unwrap();
|
||||
let p = &doc["sessionProof"];
|
||||
let q = &p["request"];
|
||||
let root = key(&doc["odinRoot"]);
|
||||
Vector {
|
||||
now: doc["nowUnixMilliseconds"].as_i64().unwrap(),
|
||||
verse_id: s(r, "verseId").to_owned(),
|
||||
route,
|
||||
request: SessionOpen {
|
||||
client_nonce: s(q, "clientNonce").into(),
|
||||
message_id: s(q, "messageId").into(),
|
||||
source_runtime_id: s(q, "sourceRuntimeId").into(),
|
||||
verse_id: s(q, "verseId").into(),
|
||||
authority_runtime_id: s(q, "authorityRuntimeId").into(),
|
||||
protocol_id: s(q, "protocolId").into(),
|
||||
route_generation: s(q, "routeGeneration").into(),
|
||||
},
|
||||
accepted: SessionAccepted {
|
||||
client_nonce: s(q, "clientNonce").into(),
|
||||
provider_key_id: s(p, "providerKeyId").into(),
|
||||
provider_signature: s(p, "providerSignature").into(),
|
||||
},
|
||||
trust: AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![root.clone()]).unwrap(),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
impl Vector {
|
||||
fn certificate(&self) -> &RouteCertificate {
|
||||
self.route.certificate.as_ref().unwrap()
|
||||
}
|
||||
fn with_certificate(&self, edit: impl FnOnce(&mut RouteCertificate)) -> AuthorityRoute {
|
||||
let mut route = self.route.clone();
|
||||
edit(route.certificate.as_mut().unwrap());
|
||||
route
|
||||
}
|
||||
fn refusal(&self, route: &AuthorityRoute, now: i64) -> String {
|
||||
let error = self.trust.validate(&self.verse_id, route, now).expect_err("route must be refused");
|
||||
assert_eq!(AuthorityRouteRefusal::REASON, "authentication");
|
||||
assert_eq!(route.endpoint, error.endpoint);
|
||||
error.message
|
||||
}
|
||||
fn proof(&self, request: &SessionOpen, accepted: &SessionAccepted) -> bool {
|
||||
verify_session_proof(request, accepted, &self.verse_id, &self.route, &self.trust, self.now).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
const INVALID: &str = "The Odin route certificate signature is invalid.";
|
||||
|
||||
#[test]
|
||||
fn cs_written_vector_verifies_route_and_session_proof() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
|
||||
assert!(v.proof(&v.request, &v.accepted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_signed_vector_verifies_and_tamper_is_refused() {
|
||||
let v = load("authority-route-vectors.ts-signed.json");
|
||||
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
|
||||
assert!(v.proof(&v.request, &v.accepted));
|
||||
let mut tampered = v.route.clone();
|
||||
tampered.priority += 1;
|
||||
assert_eq!(INVALID, v.refusal(&tampered, v.now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_route_is_length_prefixed_and_sorted() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let bytes = canonical_route(&v.verse_id, &v.route).unwrap();
|
||||
assert!(bytes.starts_with(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"));
|
||||
let needle = b"cultmesh.content.v1\x1fcultmesh.documents.v1";
|
||||
assert!(bytes.windows(needle.len()).any(|w| w == needle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_payload_fields_are_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let edits: Vec<Box<dyn Fn(&mut AuthorityRoute)>> = vec![
|
||||
Box::new(|r| r.priority = 8),
|
||||
Box::new(|r| r.endpoint = "wss://evil.example/mesh".into()),
|
||||
Box::new(|r| r.protocol_ids = vec!["cultmesh.documents.v1".into()]),
|
||||
Box::new(|r| r.generation = "other".into()),
|
||||
Box::new(|r| r.certificate.as_mut().unwrap().expires_at_unix_milliseconds += 1),
|
||||
];
|
||||
for edit in edits {
|
||||
let mut route = v.route.clone();
|
||||
edit(&mut route);
|
||||
assert_eq!(INVALID, v.refusal(&route, v.now));
|
||||
}
|
||||
let error = v.trust.validate("other-verse", &v.route, v.now).unwrap_err();
|
||||
assert_eq!(INVALID, error.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_root_key_is_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
// The provider's key is a real P-256 key that did not sign this route.
|
||||
let provider = &v.certificate().provider_key;
|
||||
let impostor = P256PublicKey::new(&v.root.key_id, &provider.x, &provider.y).unwrap();
|
||||
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![impostor]).unwrap();
|
||||
assert_eq!(INVALID, trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_odin_key_id_is_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let other = P256PublicKey::new("someone-else", &v.root.x, &v.root.y).unwrap();
|
||||
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![other]).unwrap();
|
||||
assert_eq!(
|
||||
"Odin key 'odin-vector' is not trusted by this consumer.",
|
||||
trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_and_not_yet_valid_are_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let window = "The Odin route certificate is not currently valid.";
|
||||
let (issued, expires) = (v.certificate().issued_at_unix_milliseconds, v.certificate().expires_at_unix_milliseconds);
|
||||
v.trust.validate(&v.verse_id, &v.route, expires - 1).unwrap();
|
||||
v.trust.validate(&v.verse_id, &v.route, issued).unwrap();
|
||||
assert_eq!(window, v.refusal(&v.route, expires));
|
||||
assert_eq!(window, v.refusal(&v.route, issued - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_and_unprotected_routes_are_refused() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let unsigned_message = "Remote CultMesh routes require an Odin-signed authority certificate.";
|
||||
let mut unsigned = v.route.clone();
|
||||
unsigned.certificate = None;
|
||||
assert_eq!(unsigned_message, v.refusal(&unsigned, v.now));
|
||||
let blank = v.with_certificate(|c| c.signature = " ".into());
|
||||
assert_eq!(unsigned_message, v.refusal(&blank, v.now));
|
||||
let mut plain = v.route.clone();
|
||||
plain.endpoint = "ws://provider.example/mesh".into();
|
||||
assert_eq!(
|
||||
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
|
||||
v.refusal(&plain, v.now)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_development_admits_only_unsigned_loopback() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let dev = AuthorityTrustPolicy::new(TrustMode::LocalDevelopment, vec![]).unwrap();
|
||||
let route = |endpoint: &str| AuthorityRoute::new("a", endpoint, &[], 0, None, None).unwrap();
|
||||
let loopback = route("ws://127.0.0.1:9/mesh");
|
||||
dev.validate(&v.verse_id, &loopback, v.now).unwrap();
|
||||
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &loopback, &dev, v.now).unwrap());
|
||||
for endpoint in ["ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"] {
|
||||
assert!(dev.validate(&v.verse_id, &route(endpoint), v.now).is_err(), "{endpoint}");
|
||||
}
|
||||
let remote = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![]).unwrap();
|
||||
assert!(remote.validate(&v.verse_id, &loopback, v.now).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_signatures_get_their_own_refusals() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let not_base64 = v.with_certificate(|c| c.signature = "!!not base64!!".into());
|
||||
assert_eq!("The Odin route signature is not base64.", v.refusal(¬_base64, v.now));
|
||||
let short = v.with_certificate(|c| c.signature = STANDARD.encode([b'x'; 63]));
|
||||
assert_eq!("The Odin route signature is not IEEE P1363 P-256.", v.refusal(&short, v.now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_s_signature_verifies_as_it_does_in_dotnet() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let raw = STANDARD.decode(&v.certificate().signature).unwrap();
|
||||
let signature = Signature::from_slice(&raw).unwrap();
|
||||
let flipped = Signature::from_scalars(*signature.r().as_ref(), -*signature.s().as_ref()).unwrap();
|
||||
let high = v.with_certificate(|c| c.signature = STANDARD.encode(flipped.to_bytes()));
|
||||
v.trust.validate(&v.verse_id, &high, v.now).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_proof_rejects_replay_wrong_key_and_forgery() {
|
||||
let v = load("authority-route-vectors.json");
|
||||
let mut replayed = v.request.clone();
|
||||
replayed.client_nonce = "replayed".into();
|
||||
let mut accepted = v.accepted.clone();
|
||||
accepted.client_nonce = "replayed".into();
|
||||
assert!(!v.proof(&replayed, &accepted));
|
||||
let mut wrong_id = v.accepted.clone();
|
||||
wrong_id.provider_key_id = "other".into();
|
||||
assert!(!v.proof(&v.request, &wrong_id));
|
||||
let mut wrong_nonce = v.accepted.clone();
|
||||
wrong_nonce.client_nonce = "other".into();
|
||||
assert!(!v.proof(&v.request, &wrong_nonce));
|
||||
let mut empty = v.accepted.clone();
|
||||
empty.provider_signature = String::new();
|
||||
assert!(!v.proof(&v.request, &empty));
|
||||
let mut raw = STANDARD.decode(&v.accepted.provider_signature).unwrap();
|
||||
raw[0] ^= 1;
|
||||
let mut forged = v.accepted.clone();
|
||||
forged.provider_signature = STANDARD.encode(raw);
|
||||
assert!(!v.proof(&v.request, &forged));
|
||||
let mut moved = v.request.clone();
|
||||
moved.protocol_id = "cultmesh.content.v1".into();
|
||||
assert!(!v.proof(&moved, &v.accepted));
|
||||
let expires = v.certificate().expires_at_unix_milliseconds;
|
||||
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &v.route, &v.trust, expires).is_err());
|
||||
}
|
||||
Loading…
Reference in a new issue