384 lines
13 KiB
Python
384 lines
13 KiB
Python
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass, field, replace
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from typing import Any, Mapping, Protocol
|
|
|
|
CONTRACT_VERSION = "aios.openclaw.command.v2"
|
|
SIGNATURE_ALGORITHM = "Ed25519"
|
|
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _parse_timestamp(value: str) -> datetime:
|
|
normalized = str(value or "").strip()
|
|
if not normalized:
|
|
raise ValueError("timestamp is required")
|
|
if normalized.endswith("Z"):
|
|
normalized = normalized[:-1] + "+00:00"
|
|
parsed = datetime.fromisoformat(normalized)
|
|
if parsed.tzinfo is None:
|
|
raise ValueError("timestamp must include a timezone")
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _required(value: str, label: str) -> str:
|
|
normalized = str(value or "").strip()
|
|
if not normalized:
|
|
raise ValueError(f"{label} is required")
|
|
return normalized
|
|
|
|
|
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
return json.loads(
|
|
json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str)
|
|
)
|
|
|
|
|
|
def _load_crypto() -> tuple[Any, Any, Any]:
|
|
try:
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
Ed25519PrivateKey,
|
|
Ed25519PublicKey,
|
|
)
|
|
except ImportError as exc: # pragma: no cover
|
|
raise RuntimeError("cryptography with Ed25519 support is required") from exc
|
|
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
|
|
|
|
|
class CommandSigner(Protocol):
|
|
key_id: str
|
|
fingerprint: str
|
|
|
|
def sign(self, payload: bytes) -> bytes: ...
|
|
|
|
|
|
class CommandVerifier(Protocol):
|
|
key_id: str
|
|
fingerprint: str
|
|
|
|
def verify(self, payload: bytes, signature: bytes) -> bool: ...
|
|
|
|
|
|
class Ed25519CommandSigner:
|
|
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
|
serialization, private_cls, _ = _load_crypto()
|
|
if not isinstance(private_key, private_cls):
|
|
raise ValueError("private key is not Ed25519")
|
|
self.private_key = private_key
|
|
self.key_id = _required(key_id, "key_id")
|
|
raw = private_key.public_key().public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw,
|
|
)
|
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
|
|
|
@classmethod
|
|
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
|
|
serialization, private_cls, _ = _load_crypto()
|
|
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
|
if raw.startswith(b"-----BEGIN"):
|
|
key = serialization.load_pem_private_key(raw, password=None)
|
|
elif len(raw) == 32:
|
|
key = private_cls.from_private_bytes(raw)
|
|
else:
|
|
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
|
|
return cls(key, key_id=key_id)
|
|
|
|
def sign(self, payload: bytes) -> bytes:
|
|
return self.private_key.sign(bytes(payload))
|
|
|
|
def verifier(self) -> "Ed25519CommandVerifier":
|
|
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
|
|
|
|
|
|
class Ed25519CommandVerifier:
|
|
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
|
serialization, _, public_cls = _load_crypto()
|
|
if not isinstance(public_key, public_cls):
|
|
raise ValueError("public key is not Ed25519")
|
|
self.public_key = public_key
|
|
self.key_id = _required(key_id, "key_id")
|
|
raw = public_key.public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw,
|
|
)
|
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
|
|
|
@classmethod
|
|
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
|
|
serialization, private_cls, public_cls = _load_crypto()
|
|
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
|
if raw.startswith(b"-----BEGIN"):
|
|
try:
|
|
key = serialization.load_pem_public_key(raw)
|
|
except ValueError:
|
|
private = serialization.load_pem_private_key(raw, password=None)
|
|
if not isinstance(private, private_cls):
|
|
raise ValueError("key is not Ed25519")
|
|
key = private.public_key()
|
|
elif len(raw) == 32:
|
|
key = public_cls.from_public_bytes(raw)
|
|
else:
|
|
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
|
|
return cls(key, key_id=key_id)
|
|
|
|
def verify(self, payload: bytes, signature: bytes) -> bool:
|
|
try:
|
|
self.public_key.verify(bytes(signature), bytes(payload))
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
class CommandState(str, Enum):
|
|
ACCEPTED = "ACCEPTED"
|
|
RUNNING = "RUNNING"
|
|
COMPLETED = "COMPLETED"
|
|
FAILED = "FAILED"
|
|
CANCELLED = "CANCELLED"
|
|
TIMED_OUT = "TIMED_OUT"
|
|
REJECTED = "REJECTED"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OpenClawCommand:
|
|
command_id: str
|
|
tenant_id: str
|
|
task_id: str
|
|
trace_id: str
|
|
action: str
|
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
|
idempotency_key: str = ""
|
|
created_at: str = field(default_factory=utc_now)
|
|
expires_at: str | None = None
|
|
contract_version: str = CONTRACT_VERSION
|
|
signature_algorithm: str = SIGNATURE_ALGORITHM
|
|
key_id: str = "default"
|
|
public_key_fingerprint: str = ""
|
|
signature: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
for field_name in (
|
|
"command_id",
|
|
"tenant_id",
|
|
"task_id",
|
|
"trace_id",
|
|
"action",
|
|
"idempotency_key",
|
|
"created_at",
|
|
"contract_version",
|
|
"signature_algorithm",
|
|
"key_id",
|
|
):
|
|
object.__setattr__(
|
|
self, field_name, _required(getattr(self, field_name), field_name)
|
|
)
|
|
if self.contract_version != CONTRACT_VERSION:
|
|
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
|
if self.signature_algorithm != SIGNATURE_ALGORITHM:
|
|
raise ValueError(
|
|
f"unsupported signature_algorithm: {self.signature_algorithm}"
|
|
)
|
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
|
object.__setattr__(
|
|
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
|
|
)
|
|
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
|
_parse_timestamp(self.created_at)
|
|
if self.expires_at is not None:
|
|
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
|
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
|
|
raise ValueError("expires_at must be later than created_at")
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
task_id: str,
|
|
trace_id: str,
|
|
action: str,
|
|
payload: Mapping[str, Any] | None,
|
|
idempotency_key: str,
|
|
expires_at: str | None = None,
|
|
key_id: str = "default",
|
|
command_id: str | None = None,
|
|
) -> "OpenClawCommand":
|
|
return cls(
|
|
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
|
|
tenant_id=tenant_id,
|
|
task_id=task_id,
|
|
trace_id=trace_id,
|
|
action=action,
|
|
payload=_safe_mapping(payload),
|
|
idempotency_key=idempotency_key,
|
|
expires_at=expires_at,
|
|
key_id=key_id,
|
|
)
|
|
|
|
def unsigned_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"command_id": self.command_id,
|
|
"tenant_id": self.tenant_id,
|
|
"task_id": self.task_id,
|
|
"trace_id": self.trace_id,
|
|
"action": self.action,
|
|
"payload": _safe_mapping(self.payload),
|
|
"idempotency_key": self.idempotency_key,
|
|
"created_at": self.created_at,
|
|
"expires_at": self.expires_at,
|
|
"contract_version": self.contract_version,
|
|
"signature_algorithm": self.signature_algorithm,
|
|
"key_id": self.key_id,
|
|
"public_key_fingerprint": self.public_key_fingerprint,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OpenClawResult:
|
|
command_id: str
|
|
tenant_id: str
|
|
task_id: str
|
|
trace_id: str
|
|
state: CommandState
|
|
output: Mapping[str, Any] | None = None
|
|
error: Mapping[str, Any] | None = None
|
|
started_at: str | None = None
|
|
finished_at: str | None = None
|
|
runtime_id: str | None = None
|
|
attempts: int = 1
|
|
|
|
def __post_init__(self) -> None:
|
|
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
|
object.__setattr__(
|
|
self, field_name, _required(getattr(self, field_name), field_name)
|
|
)
|
|
object.__setattr__(self, "state", CommandState(self.state))
|
|
if self.output is not None:
|
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
|
if self.error is not None:
|
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
|
if self.started_at is not None:
|
|
_parse_timestamp(self.started_at)
|
|
if self.finished_at is not None:
|
|
_parse_timestamp(self.finished_at)
|
|
if self.attempts < 1:
|
|
raise ValueError("attempts must be >= 1")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeHealth:
|
|
runtime: str
|
|
runtime_id: str
|
|
status: str
|
|
version: str
|
|
heartbeat_at: str
|
|
active_tasks: int = 0
|
|
workspace_isolated: bool = False
|
|
credential_ref_configured: bool = False
|
|
details: Mapping[str, Any] = field(default_factory=dict)
|
|
|
|
def __post_init__(self) -> None:
|
|
for field_name in (
|
|
"runtime",
|
|
"runtime_id",
|
|
"status",
|
|
"version",
|
|
"heartbeat_at",
|
|
):
|
|
object.__setattr__(
|
|
self, field_name, _required(getattr(self, field_name), field_name)
|
|
)
|
|
_parse_timestamp(self.heartbeat_at)
|
|
if self.active_tasks < 0:
|
|
raise ValueError("active_tasks must be >= 0")
|
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
|
|
|
|
|
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
|
return json.dumps(
|
|
command.unsigned_dict(),
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
|
|
|
|
def _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
|
|
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
|
return value
|
|
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
|
|
|
|
|
|
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
|
|
if isinstance(value, Ed25519CommandSigner):
|
|
return value.verifier()
|
|
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
|
return value
|
|
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
|
|
|
|
|
|
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
|
|
signer = _as_signer(command, signing_key)
|
|
if signer.key_id != command.key_id:
|
|
raise ValueError("command key_id does not match signer")
|
|
prepared = replace(
|
|
command,
|
|
signature_algorithm=SIGNATURE_ALGORITHM,
|
|
public_key_fingerprint=signer.fingerprint,
|
|
signature="",
|
|
)
|
|
signature = base64.b64encode(
|
|
signer.sign(canonical_command_bytes(prepared))
|
|
).decode("ascii")
|
|
return replace(prepared, signature=signature)
|
|
|
|
|
|
def verify_command_signature(
|
|
command: OpenClawCommand,
|
|
verification_key: Any,
|
|
*,
|
|
now: datetime | None = None,
|
|
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
|
) -> bool:
|
|
if (
|
|
not command.signature
|
|
or command.contract_version != CONTRACT_VERSION
|
|
or command.signature_algorithm != SIGNATURE_ALGORITHM
|
|
):
|
|
return False
|
|
try:
|
|
verifier = _as_verifier(command, verification_key)
|
|
signature = base64.b64decode(command.signature, validate=True)
|
|
except Exception:
|
|
return False
|
|
if verifier.key_id != command.key_id:
|
|
return False
|
|
if not command.public_key_fingerprint or not hmac.compare_digest(
|
|
command.public_key_fingerprint, verifier.fingerprint
|
|
):
|
|
return False
|
|
if not verifier.verify(canonical_command_bytes(command), signature):
|
|
return False
|
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
|
created = _parse_timestamp(command.created_at)
|
|
if created.timestamp() - current.timestamp() > clock_skew_seconds:
|
|
return False
|
|
if command.expires_at is not None:
|
|
expires = _parse_timestamp(command.expires_at)
|
|
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
|
|
return False
|
|
return True
|