已合并
feat(ws-channel): WSServiceMessageChannel 支持链路握手鉴权(向后兼容) #291
Wal1et创建于 5月27日
feat(ws-channel): WSServiceMessageChannel 支持链路握手鉴权(向后兼容) #291
已合并
共 21 个文件变更+1013-36
| @@ -1,4 +1,13 @@ | |||
| 1 | -介绍 Runtime Management 的安全 SDK(`openjiuwen_runtime.management.security`):以通用接口提供**加解密**、**加签验签**、**证书/密钥管理**三块能力,供节点管理、配置下发等场景按需引入。实现可插拔(默认 Ed25519 + X25519/AES-256-GCM),调用方只依赖接口。 | 1 | +介绍 Runtime 的安全 SDK:以通用接口提供**加解密**、**加签验签**、**证书/密钥管理**、**控制链路握手鉴权**四块能力,供节点管理、配置下发、链路握手等场景按需引入。实现可插拔(默认 Ed25519 + X25519/AES-256-GCM),调用方只依赖接口。 |
| 2 | + | ||
| 3 | +## 分层落点(foundation / management) | ||
| 4 | + | ||
| 5 | +| 层 | 模块 | 内容 | 依赖 | | ||
| 6 | +| --- | --- | --- | --- | | ||
| 7 | +| **foundation** | `openjiuwen_runtime.foundation.security` | 密码学原语、加解密、加签验签、**link-auth 握手鉴权**(含进程内 TOFU) | 仅 `cryptography` | | ||
| 8 | +| **management** | `openjiuwen_runtime.management.security` | **落库** 的 `CertificateManager` + 落库显式绑定 `CertificatePinStore`;并 re-export foundation 全部符号(历史导入路径兼容) | foundation + `DBHandler` | | ||
| 9 | + | ||
| 10 | +纯能力放 foundation,是为了让**进程级**组件(如独立 AgentServer)零控制面依赖即可使用;只有需要落库密钥管理时才依赖 management。历史代码 `from openjiuwen_runtime.management.security import ...` 继续可用。 | ||
| 2 | 11 | ||
| 3 | # 概述 | 12 | # 概述 |
| 4 | 13 | ||
| @@ -8,6 +17,7 @@ | |||
| 8 | | 加签 | Ed25519 | `ISigner` | | 17 | | 加签 | Ed25519 | `ISigner` | |
| 9 | | 验签 | Ed25519 | `IVerifier` | | 18 | | 验签 | Ed25519 | `IVerifier` | |
| 10 | | 证书/密钥管理 | 基于 foundation `DBHandler` 落库 | `ICertificateManager` | | 19 | | 证书/密钥管理 | 基于 foundation `DBHandler` 落库 | `ICertificateManager` | |
| 20 | +| 控制链路握手鉴权 | Ed25519 一次性令牌 + nonce 防重放 + 指纹固定 | `IPinStore`(指纹存储) | | ||
| 11 | 21 | ||
| 12 | 设计要点: | 22 | 设计要点: |
| 13 | 23 | ||
| @@ -189,13 +199,50 @@ gw_enc_priv = (await client_cm.get_or_create_keypair(KeyPurpose.ENCRYPT)).privat | |||
| 189 | plaintext = crypto.open(gw_enc_priv, sealed) | 199 | plaintext = crypto.open(gw_enc_priv, sealed) |
| 190 | ``` | 200 | ``` |
| 191 | 201 | ||
| 202 | +# 控制链路握手鉴权(link-auth) | ||
| 203 | + | ||
| 204 | +为两条 WebSocket 控制链路(Manager ↔ Gateway、Gateway ↔ AgentServer)提供握手期**双向身份鉴权**:连接发起方在握手头出示一枚 Ed25519 **一次性令牌**(载荷含签发者身份/类型/签发时间/nonce/签发者公钥,由其私钥签名),接收方用令牌内嵌公钥验签 → 校验有效期/类型 → nonce 防重放 → **指纹固定**比对,确认对端身份未被冒充。与加签验签复用同一套 Ed25519 原语,但作用在握手挑战上而非配置帧。 | ||
| 205 | + | ||
| 206 | +**三档开关 `CLAW_LINK_AUTH_MODE`**:`off`(默认,零行为变更)/ `observe`(验签记日志但放行,灰度)/ `enforce`(验不过即拒)。令牌有效期 `CLAW_LINK_TOKEN_TTL`(秒,默认 300)。 | ||
| 207 | + | ||
| 208 | +**指纹固定的两种后端(由集成方按链路选择:构造哪种 store + 调对应的验证入口)**: | ||
| 209 | + | ||
| 210 | +| 后端 | 类 / 入口 | 适用 | 语义 | | ||
| 211 | +| --- | --- | --- | --- | | ||
| 212 | +| 进程内 TOFU | `InMemoryPinStore` + `verify_and_pin`(同步,foundation) | 进程级、无持久 DB 的端(如独立 AgentServer) | 首次见到即记录指纹,进程重启后重新 TOFU | | ||
| 213 | +| 落库显式绑定 | `CertificatePinStore` + `verify_and_bind`(异步,management) | 有 DB 的持久端(Manager / Gateway) | 对端公钥按 `(peer_id, sign)` 落库 `bound`,可轮换/解绑,与配置下发同一套密钥管理 | | ||
| 214 | + | ||
| 215 | +```python | ||
| 216 | +# 发起方(持本端密钥对):握手头带令牌 | ||
| 217 | +from openjiuwen_runtime.foundation.security import build_token_header, generate_keypair | ||
| 218 | +priv, pub = generate_keypair() | ||
| 219 | +headers = build_token_header(service_id="gateway-1", service_type="gateway", | ||
| 220 | + private_b64=priv, public_b64=pub) # off 时返回 {} | ||
| 221 | + | ||
| 222 | +# 接收方 A:进程内 TOFU(同步) | ||
| 223 | +from openjiuwen_runtime.foundation.security import InMemoryPinStore, NonceCache, verify_and_pin | ||
| 224 | +res = verify_and_pin(InMemoryPinStore(), token, expect_type="gateway", nonce_cache=NonceCache()) | ||
| 225 | +if not res.allowed: | ||
| 226 | + ... # enforce 下拒绝连接 | ||
| 227 | + | ||
| 228 | +# 接收方 B:落库显式绑定(异步,需 CertificateManager) | ||
| 229 | +from openjiuwen_runtime.management.security import CertificatePinStore, verify_and_bind | ||
| 230 | +store = CertificatePinStore(cert_manager) # purpose 默认 sign | ||
| 231 | +res = await verify_and_bind(store, token, expect_type="gateway", nonce_cache=NonceCache()) | ||
| 232 | +``` | ||
| 233 | + | ||
| 234 | +> 信任引导(首次见到的公钥是否可信)与配置下发一致,暂由 wss/TLS、指纹预共享或 TOFU 兜底;显式绑定解决的是绑定关系的持久化与生命周期(轮换/解绑),不替代首信任。 | ||
| 235 | + | ||
| 192 | # 相关代码 | 236 | # 相关代码 |
| 193 | 237 | ||
| 194 | | 模块 | 路径 | | 238 | | 模块 | 路径 | |
| 195 | | --- | --- | | 239 | | --- | --- | |
| 196 | -| 通用接口 | `openjiuwen_runtime/management/security/interfaces.py` | | 240 | +| 通用接口 | `openjiuwen_runtime/foundation/security/interfaces.py` | |
| 197 | -| 加解密实现 | `.../security/crypto.py` | | 241 | +| 密码学原语 | `.../foundation/security/_primitives.py` | |
| 198 | -| 加签验签实现 | `.../security/signing.py` | | 242 | +| 加解密实现 | `.../foundation/security/crypto.py` | |
| 199 | -| 证书/密钥管理 | `.../security/certificate.py` | | 243 | +| 加签验签实现 | `.../foundation/security/signing.py` | |
| 200 | -| 数据模型与表定义 | `.../security/models.py` | | 244 | +| 数据模型与表定义 | `.../foundation/security/models.py` | |
| 201 | -| 单元测试 | `management/tests/unit_tests/management_security/` | | 245 | +| 链路握手鉴权(含进程内 TOFU) | `.../foundation/security/link_auth.py` | |
| 246 | +| 证书/密钥管理(落库) | `openjiuwen_runtime/management/security/certificate.py` | | ||
| 247 | +| 落库显式绑定指纹固定 | `.../management/security/certificate_pin_store.py` | | ||
| 248 | +| 单元测试 | `foundation/tests/unit_tests/test_link_auth.py`、`management/tests/unit_tests/management_security/` | | ||
| @@ -0,0 +1,100 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""OpenJiuwen Runtime 基础安全能力(foundation 层,谁都可轻量依赖)。 | ||
| 5 | + | ||
| 6 | +放在 foundation 而非 management,是为了让**进程级**组件(如独立 AgentServer)也能 | ||
| 7 | +零控制面依赖地使用: | ||
| 8 | + | ||
| 9 | +- 密码学原语:Ed25519(签验)、X25519+HKDF 信封、AES-256-GCM(``_primitives``)。 | ||
| 10 | +- 加解密 / 加签验签:``EnvelopeCryptoProvider`` / ``Ed25519Signer`` / ``Ed25519Verifier``。 | ||
| 11 | +- 控制链路握手鉴权:``link_auth``(一次性令牌 + nonce 防重放 + 指纹固定, | ||
| 12 | + off/observe/enforce 三档开关)。 | ||
| 13 | + | ||
| 14 | +DB 落库的密钥/证书管理(``CertificateManager``)在 management 层 | ||
| 15 | +(``openjiuwen_runtime.management.security``),需要 ``DBHandler``。 | ||
| 16 | +""" | ||
| 17 | + | ||
| 18 | +from .interfaces import ( | ||
| 19 | + ICertificateManager, | ||
| 20 | + ICryptoProvider, | ||
| 21 | + ISigner, | ||
| 22 | + IVerifier, | ||
| 23 | +) | ||
| 24 | +from .models import ( | ||
| 25 | + DEK_ALGORITHM, | ||
| 26 | + EncAlgorithm, | ||
| 27 | + KeyPair, | ||
| 28 | + KeyPurpose, | ||
| 29 | + PeerKey, | ||
| 30 | + SealedMessage, | ||
| 31 | + SECURITY_LOCAL_KEY_TABLE_DEF, | ||
| 32 | + SECURITY_PEER_KEY_TABLE_DEF, | ||
| 33 | + SignAlgorithm, | ||
| 34 | +) | ||
| 35 | +from .crypto import EnvelopeCryptoProvider | ||
| 36 | +from .signing import Ed25519Signer, Ed25519Verifier, make_signer, make_verifier | ||
| 37 | +from .link_auth import ( | ||
| 38 | + AuthMode, | ||
| 39 | + Claims, | ||
| 40 | + InMemoryPinStore, | ||
| 41 | + IPinStore, | ||
| 42 | + LINK_TOKEN_HEADER, | ||
| 43 | + LinkAuthError, | ||
| 44 | + NonceCache, | ||
| 45 | + VerifyResult, | ||
| 46 | + build_token, | ||
| 47 | + build_token_header, | ||
| 48 | + generate_keypair, | ||
| 49 | + get_auth_mode, | ||
| 50 | + get_ttl, | ||
| 51 | + fingerprint, | ||
| 52 | + sign_token, | ||
| 53 | + verify_and_pin, | ||
| 54 | + verify_signature, | ||
| 55 | + verify_token, | ||
| 56 | +) | ||
| 57 | + | ||
| 58 | +__all__ = ( | ||
| 59 | + # interfaces | ||
| 60 | + "ICryptoProvider", | ||
| 61 | + "ISigner", | ||
| 62 | + "IVerifier", | ||
| 63 | + "ICertificateManager", | ||
| 64 | + # models | ||
| 65 | + "KeyPurpose", | ||
| 66 | + "SignAlgorithm", | ||
| 67 | + "EncAlgorithm", | ||
| 68 | + "DEK_ALGORITHM", | ||
| 69 | + "KeyPair", | ||
| 70 | + "PeerKey", | ||
| 71 | + "SealedMessage", | ||
| 72 | + "SECURITY_LOCAL_KEY_TABLE_DEF", | ||
| 73 | + "SECURITY_PEER_KEY_TABLE_DEF", | ||
| 74 | + # crypto | ||
| 75 | + "EnvelopeCryptoProvider", | ||
| 76 | + # signing | ||
| 77 | + "Ed25519Signer", | ||
| 78 | + "Ed25519Verifier", | ||
| 79 | + "make_signer", | ||
| 80 | + "make_verifier", | ||
| 81 | + # link-auth handshake | ||
| 82 | + "LINK_TOKEN_HEADER", | ||
| 83 | + "AuthMode", | ||
| 84 | + "LinkAuthError", | ||
| 85 | + "Claims", | ||
| 86 | + "VerifyResult", | ||
| 87 | + "NonceCache", | ||
| 88 | + "IPinStore", | ||
| 89 | + "InMemoryPinStore", | ||
| 90 | + "get_auth_mode", | ||
| 91 | + "get_ttl", | ||
| 92 | + "generate_keypair", | ||
| 93 | + "fingerprint", | ||
| 94 | + "sign_token", | ||
| 95 | + "verify_signature", | ||
| 96 | + "verify_token", | ||
| 97 | + "verify_and_pin", | ||
| 98 | + "build_token", | ||
| 99 | + "build_token_header", | ||
| 100 | +) | ||
Rmanagement/openjiuwen_runtime/management/security/_primitives.py→foundation/openjiuwen_runtime/foundation/security/_primitives.py+0-0
文件重命名但无更改。
Rmanagement/openjiuwen_runtime/management/security/crypto.py→foundation/openjiuwen_runtime/foundation/security/crypto.py+0-0
文件重命名但无更改。
Rmanagement/openjiuwen_runtime/management/security/interfaces.py→foundation/openjiuwen_runtime/foundation/security/interfaces.py+0-0
文件重命名但无更改。
| @@ -0,0 +1,402 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""控制链路握手鉴权 —— 非对称(Ed25519)令牌签发与验证。 | ||
| 5 | + | ||
| 6 | +供两条 WebSocket 控制链路共用:Claw Manager ↔ Gateway、Gateway ↔ AgentServer。 | ||
| 7 | + | ||
| 8 | +模型 | ||
| 9 | +---- | ||
| 10 | +- **身份密钥对**:每个端各持一对 Ed25519 密钥,私钥永不出端、仅用于签名;公钥可公开, | ||
| 11 | + 握手时随令牌出示给对端。无任何预共享秘密。 | ||
| 12 | +- **链路令牌(CLT)**:握手期出示的一次性身份证明,载荷含签发者身份、类型、签发时间、 | ||
| 13 | + 随机数(nonce) 与**签发者公钥**,由签发者私钥签名。验证方用令牌内嵌的公钥验签,确认 | ||
| 14 | + 「持令牌者确实握有该公钥对应的私钥」。 | ||
| 15 | +- **信任固定(指纹绑定)**:验证方记录某身份的公钥指纹;之后每次握手都比对,不一致即拒。 | ||
| 16 | + 指纹存储经 :class:`IPinStore` 抽象:进程级端用 :class:`InMemoryPinStore`(TOFU); | ||
| 17 | + 持久端用 management 层的 ``CertificatePinStore``(落库显式绑定)。 | ||
| 18 | + | ||
| 19 | +密码学原语全部复用同层 :mod:`._primitives`(Ed25519/指纹/base64),本模块只负责令牌 | ||
| 20 | +信封、有效期/类型、nonce 防重放与指纹固定的编排。 | ||
| 21 | + | ||
| 22 | +开关 ``CLAW_LINK_AUTH_MODE`` | ||
| 23 | +--------------------------- | ||
| 24 | +- ``off``(默认):完全不鉴权,行为与未引入本模块时一致。 | ||
| 25 | +- ``observe``:照常验签并记日志,但不拒绝(灰度观察)。 | ||
| 26 | +- ``enforce``:验不过即拒。 | ||
| 27 | +""" | ||
| 28 | + | ||
| 29 | +from __future__ import annotations | ||
| 30 | + | ||
| 31 | +import base64 | ||
| 32 | +import enum | ||
| 33 | +import json | ||
| 34 | +import logging | ||
| 35 | +import os | ||
| 36 | +import secrets | ||
| 37 | +import time | ||
| 38 | +from dataclasses import dataclass | ||
| 39 | +from typing import Optional, Protocol, runtime_checkable | ||
| 40 | + | ||
| 41 | +from . import _primitives as _p | ||
| 42 | + | ||
| 43 | +logger = logging.getLogger(__name__) | ||
| 44 | + | ||
| 45 | +# 握手期携带令牌的 HTTP 头名(自定义头,避免与业务 Authorization 语义相撞)。 | ||
| 46 | +LINK_TOKEN_HEADER = "X-Claw-Link-Token" | ||
| 47 | + | ||
| 48 | +# 令牌默认有效期(秒);可用 CLAW_LINK_TOKEN_TTL 覆盖。 | ||
| 49 | +_DEFAULT_TTL = 300 | ||
| 50 | +# 允许的时钟前偏(秒):各端无 NTP 同步时,签发时间可能略超本机 now。 | ||
| 51 | +_CLOCK_SKEW = 60 | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +class LinkAuthError(Exception): | ||
| 55 | + """令牌格式非法、验签失败或指纹不匹配。""" | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +class AuthMode(str, enum.Enum): | ||
| 59 | + OFF = "off" | ||
| 60 | + OBSERVE = "observe" | ||
| 61 | + ENFORCE = "enforce" | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def get_auth_mode() -> AuthMode: | ||
| 65 | + """读取 ``CLAW_LINK_AUTH_MODE``,默认 ``off``(无法识别的值一律按 off,fail-safe)。""" | ||
| 66 | + raw = os.getenv("CLAW_LINK_AUTH_MODE", "").strip().lower() | ||
| 67 | + if raw == AuthMode.ENFORCE.value: | ||
| 68 | + return AuthMode.ENFORCE | ||
| 69 | + if raw == AuthMode.OBSERVE.value: | ||
| 70 | + return AuthMode.OBSERVE | ||
| 71 | + return AuthMode.OFF | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def get_ttl() -> int: | ||
| 75 | + raw = os.getenv("CLAW_LINK_TOKEN_TTL", "").strip() | ||
| 76 | + if raw: | ||
| 77 | + try: | ||
| 78 | + val = int(raw) | ||
| 79 | + if val > 0: | ||
| 80 | + return val | ||
| 81 | + except ValueError: | ||
| 82 | + logger.warning("[link_auth] invalid CLAW_LINK_TOKEN_TTL=%r, using default", raw) | ||
| 83 | + return _DEFAULT_TTL | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +# --------------------------------------------------------------------------- | ||
| 87 | +# Ed25519 密钥与指纹(薄封装,复用 _primitives;对外保持 base64 字符串接口) | ||
| 88 | +# --------------------------------------------------------------------------- | ||
| 89 | + | ||
| 90 | +def generate_keypair() -> tuple[str, str]: | ||
| 91 | + """生成一对 Ed25519 密钥,返回 ``(private_b64, public_b64)``(32 字节 Raw 的 base64)。""" | ||
| 92 | + priv, pub = _p.ed25519_generate() | ||
| 93 | + return _p.b64e(priv), _p.b64e(pub) | ||
| 94 | + | ||
| 95 | + | ||
| 96 | +def fingerprint(public_b64: str) -> str: | ||
| 97 | + """公钥指纹:Raw 公钥的 SHA-256(hex),用于记录与比对。""" | ||
| 98 | + return _p.fingerprint(_p.b64d(public_b64)) | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +def _b64url_encode(data: bytes) -> str: | ||
| 102 | + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") | ||
| 103 | + | ||
| 104 | + | ||
| 105 | +def _b64url_decode(text: str) -> bytes: | ||
| 106 | + pad = "=" * (-len(text) % 4) | ||
| 107 | + return base64.urlsafe_b64decode(text + pad) | ||
| 108 | + | ||
| 109 | + | ||
| 110 | +def new_nonce() -> str: | ||
| 111 | + return secrets.token_urlsafe(12) | ||
| 112 | + | ||
| 113 | + | ||
| 114 | +# --------------------------------------------------------------------------- | ||
| 115 | +# 令牌签发 / 验证(纯密码学,不依赖环境) | ||
| 116 | +# --------------------------------------------------------------------------- | ||
| 117 | + | ||
| 118 | + | ||
| 119 | +class Claims: | ||
| 120 | + """令牌声明:签发者身份(iss)、类型(typ)、签发时间(iat)、随机数(nonce)、签发者公钥(pub)。""" | ||
| 121 | + | ||
| 122 | + iss: str | ||
| 123 | + typ: str | ||
| 124 | + iat: int | ||
| 125 | + nonce: str | ||
| 126 | + pub: str # 签发者 Ed25519 公钥(base64),用于验签与指纹固定 | ||
| 127 | + | ||
| 128 | + | ||
| 129 | +def _canonical(payload: dict) -> str: | ||
| 130 | + return json.dumps(payload, sort_keys=True, separators=(",", ":")) | ||
| 131 | + | ||
| 132 | + | ||
| 133 | +def sign_token( | ||
| 134 | + *, service_id: str, service_type: str, private_b64: str, public_b64: str | ||
| 135 | +) -> str: | ||
| 136 | + """用私钥对声明签名,返回令牌 ``<payload_b64url>.<sig_b64url>``。""" | ||
| 137 | + payload = { | ||
| 138 | + "iss": service_id, | ||
| 139 | + "typ": service_type, | ||
| 140 | + "iat": int(time.time()), | ||
| 141 | + "nonce": new_nonce(), | ||
| 142 | + "pub": public_b64, | ||
| 143 | + } | ||
| 144 | + payload_b64 = _b64url_encode(_canonical(payload).encode("utf-8")) | ||
| 145 | + sig = _p.ed25519_sign(_p.b64d(private_b64), payload_b64.encode("ascii")) | ||
| 146 | + return f"{payload_b64}.{_b64url_encode(sig)}" | ||
| 147 | + | ||
| 148 | + | ||
| 149 | +def verify_signature( | ||
| 150 | + token: str, | ||
| 151 | + *, | ||
| 152 | + ttl: int | None = None, | ||
| 153 | + expect_type: str | None = None, | ||
| 154 | + now: int | None = None, | ||
| 155 | +) -> Claims: | ||
| 156 | + """验签并解析令牌;失败抛 :class:`LinkAuthError`。 | ||
| 157 | + | ||
| 158 | + 用令牌**内嵌的公钥**验签(证明持令牌者握有对应私钥),并校验有效期/类型。 | ||
| 159 | + 不做指纹固定与 nonce 防重放(由 :func:`verify_token` 处理)。 | ||
| 160 | + """ | ||
| 161 | + if not token or "." not in token: | ||
| 162 | + raise LinkAuthError("malformed token") | ||
| 163 | + payload_b64, _, sig_b64 = token.partition(".") | ||
| 164 | + try: | ||
| 165 | + payload = json.loads(_b64url_decode(payload_b64)) | ||
| 166 | + pub = str(payload["pub"]) | ||
| 167 | + sig = _b64url_decode(sig_b64) | ||
| 168 | + except (ValueError, TypeError, KeyError) as exc: | ||
| 169 | + raise LinkAuthError(f"bad token payload: {exc}") from exc | ||
| 170 | + | ||
| 171 | + # 用内嵌公钥验签:证明持令牌者握有该公钥对应的私钥。 | ||
| 172 | + if not _p.ed25519_verify(_p.b64d(pub), payload_b64.encode("ascii"), sig): | ||
| 173 | + raise LinkAuthError("bad signature") | ||
| 174 | + | ||
| 175 | + try: | ||
| 176 | + claims = Claims( | ||
| 177 | + iss=str(payload["iss"]), | ||
| 178 | + typ=str(payload["typ"]), | ||
| 179 | + iat=int(payload["iat"]), | ||
| 180 | + nonce=str(payload["nonce"]), | ||
| 181 | + pub=pub, | ||
| 182 | + ) | ||
| 183 | + except (KeyError, TypeError, ValueError) as exc: | ||
| 184 | + raise LinkAuthError(f"missing/invalid claims: {exc}") from exc | ||
| 185 | + | ||
| 186 | + now = int(time.time()) if now is None else now | ||
| 187 | + ttl = get_ttl() if ttl is None else ttl | ||
| 188 | + if claims.iat > now + _CLOCK_SKEW: | ||
| 189 | + raise LinkAuthError("token issued in the future") | ||
| 190 | + if claims.iat < now - ttl: | ||
| 191 | + raise LinkAuthError("token expired") | ||
| 192 | + if expect_type is not None and claims.typ != expect_type: | ||
| 193 | + raise LinkAuthError(f"unexpected service_type {claims.typ!r}, want {expect_type!r}") | ||
| 194 | + return claims | ||
| 195 | + | ||
| 196 | + | ||
| 197 | +class NonceCache: | ||
| 198 | + """进程内、带 TTL 的 nonce 缓存,用于防重放。 | ||
| 199 | + | ||
| 200 | + 仅进程内有效;多进程/多端下各端各自一份即可(合法对端每次握手都用新 nonce)。 | ||
| 201 | + """ | ||
| 202 | + | ||
| 203 | + def __init__(self, ttl: int | None = None) -> None: | ||
| 204 | + self._ttl = ttl | ||
| 205 | + self._seen: dict[str, float] = {} | ||
| 206 | + | ||
| 207 | + def check_and_add(self, nonce: str, *, now: float | None = None) -> bool: | ||
| 208 | + """nonce 未见过则记录并返回 True;已见过(重放)返回 False。""" | ||
| 209 | + now = time.time() if now is None else now | ||
| 210 | + ttl = get_ttl() if self._ttl is None else self._ttl | ||
| 211 | + if self._seen: | ||
| 212 | + expired = [n for n, exp in self._seen.items() if exp <= now] | ||
| 213 | + for n in expired: | ||
| 214 | + self._seen.pop(n, None) | ||
| 215 | + if nonce in self._seen: | ||
| 216 | + return False | ||
| 217 | + self._seen[nonce] = now + ttl + _CLOCK_SKEW | ||
| 218 | + return True | ||
| 219 | + | ||
| 220 | + | ||
| 221 | +# --------------------------------------------------------------------------- | ||
| 222 | +# 指纹固定存储抽象:进程内 TOFU / 落库显式绑定(management.CertificatePinStore) | ||
| 223 | +# --------------------------------------------------------------------------- | ||
| 224 | + | ||
| 225 | + | ||
| 226 | +class IPinStore(Protocol): | ||
| 227 | + """同步指纹固定存储接口(``iss -> fingerprint``)。 | ||
| 228 | + | ||
| 229 | + 落库的 :class:`CertificateManager` 适配器(management 层,异步)走 | ||
| 230 | + :func:`verify_and_bind`;本同步接口用于进程内 TOFU。 | ||
| 231 | + """ | ||
| 232 | + | ||
| 233 | + def pinned(self, iss: str) -> Optional[str]: | ||
| 234 | + ... | ||
| 235 | + | ||
| 236 | + def remember(self, iss: str, fp: str) -> None: | ||
| 237 | + ... | ||
| 238 | + | ||
| 239 | + | ||
| 240 | +class InMemoryPinStore: | ||
| 241 | + """进程内 TOFU 指纹固定表(``iss -> fingerprint``)。 | ||
| 242 | + | ||
| 243 | + 首次见到某身份即记录其公钥指纹;之后比对,不一致即视为冒充。仅进程内有效—— | ||
| 244 | + 身份密钥持久化保证对端指纹稳定,本端进程重启后对各对端重新 TOFU(在「首次握手可信」 | ||
| 245 | + 的前提下可接受)。 | ||
| 246 | + """ | ||
| 247 | + | ||
| 248 | + def __init__(self) -> None: | ||
| 249 | + self._pins: dict[str, str] = {} | ||
| 250 | + | ||
| 251 | + def pinned(self, iss: str) -> Optional[str]: | ||
| 252 | + return self._pins.get(iss) | ||
| 253 | + | ||
| 254 | + def remember(self, iss: str, fp: str) -> None: | ||
| 255 | + self._pins[iss] = fp | ||
| 256 | + | ||
| 257 | + | ||
| 258 | +# --------------------------------------------------------------------------- | ||
| 259 | +# 集成入口:建令牌(持私钥端)/ 验令牌(对端,含指纹固定) | ||
| 260 | +# --------------------------------------------------------------------------- | ||
| 261 | + | ||
| 262 | + | ||
| 263 | +class VerifyResult: | ||
| 264 | + """验令牌结论。 | ||
| 265 | + | ||
| 266 | + - ``allowed``:是否放行(off / observe 恒为 True)。 | ||
| 267 | + - ``ok``:验证是否真正通过(不受 mode 影响,供日志/观察)。 | ||
| 268 | + - ``reason``:原因,便于排查。 | ||
| 269 | + - ``mode``:当时开关状态。 | ||
| 270 | + - ``peer_fp``:对端公钥指纹(验签通过时给出,供指纹记录/比对)。 | ||
| 271 | + - ``claims``:解析出的声明。 | ||
| 272 | + """ | ||
| 273 | + | ||
| 274 | + allowed: bool | ||
| 275 | + ok: bool | ||
| 276 | + reason: str | ||
| 277 | + mode: AuthMode | ||
| 278 | + peer_fp: str | None = None | ||
| 279 | + claims: Claims | None = None | ||
| 280 | + | ||
| 281 | + | ||
| 282 | +def build_token( | ||
| 283 | + *, service_id: str, service_type: str, private_b64: str | None, public_b64: str | None | ||
| 284 | +) -> str | None: | ||
| 285 | + """现签一枚裸令牌(放进握手头或 connection.ack 帧字段)。 | ||
| 286 | + | ||
| 287 | + - mode=off:返回 ``None``(不签,零行为变更)。 | ||
| 288 | + - 无身份密钥:返回 ``None`` 并告警(对端若 enforce 会因此拒,属预期 fail-closed)。 | ||
| 289 | + 每次调用都用新 nonce/新签发时间,避免重连复用被 nonce/有效期拦截。 | ||
| 290 | + """ | ||
| 291 | + if get_auth_mode() is AuthMode.OFF: | ||
| 292 | + return None | ||
| 293 | + if not private_b64 or not public_b64: | ||
| 294 | + logger.warning("[link_auth] mode!=off but no identity keypair; signing no token") | ||
| 295 | + return None | ||
| 296 | + return sign_token( | ||
| 297 | + service_id=service_id, | ||
| 298 | + service_type=service_type, | ||
| 299 | + private_b64=private_b64, | ||
| 300 | + public_b64=public_b64, | ||
| 301 | + ) | ||
| 302 | + | ||
| 303 | + | ||
| 304 | +def build_token_header( | ||
| 305 | + *, service_id: str, service_type: str, private_b64: str | None, public_b64: str | None | ||
| 306 | +) -> dict[str, str]: | ||
| 307 | + """连接发起方调用:返回附加到 WS 握手的头(off / 无密钥时返回 ``{}``)。""" | ||
| 308 | + tok = build_token( | ||
| 309 | + service_id=service_id, | ||
| 310 | + service_type=service_type, | ||
| 311 | + private_b64=private_b64, | ||
| 312 | + public_b64=public_b64, | ||
| 313 | + ) | ||
| 314 | + return {LINK_TOKEN_HEADER: tok} if tok else {} | ||
| 315 | + | ||
| 316 | + | ||
| 317 | +def verify_token( | ||
| 318 | + token: str | None, | ||
| 319 | + *, | ||
| 320 | + expect_type: str | None = None, | ||
| 321 | + pinned_fp: str | None = None, | ||
| 322 | + nonce_cache: NonceCache | None = None, | ||
| 323 | +) -> VerifyResult: | ||
| 324 | + """接收方调用:验证一枚令牌(握手头值或 connection.ack 帧字段)。 | ||
| 325 | + | ||
| 326 | + 依次:验签(内嵌公钥)→ 有效期/类型 → nonce 防重放 →(若传入 ``pinned_fp``)指纹固定比对。 | ||
| 327 | + | ||
| 328 | + off → 直接放行;observe → 验证并记日志但放行;enforce → 验不过即不放行。 | ||
| 329 | + 多数集成方应改用 :func:`verify_and_pin`(自动 TOFU 记录 + 比对)。 | ||
| 330 | + """ | ||
| 331 | + mode = get_auth_mode() | ||
| 332 | + if mode is AuthMode.OFF: | ||
| 333 | + return VerifyResult(allowed=True, ok=True, reason="auth disabled (mode=off)", mode=mode) | ||
| 334 | + | ||
| 335 | + if not token: | ||
| 336 | + allowed = mode is AuthMode.OBSERVE | ||
| 337 | + reason = f"missing {LINK_TOKEN_HEADER}" | ||
| 338 | + logger.warning("[link_auth] %s allowed=%s: %s", mode.value, allowed, reason) | ||
| 339 | + return VerifyResult(allowed=allowed, ok=False, reason=reason, mode=mode) | ||
| 340 | + | ||
| 341 | + try: | ||
| 342 | + claims = verify_signature(token, expect_type=expect_type) | ||
| 343 | + except LinkAuthError as exc: | ||
| 344 | + allowed = mode is AuthMode.OBSERVE | ||
| 345 | + logger.warning("[link_auth] %s allowed=%s: verify failed: %s", mode.value, allowed, exc) | ||
| 346 | + return VerifyResult(allowed=allowed, ok=False, reason=str(exc), mode=mode) | ||
| 347 | + | ||
| 348 | + peer_fp = fingerprint(claims.pub) | ||
| 349 | + | ||
| 350 | + if nonce_cache is not None and not nonce_cache.check_and_add(claims.nonce): | ||
| 351 | + allowed = mode is AuthMode.OBSERVE | ||
| 352 | + logger.warning("[link_auth] %s allowed=%s: nonce replay iss=%s", mode.value, allowed, claims.iss) | ||
| 353 | + return VerifyResult( | ||
| 354 | + allowed=allowed, ok=False, reason="nonce replay", mode=mode, peer_fp=peer_fp, claims=claims | ||
| 355 | + ) | ||
| 356 | + | ||
| 357 | + if pinned_fp is not None and pinned_fp != peer_fp: | ||
| 358 | + allowed = mode is AuthMode.OBSERVE | ||
| 359 | + logger.warning( | ||
| 360 | + "[link_auth] %s allowed=%s: fingerprint mismatch iss=%s (possible impersonation)", | ||
| 361 | + mode.value, allowed, claims.iss, | ||
| 362 | + ) | ||
| 363 | + return VerifyResult( | ||
| 364 | + allowed=allowed, ok=False, reason="fingerprint mismatch", mode=mode, peer_fp=peer_fp, claims=claims | ||
| 365 | + ) | ||
| 366 | + | ||
| 367 | + return VerifyResult(allowed=True, ok=True, reason="ok", mode=mode, peer_fp=peer_fp, claims=claims) | ||
| 368 | + | ||
| 369 | + | ||
| 370 | +def verify_and_pin( | ||
| 371 | + store: IPinStore, | ||
| 372 | + token: str | None, | ||
| 373 | + *, | ||
| 374 | + expect_type: str | None = None, | ||
| 375 | + nonce_cache: NonceCache | None = None, | ||
| 376 | +) -> VerifyResult: | ||
| 377 | + """验令牌 + 同步指纹固定(进程内 TOFU 首选入口)。 | ||
| 378 | + | ||
| 379 | + 首次见到该身份:验签通过即记录其指纹并放行;之后:比对已记录指纹,不一致即拒。 | ||
| 380 | + 落库显式绑定见 management 层 ``verify_and_bind`` / ``CertificatePinStore``。 | ||
| 381 | + """ | ||
| 382 | + res = verify_token(token, expect_type=expect_type, nonce_cache=nonce_cache) | ||
| 383 | + if not res.ok or res.claims is None or res.peer_fp is None: | ||
| 384 | + return res | ||
| 385 | + iss = res.claims.iss | ||
| 386 | + pinned = store.pinned(iss) | ||
| 387 | + if pinned is not None and pinned != res.peer_fp: | ||
| 388 | + mode = res.mode | ||
| 389 | + logger.warning( | ||
| 390 | + "[link_auth] %s: fingerprint changed for iss=%s (possible impersonation), rejecting", | ||
| 391 | + mode.value, iss, | ||
| 392 | + ) | ||
| 393 | + return VerifyResult( | ||
| 394 | + allowed=(mode is AuthMode.OBSERVE), | ||
| 395 | + ok=False, | ||
| 396 | + reason="fingerprint mismatch", | ||
| 397 | + mode=mode, | ||
| 398 | + peer_fp=res.peer_fp, | ||
| 399 | + claims=res.claims, | ||
| 400 | + ) | ||
| 401 | + store.remember(iss, res.peer_fp) | ||
| 402 | + return res | ||
Rmanagement/openjiuwen_runtime/management/security/models.py→foundation/openjiuwen_runtime/foundation/security/models.py+0-0
文件重命名但无更改。
Rmanagement/openjiuwen_runtime/management/security/signing.py→foundation/openjiuwen_runtime/foundation/security/signing.py+0-0
文件重命名但无更改。
| @@ -0,0 +1,168 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""link-auth 单元测试(非对称 Ed25519 + 进程内 TOFU 指纹固定)。 | ||
| 5 | + | ||
| 6 | +落库显式绑定(CertificatePinStore / verify_and_bind)的测试在 management 层 | ||
| 7 | +``management/tests/unit_tests/management_security/test_link_auth_bind.py``。 | ||
| 8 | +""" | ||
| 9 | + | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import time | ||
| 13 | + | ||
| 14 | +import pytest | ||
| 15 | + | ||
| 16 | +from openjiuwen_runtime.foundation.security.link_auth import ( | ||
| 17 | + AuthMode, | ||
| 18 | + InMemoryPinStore, | ||
| 19 | + LinkAuthError, | ||
| 20 | + NonceCache, | ||
| 21 | + build_token, | ||
| 22 | + build_token_header, | ||
| 23 | + fingerprint, | ||
| 24 | + generate_keypair, | ||
| 25 | + get_auth_mode, | ||
| 26 | + sign_token, | ||
| 27 | + verify_and_pin, | ||
| 28 | + verify_signature, | ||
| 29 | + verify_token, | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def _enforce(monkeypatch): | ||
| 35 | + """多数用例在 enforce 下跑;个别用例自行覆盖。""" | ||
| 36 | + monkeypatch.setenv("CLAW_LINK_AUTH_MODE", "enforce") | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +# ---------- 纯密码学:验签 ---------- | ||
| 40 | + | ||
| 41 | +def test_sign_verify_roundtrip(): | ||
| 42 | + priv, pub = generate_keypair() | ||
| 43 | + tok = sign_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 44 | + claims = verify_signature(tok) | ||
| 45 | + assert claims.iss == "gw-1" and claims.typ == "gateway" and claims.pub == pub | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +def test_bad_signature_rejected(): | ||
| 49 | + priv, pub = generate_keypair() | ||
| 50 | + tok = sign_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 51 | + payload_b64, _, _ = tok.partition(".") | ||
| 52 | + with pytest.raises(LinkAuthError): | ||
| 53 | + verify_signature(f"{payload_b64}.AAAA") | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def test_tampered_payload_rejected(): | ||
| 57 | + priv, pub = generate_keypair() | ||
| 58 | + tok = sign_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 59 | + payload_b64, _, sig = tok.partition(".") | ||
| 60 | + with pytest.raises(LinkAuthError): | ||
| 61 | + verify_signature(f"{payload_b64}x.{sig}") | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def test_forged_pub_rejected(): | ||
| 65 | + # 攻击者把载荷里的 pub 换成别的、却用受害者私钥签 → 验签用载荷里的 pub,签名对不上 → 拒。 | ||
| 66 | + vpriv, _ = generate_keypair() | ||
| 67 | + _, apub = generate_keypair() | ||
| 68 | + tok = sign_token(service_id="gw-1", service_type="gateway", private_b64=vpriv, public_b64=apub) | ||
| 69 | + with pytest.raises(LinkAuthError): | ||
| 70 | + verify_signature(tok) | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +def test_malformed_token_rejected(): | ||
| 74 | + with pytest.raises(LinkAuthError): | ||
| 75 | + verify_signature("no-dot-here") | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +def test_expired_token_rejected(): | ||
| 79 | + priv, pub = generate_keypair() | ||
| 80 | + tok = sign_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 81 | + with pytest.raises(LinkAuthError): | ||
| 82 | + verify_signature(tok, ttl=300, now=int(time.time()) + 10_000) | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +def test_expect_type_mismatch_rejected(): | ||
| 86 | + priv, pub = generate_keypair() | ||
| 87 | + tok = sign_token(service_id="as-1", service_type="agent_server", private_b64=priv, public_b64=pub) | ||
| 88 | + with pytest.raises(LinkAuthError): | ||
| 89 | + verify_signature(tok, expect_type="gateway") | ||
| 90 | + | ||
| 91 | + | ||
| 92 | +# ---------- nonce 防重放 ---------- | ||
| 93 | + | ||
| 94 | +def test_nonce_replay(): | ||
| 95 | + cache = NonceCache(ttl=300) | ||
| 96 | + priv, pub = generate_keypair() | ||
| 97 | + tok = sign_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 98 | + r1 = verify_token(tok, expect_type="gateway", nonce_cache=cache) | ||
| 99 | + r2 = verify_token(tok, expect_type="gateway", nonce_cache=cache) # 同一令牌重放 | ||
| 100 | + assert r1.ok is True and r2.ok is False and r2.reason == "nonce replay" | ||
| 101 | + | ||
| 102 | + | ||
| 103 | +# ---------- TOFU 指纹固定 ---------- | ||
| 104 | + | ||
| 105 | +def test_tofu_same_key_passes(): | ||
| 106 | + store = InMemoryPinStore() | ||
| 107 | + priv, pub = generate_keypair() | ||
| 108 | + for _ in range(3): | ||
| 109 | + tok = build_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 110 | + res = verify_and_pin(store, tok, expect_type="gateway") | ||
| 111 | + assert res.allowed and res.ok | ||
| 112 | + | ||
| 113 | + | ||
| 114 | +def test_tofu_changed_key_rejected(): | ||
| 115 | + store = InMemoryPinStore() | ||
| 116 | + priv, pub = generate_keypair() | ||
| 117 | + tok = build_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 118 | + assert verify_and_pin(store, tok, expect_type="gateway").ok # 首次 TOFU 记录 | ||
| 119 | + | ||
| 120 | + ipriv, ipub = generate_keypair() # 冒充者:同 iss、不同密钥 | ||
| 121 | + itok = build_token(service_id="gw-1", service_type="gateway", private_b64=ipriv, public_b64=ipub) | ||
| 122 | + res = verify_and_pin(store, itok, expect_type="gateway") | ||
| 123 | + assert (not res.allowed) and res.reason == "fingerprint mismatch" | ||
| 124 | + | ||
| 125 | + | ||
| 126 | +def test_fingerprint_stable(): | ||
| 127 | + _, pub = generate_keypair() | ||
| 128 | + assert fingerprint(pub) == fingerprint(pub) and len(fingerprint(pub)) == 64 | ||
| 129 | + | ||
| 130 | + | ||
| 131 | +# ---------- 开关:off ---------- | ||
| 132 | + | ||
| 133 | +def test_mode_off_client_no_header(monkeypatch): | ||
| 134 | + monkeypatch.setenv("CLAW_LINK_AUTH_MODE", "off") | ||
| 135 | + priv, pub = generate_keypair() | ||
| 136 | + assert build_token_header(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) == {} | ||
| 137 | + | ||
| 138 | + | ||
| 139 | +def test_mode_off_server_allows(monkeypatch): | ||
| 140 | + monkeypatch.setenv("CLAW_LINK_AUTH_MODE", "off") | ||
| 141 | + res = verify_and_pin(InMemoryPinStore(), None, expect_type="gateway") | ||
| 142 | + assert res.allowed is True and res.mode is AuthMode.OFF | ||
| 143 | + | ||
| 144 | + | ||
| 145 | +def test_mode_unset_defaults_off(monkeypatch): | ||
| 146 | + monkeypatch.delenv("CLAW_LINK_AUTH_MODE", raising=False) | ||
| 147 | + assert get_auth_mode() is AuthMode.OFF | ||
| 148 | + | ||
| 149 | + | ||
| 150 | +# ---------- 开关:enforce / observe ---------- | ||
| 151 | + | ||
| 152 | +def test_enforce_roundtrip(): | ||
| 153 | + priv, pub = generate_keypair() | ||
| 154 | + headers = build_token_header(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 155 | + assert headers | ||
| 156 | + res = verify_and_pin(InMemoryPinStore(), list(headers.values())[0], expect_type="gateway") | ||
| 157 | + assert res.allowed and res.ok | ||
| 158 | + | ||
| 159 | + | ||
| 160 | +def test_enforce_missing_token_rejected(): | ||
| 161 | + res = verify_and_pin(InMemoryPinStore(), None, expect_type="gateway") | ||
| 162 | + assert res.allowed is False and res.ok is False | ||
| 163 | + | ||
| 164 | + | ||
| 165 | +def test_observe_allows_but_reports_failure(monkeypatch): | ||
| 166 | + monkeypatch.setenv("CLAW_LINK_AUTH_MODE", "observe") | ||
| 167 | + res = verify_and_pin(InMemoryPinStore(), None, expect_type="gateway") | ||
| 168 | + assert res.allowed is True and res.ok is False | ||
| @@ -171,6 +171,13 @@ class DockerDeployer(Deployer[DockerParams]): | |||
| 171 | proxy_env_filtered = {k: v for k, v in proxy_env.items() if v} | 171 | proxy_env_filtered = {k: v for k, v in proxy_env.items() if v} |
| 172 | # Merge proxy env with custom env, custom env has higher priority | 172 | # Merge proxy env with custom env, custom env has higher priority |
| 173 | env_vars = {**proxy_env_filtered, **env_vars} | 173 | env_vars = {**proxy_env_filtered, **env_vars} |
| 174 | + # link-auth:透传控制面的链路握手鉴权开关给 AgentServer 容器(容器不继承宿主 | ||
| 175 | + # 环境);显式 env 优先,故用 setdefault。enforce 下若缺,pod 不签 ack 令牌、 | ||
| 176 | + # Gateway 反向校验失败。 | ||
| 177 | + for _link_env in ("CLAW_LINK_AUTH_MODE", "CLAW_LINK_TOKEN_TTL"): | ||
| 178 | + _link_val = os.getenv(_link_env) | ||
| 179 | + if _link_val: | ||
| 180 | + env_vars.setdefault(_link_env, _link_val) | ||
| 174 | 181 | ||
| 175 | # 非低码情况 | 182 | # 非低码情况 |
| 176 | if not ir_path: | 183 | if not ir_path: |
| @@ -539,6 +539,13 @@ class K8sDeployer(Deployer[K8sParams]): | |||
| 539 | } | 539 | } |
| 540 | if settings.userdata: | 540 | if settings.userdata: |
| 541 | env_vars["RUNTIME_USERDATA"] = settings.userdata | 541 | env_vars["RUNTIME_USERDATA"] = settings.userdata |
| 542 | + # link-auth:把控制面的链路握手鉴权开关透传给 AgentServer pod。k8s pod 不继承 | ||
| 543 | + # 宿主/控制面环境,须显式注入;否则 enforce 模式下 pod 不会签 connection.ack | ||
| 544 | + # 反向令牌,Gateway 侧反向校验将失败、握手建立不起来。 | ||
| 545 | + for _link_env in ("CLAW_LINK_AUTH_MODE", "CLAW_LINK_TOKEN_TTL"): | ||
| 546 | + _link_val = os.getenv(_link_env) | ||
| 547 | + if _link_val: | ||
| 548 | + env_vars[_link_env] = _link_val | ||
| 542 | 549 | ||
| 543 | core_api, apps_api = await self._get_apis() | 550 | core_api, apps_api = await self._get_apis() |
| 544 | secret_body = self._build_secret_body( | 551 | secret_body = self._build_secret_body( |
| @@ -1,14 +1,13 @@ | |||
| 1 | # coding: utf-8 | 1 | # coding: utf-8 |
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved |
| 3 | 3 | ||
| 4 | -"""OpenJiuwen Runtime Security SDK. | 4 | +"""OpenJiuwen Runtime Security SDK(management 层)。 |
| 5 | 5 | ||
| 6 | -通用安全能力(实现可插拔,调用方仅依赖接口): | 6 | +通用安全原语(加解密 / 加签验签 / 链路握手鉴权)已下沉到 foundation |
| 7 | - | 7 | +(``openjiuwen_runtime.foundation.security``),供进程级组件零控制面依赖地复用; |
| 8 | -- 加解密:AES-256-GCM(对称)+ X25519 信封(面向公钥的混合加密)。 | 8 | +本模块保留 **落库** 的密钥/证书管理(``CertificateManager`` 需 ``DBHandler``)与其 |
| 9 | -- 加签验签:Ed25519。 | 9 | +指纹固定适配器(``CertificatePinStore`` + 异步 ``verify_and_bind``),并 **re-export** |
| 10 | -- 证书/密钥管理:密钥与证书的录入、保存、读取、删除(client/server 通用), | 10 | +foundation 的安全符号以保持 ``openjiuwen_runtime.management.security`` 历史导入路径兼容。 |
| 11 | - 并提供传输无关的握手密钥交换(``KeyExchange`` / server_/client_key_exchange)。 | ||
| 12 | 11 | ||
| 13 | 示例: | 12 | 示例: |
| 14 | from openjiuwen_runtime.management.security import ( | 13 | from openjiuwen_runtime.management.security import ( |
| @@ -17,31 +16,54 @@ | |||
| 17 | ) | 16 | ) |
| 18 | """ | 17 | """ |
| 19 | 18 | ||
| 20 | -from .interfaces import ( | 19 | +# --- re-export foundation 安全能力(向后兼容历史导入路径)--- |
| 20 | +from openjiuwen_runtime.foundation.security import ( | ||
| 21 | + DEK_ALGORITHM, | ||
| 22 | + AuthMode, | ||
| 23 | + Claims, | ||
| 24 | + Ed25519Signer, | ||
| 25 | + Ed25519Verifier, | ||
| 26 | + EncAlgorithm, | ||
| 27 | + EnvelopeCryptoProvider, | ||
| 21 | ICertificateManager, | 28 | ICertificateManager, |
| 22 | ICryptoProvider, | 29 | ICryptoProvider, |
| 30 | + InMemoryPinStore, | ||
| 31 | + IPinStore, | ||
| 23 | ISigner, | 32 | ISigner, |
| 24 | IVerifier, | 33 | IVerifier, |
| 25 | -) | ||
| 26 | -from .models import ( | ||
| 27 | - DEK_ALGORITHM, | ||
| 28 | - EncAlgorithm, | ||
| 29 | KeyPair, | 34 | KeyPair, |
| 30 | KeyPurpose, | 35 | KeyPurpose, |
| 36 | + LINK_TOKEN_HEADER, | ||
| 37 | + LinkAuthError, | ||
| 38 | + NonceCache, | ||
| 31 | PeerKey, | 39 | PeerKey, |
| 32 | SealedMessage, | 40 | SealedMessage, |
| 33 | SECURITY_LOCAL_KEY_TABLE_DEF, | 41 | SECURITY_LOCAL_KEY_TABLE_DEF, |
| 34 | SECURITY_PEER_KEY_TABLE_DEF, | 42 | SECURITY_PEER_KEY_TABLE_DEF, |
| 35 | SignAlgorithm, | 43 | SignAlgorithm, |
| 44 | + VerifyResult, | ||
| 45 | + build_token, | ||
| 46 | + build_token_header, | ||
| 47 | + fingerprint, | ||
| 48 | + generate_keypair, | ||
| 49 | + get_auth_mode, | ||
| 50 | + get_ttl, | ||
| 51 | + make_signer, | ||
| 52 | + make_verifier, | ||
| 53 | + sign_token, | ||
| 54 | + verify_and_pin, | ||
| 55 | + verify_signature, | ||
| 56 | + verify_token, | ||
| 36 | ) | 57 | ) |
| 37 | -from .crypto import EnvelopeCryptoProvider | 58 | + |
| 38 | -from .signing import Ed25519Signer, Ed25519Verifier, make_signer, make_verifier | 59 | +# --- 本层独有:落库密钥/证书管理 + 落库显式绑定的指纹固定 --- |
| 39 | from .certificate import ( | 60 | from .certificate import ( |
| 40 | CertificateManager, | 61 | CertificateManager, |
| 41 | KeyExchange, | 62 | KeyExchange, |
| 42 | client_key_exchange, | 63 | client_key_exchange, |
| 43 | server_key_exchange, | 64 | server_key_exchange, |
| 44 | ) | 65 | ) |
| 66 | +from .certificate_pin_store import CertificatePinStore, verify_and_bind | ||
| 45 | 67 | ||
| 46 | __all__ = ( | 68 | __all__ = ( |
| 47 | # interfaces | 69 | # interfaces |
| @@ -66,9 +88,30 @@ __all__ = ( | |||
| 66 | "Ed25519Verifier", | 88 | "Ed25519Verifier", |
| 67 | "make_signer", | 89 | "make_signer", |
| 68 | "make_verifier", | 90 | "make_verifier", |
| 69 | - # certificate / key management | 91 | + # link-auth handshake (foundation) |
| 92 | + "LINK_TOKEN_HEADER", | ||
| 93 | + "AuthMode", | ||
| 94 | + "LinkAuthError", | ||
| 95 | + "Claims", | ||
| 96 | + "VerifyResult", | ||
| 97 | + "NonceCache", | ||
| 98 | + "IPinStore", | ||
| 99 | + "InMemoryPinStore", | ||
| 100 | + "get_auth_mode", | ||
| 101 | + "get_ttl", | ||
| 102 | + "generate_keypair", | ||
| 103 | + "fingerprint", | ||
| 104 | + "sign_token", | ||
| 105 | + "verify_signature", | ||
| 106 | + "verify_token", | ||
| 107 | + "verify_and_pin", | ||
| 108 | + "build_token", | ||
| 109 | + "build_token_header", | ||
| 110 | + # certificate / key management (management) | ||
| 70 | "CertificateManager", | 111 | "CertificateManager", |
| 71 | "KeyExchange", | 112 | "KeyExchange", |
| 72 | "server_key_exchange", | 113 | "server_key_exchange", |
| 73 | "client_key_exchange", | 114 | "client_key_exchange", |
| 115 | + "CertificatePinStore", | ||
| 116 | + "verify_and_bind", | ||
| 74 | ) | 117 | ) |
| @@ -17,10 +17,9 @@ from datetime import datetime, timezone | |||
| 17 | from typing import Any, Optional | 17 | from typing import Any, Optional |
| 18 | 18 | ||
| 19 | from openjiuwen_runtime.foundation.db.handler import DBHandler | 19 | from openjiuwen_runtime.foundation.db.handler import DBHandler |
| 20 | - | 20 | +from openjiuwen_runtime.foundation.security import _primitives as _p |
| 21 | -from . import _primitives as _p | 21 | +from openjiuwen_runtime.foundation.security.interfaces import ICertificateManager |
| 22 | -from .interfaces import ICertificateManager | 22 | +from openjiuwen_runtime.foundation.security.models import ( |
曹 | |||
| 23 | -from .models import ( | ||
| 24 | EncAlgorithm, | 23 | EncAlgorithm, |
| 25 | KeyPair, | 24 | KeyPair, |
| 26 | KeyPurpose, | 25 | KeyPurpose, |
| @@ -0,0 +1,88 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""落库显式绑定的指纹固定(link-auth 的 DB 后端)。 | ||
| 5 | + | ||
| 6 | +把 foundation 层 link-auth 的「指纹固定」接到 management 层 :class:`CertificateManager`: | ||
| 7 | +对端公钥按 ``(peer_id=iss, purpose=sign)`` 落库(``status=bound``),握手时比对指纹。 | ||
| 8 | +相对进程内 TOFU(:class:`InMemoryPinStore`),它是**显式绑定**:持久、可按 key_version | ||
| 9 | +轮换、解绑即删行——与配置下发的密钥管理同一套模型。 | ||
| 10 | + | ||
| 11 | +因 :class:`CertificateManager` 为异步 DB 操作,本模块提供**异步** :func:`verify_and_bind`, | ||
| 12 | +与 foundation 的同步 :func:`verify_and_pin`(内存 TOFU)并列;集成方按链路是否有持久 DB | ||
| 13 | +二选一(构造 ``CertificatePinStore`` 走落库绑定,或 ``InMemoryPinStore`` 走进程内 TOFU)。 | ||
| 14 | +""" | ||
| 15 | + | ||
| 16 | +from __future__ import annotations | ||
| 17 | + | ||
| 18 | +import logging | ||
| 19 | +from typing import Optional | ||
| 20 | + | ||
| 21 | +from openjiuwen_runtime.foundation.security import _primitives as _p | ||
| 22 | +from openjiuwen_runtime.foundation.security.link_auth import ( | ||
| 23 | + AuthMode, | ||
| 24 | + NonceCache, | ||
| 25 | + VerifyResult, | ||
| 26 | + verify_token, | ||
| 27 | +) | ||
| 28 | +from openjiuwen_runtime.foundation.security.models import KeyPurpose | ||
| 29 | + | ||
| 30 | +from .certificate import CertificateManager | ||
| 31 | + | ||
| 32 | +logger = logging.getLogger(__name__) | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +class CertificatePinStore: | ||
| 36 | + """:class:`CertificateManager` 的指纹固定适配器(异步、落库、显式绑定)。 | ||
| 37 | + | ||
| 38 | + - ``pinned(iss)``:读对端 ``sign`` 公钥的已绑定指纹(无则 ``None``)。 | ||
| 39 | + - ``remember(iss, pub_b64, fp)``:录入/更新对端 ``sign`` 公钥(``status=bound``)。 | ||
| 40 | + """ | ||
| 41 | + | ||
| 42 | + def __init__( | ||
| 43 | + self, manager: CertificateManager, *, purpose: str = KeyPurpose.SIGN.value | ||
| 44 | + ) -> None: | ||
| 45 | + self._cm = manager | ||
| 46 | + self._purpose = purpose | ||
| 47 | + | ||
| 48 | + async def pinned(self, iss: str) -> Optional[str]: | ||
| 49 | + peer = await self._cm.load_peer_key(iss, self._purpose) | ||
| 50 | + return peer.fingerprint if peer is not None else None | ||
| 51 | + | ||
| 52 | + async def remember(self, iss: str, pub_b64: str, fp: str) -> None: | ||
| 53 | + await self._cm.save_peer_key(iss, self._purpose, _p.b64d(pub_b64)) | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +async def verify_and_bind( | ||
| 57 | + store: CertificatePinStore, | ||
| 58 | + token: str | None, | ||
| 59 | + *, | ||
| 60 | + expect_type: str | None = None, | ||
| 61 | + nonce_cache: NonceCache | None = None, | ||
| 62 | +) -> VerifyResult: | ||
| 63 | + """验令牌 + 落库显式绑定(持久端首选入口,异步)。 | ||
| 64 | + | ||
| 65 | + 首次见到该身份:验签通过即落库绑定其公钥并放行;之后:比对已绑定指纹,不一致即拒。 | ||
| 66 | + 内存 TOFU 版见 foundation 的 :func:`verify_and_pin`。 | ||
| 67 | + """ | ||
| 68 | + res = verify_token(token, expect_type=expect_type, nonce_cache=nonce_cache) | ||
| 69 | + if not res.ok or res.claims is None or res.peer_fp is None: | ||
| 70 | + return res | ||
| 71 | + iss = res.claims.iss | ||
| 72 | + pinned = await store.pinned(iss) | ||
| 73 | + if pinned is not None and pinned != res.peer_fp: | ||
| 74 | + mode = res.mode | ||
| 75 | + logger.warning( | ||
| 76 | + "[link_auth] %s: fingerprint changed for iss=%s (possible impersonation), rejecting", | ||
| 77 | + mode.value, iss, | ||
| 78 | + ) | ||
| 79 | + return VerifyResult( | ||
| 80 | + allowed=(mode is AuthMode.OBSERVE), | ||
| 81 | + ok=False, | ||
| 82 | + reason="fingerprint mismatch", | ||
| 83 | + mode=mode, | ||
| 84 | + peer_fp=res.peer_fp, | ||
| 85 | + claims=res.claims, | ||
| 86 | + ) | ||
| 87 | + await store.remember(iss, res.claims.pub, res.peer_fp) | ||
| 88 | + return res | ||
| @@ -6,6 +6,7 @@ | |||
| 6 | from __future__ import annotations | 6 | from __future__ import annotations |
| 7 | 7 | ||
| 8 | import asyncio | 8 | import asyncio |
| 9 | +import contextlib | ||
| 9 | import os | 10 | import os |
| 10 | import subprocess | 11 | import subprocess |
| 11 | import sys | 12 | import sys |
| @@ -159,10 +160,11 @@ class ProcessServiceHandler: | |||
| 159 | return info | 160 | return info |
| 160 | 161 | ||
| 161 | async def _wait_until_ready(self) -> None: | 162 | async def _wait_until_ready(self) -> None: |
| 162 | - import websockets | 163 | + # 探活仅需确认 AgentServer 的监听端口已就绪,故用 TCP 连接探测而非 |
| 163 | - | 164 | + # WebSocket 握手——这与 K8s 的 TCP readiness 探针语义一致,且不会触发 |
| 165 | + # 链路握手鉴权:enforce 模式下,不带 X-Claw-Link-Token 的 WS 握手会被 | ||
| 166 | + # AgentServer 拒绝(401),令探活永远失败。TCP 探测则不涉及握手鉴权。 | ||
| 164 | deadline = asyncio.get_running_loop().time() + self._ready_timeout | 167 | deadline = asyncio.get_running_loop().time() + self._ready_timeout |
| 165 | - url = f"ws://{self._host}:{self._port}" | ||
| 166 | last_error: Exception | None = None | 168 | last_error: Exception | None = None |
| 167 | 169 | ||
| 168 | while asyncio.get_running_loop().time() < deadline: | 170 | while asyncio.get_running_loop().time() < deadline: |
| @@ -172,14 +174,21 @@ class ProcessServiceHandler: | |||
| 172 | f"AgentServer process exited early with code {proc.returncode}" | 174 | f"AgentServer process exited early with code {proc.returncode}" |
| 173 | ) | 175 | ) |
| 174 | try: | 176 | try: |
| 175 | - async with websockets.connect(url, open_timeout=2.0): | 177 | + _, writer = await asyncio.wait_for( |
| 176 | - return | 178 | + asyncio.open_connection(self._host, self._port), |
| 179 | + timeout=2.0, | ||
| 180 | + ) | ||
| 181 | + writer.close() | ||
| 182 | + with contextlib.suppress(Exception): | ||
| 183 | + await writer.wait_closed() | ||
| 184 | + return | ||
| 177 | except Exception as exc: # noqa: BLE001 | 185 | except Exception as exc: # noqa: BLE001 |
| 178 | last_error = exc | 186 | last_error = exc |
| 179 | await asyncio.sleep(self._ready_poll_interval) | 187 | await asyncio.sleep(self._ready_poll_interval) |
| 180 | 188 | ||
| 181 | raise TimeoutError( | 189 | raise TimeoutError( |
| 182 | - f"AgentServer not ready within {self._ready_timeout}s url={url} last_error={last_error}" | 190 | + f"AgentServer not ready within {self._ready_timeout}s " |
| 191 | + f"host={self._host} port={self._port} last_error={last_error}" | ||
| 183 | ) | 192 | ) |
| 184 | 193 | ||
| 185 | async def delete(self) -> str: | 194 | async def delete(self) -> str: |
| @@ -86,6 +86,8 @@ class WSServiceMessageChannel: | |||
| 86 | ws_use_tls: bool = False, | 86 | ws_use_tls: bool = False, |
| 87 | payload_from_raw: Optional[PayloadBuilder] = None, | 87 | payload_from_raw: Optional[PayloadBuilder] = None, |
| 88 | connect_timeout: float = 30.0, | 88 | connect_timeout: float = 30.0, |
| 89 | + additional_headers: Optional[Any] = None, | ||
| 90 | + verify_peer: Optional[Callable[[dict], bool]] = None, | ||
| 89 | ) -> None: | 91 | ) -> None: |
| 90 | self._fallback_port = int(target_port) if target_port is not None else None | 92 | self._fallback_port = int(target_port) if target_port is not None else None |
| 91 | self._port = self._fallback_port or 0 | 93 | self._port = self._fallback_port or 0 |
| @@ -96,6 +98,15 @@ class WSServiceMessageChannel: | |||
| 96 | payload_from_raw or serialize_request_payload | 98 | payload_from_raw or serialize_request_payload |
| 97 | ) | 99 | ) |
| 98 | self._connect_timeout = connect_timeout | 100 | self._connect_timeout = connect_timeout |
| 101 | + # 可选:握手期附加的 HTTP 头(如链路鉴权令牌)。 | ||
| 102 | + # 可传 dict(静态),或无参回调 ``() -> Optional[dict]``(每次连接现取, | ||
| 103 | + # 用于令牌需逐次刷新的场景,避免重连复用同一令牌被对端的 nonce/有效期校验拦截)。 | ||
| 104 | + # 默认 None=不附加,行为与既有调用方完全一致。 | ||
| 105 | + self._additional_headers: Optional[Any] = additional_headers | ||
| 106 | + # 可选:对端核验回调 ``(connection_ack_frame: dict) -> bool``。设置后,连接建立即 | ||
| 107 | + # 消费首帧 connection.ack 交其核验(如反向链路鉴权:确认连到的是合法对端),返回 | ||
| 108 | + # False 则断开。默认 None=不核验,行为与既有调用方完全一致。 | ||
| 109 | + self._verify_peer: Optional[Callable[[dict], bool]] = verify_peer | ||
| 99 | 110 | ||
| 100 | # 强引用:接收循环须稳定持有 ServiceHandler 以 dispatch;弱引用在部分 GC/嵌入场景下 | 111 | # 强引用:接收循环须稳定持有 ServiceHandler 以 dispatch;弱引用在部分 GC/嵌入场景下 |
| 101 | # 可能在 recv 首帧前失效,导致接收协程空跑退出、全链路无下行(表现为「能连上但不转发」)。 | 112 | # 可能在 recv 首帧前失效,导致接收协程空跑退出、全链路无下行(表现为「能连上但不转发」)。 |
| @@ -209,15 +220,28 @@ class WSServiceMessageChannel: | |||
| 209 | if not self._ws_url: | 220 | if not self._ws_url: |
| 210 | raise RuntimeError("WebSocket URL 未设置") | 221 | raise RuntimeError("WebSocket URL 未设置") |
| 211 | logger.info("WSS 正在连接: %s", self._ws_url) | 222 | logger.info("WSS 正在连接: %s", self._ws_url) |
| 223 | + # dict 直接用;回调则每次连接现取一份(如刷新链路令牌:新 nonce/新签发时间)。 | ||
| 224 | + hdrs = self._additional_headers | ||
| 225 | + if callable(hdrs): | ||
| 226 | + hdrs = hdrs() | ||
| 212 | new_ws = await asyncio.wait_for( | 227 | new_ws = await asyncio.wait_for( |
| 213 | websockets.connect( | 228 | websockets.connect( |
| 214 | self._ws_url, | 229 | self._ws_url, |
| 215 | open_timeout=self._connect_timeout, | 230 | open_timeout=self._connect_timeout, |
| 216 | ping_interval=20.0, | 231 | ping_interval=20.0, |
| 217 | ping_timeout=20.0, | 232 | ping_timeout=20.0, |
| 233 | + additional_headers=hdrs, | ||
| 218 | ), | 234 | ), |
| 219 | timeout=self._connect_timeout, | 235 | timeout=self._connect_timeout, |
| 220 | ) | 236 | ) |
| 237 | + # link-auth 反向:消费首帧 connection.ack 并核验对端令牌;不通过则断开。 | ||
| 238 | + if self._verify_peer is not None: | ||
| 239 | + first_raw = await asyncio.wait_for(new_ws.recv(), timeout=self._connect_timeout) | ||
| 240 | + first = _decode_ws_message(first_raw) | ||
| 241 | + if not self._verify_peer(first if isinstance(first, dict) else {}): | ||
| 242 | + with contextlib.suppress(Exception): | ||
| 243 | + await new_ws.close() | ||
| 244 | + raise RuntimeError("WSS 对端 link-auth 校验失败") | ||
| 221 | self._ws = new_ws | 245 | self._ws = new_ws |
| 222 | p = self._default_parser | 246 | p = self._default_parser |
| 223 | if p is not None and (self._recv_task is None or self._recv_task.done()): | 247 | if p is not None and (self._recv_task is None or self._recv_task.done()): |
| @@ -24,7 +24,7 @@ dependencies = [ | |||
| 24 | "redis==7.1.0", | 24 | "redis==7.1.0", |
| 25 | # lint | 25 | # lint |
| 26 | "ruff==0.9.10", | 26 | "ruff==0.9.10", |
| 27 | - "openjiuwen-runtime-foundation==0.1.0", | 27 | + "openjiuwen_runtime_foundation @ git+https://gitcode.com/openJiuwen/agent-runtime.git@develop#subdirectory=foundation", |
| 28 | "fastapi>=0.110.0", | 28 | "fastapi>=0.110.0", |
| 29 | "httpx>=0.27.0", | 29 | "httpx>=0.27.0", |
| 30 | "uvicorn[standard]>=0.29.0", | 30 | "uvicorn[standard]>=0.29.0", |
| @@ -14,7 +14,7 @@ from openjiuwen_runtime.management.security import ( | |||
| 14 | client_key_exchange, | 14 | client_key_exchange, |
| 15 | server_key_exchange, | 15 | server_key_exchange, |
| 16 | ) | 16 | ) |
| 17 | -from openjiuwen_runtime.management.security._primitives import ( | 17 | +from openjiuwen_runtime.foundation.security._primitives import ( |
| 18 | ed25519_generate, | 18 | ed25519_generate, |
| 19 | fingerprint, | 19 | fingerprint, |
| 20 | x25519_generate, | 20 | x25519_generate, |
| @@ -9,7 +9,7 @@ import pytest | |||
| 9 | from cryptography.exceptions import InvalidTag | 9 | from cryptography.exceptions import InvalidTag |
| 10 | 10 | ||
| 11 | from openjiuwen_runtime.management.security import EnvelopeCryptoProvider, SealedMessage | 11 | from openjiuwen_runtime.management.security import EnvelopeCryptoProvider, SealedMessage |
| 12 | -from openjiuwen_runtime.management.security._primitives import x25519_generate | 12 | +from openjiuwen_runtime.foundation.security._primitives import x25519_generate |
| 13 | 13 | ||
| 14 | 14 | ||
| 15 | def test_envelope_seal_open_roundtrip(): | 15 | def test_envelope_seal_open_roundtrip(): |
| @@ -0,0 +1,83 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""link-auth 落库显式绑定单元测试(CertificatePinStore + 异步 verify_and_bind,真 sqlite)。 | ||
| 5 | + | ||
| 6 | +进程内 TOFU 版的测试在 foundation 层 ``foundation/tests/unit_tests/test_link_auth.py``。 | ||
| 7 | +""" | ||
| 8 | + | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +import pytest | ||
| 12 | + | ||
| 13 | +from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler | ||
| 14 | +from openjiuwen_runtime.foundation.security.link_auth import build_token, generate_keypair | ||
| 15 | +from openjiuwen_runtime.management.security import ( | ||
| 16 | + CertificateManager, | ||
| 17 | + CertificatePinStore, | ||
| 18 | + KeyPurpose, | ||
| 19 | + verify_and_bind, | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def _enforce(monkeypatch): | ||
| 25 | + monkeypatch.setenv("CLAW_LINK_AUTH_MODE", "enforce") | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +async def manager(tmp_path): | ||
| 30 | + handler = SQLiteHandler(str(tmp_path / "security.db")) | ||
| 31 | + await handler.init_database() | ||
| 32 | + await handler.connect() | ||
| 33 | + cm = CertificateManager(handler) | ||
| 34 | + await cm.ensure_ready() | ||
| 35 | + try: | ||
| 36 | + yield cm | ||
| 37 | + finally: | ||
| 38 | + await handler.disconnect() | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +def _pin_store(manager) -> CertificatePinStore: | ||
| 42 | + return CertificatePinStore(manager, purpose=KeyPurpose.SIGN.value) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +async def test_first_contact_binds_and_passes(manager): | ||
| 46 | + store = _pin_store(manager) | ||
| 47 | + priv, pub = generate_keypair() | ||
| 48 | + tok = build_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 49 | + res = await verify_and_bind(store, tok, expect_type="gateway") | ||
| 50 | + assert res.allowed and res.ok | ||
| 51 | + # 已落库绑定:再查指纹应等于本次对端指纹。 | ||
| 52 | + assert await store.pinned("gw-1") == res.peer_fp | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +async def test_same_key_passes_across_reconnects(manager): | ||
| 56 | + store = _pin_store(manager) | ||
| 57 | + priv, pub = generate_keypair() | ||
| 58 | + for _ in range(3): | ||
| 59 | + tok = build_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 60 | + res = await verify_and_bind(store, tok, expect_type="gateway") | ||
| 61 | + assert res.allowed and res.ok | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +async def test_changed_key_rejected(manager): | ||
| 65 | + store = _pin_store(manager) | ||
| 66 | + priv, pub = generate_keypair() | ||
| 67 | + tok = build_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 68 | + assert (await verify_and_bind(store, tok, expect_type="gateway")).ok # 首次绑定 | ||
| 69 | + | ||
| 70 | + ipriv, ipub = generate_keypair() # 冒充者:同 iss、不同密钥 | ||
| 71 | + itok = build_token(service_id="gw-1", service_type="gateway", private_b64=ipriv, public_b64=ipub) | ||
| 72 | + res = await verify_and_bind(store, itok, expect_type="gateway") | ||
| 73 | + assert (not res.allowed) and res.reason == "fingerprint mismatch" | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +async def test_binding_persists_across_store_instances(manager): | ||
| 77 | + # 同一个 DB,换一个 store 实例(模拟进程重启后重新加载),绑定仍在 → 显式绑定的持久性。 | ||
| 78 | + priv, pub = generate_keypair() | ||
| 79 | + tok = build_token(service_id="gw-1", service_type="gateway", private_b64=priv, public_b64=pub) | ||
| 80 | + res = await verify_and_bind(_pin_store(manager), tok, expect_type="gateway") | ||
| 81 | + fp = res.peer_fp | ||
| 82 | + second = _pin_store(manager) # 同一 DB、新 store 实例 | ||
| 83 | + assert await second.pinned("gw-1") == fp | ||
| @@ -13,7 +13,7 @@ from openjiuwen_runtime.management.security import ( | |||
| 13 | make_signer, | 13 | make_signer, |
| 14 | make_verifier, | 14 | make_verifier, |
| 15 | ) | 15 | ) |
| 16 | -from openjiuwen_runtime.management.security._primitives import ed25519_generate | 16 | +from openjiuwen_runtime.foundation.security._primitives import ed25519_generate |
| 17 | 17 | ||
| 18 | 18 | ||
| 19 | def test_sign_verify_roundtrip(): | 19 | def test_sign_verify_roundtrip(): |
这里依赖了foundation的最新代码,需要改一下pyproject.toml里的依赖,可以先改成源码依赖