diff --git a/.gitignore b/.gitignore index 26635383..8dfacb88 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ deploy/docker/.venv-modelscope/ .idea/ .vscode/ CLAUDE.md + +# 安全模块 +# 开发子plan +/security-plans diff --git a/bootstrap/cli/__main__.py b/bootstrap/cli/__main__.py index f36d7a53..340025f2 100644 --- a/bootstrap/cli/__main__.py +++ b/bootstrap/cli/__main__.py @@ -41,14 +41,27 @@ def build_parser() -> argparse.ArgumentParser: description="agent-memory memory engine CLI", ) parser.add_argument( - "--server", "--base-url", dest="server", - metavar="URL", default=os.environ.get("AGENT_MEMORY_SERVER"), + "--server", + "--base-url", + dest="server", + metavar="URL", + default=os.environ.get("AGENT_MEMORY_SERVER"), help="drive a running server over HTTP (Mem0 --base-url; default: in-process)", ) parser.add_argument( - "--config", action="append", default=[], metavar="PATH", + "--config", + action="append", + default=[], + metavar="PATH", help="JSON config layer stacked on OFFLINE (in-process only; repeatable)", ) + parser.add_argument( + "--api-key", + dest="api_key", + metavar="KEY", + default=None, + help="API key for --server mode (default: $AGENT_MEMORY_API_KEY)", + ) sub = parser.add_subparsers(dest="command", required=True) @@ -78,7 +91,7 @@ def main(argv: list[str] | None = None) -> int: sys.stderr.write("note: --config is ignored in --server (HTTP) mode\n") try: - client = make_client(args.server, args.config) + client = make_client(args.server, args.config, args.api_key) if args.command in ("health", "status"): return commands.run_health(client, args) if args.command == "batch": diff --git a/bootstrap/cli/client.py b/bootstrap/cli/client.py index 192e5469..37d4bdca 100644 --- a/bootstrap/cli/client.py +++ b/bootstrap/cli/client.py @@ -41,9 +41,11 @@ class EngineClient(Protocol): """A backend the CLI can drive: turn a (verb, payload) into (status, body).""" def call(self, verb: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + """Dispatch one memory-engine verb.""" ... def healthz(self) -> tuple[int, dict[str, Any]]: + """Return the backend health response.""" ... @@ -74,9 +76,24 @@ def server(self): return self._srv def call(self, verb: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + from auth_middleware import authenticated from handler import dispatch - return dispatch(self._srv, verb, payload) + from common.errors import AuthenticationError + from security.types import Credentials + + # 进程内直连没有 HTTP header,故过一个空 Credentials。DEV 模式下得到 + # ROOT,与现状一致(CLI 一直是全权限的);API_KEY 模式下会认证失败—— + # 这是**正确的**:没有凭据就不该有权限。要在 API_KEY 模式下用 CLI, + # 走 HttpClient 带 --api-key。 + # + # 认证失败转成 (401, body) 而非抛出:本方法的契约是返回状态码, + # 与 HttpClient.call 一致。 + try: + with authenticated(self._srv.authenticator, Credentials(), self._srv.audit): + return dispatch(self._srv, verb, payload) + except AuthenticationError as exc: + return 401, {"error": type(exc).__name__, "message": str(exc)} def healthz(self) -> tuple[int, dict[str, Any]]: return 200, {"status": "ok", "profile": self._srv.config.profile} @@ -85,17 +102,21 @@ def healthz(self) -> tuple[int, dict[str, Any]]: class HttpClient: """Drive a running ``bootstrap`` server over HTTP (``POST /v1/``).""" - def __init__(self, base_url: str, timeout: float = 30.0) -> None: + def __init__(self, base_url: str, timeout: float = 30.0, api_key: str = "") -> None: self.base_url = base_url.rstrip("/") self.timeout = timeout + self.api_key = api_key def _request(self, method: str, path: str, body: dict | None) -> tuple[int, dict[str, Any]]: url = f"{self.base_url}{path}" data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" req = urllib.request.Request( url, data=data, - headers={"Content-Type": "application/json"}, + headers=headers, method=method, ) try: @@ -124,8 +145,16 @@ def _read_json(resp) -> dict[str, Any]: return {"error": "BadResponse", "message": raw.decode("utf-8", "replace")} -def make_client(server_url: str | None, configs: list[str] | None = None) -> EngineClient: - """Pick a backend: HTTP when ``server_url`` is given, else in-process.""" +def make_client( + server_url: str | None, + configs: list[str] | None = None, + api_key: str | None = None, +) -> EngineClient: + """Pick a backend: HTTP when ``server_url`` is given, else in-process. + + ``api_key`` 缺省读环境变量 ``AGENT_MEMORY_API_KEY``——让 key 不必出现在 + shell history 与 ``ps`` 输出里。 + """ if server_url: - return HttpClient(server_url) + return HttpClient(server_url, api_key=api_key or os.environ.get("AGENT_MEMORY_API_KEY", "")) return InProcessClient(configs) diff --git a/bootstrap/core/auth_middleware.py b/bootstrap/core/auth_middleware.py new file mode 100644 index 00000000..02a21a91 --- /dev/null +++ b/bootstrap/core/auth_middleware.py @@ -0,0 +1,143 @@ +"""请求作用域的认证上下文——凭据提取 + ContextVar 建立/清理。 + +各 surface(HTTP / MCP / CLI 直连)用同一条中间件:把本形态的凭据材料归一成 +:class:`~security.types.Credentials`,交给装配好的 ``Authenticator``,把产出的 +``AuthContext`` 挂进 ContextVar 供 ``handler.dispatch`` 读取。 + +**本模块不决定认证策略**——模式(dev / trusted / api_key)由配置在装配期选定, +这里只负责「在正确的时机调用它、并保证退出时清理干净」。 +""" + +from __future__ import annotations + +import os +import sys +from contextlib import contextmanager +from importlib import import_module +from typing import Any, Iterator, Mapping + +_SRC = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src" +) +if _SRC not in sys.path: + sys.path.append(_SRC) + +_auth_module = import_module("common.type_def.auth") +reset_current = _auth_module.reset_current +set_current = _auth_module.set_current + +Scope = import_module("common.type_def").Scope +AuditEvent = import_module("common.type_def").AuditEvent +_errors = import_module("common.errors") +AuthenticationError = _errors.AuthenticationError +RateLimitedError = _errors.RateLimitedError +Credentials = import_module("security.types").Credentials + +_BEARER = "bearer " + +_RATE_LIMITED = "too many requests" + + +def credentials_from_headers(headers: Mapping[str, Any], peer_address: str = "") -> Credentials: + """从 HTTP header 提取凭据。 + + HTTP header 名大小写不敏感(RFC 9110 §5.1)。``http.client.HTTPMessage`` 的 + ``get`` 自己会做不敏感匹配,但传给 authenticator 的是普通 Mapping——故在这里 + 统一归一成小写键,authenticator 侧按小写常量查,两边不必各写一次 ``.lower()``。 + """ + normalized = {str(k).lower(): str(v) for k, v in headers.items()} + + api_key = "" + auth = normalized.get("authorization", "") + bearer_len = len(_BEARER) + if auth[:bearer_len].lower() == _BEARER: + api_key = auth[bearer_len:].strip() + if not api_key: + api_key = normalized.get("x-api-key", "").strip() + + return Credentials(api_key=api_key, headers=normalized, peer_address=peer_address) + + +@contextmanager +def authenticated( + authenticator, credentials, audit=None, limiter=None, *, argon2_guard=None +) -> Iterator[Any]: + """在请求作用域内建立可信认证上下文;退出时**必定** reset。 + + reset 放 ``finally`` 是硬性要求:``ThreadingHTTPServer`` 每请求一线程, + 但线程可能被池化复用;漏 reset 会让下一个请求继承上一个请求的身份—— + 最严重的一类越权。 + + ``authenticate`` 故意放在 ``try`` 之外:认证失败时没有 token 可 reset, + 放进 try 会需要一个 ``token = None`` 的分支判断,反而更容易写错。 + + ``limiter`` 在 ``authenticate`` **之前**执行(§8.1):认证本身就是要保护的 + 资源——API_KEY 模式下每次 authenticate 跑一次 Argon2id verify(128 MiB × + time_cost=4),放在认证之后限流就等于「先让攻击者把 CPU 用掉,再告诉他 + 超限了」。``limiter=None`` 表示不限流(进程内直连 / MCP stdio 无网络对端)。 + + ``argon2_guard`` 是进程级并发上限(审计 P1-3):IP 桶限请求速率,限不住 + 「同时在跑的 Argon2 verify 数」。耗尽即 429,在 limiter 之后、authenticate + 之前执行。acquire 成功后用 ``finally`` 释放;``None`` 表示不限(DEV 模式或 + 调用方确信不跑 Argon2)。 + """ + if limiter is not None and not limiter.allow(credentials.peer_address): + _record_denial(audit, authenticator, credentials, "rate_limit") + raise RateLimitedError(_RATE_LIMITED) + + guard_acquired = False + if argon2_guard is not None: + if not argon2_guard.acquire(): + _record_denial(audit, authenticator, credentials, "argon2_concurrency") + raise RateLimitedError(_RATE_LIMITED) + guard_acquired = True + + try: + ctx = authenticator.authenticate(credentials) + except AuthenticationError: + _record_denial(audit, authenticator, credentials, "authenticate") + raise + finally: + if guard_acquired: + argon2_guard.release() + + token = set_current(ctx) + try: + yield ctx + finally: + reset_current(token) + + +def _record_denial(audit, authenticator, credentials, action) -> None: + """入口拒绝落一条审计(security.md §7.2):``action`` 区分限流与认证失败。 + + 每次拒绝都记,无阈值聚合——限流器的计数器目前只用于准入判断,不对外暴露 + 统计;要做「同一 peer 连续失败 N 次告警」还需要一个独立的失败计数维度 + (限流桶按请求数计,不区分成功与失败),那是可观测性设计,不在本期。 + + ``actor`` 是空 ``Scope()``——身份未知,**不可用调用方声明的任何值填充**。 + ``detail`` 里不放 api_key、不放 key 前缀(§7.5 PII 脱敏),也不放桶余量 + (那能用来反推限流参数)。 + + 暂不记录认证失败的细分原因(``missing_credentials`` / ``unknown_principal`` / + ``bad_gateway_key``):三个 authenticator 都刻意只抛同一个笼统消息,要拿到 + 细分原因得在 authenticator 侧另开一条只进审计的通道。那是独立设计, + 不顺手塞进本期。 + """ + if audit is None: + return + try: + audit.record( + AuditEvent( + actor=Scope(), + action=action, + decision="deny", + layer="security", + detail={ + "mode": authenticator.mode().value, + "peer": credentials.peer_address, + }, + ) + ) + except Exception: # pragma: no cover - 审计后端故障不该把 401/429 变成 500 + pass diff --git a/bootstrap/core/handler.py b/bootstrap/core/handler.py index ce22205c..e32242ac 100644 --- a/bootstrap/core/handler.py +++ b/bootstrap/core/handler.py @@ -9,8 +9,11 @@ scopes by ``tenant_id`` + optional ``space`` / ``space_id`` + a single ``scope`` string, mapped onto the native ``Scope(org=tenant_id, space=space, user=scope)``. The request shape keeps old -empty-space payloads compatible, while allowing an optional claimed actor -override via ``actor_tenant_id`` / ``actor_space`` / ``actor_scope`` fields. +empty-space payloads compatible, and still describes the **target** scope +("which resource"); the **actor** ("who is asking") no longer comes from the +payload at all — it comes from the auth layer's ``AuthContext`` +(security.md §9 铁律 #1). Payloads that still carry ``actor_*`` fields are +rejected outright rather than silently ignored. """ from __future__ import annotations @@ -28,6 +31,7 @@ _errors_module = import_module("common.errors") AgentMemoryError = _errors_module.AgentMemoryError +AuthenticationError = _errors_module.AuthenticationError ConflictError = _errors_module.ConflictError NotFoundError = _errors_module.NotFoundError PermissionDeniedError = _errors_module.PermissionDeniedError @@ -42,6 +46,7 @@ MemoryUnit = _type_def_module.MemoryUnit Modality = _type_def_module.Modality Scope = _type_def_module.Scope +get_current = _type_def_module.get_current EvolveMode = import_module("construction").EvolveMode _control_types_module = import_module("control.types") @@ -62,7 +67,8 @@ _STATUS = { NotFoundError: 404, - PermissionDeniedError: 403, + AuthenticationError: 401, # 不知道你是谁 + PermissionDeniedError: 403, # 知道你是谁,但不许 ConflictError: 409, ValidationError: 400, PolicyError: 400, @@ -136,43 +142,54 @@ def _target_scope(payload: Body) -> Scope: ) -def _actor_scope(payload: Body) -> Scope: - """Claimed actor scope; defaults to payload scope, with optional explicit override.""" - has_actor_override = False - actor_fields = ( - "actor_tenant_id", - "actor_space", - "actor_space_id", - "actor_scope", - "actor_agent", - "actor_session", - ) - for key in actor_fields: - if key in payload: - has_actor_override = True - break - - if has_actor_override: - actor_org = str(payload.get("actor_tenant_id", "")) - if actor_org == "": - actor_org = str(payload.get("tenant_id", "default")) or "default" - actor_space = ( - _space_value(payload, prefix="actor_") - if "actor_space" in payload or "actor_space_id" in payload - else _space_value(payload) - ) - return Scope( - org=actor_org, - space=actor_space, - user=str(payload.get("actor_scope", "")), - agent=str(payload.get("actor_agent", "")), - session=str(payload.get("actor_session", "")), +def _identity() -> Scope: + """调用方身份——来自认证层产出的可信上下文,**不来自 payload**。 + + security.md §9 铁律 #1:身份来自上下文,不来自参数。本函数的前身 + ``_actor_scope(payload)`` 直接读 ``payload["actor_tenant_id"]`` 等字段, + 任何人提交 ``{"actor_scope": "victim"}`` 即可读到 victim 的记忆;提交 + ``{"actor_tenant_id": " "}`` 更是拿到空 ``Scope()``,命中 + ``SQLitePermissionManager.check`` 的 platform-admin 全局放行。 + + 不接受任何参数是刻意的:签名上就不给「从别处取身份」留位置。 + """ + ctx = get_current() + if ctx is None: + # 中间件未挂载或漏挂——fail-closed,绝不回退到 payload 或默认身份。 + # 装配错误应该让所有请求失败,而不是让所有请求以未知身份成功。 + raise AuthenticationError("authentication required") + return ctx.actor + + +# ``actor_space`` / ``actor_space_id`` 是 space 五维化时一并加进来的伪造面: +# 声明字段每多一维,可冒充的主体就多一维。禁止列表必须与 ``Scope`` 的维数同步—— +# 将来 ``Scope`` 再加维,这里要跟着加。 +_FORBIDDEN_IDENTITY_KEYS = ( + "actor_tenant_id", + "actor_space", + "actor_space_id", + "actor_scope", + "actor_agent", + "actor_session", +) + +# ``audit`` verb 用 actor_agent / actor_session 作**查询过滤谓词**(筛历史事件的 +# 操作者是谁),与身份声明同名但语义不同——它们不参与本次请求的授权。 +# 对该 verb 只拒其余四个(它们不是 audit 的过滤键,出现在那里同样是误以为能声明身份)。 +_AUDIT_FILTER_KEYS = ("actor_agent", "actor_session") + + +def _reject_claimed_identity(payload: Body, allow: tuple[str, ...] = ()) -> None: + """payload 里出现身份声明字段一律报错,不静默忽略。 + + 静默忽略会让「我传了 actor_scope」被误认为仍然生效,写出错误的安全认知; + 显式报错迫使调用方改用认证凭据。 + """ + present = [key for key in _FORBIDDEN_IDENTITY_KEYS if key in payload and key not in allow] + if present: + raise ValidationError( + f"identity must come from credentials, not payload: {sorted(present)}" ) - return Scope( - org=str(payload.get("tenant_id", "default")) or "default", - space=_space_value(payload), - user=str(payload.get("scope", "")), - ) def _require(payload: Body, key: str) -> Any: @@ -249,9 +266,7 @@ def _space_policy(payload: Body) -> SpacePolicy: return SpacePolicy( require_space=_bool_value(raw.get("require_space"), default=False), principal_path=_enum_value(PrincipalPath, principal_path, name="principal_path"), - storage_isolation_strategy=str( - raw.get("storage_isolation_strategy", "metadata_filter") - ), + storage_isolation_strategy=str(raw.get("storage_isolation_strategy", "metadata_filter")), retention=_string_map(raw.get("retention")), quotas=_string_map(raw.get("quotas")), index_profiles=_string_map(raw.get("index_profiles", raw.get("indexes"))), @@ -326,7 +341,7 @@ def _usage_view(usage) -> Body: def _add(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() modality = Modality(payload.get("modality", "text")) # metadata 透传:infer 等调用级开关经 metadata 下推到引擎(engine.write 从 # metadata["infer"]=="true" 判定是否同步走 evolve(EXTRACT) 抽取派生记忆)。 @@ -352,14 +367,19 @@ def _add(srv, payload: Body) -> Body: # 此时不伪造 item_id, # 如实返回 deduped 语义;非空则照常取首条返回。 if not units: - return {"ok": True, "op": "add", "item_id": None, "item": None, - "skipped": "all derived memories deduped (update/noop)"} + return { + "ok": True, + "op": "add", + "item_id": None, + "item": None, + "skipped": "all derived memories deduped (update/noop)", + } unit = units[0] return {"ok": True, "op": "add", "item_id": unit.id, "item": _unit_view(unit)} def _search(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() # extensions:把调用方在请求里给的自定义配置透传给(可能自定义的) # 检索模块。显式校验 dict:extensions 为 truthy 非 dict(字符串/列表等 # 畸形 JSON)时兜底为空, @@ -405,7 +425,7 @@ def _search(srv, payload: Body) -> Body: def _list(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() offset = _parse_non_negative_int(payload.get("offset"), name="offset", default=0) limit = _parse_positive_int(payload.get("limit"), name="limit", default=100) memory_types = _parse_string_list( @@ -434,24 +454,22 @@ def _list(srv, payload: Body) -> Body: def _get(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() unit = srv.api.get(_require(payload, "item_id"), scope, identity=actor) return {"ok": True, "op": "get", "item": _unit_view(unit)} def _update(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() patch = MemoryPatch(content=payload.get("content"), tags=payload.get("tags")) unit = srv.api.update(_require(payload, "item_id"), scope, patch, identity=actor) return {"ok": True, "op": "update", "item": _unit_view(unit)} def _delete(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() mode = DeleteMode.PURGE if payload.get("hard") else DeleteMode.FORGET - selector = DeleteSelector( - unit_ids=[_require(payload, "item_id")], scope=scope, mode=mode - ) + selector = DeleteSelector(unit_ids=[_require(payload, "item_id")], scope=scope, mode=mode) deleted = srv.api.delete(selector, identity=actor) return {"ok": True, "op": "delete", "item_id": payload["item_id"], "deleted": deleted} @@ -461,7 +479,7 @@ def _delete(srv, payload: Body) -> Body: def _evolve(srv, payload: Body) -> Body: """触发演进(extract/associate/consolidate/forget)→ Evolver 全链路 + Scheduler。""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() mode = EvolveMode(payload.get("mode", "extract")) job_id = srv.api.evolve(scope, mode, identity=actor) return {"ok": True, "op": "evolve", "mode": mode.value, "job_id": job_id} @@ -469,7 +487,7 @@ def _evolve(srv, payload: Body) -> Body: def _job(srv, payload: Body) -> Body: """查询演进任务状态(Scheduler)。""" - actor = _actor_scope(payload) + actor = _identity() info = srv.api.job_status(_require(payload, "job_id"), identity=actor) return { "ok": True, @@ -482,7 +500,7 @@ def _job(srv, payload: Body) -> Body: def _inspect(srv, payload: Body) -> Body: """治理检视:按 id 读完整单元(含失效版本)→ Governor。""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() ids = payload.get("item_ids") or [_require(payload, "item_id")] units = srv.api.inspect(ids, scope, identity=actor) return {"ok": True, "op": "inspect", "items": [_unit_view(u) for u in units]} @@ -490,14 +508,14 @@ def _inspect(srv, payload: Body) -> Body: def _trace(srv, payload: Body) -> Body: """血缘回溯:沿 supersedes 版本链 → Governor。""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() chain = srv.api.trace(_require(payload, "item_id"), scope, identity=actor) return {"ok": True, "op": "trace", "items": [_unit_view(u) for u in chain]} def _audit(srv, payload: Body) -> Body: """审计查询(Governor + AuditLogger)。""" - actor = _actor_scope(payload) + actor = _identity() filters = {} for key in ( "action", @@ -533,8 +551,8 @@ def _audit(srv, payload: Body) -> Body: def _admin(srv, payload: Body) -> Body: - """运行时策略读写:给 value 即 set、给 key 即 get、否则列全部。""" - actor = _actor_scope(payload) + """运行时策略读写(PolicyManager):给 value 即 set、给 key 即 get、否则列全部。""" + actor = _identity() key, value = payload.get("key"), payload.get("value") if key and value is not None: srv.api.admin_set(key, str(value), identity=actor) @@ -556,7 +574,7 @@ def _admin(srv, payload: Body) -> Body: def _grant(srv, payload: Body) -> Body: """跨 scope 授权(PermissionManager)。""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() grantee = Scope( org=str(payload.get("grantee_tenant_id", scope.org)) or scope.org, space=_space_value(payload, prefix="grantee_") or scope.space, @@ -576,7 +594,7 @@ def _grant(srv, payload: Body) -> Body: def _revoke(srv, payload: Body) -> Body: """Cross-scope revoke (PermissionManager).""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope, actor = _target_scope(payload), _identity() grantee = Scope( org=str(payload.get("grantee_tenant_id", scope.org)) or scope.org, space=_space_value(payload, prefix="grantee_") or scope.space, @@ -595,7 +613,7 @@ def _revoke(srv, payload: Body) -> Body: def _create_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" space = _require_space(payload) policy = _space_policy(payload) @@ -614,14 +632,14 @@ def _create_space(srv, payload: Body) -> Body: def _get_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" info = srv.api.get_space(org, _require_space(payload), identity=actor) return {"ok": True, "op": "get_space", "space": _space_info_view(info)} def _list_spaces(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" raw_status = payload.get("status") status = _enum_value(SpaceStatus, raw_status, name="status") if raw_status else None @@ -641,7 +659,7 @@ def _list_spaces(srv, payload: Body) -> Body: def _update_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" patch = SpacePatch( display_name=payload.get("display_name"), @@ -659,14 +677,14 @@ def _update_space(srv, payload: Body) -> Body: def _archive_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" info = srv.api.archive_space(org, _require_space(payload), identity=actor) return {"ok": True, "op": "archive_space", "space": _space_info_view(info)} def _delete_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" mode = _enum_value(DeleteMode, payload.get("mode", "purge"), name="mode") result = srv.api.delete_space(org, _require_space(payload), identity=actor, mode=mode) @@ -681,7 +699,7 @@ def _delete_space(srv, payload: Body) -> Body: def _export_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" export_id = srv.api.export_space( org, @@ -693,21 +711,21 @@ def _export_space(srv, payload: Body) -> Body: def _space_usage(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" usage = srv.api.space_usage(org, _require_space(payload), identity=actor) return {"ok": True, "op": "space_usage", "usage": _usage_view(usage)} def _get_space_policy(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" policy = srv.api.get_space_policy(org, _require_space(payload), identity=actor) return {"ok": True, "op": "get_space_policy", "policy": _space_policy_view(policy)} def _set_space_policy(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" policy = srv.api.set_space_policy( org, @@ -719,7 +737,7 @@ def _set_space_policy(srv, payload: Body) -> Body: def _list_space_members(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" members = srv.api.list_space_members(org, _require_space(payload), identity=actor) return { @@ -731,14 +749,14 @@ def _list_space_members(srv, payload: Body) -> Body: def _add_space_member(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" srv.api.add_space_member(org, _require_space(payload), _space_member(payload), identity=actor) return {"ok": True, "op": "add_space_member"} def _remove_space_member(srv, payload: Body) -> Body: - actor = _actor_scope(payload) + actor = _identity() org = str(payload.get("tenant_id", "default")) or "default" srv.api.remove_space_member( org, @@ -786,11 +804,11 @@ def dispatch(srv, verb: str, payload: Body) -> tuple[int, Body]: if handler is None: return 404, {"error": "UnknownVerb", "message": f"no such verb: {verb!r}"} try: + # 入口统一拒身份声明,不是每个 verb 各拒一次——单点更难漏。 + _reject_claimed_identity(payload, allow=_AUDIT_FILTER_KEYS if verb == "audit" else ()) return 200, handler(srv, payload) except AgentMemoryError as exc: - status = next( - (code for cls, code in _STATUS.items() if isinstance(exc, cls)), 400 - ) + status = next((code for cls, code in _STATUS.items() if isinstance(exc, cls)), 400) return status, {"error": type(exc).__name__, "message": str(exc)} except Exception as exc: # surface unexpected failures as 500 return 500, {"error": "InternalError", "message": str(exc)} diff --git a/bootstrap/core/server.py b/bootstrap/core/server.py index 75184db3..96237996 100644 --- a/bootstrap/core/server.py +++ b/bootstrap/core/server.py @@ -18,6 +18,7 @@ from __future__ import annotations +import logging import os import sys from importlib import import_module @@ -37,14 +38,33 @@ Kernel = _api_module.Kernel build_kernel = _api_module.build_kernel KernelConfig = import_module("config").Config +Factory = import_module("common.factory.factory").Factory + +_security_module = import_module("security") +AuthMode = _security_module.AuthMode +AuthProducer = _security_module.AuthProducer +RateLimitProducer = _security_module.RateLimitProducer +register_security = _security_module.register_security + +_LOG = logging.getLogger(__name__) class Server: """Assembled kernel + shared dispatch; base for all protocol surfaces.""" - def __init__(self, config: Config, kernel: Kernel) -> None: + def __init__( + self, + config: Config, + kernel: Kernel, + authenticator: Any = None, + rate_limiter: Any = None, + argon2_guard: Any = None, + ) -> None: self.config = config self.kernel = kernel + self.authenticator = authenticator + self.rate_limiter = rate_limiter + self.argon2_guard = argon2_guard @property def api(self): @@ -54,6 +74,14 @@ def api(self): def kv(self): return self.kernel.kv + @property + def audit(self): + """装配好的审计器(可能为 None)——认证中间件记入口事件用。""" + return self.kernel.audit + + # ``rate_limiter`` 只在有网络对端的 surface(HTTP)传给中间件:进程内直连与 + # MCP stdio 没有远端,限流无对象可分桶,见 :meth:`security.RateLimiter.allow`。 + @classmethod def build(cls, config: Config, spaces: Any = None) -> "Server": """Assemble a kernel from ``config`` and return a ``cls`` instance. @@ -65,11 +93,15 @@ def build(cls, config: Config, spaces: Any = None) -> "Server": ``profile`` / ``policies`` 撞上新配置解析期的顶层段名校验而报错。无该段时(纯 ``OFFLINE`` 档)``from_dict(None)`` 返回空配置,回落进程内默认实现,与原行为一致。 """ + # 必须在 from_dict 之前:authenticator / key_store 两个顶层段名要先进 + # Factory.known_top_names(),否则配置解析期会把它们当未知段拒掉。 + register_security() kernel_config = KernelConfig.from_dict(config.settings.get("memory_api")) - return cls( - config, - build_kernel(policies=config.policies or None, config=kernel_config), - ) + kernel = build_kernel(policies=config.policies or None, config=kernel_config) + authenticator = _build_authenticator(kernel_config) + rate_limiter = _build_rate_limiter(kernel_config, authenticator) + argon2_guard = _build_argon2_guard(kernel_config, authenticator) + return cls(config, kernel, authenticator, rate_limiter, argon2_guard) def dispatch(self, verb: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]: """Route a ``(verb, payload)`` through the shared handler.""" @@ -78,6 +110,73 @@ def dispatch(self, verb: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, A return _dispatch(self, verb, payload) +def _build_authenticator(kernel_config: Any): + """按配置的 ``authenticator`` 段装配认证器;无该段时回落 DEV 并警告。 + + 回落到 DEV(而非拒绝启动)是刻意的:不打断任何人的本地开发。第一期只是把 + 「无认证」从**隐式且不可改**变成**显式、可切换、且非 localhost 时拒绝启动** + (DEV 的绑定 guard 见 :func:`security.check_dev_binding`)。 + """ + ctx = kernel_config.context(known_top_names=Factory.known_top_names()) + names = sorted(ctx.namespaces.get(AuthProducer.TOP_NAME, {})) + if not names: + _LOG.warning( + "未配置 authenticator 段,回落 DEV 模式:所有请求以 ROOT 身份放行。" + "生产部署须显式配置 authenticator.default.target 为 api_key 或 trusted。" + ) + return AuthProducer.build("dev", {}, ctx) + # 与 ROOT_PARAMS 的引用惯例一致:具名实例,多个时取 "default",否则取唯一那个。 + name = "default" if "default" in names else names[0] + return AuthProducer.build_named(name, ctx) + + +def _build_rate_limiter(kernel_config: Any, authenticator: Any): + """按配置的 ``rate_limiter`` 段装配限流器;无该段时按认证模式给默认。 + + **默认按模式分岔**(§8.1):DEV 模式不限流,其余模式默认开 ``token_bucket``。 + 理由是限流保护的具体对象——Argon2id verify——只在 API_KEY 模式下存在;DEV + 模式已被强制绑定 localhost(:func:`security.check_dev_binding`),没有远端 + 攻击面,此时限流只会把本地压测和调试脚本卡住,是纯粹的开发阻碍。 + + 非 DEV 默认**开**而不是默认关:默认关等于「必须读过 §8.1 才知道要配」, + 而没配的后果是一个能打挂进程的可用性漏洞。默认开的代价是运维可能撞上 429, + 但那会伴随一个明确的状态码和一个明确的配置项;默认关的代价是没有信号。 + + 网关后部署(TRUSTED 模式的常见形态)所有请求共用网关出口 IP,会被当成 + 同一个 peer——这种部署应显式配 ``target: unlimited`` 把限流交给网关, + 或按聚合流量调大 ``capacity``。见 F01 归档文档的「破坏性变更」。 + """ + ctx = kernel_config.context(known_top_names=Factory.known_top_names()) + names = sorted(ctx.namespaces.get(RateLimitProducer.TOP_NAME, {})) + if names: + name = "default" if "default" in names else names[0] + return RateLimitProducer.build_named(name, ctx) + + if authenticator.mode() is AuthMode.DEV: + return RateLimitProducer.build("unlimited", {}, ctx) + return RateLimitProducer.build("token_bucket", {}, ctx) + + +def _build_argon2_guard(kernel_config: Any, authenticator: Any): + """进程级 Argon2 verify 并发上限(审计 P1-3)。 + + 只装配到 **API_KEY** 模式:只有它每次 ``authenticate`` 跑 Argon2id verify + (128 MiB × time_cost=4)。TRUSTED 只做 header/HMAC/字典角色查询,不跑 Argon2, + 装上 guard 只会让受信网关的高并发流量无端收到 429(审计验收 P2-guard)。 + DEV 不跑 Argon2,亦不装。 + + 上限由 ``argon2.max_concurrent`` 配置(默认 4,按 512 MiB / 128 MiB 算)。 + """ + if authenticator.mode() is not AuthMode.API_KEY: + return None + settings = ( + kernel_config.settings.get("argon2", {}) if hasattr(kernel_config, "settings") else {} + ) + max_concurrent = int(settings.get("max_concurrent", 4)) if settings else 4 + _guard_mod = import_module("security.concurrency_guard") + return _guard_mod.default_argon2_guard(max_concurrent=max_concurrent) + + def default_spaces() -> Dict[str, Any]: """Default scope/namespace registry (none needed for the in-memory build).""" return {} diff --git a/bootstrap/http_server/__main__.py b/bootstrap/http_server/__main__.py index 35a91e3a..61e4ddee 100644 --- a/bootstrap/http_server/__main__.py +++ b/bootstrap/http_server/__main__.py @@ -16,8 +16,10 @@ import argparse import json +import logging import os import sys +import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from importlib import import_module @@ -33,6 +35,105 @@ load_config = _profiles_module.load_config Server = import_module("server").Server +_auth_middleware = import_module("auth_middleware") +authenticated = _auth_middleware.authenticated +credentials_from_headers = _auth_middleware.credentials_from_headers + +# ``server`` / ``auth_middleware`` 导入时已把仓库 src/ 追加进 sys.path。 +_errors = import_module("common.errors") +AuthenticationError = _errors.AuthenticationError +RateLimitedError = _errors.RateLimitedError +ValidationError = _errors.ValidationError +_security = import_module("security") +AuthMode = _security.AuthMode +check_dev_binding = _security.check_dev_binding + +# 请求体大小硬上限(审计 P2-4):无上限意味着超大或慢速上传能吃满内存与线程。 +# 4 MiB 覆盖任何合理的记忆写入请求;超大资产本就该走 FS + 分片而非塞进单次 POST。 +_MAX_BODY_BYTES = 4 * 1024 * 1024 +# 读/写超时(秒):慢速上传与慢客户端会长期占住 ThreadingHTTPServer 的线程。 +_READ_TIMEOUT = 30 +# 并发连接/线程硬上限(审计验收 P1-HTTP):timeout 只限单连接占用时长,攻击者持续 +# 补充连接即可维持线程耗尽。有界 semaphore 让超出上限的连接快速被拒(503),在 +# limiter/认证之前生效--未认证来源不能靠慢上传占满处理容量。 +_MAX_CONCURRENT_REQUESTS = 256 + + +def _parse_content_length(headers) -> tuple[int, int]: + """只校验 Content-Length,不读 body。返回 (status, length)。 + + status != 200 时 length 无意义。两阶段准入的第一阶段(审计验收 P1-HTTP): + 只依赖 header,在 limiter/认证之前,通过后才由调用方按 length 读 body。 + """ + raw_len = headers.get("Content-Length", "0") + try: + length = int(raw_len) + except ValueError: + return 400, 0 + if length < 0: + return 400, 0 + if length > _MAX_BODY_BYTES: + return 413, 0 + return 200, length + + +def _read_body(rfile, length: int) -> bytes: + """按已校验的 length 读 body。length 已由 _parse_content_length 约束。""" + return rfile.read(length) if length else b"" + + +class _BoundedThreadingHTTPServer(ThreadingHTTPServer): + """有界并发的 ThreadingHTTPServer(审计验收 P1-HTTP)。 + + process_request 入口用 semaphore 限并发:耗尽时直接拒绝(503),不进 handle + 路径、不占处理线程的认证/读 body 预算。把慢连接攻击的容量从无界线程收束到 + ``_MAX_CONCURRENT_REQUESTS``。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._slots = threading.BoundedSemaphore(_MAX_CONCURRENT_REQUESTS) + + def process_request(self, request, client_address): + if not self._slots.acquire(blocking=False): + try: + self._send_503(request) + except OSError: + pass # 客户端已断开 + self.shutdown_request(request) + return + # 不在这里 release:ThreadingHTTPServer.process_request 会 spawn 线程后立即 + # 返回,若在这里 release 等于没限。release 下移到 process_request_thread + # (处理线程真正结束时)。 + t = threading.Thread(target=self._process_and_release, args=(request, client_address)) + t.daemon = self.daemon_threads + t.start() + + def _process_and_release(self, request, client_address): + try: + self.finish_request(request, client_address) + except Exception: + self.handle_error(request, client_address) + finally: + self.shutdown_request(request) + self._slots.release() + + @staticmethod + def _send_503(request) -> None: + body = b'{"error":"ServiceUnavailable","message":"too many connections"}' + crlf = bytes([13, 10]) + request.sendall( + b"HTTP/1.0 503 Service Unavailable" + + crlf + + b"Content-Length: " + + str(len(body)).encode() + + crlf + + b"Content-Type: application/json" + + crlf + + crlf + + body + ) + class HttpServer(Server): """The HTTP/socket surface over the shared kernel dispatch.""" @@ -41,6 +142,10 @@ def _handler_cls(self): srv = self class Handler(BaseHTTPRequestHandler): + # 慢速上传/慢客户端的读写超时(审计 P2-4):无超时会让一个慢连接 + # 长期占住 ThreadingHTTPServer 的线程。 + timeout = _READ_TIMEOUT + def _send(self, status: int, body: dict) -> None: data = json.dumps(body, ensure_ascii=False).encode("utf-8") self.send_response(status) @@ -50,6 +155,9 @@ def _send(self, status: int, body: dict) -> None: self.wfile.write(data) def handle_get(self) -> None: + # /healthz 不认证(§2.1 原则 2 的明文例外)。响应体只含 status + + # profile 名——profile 名是部署配置的一部分但不是秘密,且改它会破坏 + # 现有客户端的 healthz() 契约,第一期保持原样。 if self.path.rstrip("/") == "/healthz": self._send(200, {"status": "ok", "profile": srv.config.profile}) else: @@ -61,14 +169,46 @@ def handle_post(self) -> None: return prefix_len = len("/v1/") verb = self.path[prefix_len:].strip("/") - length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(length) if length else b"" - try: - payload = json.loads(raw) if raw else {} - except ValueError as exc: - self._send(400, {"error": "BadRequest", "message": str(exc)}) + # 两阶段准入(审计验收 P1-HTTP): + # 1) 只校验 Content-Length,不读 body; + # 2) 提凭据 + limiter/认证(慢连接在读 body 前就被挡住); + # 3) 通过后才按已校验长度读 body。 + status, length = _parse_content_length(self.headers) + if status == 413: + self._send( + 413, + { + "error": "PayloadTooLarge", + "message": f"body exceeds {_MAX_BODY_BYTES}B limit", + }, + ) + return + if status == 400: + self._send(400, {"error": "BadRequest", "message": "invalid Content-Length"}) return - status, body = srv.dispatch(verb, payload) # 复用基类 dispatch + creds = credentials_from_headers(self.headers, self.client_address[0]) + try: + # 认证在读 body 之前:未认证/被限流的请求不占读 body 的内存预算。 + # 中间件负责退出时 reset ContextVar。 + with authenticated( + srv.authenticator, + creds, + srv.audit, + srv.rate_limiter, + argon2_guard=srv.argon2_guard, + ): + raw = _read_body(self.rfile, length) + try: + payload = json.loads(raw) if raw else {} + except ValueError as exc: + self._send(400, {"error": "BadRequest", "message": str(exc)}) + return + status, body = srv.dispatch(verb, payload) + except AuthenticationError as exc: + status, body = 401, {"error": type(exc).__name__, "message": str(exc)} + except RateLimitedError as exc: + # 429 而非 401:限流发生在认证之前,此时还不知道凭据对不对。 + status, body = 429, {"error": type(exc).__name__, "message": str(exc)} self._send(status, body) def log_message(self, *args) -> None: # quiet by default @@ -79,7 +219,11 @@ def log_message(self, *args) -> None: # quiet by default return Handler def serve(self, host: str, port: int) -> None: - httpd = ThreadingHTTPServer((host, port), self._handler_cls()) + httpd = _BoundedThreadingHTTPServer((host, port), self._handler_cls()) + # daemon_threads:serve_forever 退出时(KeyboardInterrupt)不等待慢请求线程, + # 否则一个挂住的连接能让进程退不掉(审计 P2-4)。并发上限由 + # _BoundedThreadingHTTPServer 的 semaphore 管(审计验收 P1-HTTP)。 + httpd.daemon_threads = True sys.stderr.write( f"agent-memory server (profile={self.config.profile}) on http://{host}:{port}\n" ) @@ -103,6 +247,16 @@ def main(argv: list[str] | None = None) -> int: for path in args.config: layers.append(load_layer(path)) srv = HttpServer.build(load_config(layers)) # 基类 build → HttpServer 实例 + + # DEV 模式无认证:绑非 loopback 地址等于把全权限接口暴露给整个网络。 + # 拒绝启动而非警告——警告会被忽略,而这个错配的后果是全部数据。 + if srv.authenticator.mode() is AuthMode.DEV: + try: + check_dev_binding(args.host) + except ValidationError as exc: + logging.error("FATAL: %s", exc) + return 1 + srv.serve(args.host, args.port) return 0 diff --git a/bootstrap/mcp_server/__main__.py b/bootstrap/mcp_server/__main__.py index 10cd78a2..e1c75218 100644 --- a/bootstrap/mcp_server/__main__.py +++ b/bootstrap/mcp_server/__main__.py @@ -11,10 +11,19 @@ 或 Streamable HTTP:: MCP_TRANSPORT=http MCP_PORT=8138 scripts/run-mcp.sh /config/config.yml + +**第一期认证限制(务必知悉)**:MCP 协议自己的凭据传递机制(OAuth 2.1 资源服务器、 +工具调用级的 token 下发)是第二期内容,本 surface 目前只把一个**空凭据**过认证中间件。 +后果是:DEV 模式(缺省 OFFLINE 档)下所有工具照常可用;一旦配成 API_KEY / TRUSTED 模式, +**所有 MCP 工具调用都会失败**(认证失败)。这是有意的—— +``docs/features/common/F04-security-interfaces-and-encryption.md`` +§8.2「MCP 协议的攻击面」需要专门设计,在设计落地前,让 MCP 在生产模式下不可用, +好过让它无认证可用。 """ from __future__ import annotations +import logging import os import sys from importlib import import_module @@ -34,12 +43,20 @@ load_config = _profiles_module.load_config Server = import_module("server").Server +_auth_middleware = import_module("auth_middleware") +authenticated = _auth_middleware.authenticated +AuthenticationError = import_module("common.errors").AuthenticationError +ValidationError = import_module("common.errors").ValidationError +Credentials = import_module("security.types").Credentials +check_dev_binding = import_module("security").check_dev_binding +AuthMode = import_module("security.types").AuthMode + try: FastMCP = import_module("mcp.server.fastmcp").FastMCP -except ImportError as exc: # pragma: no cover +except ImportError as import_error: # pragma: no cover raise RuntimeError( 'MCP surface 需要 mcp SDK:pip install ".[mcp]"(或 pip install mcp)' - ) from exc + ) from import_error # --- 内核:进程内装配一次,跨工具调用共享 --- # _SRV = Server.build(load_config([OFFLINE] + [load_layer(p) for p in sys.argv[1:]])) @@ -54,8 +71,18 @@ def _call(verb: str, payload: dict) -> dict: """ 走共享 dispatch;非 2xx 抛错,让 MCP 客户端看到失败原因(None 入参不下发)。 + + 与 ``InProcessClient`` 同样过一个空 ``Credentials()``:MCP 尚无凭据通道(见模块 + docstring 的第一期限制)。DEV 模式下得到 ROOT,非 DEV 模式下这里就会抛认证失败。 """ - status, body = _SRV.dispatch(verb, {k: v for k, v in payload.items() if v is not None}) + try: + with authenticated(_SRV.authenticator, Credentials(), _SRV.audit): + status, body = _SRV.dispatch(verb, {k: v for k, v in payload.items() if v is not None}) + except AuthenticationError as auth_error: + raise RuntimeError( + f"{type(auth_error).__name__}: {auth_error}" + "(MCP surface 尚未支持凭据传递,仅可在 DEV 模式下使用)" + ) from auth_error if status >= 400: raise RuntimeError(f"{body.get('error', 'Error')}: {body.get('message', '')}") return body @@ -138,6 +165,15 @@ def memory_evolve(tenant_id: str, scope: str, mode: str = "extract") -> dict: def main() -> int: transport = os.environ.get("MCP_TRANSPORT", "stdio") if transport in ("http", "streamable-http"): + # MCP 尚无凭据通道(见模块 docstring):DEV 模式下空凭据即 ROOT,绑非 + # loopback 等于把 ROOT 级记忆工具暴露给整个网络。与 HTTP surface 同一道 + # 闸(审计 P1-4:此前 MCP HTTP 启动没调 check_dev_binding)。 + if _SRV.authenticator.mode() is AuthMode.DEV: + try: + check_dev_binding(os.environ.get("MCP_HOST", "127.0.0.1")) + except ValidationError as validation_error: + logging.error("FATAL: %s", validation_error) + return 1 mcp.run(transport="streamable-http") # host/port 已在 FastMCP(...) 设好 else: mcp.run() # stdio(默认)——Claude Desktop / Claude Code 直接挂载 diff --git a/docs/features/common/F03-scope-space-isolation.md b/docs/features/common/F03-scope-space-isolation.md index 61256654..f459b928 100644 --- a/docs/features/common/F03-scope-space-isolation.md +++ b/docs/features/common/F03-scope-space-isolation.md @@ -44,7 +44,7 @@ ID 全局唯一的前提,与 Store 的“完整 Scope 内唯一”契约冲突 目标 `Scope` 字段集合为: ```python -@dataclass +@dataclass(frozen=True) # 安全加固:身份/隔离值不可变(F01 决策 16) class Scope: org: str = "" space: str = field(default="", kw_only=True) @@ -53,6 +53,11 @@ class Scope: session: str = "" ``` +> **当前状态(安全加固后)**:`Scope` 已是 frozen value object。改某维用 +> `dataclasses.replace(scope, org=...)` 返回新值,禁止原地 `scope.x = ...` +> (抛 `FrozenInstanceError`)。防的是「签发 key 后改原 actor 的 org 让已签发身份 +> 跟着变」的越权。详见 S07 不变量 10 与 F01 决策 16。 + 字段语义: - `org`:组织、账务、合同、平台管理边界。 diff --git a/docs/features/common/F04-security-interfaces-and-encryption.md b/docs/features/common/F04-security-interfaces-and-encryption.md index aed08d06..d99e6cca 100644 --- a/docs/features/common/F04-security-interfaces-and-encryption.md +++ b/docs/features/common/F04-security-interfaces-and-encryption.md @@ -115,6 +115,12 @@ if auth_mode == AuthMode.DEV: return AuthContext(actor=Scope(org="*"), role=Role.ROOT) ``` +> **主干实现注记**(F01):主干的 ROOT actor 是**空 `Scope()`**,不是 +> `Scope(org="*")`。`SQLitePermissionManager.check` 的第一条规则是 +> `actor == Scope() → True`(platform admin 全局放行),而 `org="*"` 会先撞上 +> 「跨 org 一律拒绝」规则——用 `org="*"` 的 ROOT 反而寸步难行。 +> 见 `src/security/authenticator_impl/dev_authenticator.py`。 + **约束**:DEV 模式只允许监听 localhost。启动时如果检测到非 localhost 绑定,应当 `sys.exit(1)` 并打印错误消息。**注意覆盖容器化场景下 `0.0.0.0` 这种最危险的情况**: ```python @@ -143,6 +149,12 @@ def enforce_dev_localhost_binding(bind_host): > DEV 模式唯一正确的用途:本地开发、单机调试。**永远不要**在非 localhost 上 DEV 模式运行。容器化场景下,即使绑了 `127.0.0.1`,也要保证 Docker/K8s 的网络配置不会把端口转发出去——这一层 guard 无法替你检查。生产部署必须显式配 `auth_mode: api_key` 或 `trusted`。 +> **主干实现注记**(F01):主干把这段拆成两半——`security.binding.check_dev_binding(hosts)` +> 是**纯函数**,非 localhost 抛 `ValidationError`,容器场景走 `logging.warning`; +> `sys.exit(1)` 与 stderr 上的 `FATAL:` 留在 `bootstrap/http_server/__main__.py:main`。 +> 这样 guard 本身可被单测直接断言(`tests/unit/security/test_binding.py`), +> 而不必在测试里捕获 `SystemExit`。 + #### 2.2.2 TRUSTED 模式 **语义**:信任上游网关(如 nginx、API Gateway)已经完成认证,**网关负责校验身份**,框架只读取网关注入的 header。 @@ -176,6 +188,13 @@ if auth_mode == AuthMode.TRUSTED: **关键设计**:role 不来自 header——header 说「你是谁」,框架自己要查「你能干什么」。这样即使网关被攻破或误配,也无法任意提权。 +> **主干实现注记**(F01):`TrustedAuthenticator` 查的 header 名一律是**小写常量** +> ——归一在 `bootstrap.core.auth_middleware.credentials_from_headers` 里做了一次 +> (RFC 9110 §5.1,header 名大小写不敏感),authenticator 侧不再重复处理大小写。 +> `principal_role_store.get_role` 对应主干的 `PrincipalKeyStore.get_role(actor)`: +> 参数是一个 `Scope` 而非三元组,与本仓 `Scope` 的实际形状对齐。 +> 主体查不到时抛 `AuthenticationError`(不回落任何默认 role)。 + #### 2.2.3 API_KEY 模式 **语义**:框架自己验证 API Key。Root API Key 比对成功后直接返回 ROOT 身份;普通主体的 API Key 查注册表。 @@ -196,6 +215,12 @@ if auth_mode == AuthMode.API_KEY: return identity ``` +> **主干实现注记**(F01):`compare_digest` 在主干里两边都 `.encode("utf-8")` +> 成 **bytes** 再比。str 版本在参数含非 ASCII 字符时抛 `TypeError`,那会让一次 +> 认证失败变成 500 而不是 401——把「凭据错误」暴露成「服务器错误」, +> 且绕过了统一的失败审计路径。见 +> `src/security/authenticator_impl/api_key_authenticator.py`。 + ### 2.3 API Key 系统 #### 2.3.1 Key 的存储 @@ -249,6 +274,15 @@ class PrincipalKeyStore: return key ``` +> **主干实现注记**(F01):主干把 `store_key` 拆成对外的 +> `PrincipalKeyStore.issue(actor: Scope, role: Role) -> str` 与实现内部的前缀 +> 索引维护——索引是**实现细节**,不该出现在跨实现的 ABC 上。另有三处收紧: +> `api_key_hashing_enabled` 开关**不提供**(缺 `argon2-cffi` 时在装配期抛 +> `ValidationError`,绝不回落明文,铁律 #3);`role=ROOT` 抛 +> `PermissionDeniedError`(§3.2 禁止自签发 ROOT);`actor` 必须且只能指定 +> `user` 或 `agent` 之一。第一期唯一实现注册名为 **`memory`**(进程内), +> Argon2 是它的内部细节而非后端名。 + **重要**:`api_key_hashing_enabled` 建议**默认开启**。Argon2id 推荐参数(2024+ 标准):**`time_cost=4, memory_cost=128 * 1024 (128 MB), parallelism=2`**。这是当前 OWASP 推荐的最低值,适合 2026 年的硬件水准。金融、医疗等合规场景应进一步提高(`time_cost=6+`)。如果默认关闭,当加密层也关闭时,key 就是磁盘上的裸明文。 ```python @@ -867,6 +901,18 @@ EFK长度(2B) | KeyIV长度(2B) | DataIV长度(2B) | ← 12B 定长头 密文自描述——头里记录 provider 类型,解密时按头里的 provider 类型走对应路径。 +> **实现注记(主干与本节的偏离)**:信封实现在 +> `src/common/security/security_impl/local_envelope_security_provider.py`,头是 +> **11 字节**(`!4sBBHHH`),比下方代码块里的 `HEADER_SIZE = 12` 少一字节—— +> `4+1+1+2+2+2 = 11`,12 是把 struct 的对齐算进去了。字段构成与本节一致。 +> +> **缺口:没有 `key_id`**。密文无法自述「我是用哪把根密钥加密的」,因此轮换根 +> 密钥后所有历史密文立刻不可解——只能停机全量重加密或双写。补法是在头里加一个 +> `KeyIdLen(1B)` + 变长体最前面一段 `key_id`,轮换即退化成一次配置文件编辑 +> (keyring 保留旧 key、`current_key_id` 指向新 key)。这是信封格式的改动,属于 +> `common/security/` 的面,记在 +> [storage/F02 已知遗留](../storage/F02-encrypted-storage.md)。 + ```python ENVELOPE_MAGIC = b"ENC1" VERSION = 0x01 @@ -983,6 +1029,24 @@ async def decrypt(self, org_id: str, raw: bytes) -> bytes: ... ``` +> **实现注记(主干把它做成了开关,且落在 provider 上)**: +> `LocalEnvelopeSecurityProvider` 有 `allow_plaintext` 参数(默认 `True`,即本节 +> 描述的行为)。加个开关的理由是这条兼容规则在两个部署阶段的正确答案相反: +> +> - **迁移期**必须宽松。加密层上线时,库里全是加密前写的明文;一律拒绝就等于 +> 上线即全量不可读。 +> - **迁移完成后必须收紧**。此时「读到明文」只可能意味着有人绕过了加密层直接写 +> 底层存储,或者配置被改坏了。宽松模式下这两种情况都会被静默放行——而这正是 +> 降级攻击的着力点:攻击者只要能往底层写明文,就能让读路径完全跳过解密。 +> +> 开关只有 provider 上这一个,两个存储装饰器(KV / FS)都不重复提供同语义旋钮 +> ——两个开关意味着两处配置、两种组合,其中「装饰器宽松 + provider 严格」这类 +> 组合没有任何意义,只会在排查时多一个要查的地方。 +> +> 无论开关如何,**写路径永远加密** +> (`test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed`)。 +> 开关若顺带放松了写,迁移期写进去的数据会永远是明文而调用方毫无察觉。 + ### 5.2 Key Provider 抽象 框架应通过 Key Provider 这个策略接口来解耦上层的加密逻辑与底层的密钥托管方式: @@ -1018,6 +1082,26 @@ class KeyProvider(ABC): ... ``` +> **实现注记(主干与本节的偏离)**:主干没有独立的 `KeyProvider` 顶层抽象—— +> 对外的策略接口是 `common.security.SecurityProvider` +> (`encrypt(plaintext, *, context, aad)` / `decrypt(...)` / `health()`),密钥托管 +> 方式是它的实现细节(`LocalEnvelopeSecurityProvider` 内部持有一个 +> `LocalKeyProvider` 做 HKDF 派生与 data key 包装)。两处具体偏离: +> +> 1. **接口是同步的,不是 `async def`**。`KVStore` / `FSStore` 的方法全是同步的 +> (`get(scope, key) -> bytes`)。异步 provider 会逼着同步的 `get` 内部调 +> `asyncio.run(...)`,而这在一个已有事件循环的进程里直接抛 +> `RuntimeError: asyncio.run() cannot be called from a running event loop`—— +> 也就是说,在真实的 ASGI 部署下必炸。要么整个存储层改异步(远超本期范围), +> 要么 provider 同步。选后者。远程 provider(Vault/KMS)用同步 HTTP 客户端实现, +> 这是它们的库都支持的形态。 +> 2. **`get_encryption_root_key()` 不在对外接口上**。`SecurityProvider` 只暴露 +> `encrypt` / `decrypt` / `health`,根密钥不跨接口边界。这是收紧不是缺失:把根 +> 密钥交出接口边界,就等于要求每个调用方都正确处理它的生命周期(不落日志、 +> 不进异常、用完清零)——而 KMS/HSM 类 provider **根本交不出来**,根密钥永远 +> 不离开硬件。(`LocalKeyProvider` 上还有这个方法,但那是实现内部的类,不是 +> 存储层能看到的接口。) + #### 5.2.1 LocalProvider(本地开发/单机) Encryption Root Key 存在本地文件(hex 32 字节,+ 0600 权限): @@ -1470,6 +1554,18 @@ class AuthContext: 中间件构造 `AuthContext` 后,应通过显式参数或 `ContextVar` 在单次请求内传播,并在请求结束时可靠 reset。任何 handler、LLM tool_call 或业务参数都不能覆盖其中字段。 +> **主干实现注记**(F01):主干实现在 `src/common/type_def/auth.py` +> (横切结构,故落在 `common` 而非 `security` 私有),与上表有三处差异: +> `role` 是 **`Role` 枚举**(`USER` / `ADMIN` / `ROOT`,继承 `str, Enum` 以便直接 +> 进 `AuditEvent.detail`)而非裸 `str`,且**无默认值**——「忘了传 role」不该 +> 静默得到 `user`;dataclass 是 **`frozen=True`**,落实「任何 handler 都不能覆盖 +> 其中字段」;`actor` 同样**不给默认值**,否则漏传会得到空 `Scope()` +> 即 platform-admin 全局权限,是最糟的 fail-open 形态。 +> 传播用 `ContextVar`:`set_current` / `reset_current` / `get_current`, +> **reset 必须在 `finally`**(`ThreadingHTTPServer` 复用线程,泄漏的 ContextVar +> 会让下一个请求继承上一个的身份);`get_current()` 未认证时返回 `None`, +> **不返回默认上下文**。 + 未来可按审计和协议演进增加以下字段: | 候选字段 | 语义与约束 | diff --git a/docs/features/security/F01-authentication-kernel.md b/docs/features/security/F01-authentication-kernel.md new file mode 100644 index 00000000..4a931682 --- /dev/null +++ b/docs/features/security/F01-authentication-kernel.md @@ -0,0 +1,548 @@ +# F01 — 认证内核、三档认证模式与速率限制 + +## 元信息 + +| 项 | 值 | +|---|---| +| 日期 | 2026-07-29 | +| 影响范围 | **新增**:`src/security/`(`types.py` / `authenticator.py` / `key_store.py` / `binding.py` / `rate_limit.py` / `bootstrap.py` / `authenticator_impl/` ×3 / `key_store_impl/` ×1 / `rate_limit_impl/` ×2 / `AGENTS.md`)、`src/common/type_def/auth.py`、`bootstrap/core/auth_middleware.py`、`tests/unit/security/` ×5、`tests/unit/common/test_auth.py`、`tests/unit/bootstrap/test_auth_middleware.py`、`tests/integration/test_identity_forgery_rejected.py`
**修改**:`src/common/errors.py`、`src/common/type_def/__init__.py`、`src/common/type_def/audit.py`、`src/api/memory_api_impl/assembly.py`、`bootstrap/core/handler.py`、`bootstrap/core/server.py`、`bootstrap/http_server/__main__.py`、`bootstrap/mcp_server/__main__.py`、`bootstrap/cli/client.py`、`bootstrap/cli/__main__.py`、`pyproject.toml`、`tests/unit/api/test_handler_identity_split.py`、`tests/unit/api/test_dispatch_management_compat.py` | +| 测试基线 | 改动前 `2 failed, 656 passed, 60 skipped`;改动后 `2 failed, 788 passed, 60 skipped`。**两个失败是同一对**(`test_bge_m3_embedder.py` 的 `torch` 未安装,`embed` extra 未装),与本改动无关 | +| 依据 | [`docs/features/common/F04-security-interfaces-and-encryption.md`](../common/F04-security-interfaces-and-encryption.md) §1.1 核心不变量、§2 认证、§3 授权角色、§7 审计、§8.1 速率限制、§9 铁律 #1 | + +> **行文简称**:下文(及本模块所有代码注释)里的 **security.md** 一律指上表「依据」 +> 那份文档。它原在 `docs/security/security.md`,上游 `c76eb90` 把它迁进了 +> common 特性归档并改名为 `F04-security-interfaces-and-encryption.md`;章节编号未变, +> 故简称与 §号沿用不改。 + +> **为什么速率限制在这份文档里而不是单开一份**:它唯一的存在目的是保护认证。 +> API_KEY 模式下每次 `authenticate` 跑一次 Argon2id verify(128 MiB × time_cost=4, +> 约 50~200ms),无限制触发能把进程的 CPU 与内存同时打满——**这个可用性风险 +> 是引入 Argon2 时一并带进来的**,不是一件独立的事。把「留了个洞」和「补上了」 +> 记在同一份文档里,比拆成两份、再让读者去两处对照要诚实。 + +## 背景 + +### 漏洞:身份可由请求体伪造 + +改动前 `bootstrap/core/handler.py` 的 `_actor_scope(payload)` 直接从请求体 +读取调用方身份: + +```python +def _actor_scope(payload: Body) -> Scope: + """Claimed actor scope; defaults to payload scope, with optional explicit override.""" + if any(key in payload for key in ("actor_tenant_id", "actor_scope", ...)): + ... + return Scope(org=actor_org, user=str(payload.get("actor_scope", "")), ...) +``` + +docstring 自己写了 "Claimed" —— 这是**调用方声明的**身份,未经任何校验。 + +利用链(已在改动前的 HEAD 上端到端实测): + +```python +srv.dispatch("add", {"tenant_id": "acme", "scope": "alice", "content": "alice secret"}) +# → 200,alice 写入 + +srv.dispatch("search", {"tenant_id": "acme", "scope": "alice", "query": "secret", + "actor_tenant_id": "evil", "actor_scope": "mallory"}) +# → 403,攻击者用真实身份读,被正确拒绝 + +srv.dispatch("search", {"tenant_id": "acme", "scope": "alice", "query": "secret", + "actor_tenant_id": "acme", "actor_scope": "alice"}) # 改两个字段 +# → 200 ['alice secret'] +``` + +**授权层是对的**(honest read 正确返回 403);洞在于**认证层根本不存在**, +攻击者可以任意填写 `actor_*` 把自己变成任何人。这两行 payload 的差异就是本 +特性要消除的东西。 + +`_actor_scope` 在 `handler.py` 有 13 处调用点,覆盖 add / search / get / +update / delete / evolve / job / inspect / trace / audit / admin / grant / +revoke —— 即**全部动词**,含管理面与授权面。 + +> **一处曾经的误判,留作记录**:起初以为最短利用链是「提交 +> `{"actor_tenant_id": " "}` 得到空 `Scope()` → 命中 +> `SQLitePermissionManager.check` 的 platform-admin 全局放行」。实测不成立: +> `_actor_scope` 不做 strip,`" "` 原样进 `Scope(org=" ")`,与空 `Scope()` +> 不相等;空 org 分支会回退到 `tenant_id`(默认 `"default"`)。危害不因此 +> 降低——「冒充任意已知主体」已是完全的越权读写,只是不能一步登顶 +> platform admin。测试按实测形态写。 + +### 三道防线的覆盖变化 + +| 防线 | security.md | 改动前 | 改动后 | +|---|---|---|---| +| ① 认证 | §2 | **完全没有** | DEV / TRUSTED / API_KEY 三档,配置选定 | +| ② 授权 | §3 | 有(`PermissionManager` + PEP 在 `LocalMemoryAPI._authorize`) | 不变,但**输入端从「调用方声明」换成「认证层产出」** | +| ③ 数据保护 | §5 | 没有 | 不变(见 [storage/F02 加密存储](../storage/F02-encrypted-storage.md)) | +| 审计 | §7 | 有 | 增记认证失败与限流拒绝事件 | +| 速率限制 | §8.1 | 没有(也不需要) | `RateLimiter` 抽象 + 令牌桶实现,挂在认证之前 | + +### 速率限制要挡的是什么 + +认证挡住了「冒充身份」,但它自己成了新的攻击面:`Argon2` 的 50~200ms 单次成本 +在无限制调用下是**放大器**而非防护,几十个并发失败请求就能把 CPU 打满,认证 +本身变成 DoS 面。这不是理论风险——`memory_key_store.py` 用的是 OWASP 2024+ +推荐参数(128 MiB × time_cost=4),单次 verify 的内存占用就是 128 MiB。 + +所以三道防线之外还要补第四件事,且它必须挂在**认证之前**:等认证跑完再限流, +被保护的资源已经消耗掉了。 + +## 决策 + +### 决策 1:ROOT 的 actor 是空 `Scope()`,不是 `Scope(org="*")` + +security.md §2.2.1 的示例写 `Scope(org="*")`。在本仓这**不能用**: +`SQLitePermissionManager.check` 的第一条规则是 `actor == Scope() → True` +(platform admin 全局放行),而 `org="*"` 会先撞上「跨 org 一律拒绝」规则 +——ROOT 反而寸步难行。 + +连带结论:`AuthContext.actor` **不给默认值**。若给了,「忘了传 actor」会 +静默得到空 `Scope()` 即全局权限——最糟糕的 fail-open 形态。 + +### 决策 2:认证不进 `build_kernel` + +认证是**传输层相关**的(凭据从 HTTP header / MCP / CLI 各自的形态来), +内核形态无关。放进 `build_kernel` 会让 `LocalMemoryAPI` 同时承担 AuthN 与 +AuthZ 两件事。 + +落点:`src/security/` 提供契约与实现,`Server.build`(bootstrap 层)装配 +authenticator,`auth_middleware` 在各 surface 的请求入口调用。内核只接收 +已认证的 `identity`。 + +`register_security()` 必须在 `KernelConfig.from_dict` **之前**调用—— +`authenticator` / `key_store` 两个顶层段名要先进 +`Factory.known_top_names()`,否则配置解析期会把它们当未知段拒掉。 + +### 决策 3:无 argon2-cffi 时 fail-closed,不回退明文 + +`argon2-cffi` 是 `security` extra 的可选依赖。缺失时 `key_store` 在**装配期** +抛 `ValidationError`,绝不降级为明文比对或 sha256 单轮。security.md §2.3.1 +明说那让 key 变成磁盘裸明文;铁律 #3 fail-closed。 + +启动失败比静默地用一个不安全的存储好。 + +### 决策 4:MCP 在非 DEV 模式下不可用 + +MCP 的凭据传递机制(security.md §2.5)需要专门设计。第一期 MCP surface 与 +CLI 的 `InProcessClient` 一样过一个**空** `Credentials()`:DEV 模式下可用, +非 DEV 模式下全部工具调用认证失败。 + +**这是有意的**:在 §8.2「MCP 协议的攻击面」设计落地前,让 MCP 在生产模式下 +不可用,好过让它无认证可用。限制已写进 `mcp_server` 的模块 docstring。 + +### 决策 5:payload 里的 `actor_*` 字段报 400,不静默忽略 + +删掉读取逻辑后客户端仍会继续发这些字段。静默忽略会让运维以为「我加了 +actor_scope 限制」仍然生效,写出错误的安全认知。显式报错迫使调用方改用凭据。 + +例外:`audit` verb 的 `actor_agent` / `actor_session` 是**查询过滤谓词** +(筛「历史事件的操作者是谁」),与身份声明同名但语义不同,对该 verb 放行。 + +### 决策 6:默认配置(无 `authenticator` 段)回落 DEV 并 WARNING + +不打断任何人的本地开发。第一期只是把「无认证」从**隐式且不可改**变成 +**显式、可切换、且非 localhost 时拒绝启动**。 + +DEV 模式绑非 loopback 地址时进程**拒绝启动**(返回码 1 + stderr FATAL), +而不是警告——警告会被忽略,而这个错配的后果是全部数据。 + +### 决策 7:限流按调用方地址分桶,不按 `key_fp` + +security.md §8.1 的草图按 `key_fp` 分桶。那防的是「单个合法 key 打爆配额」 +(配额公平),不是这里要防的「攻击者打爆 CPU」——攻击者每次换一把随机 key 就 +换一个新桶,按 `key_fp` 分桶对枚举与耗尽两种攻击都不生效。真正能收敛攻击的是 +来源地址。按 key 的配额公平是独立需求,本期不做。 + +### 决策 8:`allow()` 返回 `bool`,不抛异常 + +限流是**事实陈述**,翻译成 HTTP 429 是 `auth_middleware` 的事。这与 +`PrincipalKeyStore.resolve` 返回 `None` 同理,且不构成 fail-open——调用方拿到 +`False` 唯一能做的就是拒绝。 + +`RateLimitedError` 进 `common/errors.py`(与 `AuthenticationError` 并列):它 +**是**跨层契约,429 与 401 的语义完全不同——一个该稍后重试,一个该换凭据。 +回归防线:`test_rate_limited_is_not_an_authentication_error`。 + +### 决策 9:`peer` 为空串时放行 + +进程内直连与 MCP stdio 没有网络对端,没有可收敛的攻击面,限流只会把本地 CLI +卡住。所以 `Server.build` 只在 HTTP surface 传 limiter,其余 surface 传 `None`。 + +### 决策 10:默认按认证模式分岔,非 DEV 默认**开** + +限流保护的具体对象——Argon2id verify——只在 API_KEY 模式下存在;DEV 模式已被 +强制绑定 localhost(决策 6),没有远端攻击面,此时限流只会把本地压测和调试脚本 +卡住。 + +非 DEV 默认开而不是默认关:默认关等于「必须读过 §8.1 才知道要配」,而没配的 +后果是一个能打挂进程的可用性漏洞。默认开的代价是运维可能撞上 429,但那会伴随 +一个明确的状态码和一个明确的配置项;默认关的代价是**没有信号**。 + +### 决策 11:桶表 LRU 有界 + +桶按 peer 建,而 peer 由远端决定。无界字典会让这个「防资源耗尽」的组件自己变成 +资源耗尽的入口。超出 `max_tracked`(默认 10000)时淘汰最久未活跃的那个——它最 +可能已经补满,淘汰等于重建成满桶,不丢有效状态。 + +关闭限流必须显式配 `target: unlimited`,不能靠把 `capacity` 写成 0:那种反着读 +的魔法值在配置文件里读不出意图(`capacity: 0` 是「一个令牌都不给」还是「不 +限流」?),而读不出来的配置就是会被写错的配置。参数非法在**装配期**报错。 + +### 决策 12:Argon2 verify 进程级并发上限(审计 P1-3) + +IP 令牌桶限的是「单地址的请求速率」,限不住「同时在跑的 Argon2 verify 数」-- +后者才是 CPU/内存耗尽向量:单 IP 30 个并发错误 key = 30 × 128 MiB 同时驻留 ≈ +3.75 GiB。新增 `security/concurrency_guard.py` 的 `Argon2Guard`(进程级 +`BoundedSemaphore`),在 `auth_middleware.authenticated` 里 limiter 之后、 +authenticate 之前 acquire,耗尽即 429(非阻塞,不排队--排队会让线程无界堆积)。 +acquire 成功后用 `finally` 释放。默认上限 4(按「给认证留 512 MiB」算),由 +`argon2.max_concurrent` 配置。**只装配到 `AuthMode.API_KEY`**(验收修正:TRUSTED +不跑 Argon2,装上会让受信网关高并发无端收 429);DEV 亦不装。不进 Factory: +进程级状态按配置实例化多份没有意义,用 `default_argon2_guard()` 取单例。同进程 +重复装配不同 `max_concurrent` 报错(不静默忽略);`max_concurrent=0` 装配期炸 +(不用 `or` 吞成默认)。 + +### 决策 13:加密默认 fail-closed,`allow_plaintext` 默认 False(审计 P2-3) + +`LocalEnvelopeSecurityProvider` 此前默认 `allow_plaintext=True`:读不带 ENC1 magic +的内容直接原样返回。迁移期方便,但迁移完成后,拥有底层存储写权限的攻击者可用任意 +明文替换密文,绕过 AES-GCM tag 与 AAD。改为默认 `False`(fail-closed);迁移期读 +旧明文须显式 `allow_plaintext=true`,且应有结束条件(迁移完成后关闭、计数归零)。 + +### 决策 14:HTTP 两阶段准入与全局连接上限(审计 P2-4 / 验收 P1-HTTP) + +此前 `handle_post` 先 `rfile.read(length)` 再进认证/限流,无上限意味着超大 body、 +负数/非数字 Content-Length、慢速上传都能耗尽内存与线程。改为**两阶段准入**: +(1) `_parse_content_length` 只校验 header(非数字/负数 -> 400,超 4 MiB -> 413), +不读 body;(2) 提凭据 + limiter/认证--慢连接在读 body 前就被 limiter/认证挡住; +(3) 通过后才 `_read_body` 按已校验长度读。`Handler.timeout`(默认 30s)防单连接 +慢速上传占线程;`daemon_threads=True` 让慢请求线程不阻塞进程退出。 + +验收补强:单连接 timeout 限不住「持续补充连接」的线程耗尽。新增 +`_BoundedThreadingHTTPServer`:`process_request` 入口用 `BoundedSemaphore` +(`_MAX_CONCURRENT_REQUESTS` 默认 256)限并发,耗尽直接 503 拒绝(不进 handle、 +不占读 body 预算)。release 在处理线程结束(`_process_and_release`)而非 spawn 后, +否则限不住。慢上传与超限连接测试见 `test_http_body_limits.py` / `test_http_slow_upload.py`。 + +### 决策 15:FS 文件大小硬上限(审计 P2-5 / 验收 P2-FS / 复验 P2-FS) + +AES-GCM 整块认证要求把整个明文读入内存再加密(见 `encrypted_fs_store.py` 的已知 +代价),无上限意味着一个超大输入能把进程内存吃满。`EncryptedFSStore` 新增 +`max_plaintext_bytes`(默认 64 MiB)与 `max_ciphertext_bytes`(默认明文上限 + +安全余量,可显式配)。**读写两侧都用循环有界读取**(`_read_bounded_stream`): +反复 `read(remaining)` 直到 EOF 或累计达到 `limit+1`,超限即拒。 + +为何用循环而非单次 `read(limit+1)`:`BinaryIO.read(n)` 允许短读(返回 < n 字节 +而未 EOF),单次调用会把第一段当完整文件,造成**静默数据截断**(复验问题 1)。 + +读取侧 `stat` 只作**快速早拒**,不是唯一边界--stat 与随后 `get` 之间内容可能变化 +(TOCTOU),故真正读取仍用循环有界,且解密后**复核**明文上限(密文被替换成另一个 +合法但解出超大的信封也要拒)。密文开销不硬编码某个 provider 的精确值(SecurityProvider +ABC 不暴露 ciphertext bound),用宽松余量,需精确控制时显式配 `max_ciphertext_bytes`。 + +chunked 加密(第一期不做)落地后可放宽。完整 chunked format(每块绑 chunk index、 +防重排截断拼接、spooled buffer)是独立设计,不在本期。 + +### 决策 16:`Scope` 改为 frozen 值对象(验收第三次 P2-1) + +`AuthContext(frozen=True)` 此前只是浅冻结--`actor: Scope` 可变,签发 key 后改原 +actor 的 org/user 会让已签发 key 的身份跟着变(越权)。`_Record.actor` 也直接保存 +调用方原始引用。`Scope` 改为 `@dataclass(frozen=True)`:身份/隔离是值对象,可变性 +是安全缺陷;改某维用 `dataclasses.replace(scope, org=...)` 返回新值。影响面仅 +`kv_space_manager` 两处原地修改(已改为 `replace`),不改变入参出参类型契约。 + +FS 短读修复(验收第三次 P2-2):`_read_bounded_stream` 改用 `bytearray` 累积而非 +`list[bytes]` + `join`--恶意 1-byte 短读会让 list 长出百万级元素,8 MiB 内容放大到 +~700 MiB。bytearray 是连续缓冲区,内存与字节数成正比,不随分片数放大。 + +## 需要增加的接口 + +### `common.type_def.auth`(横切结构,不属安全层私有) + +| 名称 | 签名 / 字段 | 说明 | +|---|---|---| +| `Role` | `USER` / `ADMIN` / `ROOT`(继承 `str, Enum`) | 三级角色(§3.1);继承 `str` 使其可直接进 `AuditEvent.detail` | +| `ROLE_RANK` | `dict[Role, int]` | 角色偏序,供降级检测:签发方不得签出高于自身的角色 | +| `AuthContext` | `actor: Scope`(**无默认值**)、`acting_user: str`、`role: Role`、`from_oauth: bool`、`authorizing_key_fp: str`;`frozen=True` | 认证层产出的**可信**请求级上下文 | +| `set_current(ctx) -> Token` | | 请求入口设置 | +| `reset_current(token) -> None` | | **必须在 `finally`** 中调用 | +| `get_current() -> AuthContext \| None` | | 未认证返回 `None`,**不返回默认上下文** | + +### `security.types` + +| 名称 | 字段 | 说明 | +|---|---|---| +| `AuthMode` | `DEV` / `TRUSTED` / `API_KEY` | 刻意**不定义** `OAUTH`——第二期加它时是纯新增 | +| `Credentials` | `api_key: str`、`headers: Mapping[str, str]`(键已归一小写)、`peer_address: str`;`frozen=True` | 认证层不认识 HTTP;只保留认证需要的三样 | + +### `security.authenticator` + +| 方法 | 签名 | 契约 | +|---|---|---| +| `authenticate` | `(credentials: Credentials) -> AuthContext` | **不返回 None**;失败抛 `AuthenticationError`,消息一律笼统 | +| `mode` | `() -> AuthMode` | 供启动期 guard 与审计 | +| `health` | `() -> None` | 与 `ControlOperator` 同构 | + +工厂:`AuthProducer(Factory)`,`TOP_NAME = "authenticator"`。 + +### `security.key_store` + +| 方法 | 签名 | 契约 | +|---|---|---| +| `issue` | `(actor: Scope, role: Role) -> str` | 返回**一次性明文**;`role=ROOT` 抛 `PermissionDeniedError`(§3.2 禁止自签发 ROOT);`actor` 必须且只能指定 `user` 或 `agent` 之一 | +| `resolve` | `(api_key: str) -> AuthContext \| None` | **允许返回 None**(查表未命中的事实陈述,非 fail-open);三条路径各恰好一次 Argon2 verify | +| `revoke` | `(key_fp: str) -> None` | 幂等 | +| `get_role` | `(actor: Scope) -> Role \| None` | TRUSTED 模式据此实现「role 不从 header 读」 | +| `health` | `() -> None` | | + +模块级函数:`fingerprint(api_key)`(sha256 十六进制,确定性查找键)、 +`key_prefix(api_key)`(前 8 字符,前缀索引)、`generate_api_key()` +(`secrets.token_urlsafe(32)`,256 bit 熵)。 + +工厂:`KeyStoreProducer(Factory)`,`TOP_NAME = "key_store"`。 + +### `security.binding` + +`check_dev_binding(hosts) -> None` —— 非 localhost 抛 `ValidationError`。 +纯函数、**不 `sys.exit`**:exit 语义留在进程入口,本函数可被单测直接断言。 + +### `bootstrap.core.auth_middleware` + +| 名称 | 签名 | +|---|---| +| `credentials_from_headers` | `(headers, peer_address="") -> Credentials`;header 名归一小写;`Authorization: Bearer` 优先,回落 `X-Api-Key` | +| `authenticated` | `@contextmanager (authenticator, credentials, audit=None, limiter=None) -> Iterator[AuthContext]`;限流在 `authenticate` **之前**;reset 在 `finally` | + +### `security.rate_limit` + +| 方法 | 签名 | 契约 | +|---|---|---| +| `allow` | `(peer: str) -> bool` | 有额度则消耗一个返 `True`,否则 `False`;**必须并发安全**(`ThreadingHTTPServer` 每请求一线程,「读余量 → 减一 → 写回」在 GIL 下不是原子的);`peer` 为空串放行 | +| `health` | `() -> None` | 与其他安全组件同构 | + +工厂:`RateLimitProducer(Factory)`,`TOP_NAME = "rate_limiter"`(新增顶层配置段)。 +实现:`token_bucket`(`capacity` 管突发、`refill_per_sec` 管持续速率、 +`max_tracked` 管桶表上界)与 `unlimited`(恒放行)。 + +## 配置草案 + +```yaml +memory_api: + authenticator: + default: + target: api_key # dev(缺省) / trusted / api_key + params: + root_api_key: ${AGENT_MEMORY_ROOT_KEY} # 部署级凭据,不入注册表 + key_store: shared # 引用下方具名实例 + + key_store: + shared: + target: memory # 进程内;生产需 SQLite 后端(见「已知遗留」2) +``` + +TRUSTED 模式: + +```yaml +memory_api: + authenticator: + default: + target: trusted + params: + gateway_key: ${GATEWAY_SHARED_SECRET} # 默认必须配置;缺则装配期拒绝启动(决策 P1-2) + key_store: shared +``` + +网关须注入 `X-Org-Id` / `X-Principal-Type`(`user` \| `agent`)/ +`X-Principal-Id`。**角色不从 header 读**——框架查 +`PrincipalKeyStore.get_role`。 + +CLI 在 `--server` 模式下带 key:`--api-key`,缺省读环境变量 +`AGENT_MEMORY_API_KEY`(让 key 不出现在 shell history 与 `ps` 输出里)。 + +速率限制(不配整段时按认证模式给默认,见决策 10): + +```yaml +rate_limiter: + default: + target: token_bucket # 或 unlimited + params: + capacity: 30 # 突发额度 + refill_per_sec: 5.0 # 持续速率 + max_tracked: 10000 # 桶表上界(LRU 淘汰) +``` + +默认值面向「交互式使用不该被限流,脚本化枚举必须被限流」这条线:30 个突发够 +任何人工操作和常规客户端启动时的几次探测;持续 5 QPS 远低于 Argon2 verify 打满 +一个核所需的速率。 + +## 破坏性变更 + +| 变更 | 谁受影响 | 迁移方式 | +|---|---|---| +| payload 的 `actor_*` 字段报 400 | 显式传这些字段的客户端 | 删掉这些字段,改用凭据 | +| 非 DEV 模式下无凭据请求返回 401 | 所有现有客户端 | 保持默认(DEV),或签发 key 并带 `Authorization: Bearer` | +| MCP 在非 DEV 模式下全部工具调用失败 | MCP 客户端 | 第二期解决;当前用 DEV | +| `Kernel` 新增 `audit` 字段 | 直接构造 `Kernel(...)` 的代码 | dataclass 带默认值字段,向后兼容 | +| `Server.__init__` 新增 `authenticator` 参数 | 直接构造 `Server(...)` 的代码 | 带默认值 `None`,向后兼容;但 `dispatch` 需要中间件已挂载,否则 401 | +| `handler.dispatch` 在无认证上下文时返回 401 | 直接调 `dispatch` 的测试与脚本 | 用 `set_current` / `authenticated` 包一层 | +| 非 DEV 模式下 HTTP 请求默认受限流(30 突发 / 5 QPS) | 高频客户端、压测脚本 | 调 `rate_limiter` 段的参数,或配 `target: unlimited` | + +**默认配置下(无 `authenticator` 段 → DEV)现有行为不变**:所有请求得到 ROOT, +且不限流。 + +一个**部署形态**注意事项:网关后部署(TRUSTED 模式的常见形态)所有请求共用网关 +出口 IP,会被当成同一个 peer。这种部署应显式配 `target: unlimited` 把限流交给 +网关,或按聚合流量调大 `capacity`。 + +## 验证计划 + +| 文件 | 覆盖 | 结果 | +|---|---|---| +| `tests/unit/common/test_auth.py` | `AuthContext` frozen / `actor` 无默认 / ContextVar 线程隔离与 reset | 11 passed | +| `tests/unit/security/test_authenticator.py` | ABC 契约 / Producer 注册 / bootstrap 幂等 | passed | +| `tests/unit/security/test_key_store.py` | issue / resolve / revoke / ROOT 禁签 / **timing pad** / 不存明文 | passed | +| `tests/unit/security/test_authenticator_impl.py` | 三实现的正反路径 / 错误消息一致 | passed | +| `tests/unit/security/test_binding.py` | DEV localhost guard 的各类拒绝 | passed | +| `tests/unit/security/test_rate_limit.py` | 突发/补充/并发/LRU/空 peer/装配期参数校验 | 16 passed | +| `tests/unit/bootstrap/test_auth_middleware.py` | header 归一 / bearer 提取 / **reset 保证** / 限流接线 | 20 passed | +| `tests/integration/test_identity_forgery_rejected.py` | **端到端伪造身份被拒** | 5 passed | + +`tests/unit/security/` 共 80 passed。 + +限流侧的关键断言: + +| 断言 | 落点 | +|---|---| +| 超出突发额度即拒绝;补充速率生效后恢复 | `test_burst_up_to_capacity_then_denied` / `test_tokens_refill_over_time` | +| **并发下不超发**(多线程抢最后一个令牌) | `test_concurrent_requests_do_not_exceed_capacity` | +| 桶表 LRU 有界,不随 peer 数无限增长 | `test_bucket_table_is_bounded` / `test_eviction_drops_least_recently_used` | +| `peer` 为空串放行 | `test_empty_peer_is_never_limited` | +| **限流跑在认证之前**(认证器一次都没被调到) | `test_rate_limit_runs_before_authentication` | +| 429 与 401 可分;限流拒绝不留下上下文 | `test_rate_limited_is_not_an_authentication_error` / `test_rate_limited_leaves_no_context` | +| 审计里限流与认证失败分得开,且**不记桶余量** | `test_rate_limit_denial_is_audited_distinctly` / `test_rate_limit_audit_carries_no_bucket_state` | +| 关闭限流只能显式配 `unlimited`,`capacity: 0` 报错 | `test_disabling_is_explicit_not_a_magic_value` | + +> 「不记桶余量」是一条容易漏的:余量能用来反推限流参数,然后贴着阈值发请求。 +> 审计 detail 恰好只有 `mode` 与 `peer` 两个键,测试用 `set(detail) == {...}` +> 精确断言,多一个键就红。 + +### 招牌测试的实现顺序 + +按 CLAUDE.md §5「写测试先于修 bug」:先写 +`test_identity_forgery_rejected.py`、跑一遍**看它全红**(证明漏洞真实存在)、 +再做改动、再看它全绿。其中 `test_identity_comes_from_context_not_payload` +比 `test_claimed_identity_in_payload_is_rejected` 更重要——前者证明「堵上 +之后认证与授权确实串起来了」,后者只证明「洞堵上了」。 + +### timing 测试的 flaky 防护 + +`test_resolve_pads_time_on_miss` 各跑 5 次取**中位数**(不是平均,避免单次 +GC 抖动主导),断言比值在 `[0.5, 2.0]`。区间宽是因为要检出的是「差一整个 +Argon2 verify」(~100x),不是微小偏差。 + +> **这条测试实测抓到过一个真实缺陷**:初版实现里「前缀有候选但 key 错」 +> 会跑**两次** verify(候选一次 + 落空后的 dummy 一次),而「前缀无候选」 +> 只跑一次,ratio=0.49。修的是实现不是断言——加 `verified_any` 标志, +> 只在没跑过任何候选 verify 时才补 dummy。三条路径各恰好一次。 + +### 手工验收(不进自动化测试) + +- DEV 模式 + `--host 0.0.0.0` → 进程返回码 1,stderr 有 FATAL ✓ +- DEV 模式 + `--host 127.0.0.1` → 正常启动 ✓ +- API_KEY 模式下 `/healthz` 无凭据 → 200 ✓ +- API_KEY 模式下 `POST /v1/add` 无凭据 → 401;带 root key → 200 ✓ +- `examples/quickstart.py` 行为不变——注意它**改动前就有一个既有失败** + (最后一步 `admin_all` 用普通 user 身份调管理面得 `PermissionDeniedError`)。 + 改动后仍是**同一个**失败 ✓ + +## 拒绝的方案 + +### 拒绝 1:认证做进 `build_kernel` + +内核形态无关,认证是传输层相关的;且会让 `LocalMemoryAPI` 同时承担 AuthN +与 AuthZ。见决策 2。 + +### 拒绝 2:无 argon2-cffi 时回退明文存储 + +security.md §2.3.1 明说那让 key 变成磁盘裸明文;铁律 #3 fail-closed。 +装配期抛错,见决策 3。 + +### 拒绝 3:`get_current()` 返回默认 `AuthContext` + +fail-open。中间件漏挂时请求会带着默认身份跑完,而且**没有任何症状**—— +系统看起来完全正常,直到有人发现所有操作都以同一个身份记在审计里。 +返回 `None` 迫使调用方显式处理。 + +### 拒绝 4:静默忽略 payload 里的 `actor_*` 字段 + +见决策 5。 + +### 拒绝 5:`AuthDispatcher` 一个类里 if/else 分流三种模式 + +参考 demo 的写法。拆成三个各自只做一件事的 `Authenticator` 实现 + Producer +按配置选:模式在**装配期**选定,运行期不再分流。一个 if/else 分流器意味着 +每次请求都要重新判断「我是哪种模式」,而那是启动时就确定的事。 + +### 拒绝 6:错误消息区分「主体不存在」与「凭据错误」 + +区分即主体枚举侧信道(§2.3.2)。三个 authenticator 一律抛 +`"authentication failed"`。具体原因应写进审计——但见「已知遗留」9。 + +### 拒绝 7:限流按 `key_fp` 分桶 + +见决策 7。攻击者每次换一把随机 key 就换一个新桶。 + +### 拒绝 8:`capacity: 0` 表示不限流 + +反着读的魔法值在配置文件里读不出意图,而读不出来的配置就是会被写错的配置。 +关闭限流走显式的 `target: unlimited`,见决策 11。 + +## 已知遗留 + +1. **Argon2 128MiB×4 使单次 `resolve` 约 50~200ms**,API 吞吐上限约 + 5~20 QPS/核。高 QPS 需要带撤销传播的验证缓存(第二期)。参数取 + OWASP 2024+ 推荐值,不下调。 +2. **`InMemoryKeyStore` 进程重启即丢全部 key**。生产需 SQLite 后端。 + 注册名是 `memory`(Argon2 是内部实现细节,不是后端名)。 +3. **MCP 在非 DEV 模式下全部工具调用失败**。§2.5 的凭据传递待第二期设计。 + 见决策 4。 +4. **限流是进程内的,多副本各算各的**:N 个副本 = N 倍实际额度。真正的多副本 + 限流要 Redis 之类的共享计数器,届时在 `rate_limit_impl/` 下新增一个实现, + 中间件不用改(契约已留在 `rate_limit.py`)。 +5. **按地址分桶挡不住僵尸网络**:来源足够分散时每个 IP 都拿到一个新满桶。能 + 收敛这种攻击的是**对 Argon2 verify 本身做并发上限**(一个信号量,把同时 + 进行的 verify 数压到内存能承受的范围)--已由决策 12 的 `Argon2Guard` 实现。 +6. **无按 key 的配额公平**。§8.1 草图里的 `key_fp` 分桶防的是「单个合法 key + 打爆配额」,与本期防的攻击不是一件事(决策 7)。它是独立需求。 +7. **审计无链式 HMAC 完整性保护**。§7.3,第二期。 +8. ~~**`handler.py:_event_view` 硬编码 `Scope` 四字段** + (`org` / `user` / `agent` / `session`)。F03 加 `space` 后这里会漏字段。~~ + **不成立**:上游 `c76eb90` 落地五维 `Scope` 时已一并改全了三处渲染点—— + `handler.py:_scope_view`、`storage/_support.py:scope_segments`(五段占位)、 + `SqliteAuditLogger` 的 `actor_space` 列(含 `ALTER TABLE` 迁移)。写下这条时 + 只查了四维版本的 handler,没复核上游同批提交,是我的疏漏。 +9. **`/healthz` 返回 profile 名**,未做信息暴露评估。profile 名是部署配置的 + 一部分但不是秘密,且改它会破坏现有客户端的 `healthz()` 契约,第一期保持 + 原样。 +10. **`Role.ADMIN` 无任何管理接口**。角色枚举已定义但**无任何消费方**: + `PermissionManager.check(actor, target, action, context)` 的签名里没有 role + 的位置,故 §3.2 权限清单里「管理本租户 user/agent」「创建/删除租户」 + 「系统级配置修改」三行**无法表达**,ADMIN 与 USER 走完全相同的判定路径。 + §3.5 的「提升式 ROOT」同理不存在(`check` 首条是 `actor == Scope()`,认的是 + actor 形状不是 role)。这是授权侧的缺口,归隔离/权限那一期。 +11. **认证失败审计不记细分原因**。`_record_failure` 只记 + `mode` + `peer`。真实原因(`missing_credentials` / `unknown_principal` / + `bad_gateway_key`)需要在 authenticator 侧另开一条**只进审计**的通道 + ——异常消息必须保持笼统(拒绝 6)。那是独立设计,不塞进本期。 +12. **`AuditEvent` 缺 `acting_user` / `role` / `key_fp` / `auth_mode` 字段**。 + security.md §7.2 要求记录这四样,第一期塞进 `detail`(`dict[str, str]`) + 并在 `audit.py` 的「常见约定」注释里登记。不改 `AuditEvent` 结构——那是 + 跨层结构体,改它要动 `common` / `control` / 两个 `AuditLogger` 实现 + + `handler._event_view`。若这些键稳定使用,第二期应提升为一等字段。 +13. ~~**`Scope` 仍是四维**。安全模块按四维实现,但所有 `Scope` 构造一律用 + **keyword 参数**,为 F03 插入 `space` 留接缝(位置参数会错位)。~~ + **已解除**:上游 `c76eb90` 落地了五维 `Scope`(`space` 是 `kw_only`)。 + 因为构造全用 keyword,安全模块无需任何改动即兼容;`_FORBIDDEN_IDENTITY_KEYS` + 跟着补了 `actor_space` / `actor_space_id` 两个新伪造面 + (`test_space_dimension_identity_claims_are_rejected`)。 diff --git a/docs/features/storage/F02-encrypted-storage.md b/docs/features/storage/F02-encrypted-storage.md index 978c24c1..bc251193 100644 --- a/docs/features/storage/F02-encrypted-storage.md +++ b/docs/features/storage/F02-encrypted-storage.md @@ -1,12 +1,12 @@ -# F02 — 加密 KV 存储设计(EncryptedKVStore) +# F02 — 加密存储设计(EncryptedKVStore / EncryptedFSStore) ## 元信息 | 项 | 值 | |---|---| -| 日期 | 2026-07-27 | -| 影响范围 | src/storage/kv_impl/encrypted_kv_store.py,src/storage/kv_impl/__init__.py,src/common/security/,docs/specs/S06-storage.md,docs/features/common/F04-security-interfaces-and-encryption.md | -| 测试基线 | `tests/unit/storage/test_encrypted_kv_store.py` 覆盖加密写入、读后解密、scan 解密、透传操作、工厂装配与失败关闭;`pytest -q tests/unit/storage`、相关模块测试、ruff 与 `git diff --check` 已通过 | +| 日期 | 2026-07-27(KV 侧);2026-07-29(FS 侧补入) | +| 影响范围 | src/storage/kv_impl/encrypted_kv_store.py,src/storage/kv_impl/__init__.py,src/storage/fs_impl/encrypted_fs_store.py,src/storage/fs_impl/__init__.py,src/common/security/,docs/specs/S06-storage.md,docs/features/common/F04-security-interfaces-and-encryption.md | +| 测试基线 | KV 侧:`tests/unit/storage/test_encrypted_kv_store.py` 覆盖加密写入、读后解密、scan 解密、透传操作、工厂装配与失败关闭。FS 侧:`tests/unit/storage/test_encrypted_fs_store.py` 13 条全绿;单元全量 `15 failed, 814 passed, 1 skipped`,15 个失败全为预存在的环境失败(14 个 `test_jieba_tokenizer.py` 缺 `nlp` extra,1 个上游 `test_local_security_provider.py` 断言 `0o600` 权限位、Windows `os.chmod` 设不出来,已在纯上游代码上复现)。相关模块测试、ruff 与 `git diff --check` 已通过 | | Refs | — | ## 背景 @@ -15,6 +15,8 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 因此加密能力需要落在 KV 边界:对调用方保持 `KVStore` 合同不变,对底层后端只写入密文。算法、密钥来源、明文兼容策略不归 storage 层管理,而是由 `src/common/security/` 的 `SecurityProvider` 提供。 +**FS 侧同理,且更迫切**:原模态资产(上传的文档、图片、音视频)走 `FSStore`,它们往往比 KV 里的结构化记忆更敏感,却完全裸着落盘。静态加密保护的是**访问路径之外**的泄露面——拿到磁盘快照的人绕过了认证与授权,因为快照根本不走访问路径。KV 侧先落地,FS 侧随后按同一形态补齐。 + ## 决策 1. **EncryptedKVStore 是 KV 装饰器,不是真正的物理后端** @@ -82,6 +84,78 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 `exists`、`delete`、`scopes` 只依赖 key/scope,不需要读取 value,因此直接透传给 raw KV。租户删除、session 清理、TTL 过期等生命周期操作删除的是密文记录,不要求先解密。 +## FS 侧:EncryptedFSStore + +### 9. FS 装饰器与 KV 装饰器同构,不自带任何密码学 + +`EncryptedFSStore` 做的事和 `EncryptedKVStore` 逐条对应:构造 `SecurityContext` 与 AAD,转发给注入的 `SecurityProvider`。密码学一行都不在 storage 里。 + +依赖方向 `storage → common.security`,单向;security 不认识 Store。回归防线:`test_encrypted_fs_is_registered_by_storage_bootstrap`。 + +### 10. 装饰器住在 `src/storage/`,不住在 `src/security/` + +理由不是分层美学,是**注册路径**:`api.build_kernel` 只调 `register_backends()`,从不调 `register_security()`(后者唯一调用方是 `bootstrap/core/server.py:Server.build`)。注册若挂在 security 下,任何不经 `Server.build` 的装配路径——`examples/quickstart.py`、直接调 `build_kernel` 的测试——都会得到「未注册的实现 `encrypted`」。那是一个**只在部分入口出现**的故障。 + +这与 KV 侧的落点一致,FS 侧只是照做。 + +### 11. 明文兼容开关只在 provider 上,装饰器不重复提供 + +「读到非 ENC1 的数据怎么办」有两个对立的正确答案,各自对应一个部署阶段: + +- **迁移期必须宽松**——加密层上线时库里全是加密前的明文,一律拒绝就是上线即全量不可读。 +- **迁移完成后必须收紧**——此时「读到明文」只可能是有人绕过加密层直接写了底层存储。宽松模式会静默放行,而这正是降级攻击的着力点。 + +`LocalEnvelopeSecurityProvider` 的 `allow_plaintext` 参数已经管这件事(决策 5 的最后一句)。两个装饰器都不再重复提供同语义旋钮:两个开关意味着两处配置、两种组合,其中「装饰器宽松 + provider 严格」这类组合没有任何意义,只会在排查时多一个要查的地方。 + +**写路径永远加密**,与开关无关——`test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed` 钉住这条。开关若顺带放松了写,迁移期写进去的数据会永远是明文而调用方毫无察觉。 + +### 12. FS 加密整个文件内容,`ref` 与 scope 保持明文 + +与决策 2(KV 只加密 value)同理:路径要能寻址,加密 `ref` 就没法 `get`/`stat`/`delete`。泄露的信息是「有哪些文件」,不是文件里是什么。 + +### 13. AAD 绑满五维 scope + `ref` + +与决策 4 同构,`key` 换成 `ref`,`purpose` 固定为 `fs_object`。 + +不绑 AAD 时,只要根密钥相同(同一部署),把 org A 的密文块搬进 org B 的存储位置就能解开——加密在这种攻击下等于没有。只绑 `org` 会让同 org 内的用户互读。存储层的 scope 隔离是**访问控制**,可以被绕过(直接写底层、备份恢复串了);AAD 是密码学的,绕不过。 + +`space` 是 `Scope` 五维化时新加的维度,漏了它同 org 下的两个 space 就能互读。回归防线:`test_encrypted_fs_store_aad_binds_all_five_scope_dimensions`、`test_encrypted_fs_store_cross_scope_ciphertext_move_fails`。 + +### 14. 解密失败一律 `BackendError`,不透传底层异常 + +与决策 5 一致。provider 抛的 `KeyMismatchError` / `AuthenticationFailedError` 对运维有诊断价值,但它们不是跨层契约——装饰器把它们收敛成 `BackendError` 并在消息里带上 `ref`,原异常经 `raise ... from exc` 保留在 `__cause__` 里,traceback 上一行不丢。 + +### 15. `inner` 无默认值 + +给 `inner` 一个默认会让「配错了」静默变成「加密了一个空的内存 store」——数据写得进去,重启后全没了。未配置时在装配期抛 `ValidationError`,并拒绝自引用。回归防线:`test_encrypted_fs_store_factory_requires_inner_dependency`。 + +(KV 侧的 `raw_kv_store` 同此约束;FS 侧的参数名是 `inner`,与 `fs_store` 既有的装饰器命名一致。) + +### 16. 默认关闭 + +与决策 6 一致:不配 `target: encrypted` 就没有任何加密行为。现有部署零影响,不需要迁移。 + +FS 侧配置形态: + +```yaml +security: + main_sec: + target: local + params: + key_file: /etc/agent-memory/master.key + allow_plaintext: true # 迁移完成后改 false + +fs_store: + raw_fs: + target: local + params: { root: /var/lib/agent-memory/files } + main_fs: # 上层引用这个 + target: encrypted + params: + inner: raw_fs + security: main_sec +``` + ## 拒绝的方案 - **在 MemoryAPI/write/recall/get 中分别调用 security**:被拒。上层入口太多,且未来新增 engine 或批处理入口时容易遗漏;KV 装饰器可以把加密收敛到单一边界。 @@ -89,6 +163,8 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 - **storage 层直接实现加密算法和密钥管理**:被拒。storage 只负责存取语义,不应持有算法选择、密钥加载、KMS/Vault 访问、轮换策略等安全治理能力。 - **同时加密 key 和 scope 命名空间**:本阶段拒绝。完全隐藏 key/scope 会破坏 scan、exists、delete、TTL、space 清理与审计定位。后续如需隐藏元数据,应单独设计 opaque key 或索引加密方案。 - **解密失败时返回密文或跳过记录**:被拒。这会把安全错误伪装成业务数据,导致调用方在不知情的情况下继续处理损坏或越界数据。 +- **chunked encryption(FS 侧分块加密以支持流式读)**:本阶段拒绝。F04 §5.3 自己就说了它不适合作默认方案——chunk 之间没有密码学绑定,可以被重排、截断、拼接。代价是 `FSStore.get` 必须读全文件到内存才能解密,大文件会吃内存,见「已知遗留」。 +- **在装饰器上再开一个 `allow_plaintext_read`**:被拒。见决策 11,两个同语义开关只会制造无意义的组合与多余的排查点。 ## 验证 @@ -108,10 +184,33 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 git diff --check ``` +### FS 侧断言(`tests/unit/storage/test_encrypted_fs_store.py`,13 条全绿) + +| 断言 | 落点 | +|---|---| +| 内层存的是密文,且不含明文片段 | `test_encrypted_fs_store_encrypts_content_and_decrypts_get` | +| 交给 provider 的 `SecurityContext` 带对 scope / purpose / ref | 同上 | +| AAD 绑满五维 scope + ref | `test_encrypted_fs_store_aad_binds_all_five_scope_dimensions` | +| 换 scope 搬密文解不开(绕过访问控制后仍拦得住) | `test_encrypted_fs_store_cross_scope_ciphertext_move_fails` | +| `update` 也加密(第二条写路径) | `test_encrypted_fs_store_update_also_encrypts` | +| 空文件 roundtrip | `test_encrypted_fs_store_roundtrips_empty_file` | +| `stat.size` 是密文长度(已知代价,显式钉住) | `test_encrypted_fs_store_stat_reports_ciphertext_size` | +| `get`/`delete` 的 NotFound 与幂等语义不被加密改变 | `test_encrypted_fs_store_passes_through_missing_and_delete` | +| 迁移期明文可读(provider 允许时) | `test_encrypted_fs_store_supports_plaintext_compatibility_via_provider` | +| 明文兼容开着时写路径**仍然**加密 | `test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed` | +| 解密失败 fail-closed 成 `BackendError` | `test_encrypted_fs_store_decryption_failure_is_fail_closed` | +| 具名依赖装配 / 缺 `inner` 报错 | `test_encrypted_fs_store_factory_*` | +| `encrypted` 在只调 `register_backends()` 时已注册 | `test_encrypted_fs_is_registered_by_storage_bootstrap` | + ## 已知遗留 -- 默认配置不会自动切到 encrypted KV,调用方必须显式把业务使用的 `kv_store` 实例指向 `target: encrypted`。 -- 当前只保护 KV value;vector/fulltext/fusion/graph/fs 中的索引字段、文本、向量、图边、文件资产不在该装饰器保护范围内。 -- key、scope 维度、TTL 与 raw 后端中的记录数量仍对后端可见。 +- 默认配置不会自动切到 encrypted KV,调用方必须显式把业务使用的 `kv_store` 实例指向 `target: encrypted`。FS 侧同理(`fs_store` 的 `target: encrypted`)。 +- 当前只保护 KV value 与 FS 文件内容;vector/fulltext/fusion/graph 中的索引字段、文本、向量、图边不在装饰器保护范围内。**向量本身可被反演出近似原文**,这是一个真实的信息泄露面,但加密向量就没法做 ANN 检索——需要的是加密检索方案,不是装饰器能解决的。 +- key、ref、scope 维度、TTL 与 raw 后端中的记录数量仍对后端可见。 +- **`FSStore.get` 必须读全文件到内存**才能解密(见「拒绝的方案」里的 chunked encryption)。大文件(视频、模型权重)会吃内存。 +- **`FileStat.size` 返回密文长度**,比明文长(信封头 + 包装后的数据密钥 + 两个 nonce + 两个 16B GCM tag)。不修正——修正需要先解密才能知道明文长度,代价荒谬。调用方拿它分配缓冲区只会偏大,不影响正确性。 +- **无根密钥轮换接缝**:ENC1 信封头 11 字节(`!4sBBHHH`)里没有 `key_id`,密文无法自述「我是用哪把根密钥加密的」,因此轮换根密钥后所有历史密文立刻不可解——只能停机全量重加密或双写。补法是在头里加一个 `KeyIdLen(1B)` + 变长体最前面一段 `key_id`,轮换即退化成一次配置文件编辑(keyring 保留旧 key、`current_key_id` 指向新 key)。这是信封格式的改动,属于 `common/security/` 的面。 +- **`LocalKeyProvider` 的根密钥是磁盘上的明文文件**。生产应走 KMS/Vault。 +- **根密钥文件权限在 Windows 上设不出 `0o600`**,`test_local_security_provider_encrypts_enc1_and_round_trips` 因此在 Windows 开发机上恒红。不影响 Linux 部署。 - KMS/Vault provider、密钥轮换、密钥版本迁移、批量重加密仍需在 `common/security` 与运维流程中补齐。 - cloud engine 与 encrypted KV 的端到端集成测试、space 删除后的密文清理验证、严格关闭明文兼容后的迁移验证仍需补充。 diff --git a/docs/specs/S07-common.md b/docs/specs/S07-common.md index e3726961..abb8268e 100644 --- a/docs/specs/S07-common.md +++ b/docs/specs/S07-common.md @@ -5,8 +5,8 @@ | 项 | 值 | |---|-------------| | 关联模块 | src/common/ | -| 最近一次修订日期 | 2026-07-30 | -| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/common/F01-memory-layer.md,docs/features/common/F02-dashscope-llm-provider.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F04-security-interfaces-and-encryption.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/retrieval/F03-metadata-filtering.md | +| 最近一次修订日期 | 2026-07-31 | +| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/common/F01-memory-layer.md,docs/features/common/F02-dashscope-llm-provider.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F04-security-interfaces-and-encryption.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/retrieval/F03-metadata-filtering.md,docs/features/security/F01-authentication-kernel.md | ## 范围 / 边界 @@ -37,6 +37,10 @@ 8. **标识唯一性分层**:非空 Space id 全局唯一;`MemoryUnit.id` 只要求在完整 Scope 内唯一。 9. **Scope 位置参数兼容**:`space` 可为空但只能按关键字传入;旧位置参数顺序保持 `Scope(org, user, agent, session)`。 +10. **Scope 是 frozen value object**(安全加固,F01 决策 16):`@dataclass(frozen=True)`, + 身份/隔离值不可变是跨模块安全不变量。改某维用 `dataclasses.replace(scope, org=...)` + 返回新值,禁止原地 `scope.x = ...`(抛 `FrozenInstanceError`)。frozen 同时使 Scope + 可哈希。防的是「签发 key 后改原 actor 的 org 让已签发身份跟着变」的越权。 ## 接口契约 @@ -158,7 +162,7 @@ DashScope Adapter 的 `params.enable_thinking` 由 Adapter 转换为 | `Segment` | type / content / asset_ref / metadata | 内容段 | | `Temporal` | t_event / t_ingest / t_valid / t_invalid | 时间字段 | | `Relation` | id / source_id / target_id / relation / weight / metadata | 关联关系 | -| `Scope` | org / space / user / agent / session | 作用域;非空 `space` 是全局唯一逻辑隔离标识,空值为兼容域且该字段为 keyword-only | +| `Scope` | org / space / user / agent / session | 作用域(frozen value object,`frozen=True`);非空 `space` 是全局唯一逻辑隔离标识,空值为兼容域且该字段为 keyword-only。改维度用 `dataclasses.replace`,不可原地修改(见不变量 10) | | `Context` | scope / max_tokens / extensions | 检索上下文 | | `Entity` | text / type / confidence | 实体 | | `FeatureSet` | keywords / entities / tags | 特征集合 | diff --git a/pyproject.toml b/pyproject.toml index 201b0a5d..e8df847a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,12 @@ mcp = [ "mcp>=1.2", "pyyaml>=6", ] +# 认证层(src/security):API Key 的 Argon2id 哈希。标准库无 Argon2id;缺依赖时在 +# 装配期抛 ValidationError,绝不降级为明文比对。 +# 静态加密所需的 cryptography 已在主依赖里(common.security 用)。 +security = [ + "argon2-cffi>=23.1", +] [dependency-groups] dev = [ @@ -65,6 +71,7 @@ include = [ "control*", "ingest*", "retrieval*", + "security*", "storage*", "agent_plugin*", ] diff --git a/src/AGENTS.md b/src/AGENTS.md index 9128497e..04544798 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -5,6 +5,10 @@ **每个子模块的 `AGENTS.md` 文件开头都链接了对应的 spec 规约文档**,便于快速跳转查看详细接口规范。 +### security/ - 横切安全层 + +认证(`Authenticator` 三模式:dev / trusted / api_key)+ `PrincipalKeyStore`(Argon2id 校验的 API Key 注册表)+ 速率限制(`RateLimiter`)+ DEV 模式绑定 guard(`check_dev_binding`)。`AuthContext` 落在 `common/type_def/auth.py`(横切结构)。不属模型能力插件,单独成一类横切组件;不进 `build_kernel`,由 `bootstrap` 的 surface 在请求作用域装配。 + ## 模块地图 ``` @@ -16,6 +20,7 @@ src/ ├── control/ # 编排层:MemoryEngine 跨层编排中枢 + Scheduler/Permission/Policy/Governance/Space ├── ingest/ # 接入层:多模态 → 文本投影 + MemoryUnit,不落盘 ├── retrieval/ # 检索层:scope 过滤 → 多路召回 → 融合重排 → 渐进式披露 +├── security/ # 横切安全层:认证(dev/trusted/api_key)+ Argon2id key 注册表 + 速率限制 + DEV 绑定 guard └── storage/ # 存储层:统一 CRUD + search,scope 原生隔离(vector/graph/fulltext/kv/fs/fusion) ``` diff --git a/src/api/memory_api.py b/src/api/memory_api.py index 3467bc91..4ea75c97 100644 --- a/src/api/memory_api.py +++ b/src/api/memory_api.py @@ -331,9 +331,7 @@ def set_space_policy( """替换 space 级 policy。""" @abstractmethod - def list_space_members( - self, org: str, space: str, *, identity: Scope - ) -> list[SpaceMember]: + def list_space_members(self, org: str, space: str, *, identity: Scope) -> list[SpaceMember]: """列出 space 成员。""" @abstractmethod @@ -343,7 +341,5 @@ def add_space_member( """添加或更新 space 成员角色。""" @abstractmethod - def remove_space_member( - self, org: str, space: str, member: Scope, *, identity: Scope - ) -> None: + def remove_space_member(self, org: str, space: str, member: Scope, *, identity: Scope) -> None: """移除 space 成员。""" diff --git a/src/api/memory_api_impl/assembly.py b/src/api/memory_api_impl/assembly.py index 663982d6..f30734d2 100644 --- a/src/api/memory_api_impl/assembly.py +++ b/src/api/memory_api_impl/assembly.py @@ -23,7 +23,7 @@ from dataclasses import dataclass -from common.audit.base import AuditProducer +from common.audit.base import AuditLogger, AuditProducer from common.bootstrap import register_plugins from common.factory.factory import Factory from common.log import setup_logging @@ -53,16 +53,17 @@ class Kernel: api: LocalMemoryAPI kv: KVStore space: SpaceManager | None = None + audit: AuditLogger | None = None # 装配好的审计器;surface 侧记认证失败等入口事件 def _register_all() -> None: """组装前按层触发自注册(句柄在接口、注册靠 import 实现;各 bootstrap 幂等)。""" - register_plugins() # common 共享插件 - register_backends() # storage - register_operators() # retrieval - register_ingestors() # ingest + register_plugins() # common 共享插件 + register_backends() # storage + register_operators() # retrieval + register_ingestors() # ingest register_constructors() # construction - register_controllers() # control + register_controllers() # control def build_kernel( @@ -92,16 +93,24 @@ def build_kernel( root = ComponentConfig(params=dict(ROOT_PARAMS), ctx=ctx, target="local", name="memory_api") setup_logging(root) # 初始化 agent-memory 根 logger(按 globals 的 log_* 配置;幂等) + # audit logger 装配一次、两处共用:API 内部记业务事件,Kernel.audit 暴露给 + # surface 记入口事件(认证失败等发生在 API 之外,拿不到 API 的私有引用)。 + audit_logger = AuditProducer.dep(root, default="sqlite") api = LocalMemoryAPI( engine=EngineProducer.dep(root, default="in_memory"), permission=PermissionProducer.dep(root, default="sqlite"), scheduler=SchedulerProducer.dep(root, default="in_process"), policy=PolicyProducer.dep(root, default="dict"), governor=GovernorProducer.dep(root, default="in_memory"), - audit_logger=AuditProducer.dep(root, default="sqlite"), + audit_logger=audit_logger, space=SpaceProducer.dep(root, default="kv"), ) - return Kernel(api=api, kv=KvProducer.dep(root, default="memory"), space=api.space_manager) + return Kernel( + api=api, + kv=KvProducer.dep(root, default="memory"), + space=api.space_manager, + audit=audit_logger, + ) def assemble( diff --git a/src/api/memory_api_impl/local_memory_api.py b/src/api/memory_api_impl/local_memory_api.py index 5f7bf335..b0ad7a57 100644 --- a/src/api/memory_api_impl/local_memory_api.py +++ b/src/api/memory_api_impl/local_memory_api.py @@ -319,9 +319,7 @@ def _list_routing_clauses( if value: values.add(value) if len(values) == 1: - clauses.append( - FilterClause(canonical_filter_field(field), FilterOp.EQ, values.pop()) - ) + clauses.append(FilterClause(canonical_filter_field(field), FilterOp.EQ, values.pop())) return clauses @@ -773,9 +771,7 @@ def update( return unit def delete(self, selector: DeleteSelector, *, identity: Scope) -> list[str]: - selector_is_empty = ( - not selector.unit_ids and not selector.tags and selector.before is None - ) + selector_is_empty = not selector.unit_ids and not selector.tags and selector.before is None if selector_is_empty: raise ValidationError("DeleteSelector requires unit_ids, tags, or before") # 按 selector 的目标 scope 鉴权 DELETE;未限定 scope(如纯按 id/标签的 @@ -872,9 +868,7 @@ def admin_all(self, *, identity: Scope) -> dict[str, str]: # -- 治理(直达 Governor) ---------------------------------------------- # - def inspect( - self, unit_ids: list[str], scope: Scope, *, identity: Scope - ) -> list[MemoryUnit]: + def inspect(self, unit_ids: list[str], scope: Scope, *, identity: Scope) -> list[MemoryUnit]: auth = self._authorize(identity, scope, Action.READ, "inspect") self._log(identity, "inspect", target_scope=scope, detail=auth) return self._governor.inspect(unit_ids, scope) @@ -1129,9 +1123,7 @@ def set_space_policy( ) return updated - def list_space_members( - self, org: str, space: str, *, identity: Scope - ) -> list[SpaceMember]: + def list_space_members(self, org: str, space: str, *, identity: Scope) -> list[SpaceMember]: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( @@ -1174,9 +1166,7 @@ def add_space_member( detail={**auth, "member_role": member.role}, ) - def remove_space_member( - self, org: str, space: str, member: Scope, *, identity: Scope - ) -> None: + def remove_space_member(self, org: str, space: str, member: Scope, *, identity: Scope) -> None: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( diff --git a/src/common/AGENTS.md b/src/common/AGENTS.md index 597f882f..f3c0215b 100644 --- a/src/common/AGENTS.md +++ b/src/common/AGENTS.md @@ -15,12 +15,13 @@ | `errors.py` | 自定义异常(ConflictError/NotFoundError/PermissionDeniedError/BackendError 等) | | `type_def/` | 核心数据类型定义目录 | | `type_def/memory.py` | MemoryUnit/Relation/Segment/Temporal/ContentLayers 等;MemoryUnit id 在完整 Scope 内唯一;KV key 前缀 `MEMORY_KEY_PREFIX`/`memory_key`(建索引记忆 `/memory/{id}`)。`ContentLayers`(l0/l1) 为分层披露标注,由 LayerAnnotator 对超阈 content 产出 | -| `type_def/scope.py` | Scope:`org/space/user/agent/session` 五维归属;非空 `space` 是全局唯一的逻辑隔离标识且为 keyword-only,旧位置参数保持 `org/user/agent/session` 顺序 | +| `type_def/scope.py` | Scope:`org/space/user/agent/session` 五维归属;非空 `space` 是全局唯一的逻辑隔离标识且为 keyword-only,旧位置参数保持 `org/user/agent/session` 顺序。**frozen value object**(`@dataclass(frozen=True)`):身份/隔离不可变是跨模块安全不变量,改某维用 `dataclasses.replace(scope, org=...)` 返回新值,禁止原地 `scope.x = ...`(抛 `FrozenInstanceError`)。详见 S07 不变量与 F01 决策 16 | | `type_def/filter.py` | FilterClause/FilterGroup/FilterExpr 及 normalize/evaluate;统一 API、检索和存储的树形过滤契约 | | `type_def/memory_filter.py` | MemoryUnit 字段投影与 FilterExpr 公共求值;供 retrieval 真源复核和 KV list 兼容实现共用 | | `type_def/memory_codec.py` | `MemoryUnit` ↔ bytes 编解码(`dumps`/`loads`);当前 `_v=3`,序列化 `layers`({l0,l1}) 与五段 scope,缺失取默认容错老数据,详见 F01-memory-layer / F03-scope-space-isolation | | `type_def/raw.py` | RawPayload;KV key 前缀 `MESSAGES_KEY_PREFIX`/`messages_key`(未建索引 infer 原文 `/messages/{id}`) | | `type_def/audit.py` | AuditEvent:记录 actor scope、target scope、action、decision、target_id 与 detail | +| `type_def/auth.py` | AuthContext(frozen):认证层产出的请求级安全上下文(`actor`/`acting_user`/`role`/`from_oauth`/`authorizing_key_fp`),ContextVar 传播(`set_current`/`reset_current`/`get_current`,未认证返回 `None`);`Role` 枚举(USER/ADMIN/ROOT)。横切结构,故落 `common` 而非 `security` 私有 | | `factory/factory.py` | Factory 基类:`TOP_NAME` 注册 + 三接口 `build`/`build_named`/`dep`(配置数据结构 `ComponentConfig`/`AssemblyContext`/`RawSpec` 在 `config/context.py`) | | `embedder/` | Embedder 插件目录(接口 + 实现) | | `chunker/` | Chunker 插件目录 | diff --git a/src/common/errors.py b/src/common/errors.py index f5109329..398b602a 100644 --- a/src/common/errors.py +++ b/src/common/errors.py @@ -48,6 +48,33 @@ def __init__(self, action: str = "", message: str = "") -> None: super().__init__(message or f"permission denied: {action or 'action'}") +class AuthenticationError(AgentMemoryError): + """ + 凭据缺失、格式非法或校验不通过:认证层(``src/security``)产出。 + + 与 :class:`PermissionDeniedError` 的区别是「不知道你是谁」(401)对 + 「知道你是谁但不许做」(403)——两者必须可分,否则 HTTP 层无法映射 + 正确状态码,调用方也无法区分「该带凭据」与「该申请授权」。 + + 对外错误消息一律笼统,不区分「主体不存在」与「凭据错误」:区分了就 + 成为主体枚举的侧信道。具体原因写进审计事件的 ``detail``。 + """ + + +class RateLimitedError(AgentMemoryError): + """ + 调用方超出速率上限:安全层限流(``src/security/rate_limit.py``)产出。 + + 与 :class:`AuthenticationError` 必须可分(429 对 401):限流发生在认证 + **之前**,此时还不知道凭据对不对——把它报成 401 会让「你被限流了」和 + 「你的 key 错了」混在一起,运维排障时无法区分,客户端也不知道该重试 + 还是该换凭据。 + + 对外消息同样笼统:不透露桶容量、剩余令牌、已计数的请求数——那些都能 + 用来反推限流参数并贴着阈值发请求。 + """ + + class ValidationError(AgentMemoryError): """ 入参非法或不满足约束:如 ``DeleteSelector`` 未给任何条件、参数越界、 diff --git a/src/common/security/security_impl/local_envelope_security_provider.py b/src/common/security/security_impl/local_envelope_security_provider.py index dfcce0cf..6a1dcc6c 100644 --- a/src/common/security/security_impl/local_envelope_security_provider.py +++ b/src/common/security/security_impl/local_envelope_security_provider.py @@ -184,8 +184,11 @@ def __init__( self, key_provider: LocalKeyProvider, *, - allow_plaintext: bool = True, + allow_plaintext: bool = False, ) -> None: + # allow_plaintext 默认 False(fail-closed,审计 P2-3):否则拥有底层存储写 + # 权限的攻击者可用任意明文替换密文,绕过 AES-GCM tag 与 AAD 校验。迁移期 + # 读旧明文数据须显式 opt-in(allow_plaintext=true),迁移完成后应关闭。 _ensure_crypto() self._key_provider = key_provider self._allow_plaintext = allow_plaintext @@ -286,12 +289,15 @@ def _parse_envelope(ciphertext: bytes) -> _Envelope: if len(ciphertext) < offset + body_len: raise CorruptedCiphertextError("ENC1 envelope length is incomplete") - encrypted_key = ciphertext[offset: offset + key_len] - offset += key_len - key_nonce = ciphertext[offset: offset + key_nonce_len] - offset += key_nonce_len - data_nonce = ciphertext[offset: offset + data_nonce_len] - offset += data_nonce_len + encrypted_key_end = offset + key_len + encrypted_key = ciphertext[offset:encrypted_key_end] + offset = encrypted_key_end + key_nonce_end = offset + key_nonce_len + key_nonce = ciphertext[offset:key_nonce_end] + offset = key_nonce_end + data_nonce_end = offset + data_nonce_len + data_nonce = ciphertext[offset:data_nonce_end] + offset = data_nonce_end encrypted_content = ciphertext[offset:] if not encrypted_content: raise CorruptedCiphertextError("ENC1 envelope has no encrypted content") @@ -407,9 +413,7 @@ def _decode_b64_key(value: str, *, source: str) -> bytes: def _validate_root_key(key: bytes, *, source: str) -> bytes: if len(key) != DATA_KEY_SIZE: - raise ValidationError( - f"encryption root key from {source} must be {DATA_KEY_SIZE} bytes" - ) + raise ValidationError(f"encryption root key from {source} must be {DATA_KEY_SIZE} bytes") return key @@ -448,7 +452,7 @@ def _build(config): ), ), allow_plaintext=_as_bool( - Factory.cfg_get(config, "allow_plaintext", True), - default=True, + Factory.cfg_get(config, "allow_plaintext", False), + default=False, ), ) diff --git a/src/common/type_def/__init__.py b/src/common/type_def/__init__.py index 57f85392..f44fc55a 100644 --- a/src/common/type_def/__init__.py +++ b/src/common/type_def/__init__.py @@ -1,6 +1,14 @@ """跨层共用的结构体定义。""" from .audit import AuditEvent +from .auth import ( + ROLE_RANK, + AuthContext, + Role, + get_current, + reset_current, + set_current, +) from .chat import ChatMessage from .chunk import Chunk from .context import EXT_MAX_TOKENS, Context @@ -59,6 +67,12 @@ "FeatureSet", "ChatMessage", "AuditEvent", + "AuthContext", + "Role", + "ROLE_RANK", + "set_current", + "reset_current", + "get_current", "FilterClause", "FilterOp", "FilterLogic", diff --git a/src/common/type_def/audit.py b/src/common/type_def/audit.py index 2646640b..2f726202 100644 --- a/src/common/type_def/audit.py +++ b/src/common/type_def/audit.py @@ -29,3 +29,8 @@ class AuditEvent: target: Scope = field(default_factory=Scope) # 操作目标 scope;无具体目标时为空 # 常见约定:permission_check、permission_reason、job_id、 # before_unit_id / after_unit_id、before_unit_ids / after_unit_ids + # 安全层(src/security)另加四个:acting_user、role、key_fp、auth_mode。 + # security.md §7.2 要求审计记录这四样,但它们是**认证元数据**,与本结构 + # 承载的「谁对什么做了什么」不同层;塞 detail 而非提升为一等字段,是因为 + # 改本结构要同时动 common / control / 两个 AuditLogger 实现 + + # handler._event_view。若这些键稳定使用,第二期应提升为一等字段。 diff --git a/src/common/type_def/auth.py b/src/common/type_def/auth.py new file mode 100644 index 00000000..14a63f82 --- /dev/null +++ b/src/common/type_def/auth.py @@ -0,0 +1,83 @@ +"""AuthContext — 认证上下文(横切结构,security.md §7.1)。 + +认证层(``src/security``)校验凭据后产出本结构,经 ContextVar 在请求内传播, +供 PEP(``LocalMemoryAPI._authorize``)、特权闸门与审计消费。 + +与 :class:`~common.type_def.scope.Scope` 职责不同:Scope 表达**资源归属**, +本结构表达**谁在操作、以什么身份、凭什么凭据**。鉴权通过后只把 target scope +下沉到 Engine/Store,认证元数据不污染存储接口。 + +核心不变量(security.md §1.1):身份来自本结构,不来自 URI、请求体参数或 +未经校验的 HTTP header。 +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from dataclasses import dataclass +from enum import Enum + +from .scope import Scope + + +class Role(str, Enum): + """三级角色(security.md §3.1)。 + + 继承 ``str`` 使其可直接进 ``AuditEvent.detail``(``dict[str, str]``) + 与 JSON 序列化,无需额外转换。 + """ + + USER = "user" # 普通主体:只能在自己 scope 内操作 + ADMIN = "admin" # 管理员:可管理本 org 内主体,不可跨 org + ROOT = "root" # 超级管理员:跨 org 全局 + + +ROLE_RANK: dict[Role, int] = {Role.USER: 0, Role.ADMIN: 1, Role.ROOT: 2} +"""角色偏序,用于降级检测(security.md §3.1):签发方不得签出高于自身的角色。""" + + +@dataclass(frozen=True) +class AuthContext: + """认证层完成凭据校验后产出的**可信**请求级安全上下文。 + + 不是客户端提交的数据结构:API Key、受信网关等不同认证路径最终都归一为 + 本结构。任何 handler、业务参数或 LLM tool_call 都不得覆盖其中字段—— + 故 ``frozen=True``。 + + ``actor`` **无默认值**,必须显式传入:空 ``Scope()`` 是 ROOT 的 actor + 形态(``SQLitePermissionManager.check`` 第一条规则即 ``actor == Scope()`` + 全局通过),若给它默认值,则「忘了传 actor」会静默得到全局权限。 + """ + + actor: Scope # 已认证的操作执行者;ROOT 为空 Scope() + acting_user: str = "" # 当前操作对应的 user;agent 代操作时为委托目标 + role: Role = Role.USER # 服务端角色注册表的产物,不来自请求 + from_oauth: bool = False # 区分 OAuth 与 API Key 路径(第二期消费) + authorizing_key_fp: str = "" # 签发本次凭据的 key 指纹,供轮换级联失效与追责 + + +_CURRENT: ContextVar[AuthContext | None] = ContextVar("auth_context", default=None) + + +def set_current(ctx: AuthContext) -> Token[AuthContext | None]: + """在请求入口设置当前认证上下文;返回的 token 必须在请求结束时交给 reset。""" + return _CURRENT.set(ctx) + + +def reset_current(token: Token[AuthContext | None]) -> None: + """请求结束时还原上下文。 + + 必须在 ``finally`` 中调用:``ThreadingHTTPServer`` 每请求一线程,线程可能 + 被复用,漏 reset 会让下一个请求继承上一个请求的身份(最严重的一类越权)。 + """ + _CURRENT.reset(token) + + +def get_current() -> AuthContext | None: + """取当前认证上下文;未认证返回 ``None``。 + + 刻意不返回默认 ``AuthContext``:那是 fail-open——中间件漏挂时请求会带着 + 默认身份跑完。返回 ``None`` 迫使调用方显式处理(``handler.dispatch`` 的 + 处理方式是抛 :class:`~common.errors.AuthenticationError`)。 + """ + return _CURRENT.get() diff --git a/src/common/type_def/scope.py b/src/common/type_def/scope.py index 96262f1f..3522713a 100644 --- a/src/common/type_def/scope.py +++ b/src/common/type_def/scope.py @@ -2,6 +2,10 @@ ``org > space > user/agent > session`` 五维归属,统一支撑隔离(多租户、 单 Agent 私有)与共享(跨 Agent 共享池);检索/写入默认在 scope 内。 + +**frozen=True(验收第三次 P2-1)**:Scope 是身份/隔离的值对象,可变性是安全 +缺陷--签发 key 后改原 actor 的 org,会让已签发 key 的身份跟着变(越权)。改某维 +用 ``dataclasses.replace(scope, org=...)`` 返回新值,不原地修改。 """ from __future__ import annotations @@ -9,7 +13,7 @@ from dataclasses import dataclass, field -@dataclass +@dataclass(frozen=True) class Scope: org: str = "" # 组织/租户 space: str = field(default="", kw_only=True) # 全局唯一的逻辑隔离空间标识 diff --git a/src/control/space_impl/kv_space_manager.py b/src/control/space_impl/kv_space_manager.py index 8df8eb60..7d054ec8 100644 --- a/src/control/space_impl/kv_space_manager.py +++ b/src/control/space_impl/kv_space_manager.py @@ -181,8 +181,8 @@ def _normalize_member(org: str, space: str, member: SpaceMember) -> SpaceMember: raise ValidationError("member scope org must match target space org") if scope.space and scope.space != space: raise ValidationError("member scope space must match target space") - scope.org = org - scope.space = space + # Scope 是 frozen 值对象(验收第三次 P2-1):用 replace 返回新值,不原地修改。 + scope = replace(scope, org=org, space=space) created_at = member.created_at or _now() return SpaceMember( scope=scope, @@ -198,9 +198,7 @@ def _normalize_member_scope(org: str, space: str, member: Scope) -> Scope: raise ValidationError("member scope org must match target space org") if scope.space and scope.space != space: raise ValidationError("member scope space must match target space") - scope.org = org - scope.space = space - return scope + return replace(scope, org=org, space=space) class KVSpaceManager(SpaceManager): @@ -286,7 +284,8 @@ def list( continue spaces[(info.org, info.space)] = info ordered = [spaces[key] for key in sorted(spaces)] - return ordered[offset:offset + limit] + page_end = offset + limit + return ordered[offset:page_end] def update(self, org: str, space: str, patch: SpacePatch) -> SpaceInfo: info = self.get(org, space) diff --git a/src/security/AGENTS.md b/src/security/AGENTS.md new file mode 100644 index 00000000..15aab7c2 --- /dev/null +++ b/src/security/AGENTS.md @@ -0,0 +1,115 @@ +# Agent Memory Security + +**规约文档**:[docs/features/common/F04-security-interfaces-and-encryption.md](../../docs/features/common/F04-security-interfaces-and-encryption.md) + +> `docs/specs/` 下暂无安全模块 spec(S01~S07 无 security)。本模块规约以 +> 上述 F04(下文简称 **security.md**,它是原 `docs/security/security.md` +> 迁入 common 特性归档后的位置)为准;跨模块契约变动(如 `AuthContext` 进入 +> `PermissionManager.check`)需同步 `docs/specs/S03-control.md`。 +> 第二期认证契约稳定后再考虑新增 `S08-security.md`。 + +认证层(三道防线的第①道):把一次请求的凭据材料校验成可信身份 `AuthContext`。 +**只回答「你是谁」,不回答「你能做什么」**——授权(第②道)在 +`src/control/permission.py`,静态加密(第③道)在 `src/common/security/`(密码学 +内核)+ `src/storage/{kv,fs}_impl/encrypted_*_store.py`(接线),都不在本模块。 + +认证模式由配置在装配期选定(`dev` / `trusted` / `api_key`),运行期不再分流: +三种模式是三个独立实现类,不是一个类里的 if/else。所有实现继承 `Authenticator` +或 `PrincipalKeyStore`,由外部装配注入,本模块不决定自己何时被调用。 + +## 模块地图 + +| 文件 | 职责 | +|---|---| +| `types.py` | `AuthMode` 枚举 + `Credentials`(frozen,一次请求的原始凭据材料);不依赖本层其他文件 | +| `authenticator.py` | `Authenticator` 抽象接口 + `AuthProducer`(`TOP_NAME = "authenticator"`) | +| `key_store.py` | `PrincipalKeyStore` 抽象接口 + `KeyStoreProducer`(`TOP_NAME = "key_store"`)+ 模块级 helper:`fingerprint` / `key_prefix` / `generate_api_key` | +| `binding.py` | `check_dev_binding()`——DEV 模式的 localhost 强制绑定 guard,供接入形态在启动期调用 | +| `rate_limit.py` | `RateLimiter` 抽象接口 + `RateLimitProducer`(`TOP_NAME = "rate_limiter"`) | +| `bootstrap.py` | `register_security()` 统一 import 各 `*_impl/` 包,触发实现自注册(幂等) | +| `authenticator_impl/` | 认证实现目录。当前实现:`dev_authenticator.py` / `trusted_authenticator.py` / `api_key_authenticator.py` | +| `key_store_impl/` | 主体 key 存储实现目录。当前实现:`memory_key_store.py`(进程内注册表 + Argon2id) | +| `rate_limit_impl/` | 限流实现目录。当前实现:`token_bucket_limiter.py` / `unlimited_limiter.py` | +| `__init__.py` | 公开导出抽象、工厂、helper 与 `register_security` | + +`AuthContext` / `Role` / `ROLE_RANK` 及其 ContextVar 存取(`set_current` / +`reset_current` / `get_current`)不在本模块——它们是跨层数据类型,住在 +`common/type_def/auth.py`。本模块产出 `AuthContext`,`api/` 与 `control/` 消费它。 + +## 文件关系 + +- 顶层 `.py` 只定义抽象接口与无状态 helper,零认证逻辑 +- `types.py` 不依赖本层其他文件(纯数据定义) +- 顶层接口文件不 import `*_impl/`;`*_impl/` import 顶层接口文件 +- Producer 工厂定义在对应顶层接口文件中(`authenticator.py` 的 `AuthProducer`、 + `key_store.py` 的 `KeyStoreProducer`),不新增独立 `*_producer.py` +- `trusted_authenticator.py` / `api_key_authenticator.py` 依赖注入的 + `PrincipalKeyStore`;`dev_authenticator.py` 无依赖 +- `binding.py` 独立,不被本模块其他文件引用——它的调用方是接入形态的启动入口 + +## 行为铁律 + +1. **认证失败一律抛 `AuthenticationError`**:不返回 `None`、不返回默认身份。 + 返回 `None` 会诱导调用方写 `if ctx is None: ctx = default` 这类 fail-open + 分支。认证只有「成功」与「失败」两种结果。 + (`PrincipalKeyStore.resolve` 返回 `AuthContext | None` 是**查询语义**不是认证 + 语义——它的 `None` 必须由 `Authenticator` 翻译成 `AuthenticationError`。) +2. **所有密钥比对走 `hmac.compare_digest` 或 Argon2 的 `verify`**,禁止 `==` / `!=`。 + `compare_digest` 传入两侧都要 `.encode("utf-8")`:str 版对非 ASCII 抛 + `TypeError`,会把 401 变成 500。 +3. **对外错误消息笼统**:所有失败路径共用 `"authentication failed"`,不区分 + 「凭据缺失」「主体不存在」「凭据错误」——区分即主体枚举侧信道。具体原因 + 只进审计事件的 `detail`。`tests/unit/security/test_authenticator_impl.py::test_all_failures_share_one_message` + 是这条的回归防线。 +4. **顶层 `.py` 是纯抽象,不 import `*_impl/`**(与 control 同规)。 +5. **ROOT 的 actor 是空 `Scope()`,不是 `Scope(org="*")`**: + `SQLitePermissionManager.check` 的首条规则是 `actor == Scope() → True`,而 + `org="*"` 会撞上「跨 org 拒绝」规则,ROOT 反而寸步难行。 +6. **构造 `Scope` 一律用 keyword**:`Scope(org=..., user=...)`。字段可能新增 + (如 `space`),位置参数会静默错位成越权。 +7. **fail-closed,绝不降级**:`argon2-cffi` 缺失时在装配期抛 `ValidationError`, + 不回退到明文比对——回退等于把 key 变成磁盘上的裸明文。 +8. **role 不从 header 读**:TRUSTED 模式下 header 只声明「你是谁」 + (org / principal_type / principal_id),「你能干什么」由框架自己查 + `PrincipalKeyStore.get_role`。防的是网关被攻破或误配时的任意提权。 + +## 与其他子目录的边界 + +**本模块管**: +- 凭据 → `AuthContext` 的校验(三种模式) +- 主体 API Key 的签发 / 解析 / 撤销(Argon2id 哈希,注册表不存明文) +- DEV 模式的 localhost 绑定 guard +- 按主体维度的速率限制(`rate_limit.py` + `rate_limit_impl/`) + +**不管**: +- 授权判定(`actor` 能否操作 `target`)→ `control/permission.py` +- `AuthContext` 类型定义与 ContextVar 传播 → `common/type_def/auth.py` +- 从 HTTP / MCP / CLI 提取 `Credentials`、决定何时调用认证 → `bootstrap/` +- 静态加密的密码学内核(信封 / AES-GCM / HKDF / 根密钥)→ `common/security/`; + 把它接到存储上的两个装饰器 → `src/storage/{kv,fs}_impl/encrypted_*_store.py`。 + 本模块与加密**无依赖关系**:`register_security()` 不注册任何加密实现, + `api.build_kernel` 也从不调 `register_security()`。 + +## 本地约束 + +- `Credentials.headers` 的 key **一律小写**:HTTP header 大小写不敏感,提取方 + (`bootstrap/`)负责归一化,本模块按小写常量匹配,不在读取处重复 `.lower()` +- `key_fp`(sha256)是确定性查找键,`key_hash`(Argon2id)才是校验凭据; + 两者用途不可互换——`key_fp` 不能用于校验,`key_hash` 不能用于索引 +- `InMemoryKeyStore.resolve` 的三条路径(命中 / 有候选但 key 错 / 无候选) + 必须**恰好各跑一次** Argon2 verify。无条件补 dummy 会让「有候选但 key 错」 + 跑两次,造出反向的 2x 时间差——同样是可测量的侧信道 +- `PrincipalKeyStore.issue` 拒签 ROOT key:ROOT 只能来自配置声明的 Root API Key +- 主体 scope 必须恰好设置 `user` / `agent` 之一,且必须有 `org`;两者都设或都不设 + 会签出「整个 org」这种无主体的 key +- `check_dev_binding` 抛 `ValidationError`,不 `sys.exit`——启动期 guard 也要可测 +- **TRUSTED 模式装配期必须配 `gateway_key`**:未配置时身份 header(X-Org-Id / X-Principal-* 等)可被任意能连到本端口的调用方伪造。`_build` 默认拒绝启动;确需仅靠网络隔离时显式 `allow_no_gateway_key=true` opt-in(审计 P1-2) +- **role 按 principal(org + user/agent)索引**,不含 `session`、不含 `space`:§3.1 角色是 principal 级,session/space 是资源维度不是身份维度。含 session 会让同 principal 换 session 登录查不到 role;含 space 会让「同 principal 同 role」变成两条互覆记录。revoke 单 key 时须检查同 principal 是否仍有未撤销 key,否则会误删共享 role 条目(审计 P2-2) +- **Argon2 verify 有进程级并发上限**(`concurrency_guard.py`,审计 P1-3):IP + 令牌桶限请求速率,限不住「同时在跑的 Argon2 verify 数」--后者才是 CPU/内存 + 耗尽向量。`Argon2Guard` 是进程级 `BoundedSemaphore`,非阻塞 acquire,耗尽即 + 429。DEV 模式不跑 Argon2 不需 guard;默认上限 4(按 512 MiB / 128 MiB), + 由 `argon2.max_concurrent` 配置。不进 Factory:进程级状态按配置实例化多份 + 没有意义 +- 已知遗留(`memory_key_store.py` 顶部有详述):无验证缓存(5~20 QPS/核)、 + 进程重启后已签发 key 全部失效(生产需 SQLite 后端) diff --git a/src/security/__init__.py b/src/security/__init__.py new file mode 100644 index 00000000..349fcb08 --- /dev/null +++ b/src/security/__init__.py @@ -0,0 +1,34 @@ +"""安全层(认证):把凭据校验成可信身份。 + +三道防线(`docs/features/common/F04-security-interfaces-and-encryption.md` §1)中的 +第①道。授权(②)在 ``src/control/permission.py``,本模块只回答「你是谁」, +不回答「你能做什么」。 + +限流(§8.1)也在本模块:它保护的是认证本身——Argon2 verify 是 CPU/内存 +密集操作,无限制触发能把进程打挂。故限流是认证的前置门,不是独立关注点。 + +对外只暴露抽象与工厂;实现在 ``authenticator_impl/`` / ``key_store_impl/`` / +``rate_limit_impl/`` 下自注册,由 :func:`security.bootstrap.register_security` 触发。 +""" + +from .authenticator import Authenticator, AuthProducer +from .binding import check_dev_binding +from .bootstrap import register_security +from .key_store import KeyStoreProducer, PrincipalKeyStore, fingerprint, generate_api_key +from .rate_limit import RateLimiter, RateLimitProducer +from .types import AuthMode, Credentials + +__all__ = [ + "Authenticator", + "AuthProducer", + "AuthMode", + "Credentials", + "PrincipalKeyStore", + "KeyStoreProducer", + "RateLimiter", + "RateLimitProducer", + "fingerprint", + "generate_api_key", + "check_dev_binding", + "register_security", +] diff --git a/src/security/authenticator.py b/src/security/authenticator.py new file mode 100644 index 00000000..e84d5df8 --- /dev/null +++ b/src/security/authenticator.py @@ -0,0 +1,51 @@ +"""Authenticator — 认证契约(security.md §2.1 / §2.2)。 + +把一次请求的凭据材料校验成 :class:`~common.type_def.auth.AuthContext`。 +实现按认证模式区分(dev / trusted / api_key),由配置在装配期选定;运行期 +不再分流——参考 demo 里 ``AuthDispatcher`` 一个类里 if/else 三种模式的写法, +在此拆成三个各自只做一件事的实现。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from common.factory.factory import Factory +from common.type_def.auth import AuthContext + +from .types import AuthMode, Credentials + + +class AuthProducer(Factory): + """Authenticator 的注册式工厂(与契约同处接口层)。 + + ``target`` 即认证模式名。各实现在 ``authenticator_impl`` 下以 + ``@AuthProducer.register("<模式>")`` 自注册——注册发生在 import 实现模块时, + 由 :func:`security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "authenticator" + + +class Authenticator(ABC): + """凭据 → 可信身份。""" + + @abstractmethod + def authenticate(self, credentials: Credentials) -> AuthContext: + """校验凭据,返回可信身份;失败抛 :class:`~common.errors.AuthenticationError`。 + + **不返回 None**:认证只有「成功」与「失败」两种结果。返回 None 会诱导 + 调用方写 ``if ctx is None: ctx = default`` 这类 fail-open 分支。 + + 对外错误消息一律笼统(``"authentication failed"``),不区分「凭据缺失」 + 「主体不存在」「凭据错误」——区分即主体枚举侧信道(§2.3.2)。具体原因 + 写进审计事件的 ``detail``,不进异常消息。 + """ + + @abstractmethod + def mode(self) -> AuthMode: + """自描述当前认证模式,供启动期 guard(DEV 的 localhost 强制绑定)与审计。""" + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与 ``ControlOperator`` 同构。""" diff --git a/src/security/authenticator_impl/__init__.py b/src/security/authenticator_impl/__init__.py new file mode 100644 index 00000000..03d3a65a --- /dev/null +++ b/src/security/authenticator_impl/__init__.py @@ -0,0 +1,15 @@ +"""authenticator_impl 实现集:工厂 AuthProducer + 各实现。 + +import 各实现模块即触发其 ``@AuthProducer.register(...)`` 自注册; +本包只对外暴露工厂 AuthProducer。 +""" + +from importlib import import_module + +from security.authenticator import AuthProducer + +import_module(".api_key_authenticator", __name__) +import_module(".dev_authenticator", __name__) +import_module(".trusted_authenticator", __name__) + +__all__ = ["AuthProducer"] diff --git a/src/security/authenticator_impl/api_key_authenticator.py b/src/security/authenticator_impl/api_key_authenticator.py new file mode 100644 index 00000000..a4693897 --- /dev/null +++ b/src/security/authenticator_impl/api_key_authenticator.py @@ -0,0 +1,70 @@ +"""API_KEY 认证:框架自校验 API Key(security.md §2.2.3)。 + +两步:先常时间比对配置声明的 Root API Key,未命中再查主体注册表。 +Root Key **不入注册表**(§2.3.1)——它是部署级凭据,不属于任何 org。 +""" + +from __future__ import annotations + +import hmac +import logging + +from common.errors import AuthenticationError +from common.type_def.auth import AuthContext, Role +from common.type_def.scope import Scope +from security.authenticator import Authenticator, AuthProducer +from security.key_store import KeyStoreProducer, PrincipalKeyStore +from security.types import AuthMode, Credentials + +_LOG = logging.getLogger(__name__) + +_FAILED = "authentication failed" + + +class ApiKeyAuthenticator(Authenticator): + """Root Key 常时间比对 + 主体注册表查询。""" + + def __init__(self, key_store: PrincipalKeyStore, root_api_key: str = "") -> None: + self._key_store = key_store + self._root_key = root_api_key + + def authenticate(self, credentials: Credentials) -> AuthContext: + api_key = credentials.api_key + if not api_key: + raise AuthenticationError(_FAILED) + + # Step 1: Root API Key。 + # encode 成 bytes 再比:compare_digest 的 str 版要求两边都是 ASCII-only, + # 攻击者提交的非 ASCII key 会让它抛 TypeError(→ 500 而非 401), + # 且泄露「你提交了非 ASCII」。str.encode 对任何 str 都成功,且 + # compare_digest 对长度不等的输入仍不早退。 + if self._root_key and hmac.compare_digest( + self._root_key.encode("utf-8"), api_key.encode("utf-8") + ): + return AuthContext(actor=Scope(), role=Role.ROOT) + + # Step 2: 主体注册表(内部已做常时间比对与 dummy pad)。 + identity = self._key_store.resolve(api_key) + if identity is None: + raise AuthenticationError(_FAILED) + return identity + + def mode(self) -> AuthMode: + return AuthMode.API_KEY + + def health(self) -> None: + self._key_store.health() + + +@AuthProducer.register("api_key") +def _build(config): + root_key = str(config.get("root_api_key", "") or "").strip() + if not root_key: + # 引导问题(§3.5):没有 root key 就没人能签发第一把主体 key。 + # 第一期只警告不阻断——root key 已轮换掉、只留主体 key 的部署是合法的。 + _LOG.warning( + "api_key 认证模式未配置 root_api_key:无法签发首把主体 key。" + "若这是有意的(root key 已轮换),可忽略本警告。" + ) + key_store = KeyStoreProducer.dep(config, "key_store", default="memory") + return ApiKeyAuthenticator(key_store=key_store, root_api_key=root_key) diff --git a/src/security/authenticator_impl/dev_authenticator.py b/src/security/authenticator_impl/dev_authenticator.py new file mode 100644 index 00000000..f89b1ba9 --- /dev/null +++ b/src/security/authenticator_impl/dev_authenticator.py @@ -0,0 +1,38 @@ +"""DEV 认证:无条件返回 ROOT(security.md §2.2.1)。 + +**只用于本地开发。** 配套的 localhost 强制绑定在 +:func:`security.binding.check_dev_binding`,由 HTTP surface 在启动时调用—— +本类不知道服务器绑了哪个地址,也不该在一个可被单测 import 的类里 ``sys.exit``。 +""" + +from __future__ import annotations + +from common.type_def.auth import AuthContext, Role +from common.type_def.scope import Scope +from security.authenticator import Authenticator, AuthProducer +from security.types import AuthMode, Credentials + + +class DevAuthenticator(Authenticator): + """恒 ROOT,不校验任何凭据。""" + + def authenticate(self, credentials: Credentials) -> AuthContext: + """无条件返回 ROOT 身份。 + + ROOT 的 actor 是**空 Scope()**——与 ``LocalMemoryAPI._ROOT`` 及 + ``SQLitePermissionManager.check`` 的第一条规则(``actor == Scope()`` + 全局通过)一致。security.md §2.2.1 示例写的 ``Scope(org="*")`` 是参考 + demo 的形态,在本主干会被 check 的「跨 org 拒绝」规则挡住,不可照搬。 + """ + return AuthContext(actor=Scope(), role=Role.ROOT) + + def mode(self) -> AuthMode: + return AuthMode.DEV + + def health(self) -> None: + return None + + +@AuthProducer.register("dev") +def _build(config): + return DevAuthenticator() diff --git a/src/security/authenticator_impl/trusted_authenticator.py b/src/security/authenticator_impl/trusted_authenticator.py new file mode 100644 index 00000000..2287d4cd --- /dev/null +++ b/src/security/authenticator_impl/trusted_authenticator.py @@ -0,0 +1,99 @@ +"""TRUSTED 认证:信任上游网关已完成认证(security.md §2.2.2)。 + +网关注入身份声明 header,框架据此构造 actor。**关键设计:role 不从 header 读** +——header 说「你是谁」,框架自己查注册表得「你能干什么」。这样即使网关被攻破 +或误配,攻击者也无法通过伪造 ``X-Role: root`` 提权。 +""" + +from __future__ import annotations + +import hmac +import logging + +from common.errors import AuthenticationError, ValidationError +from common.type_def.auth import AuthContext +from common.type_def.scope import Scope +from security.authenticator import Authenticator, AuthProducer +from security.key_store import KeyStoreProducer, PrincipalKeyStore +from security.types import AuthMode, Credentials + +_LOG = logging.getLogger(__name__) + +# header 名硬编码,不做成配置项:没有第二个网关约定的时候,可配置只是多一处 +# 误配可能(配错了就静默认证失败)。gateway_key 是配置项,因为它是部署相关 +# 的秘密,必须能从环境变量注入。 +# +# 键为小写:HTTP header 名大小写不敏感(RFC 9110 §5.1), +# ``credentials_from_headers`` 已把所有键归一为小写。 +_H_ORG = "x-org-id" +_H_TYPE = "x-principal-type" +_H_ID = "x-principal-id" + +_PRINCIPAL_TYPES = frozenset({"user", "agent"}) + +_FAILED = "authentication failed" + + +class TrustedAuthenticator(Authenticator): + """读网关注入的身份声明,角色查本地注册表。""" + + def __init__(self, key_store: PrincipalKeyStore, gateway_key: str = "") -> None: + self._key_store = key_store + self._gateway_key = gateway_key + + def authenticate(self, credentials: Credentials) -> AuthContext: + headers = credentials.headers + org = str(headers.get(_H_ORG, "")).strip() + principal_type = str(headers.get(_H_TYPE, "")).strip().lower() + principal_id = str(headers.get(_H_ID, "")).strip() + + if not org or principal_type not in _PRINCIPAL_TYPES or not principal_id: + raise AuthenticationError(_FAILED) + + # 网关到框架这一跳的共享密钥(可选):配了就必须对上,防止绕过网关直连。 + # encode 成 bytes 再比:compare_digest 的 str 版对非 ASCII 输入抛 TypeError。 + if self._gateway_key and not hmac.compare_digest( + self._gateway_key.encode("utf-8"), credentials.api_key.encode("utf-8") + ): + raise AuthenticationError(_FAILED) + + # keyword 构造:F03 将给 Scope 加 space 字段,位置参数会错位。 + actor = Scope(org=org, **{principal_type: principal_id}) + + role = self._key_store.get_role(actor) + if role is None: + # 未注册主体一律拒绝,不默认给 USER 放行——fail-closed。 + raise AuthenticationError(_FAILED) + + return AuthContext(actor=actor, acting_user=actor.user, role=role) + + def mode(self) -> AuthMode: + return AuthMode.TRUSTED + + def health(self) -> None: + self._key_store.health() + + +def _truthy(value) -> bool: + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +@AuthProducer.register("trusted") +def _build(config): + gateway_key = str(config.get("gateway_key", "") or "").strip() + if not gateway_key: + # 未配 gateway_key 时,全部身份 header(X-Org-Id / X-Principal-* 等)可被 + # 任意能连到本端口的调用方伪造。默认拒绝启动;确需仅靠网络隔离时,必须 + # 显式 opt-in,让「没有网关密钥」成为一个可见的部署决定而非默认状态。 + if not _truthy(config.get("allow_no_gateway_key", False)): + raise ValidationError( + "trusted 模式必须配置 gateway_key:未配置时身份 header 可被任意调用方" + "伪造。若确需仅靠网络隔离(受信反代/mTLS 已到位),显式设" + " allow_no_gateway_key=true。" + ) + _LOG.warning( + "trusted 模式未配 gateway_key(allow_no_gateway_key=true):信任全部" + "身份 header,仅可用于网络已隔离的部署。" + ) + key_store = KeyStoreProducer.dep(config, "key_store", default="memory") + return TrustedAuthenticator(key_store=key_store, gateway_key=gateway_key) diff --git a/src/security/binding.py b/src/security/binding.py new file mode 100644 index 00000000..07d1c545 --- /dev/null +++ b/src/security/binding.py @@ -0,0 +1,63 @@ +"""DEV 模式的绑定地址校验(security.md §2.2.1)。 + +DEV 模式恒返回 ROOT 身份,绑到非 localhost 就是把全权限暴露给整个网络。 +本模块是纯函数、抛异常,**不 ``sys.exit``**:exit 语义留在真正的进程入口 +(``bootstrap/http_server/__main__.py:main``),这样本函数可被单测直接断言, +而不会让测试进程退出。 +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Sequence + +from common.errors import ValidationError + +_LOG = logging.getLogger(__name__) + +_LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"}) +# 「绑定所有网卡」的各种写法。空串在 socket 语义里等价于 0.0.0.0—— +# 这是容器化场景下最危险的情况:以为只是没配,实际暴露给了整个网络。 +_WILDCARD = frozenset({"0.0.0.0", "::", "*", ""}) + + +def _in_container() -> bool: + return Path("/.dockerenv").exists() or bool(os.environ.get("KUBERNETES_SERVICE_HOST")) + + +def check_dev_binding(hosts: str | Sequence[str] | None) -> None: + """DEV 模式的绑定地址校验:非 localhost 抛 :class:`~common.errors.ValidationError`。 + + 调用方负责把异常翻译成打印 + 退出码。多网卡时**任一** host 危险即拒绝。 + + 容器环境只打 WARNING 不拒绝:容器里绑 127.0.0.1 本身是合法的,是否真的 + 暴露取决于 port mapping / Service,框架无法检查(§2.2.1 原话)。 + """ + if hosts is None: + candidates: list[str] = [""] + elif isinstance(hosts, str): + candidates = [hosts] + else: + candidates = [str(h) for h in hosts] or [""] + + for host in candidates: + normalized = host.strip().strip("[]").lower() + if normalized in _WILDCARD: + raise ValidationError( + f"DEV 认证模式禁止绑定 {host!r}(等价于所有网卡):该模式恒返回 ROOT 身份," + "绑到非 localhost 等于把全权限暴露给整个网络。" + "请改绑 127.0.0.1,或配置 authenticator.default.target 为 api_key / trusted。" + ) + if normalized not in _LOOPBACK: + raise ValidationError( + f"DEV 认证模式只允许绑定 localhost,得到 {host!r}。" + "请改绑 127.0.0.1,或配置 authenticator.default.target 为 api_key / trusted。" + ) + + if _in_container(): + _LOG.warning( + "检测到容器环境且认证模式为 DEV:即使绑定 127.0.0.1,是否对外暴露仍取决于 " + "port mapping / Service 配置,框架无法检查。生产部署请使用 api_key 或 trusted 模式。" + ) diff --git a/src/security/bootstrap.py b/src/security/bootstrap.py new file mode 100644 index 00000000..47d478d7 --- /dev/null +++ b/src/security/bootstrap.py @@ -0,0 +1,28 @@ +"""注册引导:import 各安全实现包,触发其 ``@Producer.register`` 自注册。 + +工厂句柄定义在接口模块(:class:`~security.authenticator.AuthProducer`、 +:class:`~security.key_store.KeyStoreProducer`),消费方只依赖接口层;实现的 +注册发生在 import 实现模块时,由本函数在装配入口统一触发。与各层 bootstrap 同构。 + +**调用点**:``bootstrap/core/server.py:Server.build`` 的开头,必须在 +``KernelConfig.from_dict(...)`` **之前**——否则 ``authenticator`` / ``key_store`` / +``rate_limiter`` 三个顶层段会因未注册进 ``Factory.known_top_names()`` 而被配置 +解析期的段名校验拒掉。 +""" + +from __future__ import annotations + +from importlib import import_module + +_REGISTERED = False + + +def register_security() -> None: + """import 各安全实现包,完成自注册(幂等;import 已缓存,重复调用近乎零成本)。""" + global _REGISTERED + if _REGISTERED: + return + import_module("security.key_store_impl") + import_module("security.authenticator_impl") + import_module("security.rate_limit_impl") + _REGISTERED = True diff --git a/src/security/concurrency_guard.py b/src/security/concurrency_guard.py new file mode 100644 index 00000000..ad6a36c3 --- /dev/null +++ b/src/security/concurrency_guard.py @@ -0,0 +1,89 @@ +"""Argon2 verify 的进程级并发上限(security.md §8.1 / 审计 P1-3)。 + +IP 令牌桶限的是「单地址的请求速率」,限不住「同时在跑的 Argon2 verify 数」-- +后者才是 CPU/内存耗尽攻击的真正向量:单 IP 30 个并发错误 key = 30 × 128 MiB +同时驻留。本模块是进程级 ``BoundedSemaphore``,在 ``authenticate`` 之前 acquire, +耗尽即拒(返回 429),是 IP 桶之上的第一层。 + +**不进 Factory / Producer**:进程级状态按配置实例化多份没有意义--一个进程只有 +一份「正在跑的 verify 数」计数器。装配在 :func:`security.bootstrap.register_security` +里算一次默认上限(见 ``default_argon2_guard``),``auth_middleware`` 取它用。 + +DEV 模式不跑 Argon2(恒返回 ROOT),不需要 guard。 +""" + +from __future__ import annotations + +import logging +import threading +from typing import Optional + +_LOG = logging.getLogger(__name__) + +# Argon2id 单次 verify 内存 128 MiB。默认按「给认证留 ~512 MiB」预算:4 个并发 +# 同时最多吃 512 MiB,留出业务内存。可按机器内存调(见 default_argon2_guard)。 +_DEFAULT_MAX_CONCURRENT = 4 + +# 进程级单例:所有请求共享。None 表示「不限」(DEV 模式或显式关闭)。 +_guard: "Optional[Argon2Guard]" = None +_guard_lock = threading.Lock() + + +class Argon2Guard: + """进程级 Argon2 verify 并发上限。 + + acquire 非阻塞:有空槽立刻占用并返回 True,否则返回 False(让中间件 + 翻译成 429)。不在 acquire 处阻塞等待--排队会让线程无界堆积,且 + 攻击者能用慢请求占满队列把后续正常请求也堵死。 + """ + + def __init__(self, max_concurrent: int) -> None: + if max_concurrent < 1: + raise ValueError(f"max_concurrent 须 >= 1,得到 {max_concurrent}") + self._max = max_concurrent + self._sem = threading.BoundedSemaphore(max_concurrent) + + def acquire(self) -> bool: + return self._sem.acquire(blocking=False) + + def release(self) -> None: + try: + self._sem.release() + except ValueError: + # release 过多次(acquire 失败后误 release):不抛,但记一笔-- + # BoundedSemaphore 超过初始值会 ValueError,吞掉会让计数器永久偏。 + _LOG.error("Argon2Guard release 越界(acquire 未成功即 release?)", exc_info=True) + + @property + def max_concurrent(self) -> int: + return self._max + + +def default_argon2_guard(max_concurrent: int | None = None) -> Argon2Guard: + """取/建进程级 guard 单例。 + + 第一次调用按 ``max_concurrent``(默认 4)建;后续调用返回同一实例。若后续调用 + 传了**不同**的 ``max_concurrent``,抛 ``ValueError``--同进程多 Server / 热重载 + 场景下静默忽略配置会让 ``argon2.max_concurrent`` 失效(审计验收 P2-guard)。 + + 传 ``None`` 用默认上限;显式传 0 是非法(须装配期报错,不能用 ``or`` 吞成默认)。 + """ + global _guard + effective = _DEFAULT_MAX_CONCURRENT if max_concurrent is None else max_concurrent + with _guard_lock: + if _guard is None: + _guard = Argon2Guard(effective) + return _guard + if effective != _guard.max_concurrent: + raise ValueError( + f"argon2.max_concurrent 配置冲突:进程已有 guard(max={_guard.max_concurrent})," + f"本次请求 max={effective}。进程级 guard 是单例,同进程不可配不同上限。" + ) + return _guard + + +def reset_guard() -> None: + """重置单例(测试隔离用)。""" + global _guard + with _guard_lock: + _guard = None diff --git a/src/security/key_store.py b/src/security/key_store.py new file mode 100644 index 00000000..02020241 --- /dev/null +++ b/src/security/key_store.py @@ -0,0 +1,112 @@ +"""PrincipalKeyStore — 主体 API Key 注册表(security.md §2.3)。 + +只负责「key ↔ 主体身份」的映射:签发、解析、撤销、查角色。不做认证分流 +(那是 :class:`~security.authenticator.Authenticator` 的事),也不管 Root API +Key——它不入注册表,由 api_key authenticator 单独 ``compare_digest`` 比对 +(§2.3.1)。 +""" + +from __future__ import annotations + +import hashlib +import secrets +from abc import ABC, abstractmethod + +from common.factory.factory import Factory +from common.type_def.auth import AuthContext, Role +from common.type_def.scope import Scope + +_PREFIX_LEN = 8 # 前缀索引长度(§2.3.1) +_KEY_BYTES = 32 # 256 bit + + +class KeyStoreProducer(Factory): + """PrincipalKeyStore 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``key_store_impl`` 下以 ``@KeyStoreProducer.register("<后端>")`` + 自注册,由 :func:`security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "key_store" + + +def fingerprint(api_key: str) -> str: + """key 的 sha256 十六进制指纹。 + + 三个用途:(1) 注册表的确定性查找键——Argon2 每次 salt 不同,哈希值不能作键; + (2) 撤销的定位键;(3) 未来 OAuth token 的绑定锚(§6.5)。 + + **必须在哈希之前用明文算**——密码哈希不可逆,事后无法补算。 + + 指纹不可逆但**可枚举**(若 key 空间小可暴力),故 key 生成必须高熵, + 见 :func:`generate_api_key`。指纹本身进审计日志是安全的。 + """ + return hashlib.sha256(api_key.encode("utf-8")).hexdigest() + + +def key_prefix(api_key: str) -> str: + """前缀索引键:定位候选记录,避免 resolve 全表扫描(§2.3.1)。 + + 43 字符 URL-safe base64 的前 8 字符约 48 bit,候选列表基本恒为 1。 + 前缀索引本身是**非常时间**的 dict 查找(已知缝隙,§2.3.2),由 resolve + 未命中时的 dummy verify 补偿。 + """ + return api_key[:_PREFIX_LEN] + + +def generate_api_key() -> str: + """生成一把高熵 API Key:43 字符 URL-safe base64,256 bit 熵。 + + 必须用 ``secrets`` 而非 ``random``——后者是可预测的 Mersenne Twister。 + """ + return secrets.token_urlsafe(_KEY_BYTES) + + +class PrincipalKeyStore(ABC): + """主体 API Key 注册表:签发、解析、撤销。""" + + @abstractmethod + def issue(self, actor: Scope, role: Role) -> str: + """为 ``actor`` 签发一把 API Key,返回**一次性明文**。 + + 明文只在此刻返回一次,服务端随后只保存验证材料(密码哈希 + sha256 + 指纹)。 + + ``role`` 不得为 :attr:`~common.type_def.auth.Role.ROOT`——ROOT 只能来自 + 配置声明的 Root API Key(§3.2「明确禁止」自签发 ROOT),传 ROOT 抛 + :class:`~common.errors.PermissionDeniedError`。 + + ``actor`` 必须且只能指定 ``user`` 或 ``agent`` 之一(§4.1),否则抛 + :class:`~common.errors.ValidationError`。 + """ + + @abstractmethod + def resolve(self, api_key: str) -> AuthContext | None: + """按明文 key 反查主体身份;未命中返回 ``None``。 + + **本方法允许返回 None**,与 :meth:`~security.authenticator.Authenticator. + authenticate` 不同:它是「查表未命中」的事实陈述,由调用方翻译成 + ``AuthenticationError``。这不构成 fail-open——调用方拿到 None 唯一能做的 + 就是拒绝。 + + 实现必须满足 §2.3.2:前缀索引定位候选、常时间比对、**未命中时补一次 + dummy verify** 把耗时 pad 到与命中路径同量级,否则「前缀是否存在」成为 + 可测量的侧信道。 + """ + + @abstractmethod + def revoke(self, key_fp: str) -> None: + """按指纹撤销一把 key(幂等)。撤销后 :meth:`resolve` 立即不再命中。""" + + @abstractmethod + def get_role(self, actor: Scope) -> Role | None: + """查主体的服务端注册角色;未注册返回 ``None``。 + + TRUSTED 模式据此实现「role 不从 header 读」(§2.2.2 关键设计)——网关说 + 「你是谁」,框架自己查「你能干什么」。这样即使网关被攻破或误配,也无法 + 任意提权。 + """ + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。""" diff --git a/src/security/key_store_impl/__init__.py b/src/security/key_store_impl/__init__.py new file mode 100644 index 00000000..3a21b71a --- /dev/null +++ b/src/security/key_store_impl/__init__.py @@ -0,0 +1,13 @@ +"""key_store_impl 实现集:工厂 KeyStoreProducer + 各实现。 + +import 各实现模块即触发其 ``@KeyStoreProducer.register(...)`` 自注册; +本包只对外暴露工厂 KeyStoreProducer。 +""" + +from importlib import import_module + +from security.key_store import KeyStoreProducer + +import_module(".memory_key_store", __name__) + +__all__ = ["KeyStoreProducer"] diff --git a/src/security/key_store_impl/memory_key_store.py b/src/security/key_store_impl/memory_key_store.py new file mode 100644 index 00000000..e47dd8c3 --- /dev/null +++ b/src/security/key_store_impl/memory_key_store.py @@ -0,0 +1,269 @@ +"""进程内 :class:`~security.key_store.PrincipalKeyStore`,Argon2id 校验。 + +**已知限制(两条,均在归档文档「已知遗留」列明)**: + +1. **性能**:Argon2id 128 MiB × time_cost=4 的单次 verify 在典型硬件上 + 50~200ms,意味着 API 吞吐上限约 5~20 QPS/核。第一期**不做验证缓存**—— + 缓存会带来撤销延迟(撤销后缓存内 key 仍有效 = 安全漏洞)这个新的安全问题, + 在没有生产流量的阶段不值得引入。高 QPS 场景需要带撤销传播的缓存。 +2. **持久化**:进程重启后所有已签发的 key 失效。生产需要 SQLite 后端。 + +注册名是 ``memory`` 而非 ``argon2``:Argon2 描述的是**哈希算法**(内部细节), +``memory`` 描述的是**存储后端**,与主干 ``sqlite_permission_manager`` / +``in_memory_governor`` 的命名惯例一致。 +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import Any + +from common.errors import PermissionDeniedError, ValidationError +from common.type_def.auth import AuthContext, Role +from common.type_def.scope import Scope +from security.key_store import ( + KeyStoreProducer, + PrincipalKeyStore, + fingerprint, + generate_api_key, + key_prefix, +) + +# Argon2id 参数(OWASP 2024+,security.md §2.3.1)。 +# 显式指定全部五项,不用库默认——argon2-cffi 的默认 memory_cost 是 64 MiB, +# 低于 OWASP 推荐,且默认值随版本变化。 +_TIME_COST = 4 +_MEMORY_COST = 131072 # 128 MiB +_PARALLELISM = 2 +_HASH_LEN = 32 +_SALT_LEN = 16 + +_DUMMY_KEY = "dummy-key-for-timing-pad" + + +@dataclass +class _Record: + """一条主体 key 记录。**不含明文**——明文只在 issue 时返回一次。""" + + key_fp: str + key_hash: str + actor: Scope + role: Role + revoked: bool = False + + +class InMemoryKeyStore(PrincipalKeyStore): + """进程内注册表 + Argon2id 校验。 + + ``hasher`` 与异常类型由 ``_build`` 注入:argon2-cffi 是可选依赖,模块顶层 + import 会让缺依赖的环境连 DEV 模式都起不来(``register_security()`` 无差别 + import 全部实现包)。见 ``_build``。 + """ + + def __init__(self, hasher: Any, mismatch_errors: tuple[type[BaseException], ...]) -> None: + self._hasher = hasher + self._mismatch_errors = mismatch_errors + self._records: dict[str, _Record] = {} # key_fp -> record + self._prefix_index: dict[str, list[str]] = {} # key 前缀 -> [key_fp] + # role 按 **principal**(org + user/agent)索引,不含 space、不含 session: + # §3.1 角色是 principal 级(一个 user 是 USER/ADMIN/ROOT,不随 space 或 session + # 变)。含 space 会让「同 principal 不同 space 同 role」变成两条互覆记录; + # 含 session 会让「同一 principal 换个 session 登录」查不到 role。两者都是 + # 把非身份维度塞进了身份索引。 + self._roles: dict[tuple[str, str, str], Role] = {} # (org, user, agent) -> role + # 状态锁:issue 的「检查 role -> hash -> 写 record/role」、revoke 的「标记撤销 + # -> 重算 role」、resolve 的「取候选 -> 确认未撤销」都必须原子(验收复验 P2-role: + # 否则两线程并发 issue 不同 role,都看到 existing=None,最终 _records 同时存在 + # USER/ADMIN 而 _roles 只留最后写入者)。RLock 因 resolve 在锁内调 _verify 之外 + # 不需要重入,但 revoke/get_role 可能被同链路调用,RLock 更稳。 + self._lock = threading.RLock() + # dummy 哈希供 resolve 未命中时 pad 时间。装配期算一次(约 100ms), + # 之后每次 resolve 复用。 + self._dummy_hash: str = hasher.hash(_DUMMY_KEY) + + # -- 内部 ------------------------------------------------------------ # + + @staticmethod + def _role_key(actor: Scope) -> tuple[str, str, str]: + """principal 级 role 索引键:(org, user, agent)。 + + Scope 是可变 dataclass(unhashable),不能直接作 dict key。这里只取身份 + 维度(§3.1:role 是 principal 级),不含 space / session--见 ``_roles`` 注释。 + """ + return (actor.org, actor.user, actor.agent) + + def _verify(self, stored_hash: str, provided: str) -> bool: + """常时间校验。 + + 只捕获 argon2 的校验类异常:未预期的异常(如内存不足)应该炸出来, + 静默 ``return False`` 会把系统性故障伪装成认证失败。 + """ + try: + return bool(self._hasher.verify(stored_hash, provided)) + except self._mismatch_errors: + return False + + # -- 契约 ------------------------------------------------------------ # + + def issue(self, actor: Scope, role: Role) -> str: + if role is Role.ROOT: + # §3.2「明确禁止」:ROOT 只能来自配置声明的 Root API Key。 + raise PermissionDeniedError("issue", message="cannot issue a ROOT key") + if bool(actor.user) == bool(actor.agent): + # §4.1:同一个归属 scope 不应同时设置 user 与 agent;也不能都不设, + # 否则签出的是「整个 org」这种无主体的 key。 + raise ValidationError("principal scope must set exactly one of user / agent") + if not actor.org: + raise ValidationError("principal scope must set org") + + # role 是 principal 的唯一权威状态(§3.1),不是每把 key 的可冲突副本: + # 同 principal 已有不同 role 的有效 key 时拒绝签发(审计验收 P2-role)。 + # 否则 issue 覆盖 _roles 后,resolve(读 record.role)与 get_role(读 _roles) + # 会返回不一致;revoke ADMIN key 后 _roles 仍残留 ADMIN = 撤销后提权残留。 + # 换 role 须先 revoke 该 principal 全部 key,或走专门的 set_role(本期未提供)。 + # + # 并发原子性(验收复验 P2-role):Argon2 hash(~200ms)在锁**外**算,进锁后 + # **重新检查** principal role 再原子提交 record/index/role。否则两线程并发 + # issue 不同 role,都看到 existing=None,最终 _records 同时存在 USER/ADMIN。 + api_key = generate_api_key() + key_fp = fingerprint(api_key) + key_hash = self._hasher.hash(api_key) # 昂贵,锁外算 + role_key = self._role_key(actor) + with self._lock: + existing = self._roles.get(role_key) + if existing is not None and existing is not role: + raise ValidationError( + f"principal 已持有 role={existing.value},签发不同 role={role.value} 前须先 " + f"revoke 其全部 key" + ) + # 检查通过 -> 原子提交三者 + self._records[key_fp] = _Record( + key_fp=key_fp, + key_hash=key_hash, + actor=actor, + role=role, + ) + self._prefix_index.setdefault(key_prefix(api_key), []).append(key_fp) + self._roles[role_key] = role + return api_key + + def resolve(self, api_key: str) -> AuthContext | None: + # 并发契约(验收复验 P2-role):不在锁内跑完整 Argon2(~200ms,会串行化所有 + # 认证)。先锁内取候选快照,锁外 verify,命中后再锁内确认记录未被撤销。 + with self._lock: + candidates = [ + self._records.get(key_fp) + for key_fp in self._prefix_index.get(key_prefix(api_key), ()) + ] + candidates = [r for r in candidates if r is not None and not r.revoked] + verified_any = False + for record in candidates: + verified_any = True + if self._verify(record.key_hash, api_key): + # 命中:锁内确认记录仍存在且未撤销(revoke 可能在这期间发生) + with self._lock: + current = self._records.get(record.key_fp) + if current is None or current.revoked: + continue + return AuthContext( + actor=current.actor, + # user 主体自操作;agent 主体的 actor.user 为空--未经 OAuth + # 授权时无委托目标,正是想要的。 + acting_user=current.actor.user, + role=current.role, + authorizing_key_fp=record.key_fp, + ) + + # 无候选时补一次 dummy verify,把耗时 pad 到与「有候选」路径同量级。 + # 少了它,「前缀不存在」比「前缀存在但 key 错」快一整个 Argon2 verify + # (~200ms),可用来枚举有效 key 前缀(§2.3.2)。 + # + # 条件是 `not verified_any` 而非无条件:无条件 pad 会让「有候选但 key 错」 + # 跑两次 verify,反而造出一个反向的 2x 时间差--同样是可测量的侧信道。 + # 三条路径(命中 / 有候选未命中 / 无候选)都恰好一次 verify 才是对的。 + if not verified_any: + self._verify(self._dummy_hash, api_key) + return None + + def revoke(self, key_fp: str) -> None: + with self._lock: + record = self._records.get(key_fp) + if record is None: + return # 幂等 + record.revoked = True + # 按剩余有效 key 重算 role(审计验收 P2-role):此前「还有任意有效 key 就保留 + # 当前 _roles」不重算,会残留被撤销 key 的 role。现在取剩余有效 key 的 role-- + # 因 issue 已禁止同 principal 不同 role,剩余 key 的 role 恒与被撤销的一致, + # 但重算使「先 revoke ADMIN 再 revoke USER」等顺序无关。无剩余 key 则清空。 + key = self._role_key(record.actor) + remaining = [ + r + for r in self._records.values() + if not r.revoked and self._role_key(r.actor) == key + ] + if remaining: + self._roles[key] = remaining[0].role + else: + self._roles.pop(key, None) + + def get_role(self, actor: Scope) -> Role | None: + with self._lock: + return self._roles.get(self._role_key(actor)) + + def health(self) -> None: + return None + + +# -- 注册到 KeyStoreProducer ------------------------------------------------ # + + +@KeyStoreProducer.register("memory") +def _build(config): + """装配 InMemoryKeyStore。 + + argon2-cffi 的 import 在**这里**而非模块顶层:``register_security()`` 会 + 无差别 import 整个 ``key_store_impl`` 包,顶层 import 会让缺依赖的环境连 + DEV 模式都起不来。挪进 builder 后,注册总能成功,只有真正装配本实现时才 + 要求依赖,且失败是装配期的清晰 ``ValidationError``。 + + **绝不回退到明文比对**:§2.3.1 明说,加密层也关闭时 key 就是磁盘上的裸明文。 + 铁律 #3 fail-closed。 + """ + try: + from argon2 import PasswordHasher + from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + except ImportError as exc: + # 区分两种情况给运维可操作的诊断: + # - argon2 包本身没装 -> 装 extra; + # - 包装了但版本太旧(如 21.3.0 没有 InvalidHashError)-> 升级到 >=23.1。 + # 两者都是 ImportError(子模块存在但缺名字也抛 ImportError),靠 argon2 顶层 + # 能否 import 区分。 + try: + import argon2 # noqa: F401 + except ImportError: + raise ValidationError( + "key_store 'memory' 需要 argon2-cffi:pip install 'JiuwenMemory[security]'。" + "不回退到明文比对--那会让 key 变成磁盘上的裸明文。" + ) from exc + # argon2 顶层能 import 但 from ... import 失败 = 版本过旧(如 21.3.0 + # 无 InvalidHashError)。区分两路径给运维可操作诊断(审计验收 P1-uv.lock)。 + raise ValidationError( + "key_store 'memory' 需要 argon2-cffi>=23.1(当前版本过旧,缺少" + " InvalidHashError):升级 pip install 'JiuwenMemory[security]' --upgrade。" + "不回退到明文比对。" + ) from exc + + hasher = PasswordHasher( + time_cost=_TIME_COST, + memory_cost=_MEMORY_COST, + parallelism=_PARALLELISM, + hash_len=_HASH_LEN, + salt_len=_SALT_LEN, + ) + # VerifyMismatchError 是正常的「key 错」路径;InvalidHashError / + # VerificationError 是哈希损坏或参数不符 → 同样 fail-closed 判为不通过。 + return InMemoryKeyStore( + hasher=hasher, + mismatch_errors=(VerifyMismatchError, InvalidHashError, VerificationError), + ) diff --git a/src/security/rate_limit.py b/src/security/rate_limit.py new file mode 100644 index 00000000..fb3fb378 --- /dev/null +++ b/src/security/rate_limit.py @@ -0,0 +1,58 @@ +"""RateLimiter — 按调用方地址的请求限流(security.md §8.1)。 + +限流挂在**认证之前**:认证本身就是要保护的资源。API_KEY 模式下每次 +``authenticate`` 都跑一次 Argon2id verify(128 MiB × time_cost=4,约 +50~200ms),无限制地触发它能把进程的 CPU 与内存同时打满——这是第一期引入 +Argon2 时一并带进来的可用性风险,本模块把它堵上。 + +**限流维度是调用方地址,不是 key 指纹。** §8.1 的草图按 ``key_fp`` 分桶, +那防的是「单个合法 key 打爆配额」(配额公平),不是「攻击者打爆 CPU」—— +攻击者每次换一把随机 key 就换一个新桶,按 key_fp 分桶对枚举与耗尽两种攻击 +都不生效。真正能收敛攻击的是来源地址。按 key 的配额公平是独立需求, +本期不做(见模块末「已知限制」)。 + +抽象与实现分离的理由:分布式部署下进程内桶各算各的(N 个副本 = N 倍额度), +真正的多副本限流要 Redis 之类的共享计数器。契约留在这里,届时新增一个实现 +即可,无需改中间件。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from common.factory.factory import Factory + + +class RateLimitProducer(Factory): + """RateLimiter 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``rate_limit_impl`` 下以 ``@RateLimitProducer.register("<后端>")`` + 自注册,由 :func:`security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "rate_limiter" + + +class RateLimiter(ABC): + """请求准入:一次调用消耗一个额度。""" + + @abstractmethod + def allow(self, peer: str) -> bool: + """``peer`` 还有额度则消耗一个并返回 ``True``,否则返回 ``False``。 + + **返回 bool 而非抛异常**:限流是「事实陈述」,翻译成 HTTP 状态码是 + 调用方(``auth_middleware``)的事。这与 + :meth:`~security.key_store.PrincipalKeyStore.resolve` 返回 ``None`` + 同理,且不构成 fail-open——调用方拿到 ``False`` 唯一能做的就是拒绝。 + + 实现必须是**并发安全**的:``ThreadingHTTPServer`` 每请求一线程, + 「读余量 → 减一 → 写回」在 GIL 下不是原子的,两个线程能同时看到 + 最后一个令牌。 + + ``peer`` 为空串(进程内直连 / MCP stdio,无网络对端)时应放行: + 没有远端就没有可收敛的攻击面,限流反而会把本地 CLI 卡住。 + """ + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与其他安全组件同构。""" diff --git a/src/security/rate_limit_impl/__init__.py b/src/security/rate_limit_impl/__init__.py new file mode 100644 index 00000000..7b105964 --- /dev/null +++ b/src/security/rate_limit_impl/__init__.py @@ -0,0 +1,14 @@ +"""rate_limit_impl 实现集:工厂 RateLimitProducer + 各实现。 + +import 各实现模块即触发其 ``@RateLimitProducer.register(...)`` 自注册; +本包只对外暴露工厂 RateLimitProducer。 +""" + +from importlib import import_module + +from security.rate_limit import RateLimitProducer + +import_module(".token_bucket_limiter", __name__) +import_module(".unlimited_limiter", __name__) + +__all__ = ["RateLimitProducer"] diff --git a/src/security/rate_limit_impl/token_bucket_limiter.py b/src/security/rate_limit_impl/token_bucket_limiter.py new file mode 100644 index 00000000..e278152c --- /dev/null +++ b/src/security/rate_limit_impl/token_bucket_limiter.py @@ -0,0 +1,123 @@ +"""进程内令牌桶限流,按调用方地址分桶(security.md §8.1)。 + +两个参数分别管两件事:``capacity`` 是**突发**额度(桶满时能一口气放多少个), +``refill_per_sec`` 是**持续**速率(长期平均每秒放多少个)。交互式客户端天然 +是「短突发 + 长空闲」,所以默认给一个偏大的桶配一个偏小的补充速率。 + +**桶表是 LRU 有界的**:桶按 peer 建,peer 由远端决定,无界字典会让这个 +「防资源耗尽」的组件自己变成资源耗尽的入口。超出 ``max_tracked`` 时淘汰最久 +未活跃的那个——它最可能已经补满,淘汰等于重建成满桶,不丢有效状态。 + +**已知限制**(两条,都不是本实现能解决的): + +1. **多副本各算各的**:进程内计数,N 个副本 = N 倍实际额度。真正的多副本 + 限流要 Redis 之类的共享计数器,届时在 ``rate_limit_impl`` 下新增一个实现, + 中间件不用改。 +2. **按地址分桶挡不住僵尸网络**:来源足够分散时每个 IP 都拿到一个新满桶。 + 能收敛这种攻击的是**对 Argon2 verify 本身做并发上限**(一个信号量,把 + 同时进行的 verify 数压到内存能承受的范围),那是与限流互补的另一个机制, + 本期不做。 +""" + +from __future__ import annotations + +import threading +import time +from collections import OrderedDict +from dataclasses import dataclass + +from common.errors import ValidationError +from security.rate_limit import RateLimiter, RateLimitProducer + +# 默认值面向「交互式使用不该被限流,脚本化枚举必须被限流」这条线: +# 30 个突发够任何人工操作和常规客户端启动时的几次探测;持续 5 QPS 远低于 +# Argon2 verify 打满一个核所需的速率。 +_DEFAULT_CAPACITY = 30 +_DEFAULT_REFILL_PER_SEC = 5.0 +_DEFAULT_MAX_TRACKED = 10_000 + + +@dataclass +class _Bucket: + """一个 peer 的桶。``last`` 是 ``time.monotonic()`` 读数,不是墙上时间。""" + + tokens: float + last: float + + +class TokenBucketLimiter(RateLimiter): + """按 peer 分桶的令牌桶;LRU 有界,并发安全。""" + + def __init__(self, capacity: int, refill_per_sec: float, max_tracked: int) -> None: + self._capacity = float(capacity) + self._refill = refill_per_sec + self._max_tracked = max_tracked + self._buckets: OrderedDict[str, _Bucket] = OrderedDict() + # 一把全局锁,不做分桶锁:临界区只有几次浮点运算,而其后紧跟的 + # Argon2 verify 是 50~200ms——锁竞争在这个量级下不值得优化。 + self._lock = threading.Lock() + + def allow(self, peer: str) -> bool: + if not peer: + # 无网络对端(进程内直连 / MCP stdio)。没有远端就没有可收敛的 + # 攻击面,限流只会把本地 CLI 卡住。 + return True + + now = time.monotonic() + with self._lock: + bucket = self._buckets.get(peer) + if bucket is None: + bucket = _Bucket(tokens=self._capacity, last=now) + self._buckets[peer] = bucket + if len(self._buckets) > self._max_tracked: + # 只可能超出 1 个(每次调用最多插一个),故一次淘汰即可。 + # 刚插入的在末尾,不会被 last=False 弹掉。 + self._buckets.popitem(last=False) + else: + self._buckets.move_to_end(peer) # 维护 LRU 次序 + refilled = bucket.tokens + (now - bucket.last) * self._refill + bucket.tokens = min(self._capacity, refilled) + bucket.last = now + + if bucket.tokens < 1.0: + return False + bucket.tokens -= 1.0 + return True + + def health(self) -> None: + return None + + +@RateLimitProducer.register("token_bucket") +def _build(config): + """装配 TokenBucketLimiter;参数非法在**装配期**报错。 + + 参数错了要在启动时炸,不能等到运行期:``capacity=0`` 会让服务拒绝一切 + 请求,``refill_per_sec=0`` 会让桶空了再也补不回来——两者都是「配置写错 + 等于服务下线」,而运行期才暴露就是一次生产事故。要关闭限流请显式配 + ``target: unlimited``,不要靠把参数写成 0。 + """ + capacity = int(config.get("capacity", _DEFAULT_CAPACITY)) + refill_per_sec = float(config.get("refill_per_sec", _DEFAULT_REFILL_PER_SEC)) + max_tracked = int(config.get("max_tracked", _DEFAULT_MAX_TRACKED)) + + if capacity < 1: + raise ValidationError( + f"rate_limiter 'token_bucket' 的 capacity 须 >= 1,得到 {capacity}。" + "要关闭限流请配 target: unlimited。" + ) + if refill_per_sec <= 0: + raise ValidationError( + f"rate_limiter 'token_bucket' 的 refill_per_sec 须 > 0,得到 {refill_per_sec}。" + "为 0 时桶一旦耗尽就永不恢复,等于把调用方永久拉黑。" + ) + if max_tracked < 1: + raise ValidationError( + f"rate_limiter 'token_bucket' 的 max_tracked 须 >= 1,得到 {max_tracked}" + ) + + return TokenBucketLimiter( + capacity=capacity, + refill_per_sec=refill_per_sec, + max_tracked=max_tracked, + ) diff --git a/src/security/rate_limit_impl/unlimited_limiter.py b/src/security/rate_limit_impl/unlimited_limiter.py new file mode 100644 index 00000000..c4378a65 --- /dev/null +++ b/src/security/rate_limit_impl/unlimited_limiter.py @@ -0,0 +1,27 @@ +"""显式关闭限流的实现:恒放行(security.md §8.1)。 + +存在的理由是 TRUSTED 模式的真实部署形态——网关已在边缘做了限流,框架再做 +一层只会把「网关的单个出口 IP」当成一个 peer,从而把全部正常流量误伤成 429。 +这种部署需要一个**写在配置里、看得见**的关闭方式,而不是把 ``capacity`` 写成 +某个反着读的魔法值(``capacity: 0`` 是「一个令牌都不给」还是「不限流」? +配置文件里读不出来,而读不出来的配置就是会被写错的配置)。 +""" + +from __future__ import annotations + +from security.rate_limit import RateLimiter, RateLimitProducer + + +class NoRateLimit(RateLimiter): + """恒放行。""" + + def allow(self, peer: str) -> bool: + return True + + def health(self) -> None: + return None + + +@RateLimitProducer.register("unlimited") +def _build(config): + return NoRateLimit() diff --git a/src/security/types.py b/src/security/types.py new file mode 100644 index 00000000..14219c96 --- /dev/null +++ b/src/security/types.py @@ -0,0 +1,42 @@ +"""安全层数据类型:认证模式与凭据材料(security.md §2.2)。 + +纯数据定义,不依赖本层其他文件——与 ``control/types.py`` 的地位一致。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Mapping + + +class AuthMode(str, Enum): + """认证模式(security.md §2.2)。 + + 刻意**不定义** ``OAUTH``:OAuth 2.1 是第二期(§2.4)。定义一个没有实现的 + 枚举值,只会让 ``target: oauth`` 得到「未注册的实现」这种间接报错。 + 第二期加它时是纯新增。 + """ + + DEV = "dev" # 无认证,恒返回 ROOT;只允许 localhost 绑定 + TRUSTED = "trusted" # 信任上游网关已认证,只读网关注入的身份声明 + API_KEY = "api_key" # 框架自校验 API Key + + +@dataclass(frozen=True) +class Credentials: + """一次请求携带的原始凭据材料,由各 surface(HTTP / MCP / CLI)归一后传入。 + + 认证层不认识 HTTP:若直接把 ``http.client.HTTPMessage`` 传进 + :class:`~security.authenticator.Authenticator`,实现里就会出现传输层耦合, + MCP / CLI 无法复用。这里只保留认证需要的三样东西。 + + ``headers`` 仍保留,因为 TRUSTED 模式的语义就是「读网关注入的 header」 + (§2.2.2),无法进一步抽象;但**只有 TRUSTED 实现读它**。键**必须已归一 + 为小写**(HTTP header 名大小写不敏感,RFC 9110 §5.1),归一职责在 + ``bootstrap/core/auth_middleware.credentials_from_headers``。 + """ + + api_key: str = "" # Authorization: Bearer / X-Api-Key 提取后的裸 key + headers: Mapping[str, str] = field(default_factory=dict) # 小写键 + peer_address: str = "" # 调用方地址,供审计与(未来)速率限制 diff --git a/src/storage/AGENTS.md b/src/storage/AGENTS.md index 86cce320..0762ae38 100644 --- a/src/storage/AGENTS.md +++ b/src/storage/AGENTS.md @@ -23,7 +23,7 @@ | `graph_impl/` | GraphStore 实现目录(memory) | | `fulltext_impl/` | FulltextStore 实现目录(memory) | | `fusion_impl/` | FusionStore 实现目录(memory) | -| `fs_impl/` | FSStore 实现目录(local) | +| `fs_impl/` | FSStore 实现目录(local / encrypted) | | `bootstrap.py` | 统一触发所有存储后端注册 | ## 统一 CRUD 动词 @@ -62,10 +62,34 @@ 7. **后端不可用统一抛 BackendError** 连接失败/超时/服务不可用等非预期失败统一抛 `BackendError`(不抛泛化的 Exception)。 -8. **EncryptedKVStore 只做装饰,不做算法** - `encrypted` KV target 必须显式包装一个 raw KVStore,并调用 `common.security.SecurityProvider` - 做 value 加解密;`list` 必须在解密后执行 MemoryUnit 过滤,不能把过滤下推到密文 raw KV。 - 真实加密算法不放在 storage 层。 +8. **加密装饰器只做装饰,不做算法** + `encrypted` KV / FS target 必须显式包装一个 raw Store,并调用 + `common.security.SecurityProvider` 做加解密;KV 的 `list` 必须在解密后执行 + MemoryUnit 过滤,不能把过滤下推到密文 raw KV。真实加密算法不放在 storage 层。 + +9. **加密装饰器的写路径永远加密** + 兼容明文读(迁移期读加密上线前写入的老数据)由 `security` 组件的 + `allow_plaintext` 参数控制,且**只影响读**。它若顺带放松了写,迁移期写进去的 + 数据会永远是明文,而调用方看不出任何区别。 + +## 加密装饰器(第③道防线的接线) + +`kv_impl/encrypted_kv_store.py` 与 `fs_impl/encrypted_fs_store.py` 是**装饰器**: +包住任意一个同类 Store,写前加密、读后解密,对上仍是一个普通 `KVStore` / `FSStore`。 + +- **依赖方向是 `storage → common.security`(单向)**。密码学一行都不在 storage 里, + 全在 `common.security.security_impl`;这两个文件只构造 `SecurityContext` / AAD 并转发。 + 反向依赖不存在,security 不认识 Store。 +- **KV 只加密 `value`**。`key` 明文是必须的(加密它就没法 `list(prefix=...)`、 + 没法点查);`ttl` 明文是必须的(它是后端的原生能力,加密它等于放弃过期功能)。 +- **FS 加密整个文件内容**,`ref` 与 scope 保持明文(路径要能寻址),`ref` 进 AAD。 + 代价是 `get` 必须读全文件到内存才能解密(AES-GCM 整块认证的直接后果),且 + `FileStat.size` 返回的是密文长度(修正需先解密才知道明文长度,代价荒谬)。 +- **AAD 绑满五维 scope + 定位信息**(KV 是 `key`,FS 是 `ref`)。存储层的 scope + 隔离是访问控制、可以被绕过(直接写底层、备份恢复串了);AAD 是密码学的,绕不过。 +- **`cryptography` 缺失时在装配期抛 `BackendError`,绝不回落明文存储**——回落 + 会让「以为加密了」的部署实际裸奔,比不加密更危险。 +- 默认关闭:不配 `target: encrypted` 就没有任何加密行为,现有部署零影响。 ## 与其他子目录的边界 @@ -75,12 +99,14 @@ - 文件系统存储(FSStore) - 统一 CRUD 动词 - scope 原生隔离 +- 静态加密的**接线**(两个装饰器 + 它们的注册),密码学本身归 `security` **不管**: - 鉴权(归 `api`) - 检索编排(归 `retrieval`) - 索引构建逻辑(归 `construction`) - 具体后端选型决策(由装配层配置) +- 信封格式、密钥派生与包装、AES-GCM/HKDF(归 `common/security/`) ## 本地约束 @@ -90,4 +116,5 @@ 4. KVStore 的 `ttl` 单位为秒(float),`0` 表示永不过期。 5. GraphStore 的 `seed_ids` 用于图召回时定位入口节点,匹配语义由后端定义(允许实现差异)。 6. FusionStore 的 `FusionRecord` 可部分字段为 None(如只写向量不写文本)。 -7. `EncryptedKVStore` 的 `raw_kv_store` 不能指向自身;未配置 raw 依赖时必须在装配阶段报错。 +7. `EncryptedKVStore` 的 `raw_kv_store` / `EncryptedFSStore` 的 `inner` 不能指向自身; + 未配置该依赖时必须在装配阶段报错(给默认值只会把数据写到调用方没预期的地方)。 diff --git a/src/storage/fs_impl/__init__.py b/src/storage/fs_impl/__init__.py index 4678003f..4ffc0c56 100644 --- a/src/storage/fs_impl/__init__.py +++ b/src/storage/fs_impl/__init__.py @@ -9,5 +9,6 @@ import_module(".in_memory_fs_store", __name__) import_module(".local_fs", __name__) +import_module(".encrypted_fs_store", __name__) __all__ = ["FsProducer"] diff --git a/src/storage/fs_impl/encrypted_fs_store.py b/src/storage/fs_impl/encrypted_fs_store.py new file mode 100644 index 00000000..e03a92de --- /dev/null +++ b/src/storage/fs_impl/encrypted_fs_store.py @@ -0,0 +1,238 @@ +"""EncryptedFSStore — FSStore 加密装饰器。 + +与 ``EncryptedKVStore`` 同构:不含任何加解密算法,只在 FS 边界统一构造 +``SecurityContext`` / AAD,并委托注入的 ``SecurityProvider``。真实算法位于 +``common.security.security_impl``。 + +文件内容整体加密成一个信封再落盘,``ref`` / ``scope`` 保持明文(路径要能寻址), +``ref`` 进 AAD。 + +**已知代价(两条,都是 AES-GCM 整块认证的直接后果)**: + +1. ``get`` 必须**读全文件到内存**再整体解密——没有跨块认证绑定就不能流式部分 + 解密。大文件(视频、模型权重)会吃内存。第一期不做 chunked encryption: + chunk 间无绑定,可被重排/截断,不适合作默认方案。 +2. :attr:`~storage.types.FileStat.size` 返回的是**密文长度**,比明文长(信封头 + + 包装后的数据密钥 + 两个 nonce + 两个 16B 的 GCM tag)。不修正——修正需要先 + 解密才能知道明文长度,代价荒谬。调用方拿它去分配缓冲区只会偏大,不影响正确性。 + +迁移期兼容明文读由 ``security`` 组件的 ``allow_plaintext`` 参数控制(provider 层 +统一开关,KV / FS 共用),本装饰器不再重复提供同语义的旋钮。 +""" + +from __future__ import annotations + +import io +import json +from typing import Any, BinaryIO + +from common.errors import BackendError, ValidationError +from common.security import SecurityContext, SecurityProducer, SecurityProvider +from common.type_def import Scope +from storage.base import StoreType +from storage.fs import FsProducer, FSStore +from storage.types import FileStat + +_AAD_VERSION = 1 +_PURPOSE_FS_OBJECT = "fs_object" + +# 单文件明文大小硬上限。AES-GCM 整块认证要求把整个明文读入内存再加密(见模块 +# docstring 的已知代价),无上限意味着一个超大输入能把进程内存吃满。64 MiB 覆盖 +# 文本/图片/中等模型分片等记忆资产;视频/原始模型权重本就该走专用对象存储而非 +# memory 系统。chunked 加密(第一期不做)落地后可放宽。 +_DEFAULT_MAX_PLAINTEXT_BYTES = 64 * 1024 * 1024 + +# 密文上限的默认安全余量(加在明文上限上)。SecurityProvider 的 ABC 不暴露密文 +# overhead,故不硬编码某个 provider 的精确值--用宽松余量覆盖 ENC1 信封固定开销 +# (header + 加密 data key + nonce + GCM tag ≈ 100),宁可拒偏大也不读入超大密文。 +# 需要精确控制时显式配 max_ciphertext_bytes(验收复验 P2-FS)。 +_DEFAULT_CIPHERTEXT_OVERHEAD = 4 * 1024 # 4 KiB,远大于 ~100 字节信封开销 + + +def _read_bounded_stream(stream: BinaryIO, limit: int, *, ref: str) -> bytes: + """循环有界读取:反复 ``read`` 直到 EOF 或累计达到 ``limit + 1``。 + + BinaryIO.read(n) 允许短读(返回 < n 字节而未 EOF)。单次 read 会把第一段当完整 + 内容,造成静默截断(验收复验 P2-FS 问题 1)。循环读取并在累计超过 limit 时 + 拒绝,才真正守住边界。多读 1 字节用于判定超限。 + + 用 ``bytearray`` 累积而非 ``list[bytes]`` + ``join``(验收第三次 P2-2):恶意 + 1-byte 短读会让 list 长出百万级元素 + join 拼接元数据,8 MiB 内容能放大到 ~700 MiB。 + bytearray.extend 是单个连续缓冲区,内存与内容字节数成正比,不随分片数放大。 + """ + buffer = bytearray() + while len(buffer) <= limit: + chunk = stream.read(limit + 1 - len(buffer)) + if not chunk: + break + buffer.extend(chunk) + if len(buffer) > limit: + raise ValidationError(f"fs encrypted: 内容超过单文件上限 {limit}B(ref={ref!r})") + return bytes(buffer) + + +def _scope_payload(scope: Scope) -> dict[str, str]: + return { + "org": scope.org, + "space": str(getattr(scope, "space", "")), + "user": scope.user, + "agent": scope.agent, + "session": scope.session, + } + + +def _aad(scope: Scope, ref: str) -> bytes: + payload = { + "version": _AAD_VERSION, + "scope": _scope_payload(scope), + "ref": ref, + "purpose": _PURPOSE_FS_OBJECT, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _security_context(scope: Scope, ref: str) -> SecurityContext: + return SecurityContext( + scope=scope, + purpose=_PURPOSE_FS_OBJECT, + metadata={ + "ref": ref, + "aad_version": str(_AAD_VERSION), + }, + ) + + +class EncryptedFSStore(FSStore): + """对任意 FSStore 做透明加解密的装饰器。""" + + def __init__( + self, + inner: FSStore, + security: SecurityProvider, + *, + max_plaintext_bytes: int, + max_ciphertext_bytes: int = 0, + ) -> None: + self._inner = inner + self._security = security + self._max_plaintext_bytes = max_plaintext_bytes + # 密文上限:默认按明文上限 + 安全余量(覆盖 ENC1 信封固定开销 + 一点 buffer), + # 可显式配置覆盖。不硬编码某个 provider 的精确开销--SecurityProvider 的 ABC + # 不暴露 ciphertext bound,硬编码 128 会随 provider 实现变化失准(验收复验 P2-FS)。 + self._max_ciphertext_bytes = ( + max_ciphertext_bytes or max_plaintext_bytes + _DEFAULT_CIPHERTEXT_OVERHEAD + ) + + def store_type(self) -> StoreType: + return StoreType.FS + + def health(self) -> None: + self._inner.health() + self._security.health() + + # -- 写:永远加密 ---------------------------------------------------- # + + def insert(self, scope: Scope, key: str, data: BinaryIO) -> str: + plaintext = self._read_bounded(data, ref=key) + return self._inner.insert(scope, key, io.BytesIO(self._encrypt(scope, key, plaintext))) + + def update(self, scope: Scope, ref: str, data: BinaryIO) -> str: + plaintext = self._read_bounded(data, ref=ref) + return self._inner.update(scope, ref, io.BytesIO(self._encrypt(scope, ref, plaintext))) + + # -- 读:解密 -------------------------------------------------------- # + + def get(self, scope: Scope, ref: str) -> BinaryIO: + # stat 只作快速早拒(避免无谓打开超大对象);它不是唯一边界--stat 与随后 get + # 之间内容可能变化(TOCTOU),故真正读取仍用有界循环(验收复验 P2-FS)。 + stat = self._inner.stat(scope, ref) + if stat.size > self._max_ciphertext_bytes: + raise ValidationError( + f"fs encrypted: 密文 {stat.size}B 超过单文件上限 " + f"{self._max_ciphertext_bytes}B(ref={ref!r})" + ) + with self._inner.get(scope, ref) as fh: + stored = _read_bounded_stream(fh, self._max_ciphertext_bytes, ref=ref) + plaintext = self._decrypt(scope, ref, stored) + # 解密后复核明文上限:密文长度通过不代表明文通过(密文可被替换成另一个合法但 + # 解压后超大的信封,或 stat/get 不一致时绕过了上面的早拒)。 + if len(plaintext) > self._max_plaintext_bytes: + raise ValidationError( + f"fs encrypted: 解密后明文 {len(plaintext)}B 超过单文件上限 " + f"{self._max_plaintext_bytes}B(ref={ref!r})" + ) + return io.BytesIO(plaintext) + + # -- 不涉加解密的纯转发 ---------------------------------------------- # + + def delete(self, scope: Scope, ref: str) -> None: + self._inner.delete(scope, ref) + + def stat(self, scope: Scope, ref: str) -> FileStat: + # size 是密文长度,见模块 docstring。 + return self._inner.stat(scope, ref) + + # -- 内部 ------------------------------------------------------------ # + + def _read_bounded(self, data: BinaryIO, *, ref: str) -> bytes: + """有界读取明文:循环 read 直到 EOF 或累计达到 limit+1。 + + 验收复验 P2-FS:单次 ``read(limit+1)`` 不等于「读到 EOF 或上限」--BinaryIO + 允许短读(返回 < n 字节而未 EOF),单次调用会把第一段当完整文件,造成静默 + 数据截断。循环读取并在超限时拒绝,才能真正守住边界。 + """ + return _read_bounded_stream(data, self._max_plaintext_bytes, ref=ref) + + def _encrypt(self, scope: Scope, ref: str, plaintext: bytes) -> bytes: + try: + return self._security.encrypt( + plaintext, + context=_security_context(scope, ref), + aad=_aad(scope, ref), + ) + except Exception as exc: + raise BackendError(f"fs encryption failed: ref={ref!r}") from exc + + def _decrypt(self, scope: Scope, ref: str, ciphertext: bytes) -> bytes: + try: + return self._security.decrypt( + ciphertext, + context=_security_context(scope, ref), + aad=_aad(scope, ref), + ) + except Exception as exc: + raise BackendError(f"fs decryption failed: ref={ref!r}") from exc + + +def _inner_store(config: Any) -> FSStore: + """取被包住的 Store。无默认值——加密装饰器必须显式指明包住哪个 Store, + 猜一个默认后端只会把数据写到调用方没预期的地方(理由同 EncryptedKVStore)。 + """ + inner = config.params.get("inner") + if inner is None: + raise ValidationError("fs_store.encrypted params.inner 必须配置") + if isinstance(inner, str) and inner == config.name: + raise ValidationError("fs_store.encrypted params.inner 不能指向自身") + return FsProducer.dep(config, "inner") + + +@FsProducer.register("encrypted") +def _build(config): + max_plaintext_bytes = int( + config.params.get("max_plaintext_bytes", _DEFAULT_MAX_PLAINTEXT_BYTES) + ) + if max_plaintext_bytes < 1: + raise ValidationError( + f"fs_store.encrypted params.max_plaintext_bytes 须 >= 1,得到 {max_plaintext_bytes}" + ) + max_ciphertext_bytes = int(config.params.get("max_ciphertext_bytes", 0)) + if max_ciphertext_bytes < 0: + raise ValidationError( + f"fs_store.encrypted params.max_ciphertext_bytes 须 >= 0,得到 {max_ciphertext_bytes}" + ) + return EncryptedFSStore( + inner=_inner_store(config), + security=SecurityProducer.dep(config), + max_plaintext_bytes=max_plaintext_bytes, + max_ciphertext_bytes=max_ciphertext_bytes, + ) diff --git a/src/storage/kv_impl/encrypted_kv_store.py b/src/storage/kv_impl/encrypted_kv_store.py index 481e4be0..efdc3a68 100644 --- a/src/storage/kv_impl/encrypted_kv_store.py +++ b/src/storage/kv_impl/encrypted_kv_store.py @@ -129,9 +129,7 @@ def _encrypt(self, scope: Scope, key: str, plaintext: bytes) -> bytes: try: return self._security.encrypt(plaintext, context=context, aad=aad) except Exception as exc: - raise BackendError( - f"kv encryption failed: key={key!r} purpose={purpose!r}" - ) from exc + raise BackendError(f"kv encryption failed: key={key!r} purpose={purpose!r}") from exc def _decrypt(self, scope: Scope, key: str, ciphertext: bytes) -> bytes: purpose = _purpose_for_key(key) @@ -140,9 +138,7 @@ def _decrypt(self, scope: Scope, key: str, ciphertext: bytes) -> bytes: try: return self._security.decrypt(ciphertext, context=context, aad=aad) except Exception as exc: - raise BackendError( - f"kv decryption failed: key={key!r} purpose={purpose!r}" - ) from exc + raise BackendError(f"kv decryption failed: key={key!r} purpose={purpose!r}") from exc def _raw_kv_store(config: Any) -> KVStore: diff --git a/src/storage/kv_impl/in_memory_kv_store.py b/src/storage/kv_impl/in_memory_kv_store.py index 80a33cad..c1b01b14 100644 --- a/src/storage/kv_impl/in_memory_kv_store.py +++ b/src/storage/kv_impl/in_memory_kv_store.py @@ -30,9 +30,7 @@ class InMemoryKVStore(KVStore): """纯内存键值存储:``{scope: {key: (value, expires_at)}}``,按 scope 隔离。""" def __init__(self) -> None: - self._data: dict[_ScopeKey, dict[str, tuple[bytes, float | None]]] = ( - defaultdict(dict) - ) + self._data: dict[_ScopeKey, dict[str, tuple[bytes, float | None]]] = defaultdict(dict) def store_type(self) -> StoreType: return StoreType.KV @@ -105,8 +103,7 @@ def list( def scopes(self) -> list[Scope]: return [ - Scope(org=k[0], space=k[1], user=k[2], agent=k[3], session=k[4]) - for k in self._data + Scope(org=k[0], space=k[1], user=k[2], agent=k[3], session=k[4]) for k in self._data ] diff --git a/src/storage/kv_impl/memory_list.py b/src/storage/kv_impl/memory_list.py index f0a2f8a7..a68b6b43 100644 --- a/src/storage/kv_impl/memory_list.py +++ b/src/storage/kv_impl/memory_list.py @@ -47,7 +47,8 @@ def list_memory_entries( matches.append((key, raw, unit)) matches.sort(key=lambda item: _sort_key(item[2]), reverse=True) count = len(matches) - page = matches[offset:offset + limit] + page_end = offset + limit + page = matches[offset:page_end] return KVMemoryListResult( entries=[(key, raw) for key, raw, _ in page], count=count, diff --git a/src/storage/kv_impl/redis_kv.py b/src/storage/kv_impl/redis_kv.py index 59c66124..5a5a0984 100644 --- a/src/storage/kv_impl/redis_kv.py +++ b/src/storage/kv_impl/redis_kv.py @@ -53,14 +53,10 @@ def client(self) -> Any: try: import redis except ImportError as exc: # 依赖缺失归一为后端不可用 - raise BackendError( - "redis client not installed (pip install redis)" - ) from exc + raise BackendError("redis client not installed (pip install redis)") from exc with wrap_backend("redis connect"): if self._url: - self._client = redis.Redis.from_url( - self._url, decode_responses=False - ) + self._client = redis.Redis.from_url(self._url, decode_responses=False) else: self._client = redis.Redis(decode_responses=False, **self._conn) return self._client @@ -119,11 +115,12 @@ def scan(self, scope: Scope, prefix: str = "") -> list[tuple[str, bytes]]: keys = list(self.client.scan_iter(match=f"{ns}{prefix}*")) values = self.client.mget(keys) if keys else [] out: list[tuple[str, bytes]] = [] + prefix_len = len(ns) for raw, value in zip(keys, values): if value is None: # scan 与 mget 之间过期/删除 continue k = raw.decode("utf-8") if isinstance(raw, bytes) else raw - out.append((k[len(ns):], value)) # 去掉命名空间前缀还原逻辑 key + out.append((k[prefix_len:], value)) # 去掉命名空间前缀还原逻辑 key return out def list( diff --git a/src/storage/kv_impl/sqlite_kv_store.py b/src/storage/kv_impl/sqlite_kv_store.py index 4419baa0..b52926bd 100644 --- a/src/storage/kv_impl/sqlite_kv_store.py +++ b/src/storage/kv_impl/sqlite_kv_store.py @@ -64,9 +64,7 @@ def _expiry(ttl: float) -> float | None: return time.time() + ttl if ttl else None def _migrate_schema(self) -> None: - columns = { - row[1] for row in self._conn.execute("PRAGMA table_info(kv)").fetchall() - } + columns = {row[1] for row in self._conn.execute("PRAGMA table_info(kv)").fetchall()} if not columns or "space" in columns: return self._conn.execute("ALTER TABLE kv RENAME TO kv_legacy") diff --git a/tests/integration/test_identity_forgery_rejected.py b/tests/integration/test_identity_forgery_rejected.py new file mode 100644 index 00000000..89234060 --- /dev/null +++ b/tests/integration/test_identity_forgery_rejected.py @@ -0,0 +1,197 @@ +"""身份伪造必须被拒——第一期唯一改变系统安全性的回归防线。 + +改动前的行为(已复现):``handler._actor_scope`` 从 payload 读 +``actor_tenant_id`` / ``actor_scope``,任何调用方声明 ``actor_scope: "alice"`` +即可读到 alice 的记忆;声明空值即可拿到空 ``Scope()``,命中 +``SQLitePermissionManager.check`` 的 platform-admin 全局放行。 + +改动后:身份只来自认证层产出的 ``AuthContext``(security.md §9 铁律 #1), +payload 里出现身份声明字段一律 400。 + +本文件测的是**跨 bootstrap 与 src 的完整链路**(认证中间件 → dispatch → +PermissionManager),故落 integration 而非 unit。 +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# bootstrap/core 是 flat import root(server.py / handler.py / profiles.py), +# 不是包;与 http_server/cli surface 用同样的方式接进来。 +_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_CORE_DIR = os.path.join(_ROOT, "bootstrap", "core") +if _CORE_DIR not in sys.path: + sys.path.append(_CORE_DIR) + +from common.errors import AuthenticationError # noqa: E402 +from common.type_def.auth import Role, reset_current, set_current # noqa: E402 +from common.type_def.scope import Scope # noqa: E402 +from config.context import AssemblyContext # noqa: E402 +from security.bootstrap import register_security # noqa: E402 +from security.key_store import KeyStoreProducer # noqa: E402 + +pytestmark = pytest.mark.integration + +_ALICE = Scope(org="acme", user="alice") +_MALLORY = Scope(org="acme", user="mallory") + + +@pytest.fixture(scope="module") +def srv(): + """一个装配好的进程内 Server(OFFLINE profile,纯内存栈)。""" + import server + from profiles import OFFLINE, load_config + + return server.build(load_config([OFFLINE])) + + +def _dispatch(srv, verb, payload): + from handler import dispatch + + return dispatch(srv, verb, payload) + + +# -- 核心:payload 不再能声明身份 ------------------------------------------- # + + +def test_claimed_identity_in_payload_is_rejected(srv) -> None: + """曾经的越权路径:mallory 声明 ``actor_scope: alice`` 读到了 alice 的数据。 + + 现在这类字段一律 400——**静默忽略是不够的**:运维会以为 + 「我传了 actor_scope」仍然生效,写出错误的安全认知。 + """ + token = set_current(_make_ctx(_MALLORY)) + try: + for forged in ( + {"actor_scope": "alice"}, + {"actor_tenant_id": "acme", "actor_scope": "alice"}, + {"actor_tenant_id": " "}, # 曾经命中 platform-admin 全局放行 + {"actor_agent": "bot"}, + {"actor_session": "s1"}, + ): + payload = {"tenant_id": "acme", "scope": "alice", "item_id": "x", **forged} + status, body = _dispatch(srv, "get", payload) + assert status == 400, f"{forged} → {status} {body}" + assert body["error"] == "ValidationError" + finally: + reset_current(token) + + +def test_identity_comes_from_context_not_payload(srv) -> None: + """同一个 payload,认证上下文不同 → 授权结果不同。 + + 这条直接钉死「身份来自上下文」:payload 一字未改,只换了 AuthContext, + alice 能读、mallory 不能。 + """ + token = set_current(_make_ctx(_ALICE)) + try: + status, body = _dispatch( + srv, "add", {"tenant_id": "acme", "scope": "alice", "content": "alice salary 999"} + ) + assert status == 200, body + item_id = body["item_id"] + finally: + reset_current(token) + + payload = {"tenant_id": "acme", "scope": "alice", "item_id": item_id} + + token = set_current(_make_ctx(_ALICE)) + try: + assert _dispatch(srv, "get", payload)[0] == 200 + finally: + reset_current(token) + + token = set_current(_make_ctx(_MALLORY)) + try: + status, body = _dispatch(srv, "get", payload) + assert status == 403, body + finally: + reset_current(token) + + +def test_no_context_fails_closed(srv) -> None: + """中间件漏挂时必须 401,绝不回退到 payload 或默认身份。 + + 这是 fail-closed 的落点:一个装配错误应该让所有请求失败, + 而不是让所有请求以未知身份成功。 + """ + status, body = _dispatch(srv, "get", {"tenant_id": "acme", "scope": "alice", "item_id": "x"}) + assert status == 401, body + assert body["error"] == "AuthenticationError" + + +# -- 认证与授权确实串起来了 --------------------------------------------------- # + + +def test_api_key_binds_identity_end_to_end(srv) -> None: + """用 A 主体的 key 去读 B 主体的数据 → 403(不是 200,也不是 401)。 + + 401 说明认证没过(key 无效),403 说明认证过了但授权拒了。 + 这条要的是后者——证明 key → AuthContext → PermissionManager 整条链通了。 + """ + from security.authenticator_impl.api_key_authenticator import ApiKeyAuthenticator + from security.types import Credentials + + register_security() + store = KeyStoreProducer.build("memory", {}, AssemblyContext()) + alice_key = store.issue(_ALICE, Role.USER) + mallory_key = store.issue(_MALLORY, Role.USER) + auth = ApiKeyAuthenticator(key_store=store, root_api_key="") + + from auth_middleware import authenticated + + with authenticated(auth, Credentials(api_key=alice_key)): + status, body = _dispatch( + srv, "add", {"tenant_id": "acme", "scope": "alice", "content": "key-bound secret"} + ) + assert status == 200, body + item_id = body["item_id"] + + payload = {"tenant_id": "acme", "scope": "alice", "item_id": item_id} + + with authenticated(auth, Credentials(api_key=alice_key)): + assert _dispatch(srv, "get", payload)[0] == 200 + + with authenticated(auth, Credentials(api_key=mallory_key)): + assert _dispatch(srv, "get", payload)[0] == 403 + + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="not-a-real-key")): + pass # pragma: no cover - authenticate 在进入 with 体之前就抛了 + + +def test_context_is_reset_after_failed_authentication(srv) -> None: + """认证失败后 ContextVar 必须干净——否则下一个请求会继承上一个的身份。 + + `ThreadingHTTPServer` 每请求一线程但线程可能被复用,这是最严重的一类越权。 + """ + from auth_middleware import authenticated + + from security.authenticator_impl.api_key_authenticator import ApiKeyAuthenticator + from security.types import Credentials + + register_security() + store = KeyStoreProducer.build("memory", {}, AssemblyContext()) + alice_key = store.issue(_ALICE, Role.USER) + auth = ApiKeyAuthenticator(key_store=store, root_api_key="") + + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="wrong")): + pass # pragma: no cover + + # 失败之后仍应是「无身份」,而不是残留上一次的 + assert _dispatch(srv, "get", {"tenant_id": "acme", "scope": "alice", "item_id": "x"})[0] == 401 + + with authenticated(auth, Credentials(api_key=alice_key)) as ctx: + assert ctx.actor == _ALICE + + assert _dispatch(srv, "get", {"tenant_id": "acme", "scope": "alice", "item_id": "x"})[0] == 401 + + +def _make_ctx(actor: Scope): + from common.type_def.auth import AuthContext + + return AuthContext(actor=actor, acting_user=actor.user, role=Role.USER) diff --git a/tests/unit/api/test_dispatch_management_compat.py b/tests/unit/api/test_dispatch_management_compat.py index 25588979..84fe1aee 100644 --- a/tests/unit/api/test_dispatch_management_compat.py +++ b/tests/unit/api/test_dispatch_management_compat.py @@ -1,12 +1,23 @@ +"""管理面 verb 的 dispatch 兼容性。 + +原先本文件不带任何认证上下文直接 ``srv.dispatch(...)``,靠 ``_actor_scope`` +从 payload 里凑出身份。身份改由认证上下文提供后(security.md §9 铁律 #1), +每条用例都必须显式声明「谁在发这个请求」——这正是要的效果: +**签名上就不给「不指定身份也能调」留位置**。 +""" + from __future__ import annotations import importlib import os import sys +from contextlib import contextmanager import pytest from common.type_def import Segment +from common.type_def.auth import AuthContext, Role, reset_current, set_current +from common.type_def.scope import Scope from control import MemoryListResult, PrincipalPath, SpaceInfo, SpaceStatus pytestmark = pytest.mark.unit @@ -26,37 +37,72 @@ load_config = profiles.load_config Server = server.Server +_OWNER = Scope(org="acme", user="owner") + + +@contextmanager +def _as(actor: Scope, role: Role = Role.USER): + """以 ``actor`` 的身份发起请求(等价于中间件认证通过后的状态)。""" + token = set_current(AuthContext(actor=actor, acting_user=actor.user, role=role)) + try: + yield + finally: + reset_current(token) + def test_dispatch_admin_requires_platform_admin_under_default_kernel() -> None: srv = Server.build(load_config([OFFLINE])) - status, body = srv.dispatch("admin", {"tenant_id": "acme", "scope": "alice"}) + with _as(Scope(org="acme", user="alice")): + status, body = srv.dispatch("admin", {"tenant_id": "acme", "scope": "alice"}) assert status == 403 assert body["error"] == "PermissionDeniedError" -def test_dispatch_admin_rejects_missing_identity_fields() -> None: +def test_dispatch_admin_without_credentials_is_unauthenticated_not_forbidden() -> None: + """无凭据是 401 而不是 403。 + + 401「不知道你是谁」与 403「知道你是谁但不许」是两件事;旧实现把前者伪装 + 成后者(payload 凑出的身份恰好没权限),掩盖了「认证层根本不存在」。 + """ srv = Server.build(load_config([OFFLINE])) - payload = {} + status, body = srv.dispatch("admin", {}) - status, body = srv.dispatch("admin", payload) - - assert status == 403 - assert body["error"] == "PermissionDeniedError" + assert status == 401 + assert body["error"] == "AuthenticationError" def test_dispatch_revoke_supports_scope_owner() -> None: srv = Server.build(load_config([OFFLINE])) - status, body = srv.dispatch( - "revoke", - {"tenant_id": "acme", "scope": "owner", "grantee": "reader"}, - ) + with _as(_OWNER): + status, body = srv.dispatch( + "revoke", + {"tenant_id": "acme", "scope": "owner", "grantee": "reader"}, + ) assert status == 200 assert body["grantee"]["user"] == "reader" +def test_dispatch_revoke_denied_for_non_owner() -> None: + """撤销别人 scope 下的授权 → 403。 + + 旧用例靠 payload 里塞 ``actor_scope: outsider`` 制造这个非属主身份; + 现在身份来自上下文,构造方式变了,**要断言的行为没变**。 + """ + srv = Server.build(load_config([OFFLINE])) + + with _as(Scope(org="acme", user="outsider")): + status, body = srv.dispatch( + "revoke", + {"tenant_id": "acme", "scope": "owner", "grantee": "reader"}, + ) + + assert status == 403 + assert body["error"] == "PermissionDeniedError" + + @pytest.mark.parametrize( "actor_override", [ @@ -64,23 +110,26 @@ def test_dispatch_revoke_supports_scope_owner() -> None: {"actor_tenant_id": "acme", "actor_scope": "outsider"}, ], ) -def test_dispatch_revoke_rejects_non_owner_actor_overrides( +def test_dispatch_revoke_rejects_payload_identity_claims( actor_override: dict[str, str], ) -> None: + """payload 里的身份声明一律 400——包括曾经能命中全局放行的空 ``actor_tenant_id``。""" srv = Server.build(load_config([OFFLINE])) - status, body = srv.dispatch( - "revoke", - { - "tenant_id": "acme", - "scope": "owner", - "grantee": "reader", - **actor_override, - }, - ) + with _as(_OWNER): + status, body = srv.dispatch( + "revoke", + { + "tenant_id": "acme", + "scope": "owner", + "grantee": "reader", + **actor_override, + }, + ) - assert status == 403 - assert body["error"] == "PermissionDeniedError" + assert status == 400 + assert body["error"] == "ValidationError" + assert "identity must come from credentials" in body["message"] def test_dispatch_audit_forwards_structured_filters() -> None: @@ -105,16 +154,17 @@ def __init__(self) -> None: srv = _Srv() - status, body = handler.dispatch( - srv, - "audit", - { - "action": "write", - "decision": "allow", - "actor_user": "owner", - "target_space": "coding", - }, - ) + with _as(_OWNER): + status, body = handler.dispatch( + srv, + "audit", + { + "action": "write", + "decision": "allow", + "actor_user": "owner", + "target_space": "coding", + }, + ) assert status == 200 assert srv.api.filters == { @@ -128,6 +178,33 @@ def __init__(self) -> None: assert body["events"][0]["target"]["space"] == "coding" +def test_dispatch_audit_keeps_actor_agent_as_query_filter() -> None: + """``audit`` 的 ``actor_agent`` / ``actor_session`` 是**查询谓词**不是身份声明。 + + 与身份声明字段同名但语义不同(筛「历史事件的操作者是谁」),故对该 verb 放行。 + """ + + class _Api: + def __init__(self) -> None: + self.filters = None + + def audit(self, filters, *, identity, limit=100): + self.filters = filters + return [] + + class _Srv: + def __init__(self) -> None: + self.api = _Api() + + srv = _Srv() + + with _as(_OWNER): + status, _ = handler.dispatch(srv, "audit", {"actor_agent": "bot", "actor_session": "s1"}) + + assert status == 200 + assert srv.api.filters == {"actor_agent": "bot", "actor_session": "s1"} + + @pytest.mark.parametrize("limit", ["not-a-number", "", [], -1, 0]) def test_dispatch_audit_rejects_invalid_limit(limit) -> None: class _Api: @@ -139,7 +216,8 @@ class _Srv: def __init__(self) -> None: self.api = _Api() - status, body = handler.dispatch(_Srv(), "audit", {"limit": limit}) + with _as(_OWNER): + status, body = handler.dispatch(_Srv(), "audit", {"limit": limit}) assert status == 400 assert body["error"] == "ValidationError" @@ -187,20 +265,21 @@ def __init__(self) -> None: self.api = _Api() srv = _Srv() - status, body = handler.dispatch( - srv, - "list", - { - "tenant_id": "acme", - "scope": "owner", - "actor_scope": "reader", - "offset": "2", - "limit": "5", - "memory_types": "coding,episodic", - "extensions": {"vendor_mode": 3}, - "filter": {"metadata.project": "alpha"}, - }, - ) + # identity 从认证上下文来,不从 payload 的 actor_scope 来——后者现在会被拒。 + with _as(handler.Scope(org="acme", user="reader")): + status, body = handler.dispatch( + srv, + "list", + { + "tenant_id": "acme", + "scope": "owner", + "offset": "2", + "limit": "5", + "memory_types": "coding,episodic", + "extensions": {"vendor_mode": "3"}, + "filter": {"metadata.project": "alpha"}, + }, + ) assert status == 200 assert srv.api.call == { @@ -239,11 +318,12 @@ def list(*args, **kwargs): class _Srv: api = _Api() - status, body = handler.dispatch( - _Srv(), - "list", - {"tenant_id": "acme", "scope": "owner", **payload}, - ) + with _as(handler.Scope(org="acme", user="reader")): + status, body = handler.dispatch( + _Srv(), + "list", + {"tenant_id": "acme", "scope": "owner", **payload}, + ) assert status == 400 assert body["error"] == "ValidationError" @@ -271,20 +351,22 @@ def __init__(self) -> None: self.api = _Api() srv = _Srv() - status, body = handler.dispatch( - srv, - "create_space", - { - "tenant_id": "acme", - "space": "coding", - "actor_space": "", - "actor_scope": "", - "display_name": "Coding", - "principal_path": "agent_user", - "policy": {"pipeline_profiles": {"coding": "coding"}}, - "metadata": {"env": "prod"}, - }, - ) + # 上游原版在 payload 里塞 ``actor_space: ""`` / ``actor_scope: ""`` 来把 identity + # 压成 ``Scope(org="acme")``;这两个字段现在会被拒。要断言的东西没变, + # 换成从认证上下文给同一个身份。 + with _as(handler.Scope(org="acme")): + status, body = handler.dispatch( + srv, + "create_space", + { + "tenant_id": "acme", + "space": "coding", + "display_name": "Coding", + "principal_path": "agent_user", + "policy": {"pipeline_profiles": {"coding": "coding"}}, + "metadata": {"env": "prod"}, + }, + ) assert status == 200 assert srv.api.call["identity"] == handler.Scope(org="acme") diff --git a/tests/unit/api/test_handler_identity_split.py b/tests/unit/api/test_handler_identity_split.py index f8c21a95..148e2948 100644 --- a/tests/unit/api/test_handler_identity_split.py +++ b/tests/unit/api/test_handler_identity_split.py @@ -1,13 +1,27 @@ +"""handler 的「身份 / 目标」分离——身份来自认证上下文,目标来自 payload。 + +本文件原先测的是 ``_actor_scope(payload)``:身份从 ``actor_tenant_id`` / +``actor_scope`` 读、缺省回落成目标 scope。那正是 security.md §9 铁律 #1 要堵的 +洞(见 ``tests/integration/test_identity_forgery_rejected.py``),故断言随实现 +一并改写。 + +与集成测试的分工:那边验端到端的授权结果(200/403),这边用 recording API +验**传给 API 边界的 identity 到底是哪个值**——集成测试看不到这一层。 +""" + from __future__ import annotations import importlib import os import sys +from contextlib import contextmanager from types import SimpleNamespace import pytest from common.type_def import Segment +from common.type_def.auth import AuthContext, Role, reset_current, set_current +from common.type_def.scope import Scope pytestmark = pytest.mark.unit @@ -21,6 +35,18 @@ handler = importlib.import_module("handler") +_ALICE = Scope(org="acme", user="alice") + + +@contextmanager +def _as(actor: Scope): + """以 ``actor`` 的身份发起请求(等价于中间件认证通过后的状态)。""" + token = set_current(AuthContext(actor=actor, acting_user=actor.user, role=Role.USER)) + try: + yield + finally: + reset_current(token) + class _RecordingApi: def __init__(self) -> None: @@ -67,34 +93,80 @@ def _dispatch_add(payload: dict) -> dict: return srv.api.write_calls[0] -def test_actor_scope_and_target_scope_match_when_actor_fields_are_omitted() -> None: - call = _dispatch_add({"tenant_id": "acme", "space": "product", "scope": "alice"}) +def test_identity_comes_from_context_target_from_payload() -> None: + """同一次请求里两者可以不同:alice 往 owner 的 scope 写。 + + 能不能写由 PermissionManager 判(这里的 API 是 recording stub,不判); + handler 的职责只是**把两个值从各自的来源取对**。 + """ + with _as(_ALICE): + call = _dispatch_add({"tenant_id": "acme", "space": "product", "scope": "owner"}) - assert call["identity"] == call["scope"] - assert call["identity"].org == "acme" - assert call["identity"].space == "product" - assert call["identity"].user == "alice" + assert call["identity"] == _ALICE + assert call["scope"] == Scope(org="acme", space="product", user="owner") -def test_actor_scope_uses_default_scope_when_identity_fields_are_omitted() -> None: - call = _dispatch_add({}) +def test_identity_is_never_derived_from_target_scope() -> None: + """payload 完全不给身份线索时,identity 仍是上下文里的那个。 - assert call["identity"] == handler.Scope(org="default", user="") - assert call["scope"] == handler.Scope(org="default", user="") + 旧实现在这种情况下让 identity 回落成 target scope——等于「谁访问谁就是主人」。 + """ + with _as(_ALICE): + call = _dispatch_add({}) + + assert call["identity"] == _ALICE + assert call["scope"] == Scope(org="default", user="") + + +def test_payload_identity_claims_are_rejected_not_ignored() -> None: + """``actor_scope`` 这类字段一律 400。 + + 静默忽略会让调用方以为它仍然生效,写出错误的安全认知。 + """ + srv = _RecordingServer() + with _as(_ALICE): + status, body = handler.dispatch( + srv, + "add", + {"content": "hello", "tenant_id": "acme", "scope": "owner", "actor_scope": "auditor"}, + ) + assert status == 400, body + assert body["error"] == "ValidationError" + assert "identity must come from credentials" in body["message"] + assert srv.api.write_calls == [] # 拒在进 API 之前 -def test_actor_scope_override_inherits_target_tenant_when_actor_tenant_not_provided() -> None: - call = _dispatch_add( - { - "tenant_id": "acme", - "space_id": "product", - "scope": "owner", - "actor_scope": "auditor", - } - ) - assert call["identity"] == handler.Scope(org="acme", space="product", user="auditor") - assert call["scope"] == handler.Scope(org="acme", space="product", user="owner") +def test_space_dimension_identity_claims_are_rejected() -> None: + """``actor_space`` / ``actor_space_id`` 与其余四维同等对待。 + + space 是 ``Scope`` 五维化时新加的维度,声明字段每多一维、可冒充的主体就多一维; + 禁止列表若漏了它,伪造面就跟着 ``Scope`` 一起长回来。 + + (这条取代了原先断言 ``actor_space`` **能**覆盖 identity 的用例——那个行为 + 正是 §9 铁律 #1 要堵的洞。) + """ + srv = _RecordingServer() + for key in ("actor_space", "actor_space_id"): + with _as(_ALICE): + status, body = handler.dispatch( + srv, "add", {"content": "hello", "tenant_id": "acme", key: "product"} + ) + + assert status == 400, body + assert body["error"] == "ValidationError" + assert key in body["message"] + assert srv.api.write_calls == [] + + +def test_missing_context_fails_closed() -> None: + """中间件漏挂 → 401,绝不以某个默认身份跑完。""" + srv = _RecordingServer() + status, body = handler.dispatch(srv, "add", {"content": "hello", "tenant_id": "acme"}) + + assert status == 401, body + assert body["error"] == "AuthenticationError" + assert srv.api.write_calls == [] def test_search_forwards_filter_dsl_to_api_boundary() -> None: @@ -106,26 +178,12 @@ def test_search_forwards_filter_dsl_to_api_boundary() -> None: ] } - status, body = handler.dispatch( - srv, - "search", - {"query": "pytest", "tenant_id": "acme", "scope": "alice", "filters": filters}, - ) + with _as(_ALICE): + status, body = handler.dispatch( + srv, + "search", + {"query": "pytest", "tenant_id": "acme", "scope": "alice", "filters": filters}, + ) assert status == 200, body assert srv.api.recall_calls[0]["filters"] == filters - - -def test_actor_space_override_can_differ_from_target_space() -> None: - call = _dispatch_add( - { - "tenant_id": "acme", - "space": "product", - "scope": "owner", - "actor_space": "coding", - "actor_scope": "reader", - } - ) - - assert call["identity"] == handler.Scope(org="acme", space="coding", user="reader") - assert call["scope"] == handler.Scope(org="acme", space="product", user="owner") diff --git a/tests/unit/bootstrap/test_auth_middleware.py b/tests/unit/bootstrap/test_auth_middleware.py new file mode 100644 index 00000000..fec78aa2 --- /dev/null +++ b/tests/unit/bootstrap/test_auth_middleware.py @@ -0,0 +1,498 @@ +"""bootstrap/core/auth_middleware:凭据归一 + ContextVar 生命周期。 + +中间件本身不含认证策略(模式由配置在装配期选定),故这里测的只有两件事: +**凭据材料被正确归一**,以及**上下文一定被清理**。后者是 05 最容易出的 bug, +测法是验证行为后果(``get_current()`` 是否干净),不是验证 ``reset_current`` +被调用过。 +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# bootstrap/core 是 flat import root(不是包),与各 surface 用同样的方式接进来。 +_CORE_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "bootstrap", "core") +) +if _CORE_DIR not in sys.path: + sys.path.append(_CORE_DIR) + +from auth_middleware import authenticated, credentials_from_headers # noqa: E402 + +from common.errors import AuthenticationError, RateLimitedError # noqa: E402 +from common.type_def.auth import Role, get_current # noqa: E402 +from common.type_def.scope import Scope # noqa: E402 +from config.context import AssemblyContext # noqa: E402 +from security.authenticator_impl.api_key_authenticator import ApiKeyAuthenticator # noqa: E402 +from security.authenticator_impl.dev_authenticator import DevAuthenticator # noqa: E402 +from security.authenticator_impl.trusted_authenticator import TrustedAuthenticator # noqa: E402 +from security.bootstrap import register_security # noqa: E402 +from security.key_store import KeyStoreProducer # noqa: E402 +from security.types import Credentials # noqa: E402 + +pytestmark = pytest.mark.unit + +_ALICE = Scope(org="acme", user="alice") + + +@pytest.fixture(scope="module") +def key_store(): + register_security() + return KeyStoreProducer.build("memory", {}, AssemblyContext()) + + +@pytest.fixture(scope="module") +def alice_key(key_store) -> str: + return key_store.issue(_ALICE, Role.USER) + + +# -- 凭据归一 ---------------------------------------------------------------- # + + +def test_bearer_scheme_is_case_insensitive() -> None: + """RFC 9110 §11.1:auth-scheme 大小写不敏感。三种写法必须取到同一个 key。""" + for raw in ("Bearer k123", "bearer k123", "BEARER k123"): + assert credentials_from_headers({"Authorization": raw}).api_key == "k123" + + +def test_header_names_are_normalized_to_lowercase() -> None: + """header 名大小写不敏感(RFC 9110 §5.1)——TRUSTED 实现按小写常量查。 + + 归一放在这里而不是各 authenticator 里,是为了让「查 header」只有一种写法。 + """ + creds = credentials_from_headers({"X-ORG-Id": "acme", "x-Principal-TYPE": "user"}) + assert creds.headers == {"x-org-id": "acme", "x-principal-type": "user"} + + +def test_x_api_key_is_the_fallback_not_the_override(alice_key) -> None: + """Authorization 优先;它缺失或非 Bearer 时才回落 X-Api-Key。 + + 顺序反过来会让「同时带两个 header」的请求用哪个 key 取决于实现细节。 + """ + both = credentials_from_headers({"Authorization": "Bearer from-bearer", "X-Api-Key": "from-x"}) + assert both.api_key == "from-bearer" + + only_x = credentials_from_headers({"X-Api-Key": "from-x"}) + assert only_x.api_key == "from-x" + + # Basic 不是 Bearer,不该被当成 api_key 提取,此时回落 X-Api-Key。 + basic = credentials_from_headers({"Authorization": "Basic dXNlcjpwdw==", "X-Api-Key": "from-x"}) + assert basic.api_key == "from-x" + + +def test_missing_credentials_yield_empty_key_not_none() -> None: + """无凭据是空串而非 None——authenticator 侧不必再写 None 判断。""" + creds = credentials_from_headers({}) + assert creds.api_key == "" + assert creds.headers == {} + assert creds.peer_address == "" + + +def test_peer_address_is_carried_through() -> None: + """审计要记调用方地址(未来还要给速率限制用)。""" + assert credentials_from_headers({}, "10.0.0.7").peer_address == "10.0.0.7" + + +def test_surrounding_whitespace_is_stripped() -> None: + assert credentials_from_headers({"Authorization": "Bearer k123 "}).api_key == "k123" + assert credentials_from_headers({"X-Api-Key": " k123 "}).api_key == "k123" + + +# -- ContextVar 生命周期 ------------------------------------------------------ # + + +def test_context_is_set_inside_and_cleared_outside() -> None: + assert get_current() is None + with authenticated(DevAuthenticator(), Credentials()) as ctx: + assert get_current() is ctx + assert ctx.role is Role.ROOT + assert get_current() is None + + +def test_context_is_cleared_when_body_raises() -> None: + """with 体内抛异常同样要清理——否则一次 500 就污染整条线程。""" + with pytest.raises(RuntimeError): + with authenticated(DevAuthenticator(), Credentials()): + raise RuntimeError("boom") + assert get_current() is None + + +def test_failed_authentication_leaves_no_context(key_store) -> None: + """认证失败后必须仍是「无身份」,不能残留上一次的。""" + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="not-a-real-key")): + pass # pragma: no cover - authenticate 在进入 with 体之前就抛了 + assert get_current() is None + + +def test_consecutive_requests_do_not_inherit_identity(key_store, alice_key) -> None: + """池化线程上连续两个请求:第二个必须看不到第一个的身份。 + + 这是漏 reset 的真实后果,也是本中间件存在的主要理由。 + """ + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + + with authenticated(auth, Credentials(api_key=alice_key)) as ctx: + assert ctx.actor == _ALICE + assert get_current() is None + + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="wrong")): + pass # pragma: no cover + assert get_current() is None + + +# -- 归一后的 header 确实能被 TRUSTED 消费 ------------------------------------- # + + +def test_normalized_headers_authenticate_under_trusted(key_store) -> None: + """端到端一小段:大小写混乱的网关 header 经归一后仍认得出主体。 + + 单独测归一、单独测 TRUSTED 都会漏掉「两边约定不一致」这个真实故障。 + """ + auth = TrustedAuthenticator(key_store=key_store, gateway_key="") + creds = credentials_from_headers( + {"X-Org-ID": "acme", "X-Principal-Type": "User", "X-PRINCIPAL-ID": "alice"} + ) + with authenticated(auth, creds) as ctx: + assert ctx.actor == _ALICE + assert ctx.role is Role.USER + + +# -- 限流(§8.1) ------------------------------------------------------------- # + + +class _CountingAuth(DevAuthenticator): + """记下 authenticate 被调了几次——限流是否真的挡在认证之前,只能这样测。""" + + def __init__(self) -> None: + self.calls = 0 + + def authenticate(self, credentials): + self.calls += 1 + return super().authenticate(credentials) + + +class _Blocked: + """恒拒绝的限流器。""" + + @staticmethod + def allow(peer): + return False + + @staticmethod + def health() -> None: + return None + + +class _Open: + @staticmethod + def allow(peer): + return True + + @staticmethod + def health() -> None: + return None + + +def _peer() -> Credentials: + """带对端地址的凭据:限流按 peer 建桶,没有 peer 就不限流。""" + return Credentials(peer_address="10.0.0.7") + + +def test_rate_limit_runs_before_authentication() -> None: + """这是限流存在的全部理由:被限流的请求**不能**触发 Argon2 verify。 + + 放在认证之后限流,等于「先让攻击者把 CPU 用掉,再告诉他超限了」—— + §8.1 要防的资源耗尽就完全没防住。 + """ + auth = _CountingAuth() + with pytest.raises(RateLimitedError): + with authenticated(auth, _peer(), None, _Blocked()): + pass # pragma: no cover - 限流在进入 with 体之前就抛了 + assert auth.calls == 0 + + +def test_rate_limited_is_not_an_authentication_error() -> None: + """429 与 401 必须可分:一个该稍后重试,一个该换凭据。""" + with pytest.raises(RateLimitedError) as exc: + with authenticated(DevAuthenticator(), _peer(), None, _Blocked()): + pass # pragma: no cover + assert not isinstance(exc.value, AuthenticationError) + + +def test_rate_limited_leaves_no_context() -> None: + with pytest.raises(RateLimitedError): + with authenticated(DevAuthenticator(), _peer(), None, _Blocked()): + pass # pragma: no cover + assert get_current() is None + + +def test_no_limiter_means_no_limiting() -> None: + """``limiter=None`` 是进程内直连 / MCP stdio 的形态,行为与一期一致。""" + auth = _CountingAuth() + for _ in range(5): + with authenticated(auth, Credentials()): + pass + assert auth.calls == 5 + + +def test_passing_limiter_does_not_change_the_allowed_path() -> None: + with authenticated(DevAuthenticator(), _peer(), None, _Open()) as ctx: + assert ctx.role is Role.ROOT + assert get_current() is None + + +def test_rate_limit_denial_is_audited_distinctly() -> None: + """审计里限流与认证失败要分得开,否则运维看到一堆 deny 不知道该调哪个。""" + recorded = [] + + class _Recorder: + @staticmethod + def record(event): + recorded.append(event) + + with pytest.raises(RateLimitedError): + with authenticated( + DevAuthenticator(), Credentials(peer_address="10.0.0.7"), _Recorder(), _Blocked() + ): + pass # pragma: no cover + + assert len(recorded) == 1 + assert recorded[0].action == "rate_limit" + assert recorded[0].decision == "deny" + assert recorded[0].actor == Scope() # 身份未知,不可用调用方声明的值填充 + assert recorded[0].detail["peer"] == "10.0.0.7" + + +def test_rate_limit_audit_carries_no_bucket_state() -> None: + """不记桶余量:那能用来反推限流参数,然后贴着阈值发请求。""" + recorded = [] + + class _Recorder: + @staticmethod + def record(event): + recorded.append(event) + + with pytest.raises(RateLimitedError): + with authenticated( + DevAuthenticator(), + Credentials(api_key="secret-key", peer_address="10.0.0.7"), + _Recorder(), + _Blocked(), + ): + pass # pragma: no cover + + detail = recorded[0].detail + assert set(detail) == {"mode", "peer"} + assert "secret-key" not in str(detail) # §7.5:凭据不进审计 + + +def test_audit_backend_failure_does_not_mask_429() -> None: + """审计写失败不该把 429 变成 500——与 401 同样的取舍。""" + + class _Exploding: + @staticmethod + def record(event): + raise RuntimeError("audit backend down") + + with pytest.raises(RateLimitedError): + with authenticated( + DevAuthenticator(), Credentials(peer_address="10.0.0.7"), _Exploding(), _Blocked() + ): + pass # pragma: no cover + + +def test_audit_backend_failure_does_not_mask_401(key_store) -> None: + """审计写失败不该把 401 变成 500——认证结论优先于可观测性。""" + + class _Exploding: + @staticmethod + def record(event): + raise RuntimeError("audit backend down") + + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="wrong"), _Exploding()): + pass # pragma: no cover + assert get_current() is None + + +# -- Argon2 并发上限(审计 P1-3) ------------------------------------------- # + + +def test_argon2_guard_release_on_success() -> None: + """guard 在认证成功后必须释放,否则槽位泄漏把后续请求也堵死。""" + from security.concurrency_guard import Argon2Guard + + guard = Argon2Guard(max_concurrent=1) + auth = _CountingAuth() + with authenticated(auth, Credentials(), None, None, argon2_guard=guard): + pass + # 认证后槽位已释放,能再 acquire + assert guard.acquire() is True + guard.release() + assert auth.calls == 1 + + +def test_argon2_guard_release_on_auth_failure() -> None: + """认证失败也要释放(finally)。""" + from security.concurrency_guard import Argon2Guard + + guard = Argon2Guard(max_concurrent=1) + + # 用一个恒失败的 auth:走认证失败路径,验证 guard 在 finally 释放 + class _Fail: + mode = DevAuthenticator().mode + + @staticmethod + def authenticate(credentials): + raise AuthenticationError("nope") + + with pytest.raises(AuthenticationError): + with authenticated(_Fail(), Credentials(), None, None, argon2_guard=guard): + pass # pragma: no cover + assert guard.acquire() is True + guard.release() + + +def test_argon2_guard_blocks_when_slots_exhausted() -> None: + """耗尽并发槽返回 429,不进入 authenticate。""" + from security.concurrency_guard import Argon2Guard + + guard = Argon2Guard(max_concurrent=1) + # 占满唯一槽位 + assert guard.acquire() is True + auth = _CountingAuth() + with pytest.raises(RateLimitedError): + with authenticated(auth, Credentials(), None, None, argon2_guard=guard): + pass # pragma: no cover + assert auth.calls == 0 + guard.release() + + +def test_argon2_guard_released_on_rate_limit_before_it() -> None: + """IP 桶先挡住时 guard 不该 acquire(两层独立)。""" + from security.concurrency_guard import Argon2Guard + + guard = Argon2Guard(max_concurrent=1) + with pytest.raises(RateLimitedError): + with authenticated(DevAuthenticator(), _peer(), None, _Blocked(), argon2_guard=guard): + pass # pragma: no cover + # guard 没被占 + assert guard.acquire() is True + guard.release() + + +def test_argon2_guard_none_means_unlimited() -> None: + """None 表示不限(DEV / 进程内直连),与一期行为一致。""" + auth = _CountingAuth() + for _ in range(10): + with authenticated(auth, Credentials()): + pass + assert auth.calls == 10 + + +def test_argon2_guard_rejects_zero_max_concurrent() -> None: + """max_concurrent=0 是非法,装配期炸,不用 or 吞成默认(审计验收 P2-guard)。""" + from security.concurrency_guard import Argon2Guard, reset_guard + + reset_guard() + with pytest.raises(ValueError): + Argon2Guard(max_concurrent=0) + reset_guard() + + +def test_argon2_guard_conflicting_config_raises() -> None: + """同进程重复装配不同 max_concurrent 报错,不静默忽略(审计验收 P2-guard)。""" + from security.concurrency_guard import default_argon2_guard, reset_guard + + reset_guard() + default_argon2_guard(max_concurrent=2) + with pytest.raises(ValueError): + default_argon2_guard(max_concurrent=4) + # 相同配置不报错 + default_argon2_guard(max_concurrent=2) + reset_guard() + + +def test_argon2_guard_concurrency_is_actually_bounded() -> None: + """真实并发测试:同时进入 authenticate 的数 <= max_concurrent。 + + 复验 P3:此前 gate.set() 没等前两个确定占住槽,调度型竞态导致偶发 + ``assert 1 == 2``。改为 Barrier 明确同步 happens-before: + 1) 先启 2 线程,等它们都进 _Blocking.authenticate(占住两个槽); + 2) 再启 2 线程,它们应被 guard 挡(acquire 失败 -> 429); + 3) 最后 set gate 释放前两个。 + """ + import threading + + from security.concurrency_guard import Argon2Guard + + guard = Argon2Guard(max_concurrent=2) + in_flight = 0 + peak = 0 + lock = threading.Lock() + gate = threading.Event() + # 前 2 个线程进入 authenticate 后用它通知主线程「已占住槽」 + holders_inside = threading.Barrier(2) + holders_ready = threading.Event() + + class _Blocking: + mode = DevAuthenticator().mode + + @staticmethod + def authenticate(credentials): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + # 通知主线程:我已占住槽。用 Barrier 让 2 个 holder 都到齐再统一放行。 + try: + holders_inside.wait(timeout=2) + except threading.BrokenBarrierError: + pass + holders_ready.set() + gate.wait(timeout=3) + with lock: + in_flight -= 1 + return DevAuthenticator().authenticate(credentials) + + def fire(i, results): + try: + with authenticated(_Blocking(), Credentials(), None, None, argon2_guard=guard): + results.append(i) + except RateLimitedError: + results.append(f"blocked-{i}") + + # 阶段 1:先启 2 个占槽线程,等它们都进入 authenticate + holder_results = [] + holders = [threading.Thread(target=fire, args=(i, holder_results)) for i in range(2)] + for t in holders: + t.start() + # 等两个 holder 都进 authenticate(Barrier 到齐 -> holders_ready set) + assert holders_ready.wait(timeout=3), "holders 未在限时内占住槽" + # 此时两个槽被占 + + # 阶段 2:再启 2 个线程,应被 guard 挡(429) + blocked_results = [] + seekers = [threading.Thread(target=fire, args=(i, blocked_results)) for i in (2, 3)] + for t in seekers: + t.start() + for t in seekers: + t.join(timeout=2) + + # 阶段 3:放行前两个 + gate.set() + for t in holders: + t.join(timeout=2) + + blocked = [x for x in blocked_results if isinstance(x, str)] + accepted = [x for x in blocked_results if isinstance(x, int)] + assert accepted == [], "槽位已满时 seeker 不应进入 authenticate" + assert len(blocked) == 2, f"应有 2 个被挡,得到 {blocked_results}" + assert peak == 2 diff --git a/tests/unit/bootstrap/test_http_body_limits.py b/tests/unit/bootstrap/test_http_body_limits.py new file mode 100644 index 00000000..c8650e64 --- /dev/null +++ b/tests/unit/bootstrap/test_http_body_limits.py @@ -0,0 +1,202 @@ +"""HTTP surface 的两阶段准入与并发上限(审计验收 P1-HTTP / P2-4)。 + +`_parse_content_length` 只校验 header 不读 body(第一阶段);`_read_body` 在 +认证通过后按已校验长度读(第三阶段)。中间的 limiter/认证在 body 之前,慢连接 +在读 body 前就被挡住。集成层起真实 HTTP server,端到端验证 413/400/503/正常, +并验证慢上传在占满全局连接额度后不能继续创建处理线程。 +""" + +from __future__ import annotations + +# These tests intentionally exercise the HTTP adapter's private admission primitives. +# pylint: disable=protected-access +import importlib +import io +import json +import os +import socket +import sys +import threading +import time + +import pytest + +pytestmark = pytest.mark.unit + +_BOOT_DIR = "bootstrap/http_server" +_CORE_DIR = os.path.join("bootstrap", "core") +for _p in (_BOOT_DIR, _CORE_DIR, "src"): + if _p not in sys.path: + sys.path.append(_p) + +_mod = importlib.import_module("bootstrap.http_server.__main__") # noqa: E402 +_parse_content_length = _mod._parse_content_length +_read_body = _mod._read_body +_MAX = _mod._MAX_BODY_BYTES +BoundedServer = _mod._BoundedThreadingHTTPServer + + +class _Headers: + def __init__(self, length: str | None): + self._d = {} if length is None else {"Content-Length": length} + + def get(self, key, default=None): + return self._d.get(key, default) + + +def test_parse_length_rejects_negative() -> None: + assert _parse_content_length(_Headers("-1"))[0] == 400 + + +def test_parse_length_rejects_non_numeric() -> None: + assert _parse_content_length(_Headers("abc"))[0] == 400 + + +def test_parse_length_rejects_oversized() -> None: + assert _parse_content_length(_Headers(str(_MAX + 1)))[0] == 413 + + +def test_parse_length_accepts_at_limit() -> None: + status, length = _parse_content_length(_Headers(str(_MAX))) + assert status == 200 + assert length == _MAX + + +def test_parse_length_zero_or_missing() -> None: + assert _parse_content_length(_Headers("0")) == (200, 0) + assert _parse_content_length(_Headers(None)) == (200, 0) + + +def test_read_body_returns_exact_bytes() -> None: + data = b"payload" + assert _read_body(io.BytesIO(data), len(data)) == data + assert _read_body(io.BytesIO(b""), 0) == b"" + + +# -- 集成:真实 HTTP server ------------------------------------------------- # + + +def _start_server(): + profiles = importlib.import_module("profiles") + http_mod = importlib.import_module("bootstrap.http_server.__main__") + srv = http_mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + httpd = http_mod._BoundedThreadingHTTPServer(("127.0.0.1", 0), srv._handler_cls()) + httpd.daemon_threads = True + port = httpd.server_address[1] + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + return httpd, port + + +def _post(port: int, body: bytes, content_length: str | None = None) -> tuple[int, dict]: + s = socket.create_connection(("127.0.0.1", port), timeout=5) + try: + headers = "POST /v1/list HTTP/1.1\r\nHost: 127.0.0.1\r\n" + if content_length is not None: + headers += f"Content-Length: {content_length}\r\n" + else: + headers += f"Content-Length: {len(body)}\r\n" + headers += "Content-Type: application/json\r\n\r\n" + s.sendall(headers.encode() + body) + data = s.recv(65536) + finally: + s.close() + head, _, payload = data.partition(b"\r\n\r\n") + status_line = head.split(b"\r\n", 1)[0] + code = int(status_line.split()[1]) + try: + return code, json.loads(payload) if payload else {} + except ValueError: + return code, {"raw": payload} + + +def test_http_rejects_oversized_body() -> None: + httpd, port = _start_server() + try: + code, _ = _post(port, b"x" * 10, content_length=str(_MAX + 1)) + assert code == 413 + finally: + httpd.shutdown() + + +def test_http_rejects_invalid_length() -> None: + httpd, port = _start_server() + try: + code, _ = _post(port, b"", content_length="-1") + assert code == 400 + finally: + httpd.shutdown() + + +def test_http_accepts_normal_request() -> None: + httpd, port = _start_server() + try: + code, _ = _post(port, json.dumps({"scope": {"org": "acme", "user": "alice"}}).encode()) + assert code == 200 + finally: + httpd.shutdown() + + +def test_http_concurrency_limit_rejects_excess() -> None: + """审计验收 P1-HTTP:占满全局连接额度后,多余连接快速被拒(503)。 + + 用一个故意阻塞的 handler 钉住连接槽,开满 _MAX_CONCURRENT_REQUESTS + N 个, + 断言多余的被 503 拒而非无限创建线程。 + """ + profiles = importlib.import_module("profiles") + http_mod = importlib.import_module("bootstrap.http_server.__main__") + srv = http_mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + handler = srv._handler_cls() + + # 用小额度 server 避免开几百连接 + class _TinyServer(http_mod._BoundedThreadingHTTPServer): + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self._slots = threading.BoundedSemaphore(2) + + httpd = _TinyServer(("127.0.0.1", 0), handler) + httpd.daemon_threads = True + port = httpd.server_address[1] + gate = threading.Event() + + # 用阻塞的 do_POST 占住槽 + class _Block(handler): + def handle_blocked_post(self): + gate.wait(timeout=3) + self.send_response(200) + self.end_headers() + + # 替换 handler 为阻塞版 + setattr(_Block, "do_POST", _Block.handle_blocked_post) + setattr(httpd, "RequestHandlerClass", _Block) + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + + results = [] + + def fire(): + try: + s = socket.create_connection(("127.0.0.1", port), timeout=3) + s.sendall(b"POST /v1/x HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\n\r\n") + data = s.recv(256) + results.append(int(data.split()[1])) + s.close() + except OSError: + results.append(-1) + + # 开 5 个连接,额度 2,预期 2 个 200、3 个 503(或被拒) + threads = [threading.Thread(target=fire) for _ in range(5)] + for th in threads: + th.start() + time.sleep(0.05) # 错开让前两个先进 + time.sleep(0.5) + gate.set() # 放行阻塞的 + for th in threads: + th.join(timeout=3) + + httpd.shutdown() + accepted = results.count(200) + rejected = sum(1 for r in results if r in (503, -1)) + # 最多 2 个被处理,其余被拒 + assert accepted <= 2 + assert accepted + rejected == 5 diff --git a/tests/unit/bootstrap/test_http_slow_upload.py b/tests/unit/bootstrap/test_http_slow_upload.py new file mode 100644 index 00000000..a933837f --- /dev/null +++ b/tests/unit/bootstrap/test_http_slow_upload.py @@ -0,0 +1,57 @@ +"""HTTP 慢上传测试(审计验收 P1-HTTP)。 + +慢上传客户端只发 header + 部分 body,验证两阶段准入下 server 不会在读 body 阶段 +无限阻塞、不崩溃、连接最终被处理或超时关闭。 +""" + +from __future__ import annotations + +# These tests intentionally exercise the HTTP adapter's private server helpers. +# pylint: disable=protected-access +import importlib +import socket +import sys +import threading + +import pytest + +pytestmark = pytest.mark.unit + +for _p in ("bootstrap/http_server", "bootstrap/core", "src"): + if _p not in sys.path: + sys.path.append(_p) + +_mod = importlib.import_module("bootstrap.http_server.__main__") # noqa: E402 + + +def _start_server(): + profiles = importlib.import_module("profiles") + srv = _mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + httpd = _mod._BoundedThreadingHTTPServer(("127.0.0.1", 0), srv._handler_cls()) + httpd.daemon_threads = True + port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd, port + + +def test_slow_upload_does_not_crash_server() -> None: + """慢上传:发 header + 部分 body 后停住,server 不崩,连接最终超时/关闭。""" + httpd, port = _start_server() + try: + s = socket.create_connection(("127.0.0.1", port), timeout=5) + # 声明 100 字节 body,只发部分 + head = ( + b"POST /v1/list HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n" + b"Content-Type: application/json\r\n\r\n" + ) + s.sendall(head + b'{"scope":') + s.settimeout(3) + try: + data = s.recv(4096) + except socket.timeout: + data = b"" + s.close() + # 关键:server 没崩,连接要么返回响应要么超时关闭(不无限挂住线程) + assert data == b"" or b"HTTP" in data + finally: + httpd.shutdown() diff --git a/tests/unit/common/test_auth.py b/tests/unit/common/test_auth.py new file mode 100644 index 00000000..c708f7b0 --- /dev/null +++ b/tests/unit/common/test_auth.py @@ -0,0 +1,123 @@ +"""auth: AuthContext 不可变性、actor 必填、ContextVar 传播与线程隔离。""" + +from __future__ import annotations + +import threading +from dataclasses import FrozenInstanceError + +import pytest + +from common.errors import AgentMemoryError, AuthenticationError, PermissionDeniedError +from common.type_def.auth import ( + ROLE_RANK, + AuthContext, + Role, + get_current, + reset_current, + set_current, +) +from common.type_def.scope import Scope + +pytestmark = pytest.mark.unit + + +def test_actor_has_no_default() -> None: + """无参构造必须失败:否则「忘了传 actor」会静默得到 ROOT 的空 Scope()。""" + with pytest.raises(TypeError): + AuthContext() # type: ignore[call-arg] + + +def test_context_is_frozen() -> None: + ctx = AuthContext(actor=Scope(org="acme", user="alice")) + with pytest.raises(FrozenInstanceError): + ctx.actor = Scope() # type: ignore[misc] + with pytest.raises(FrozenInstanceError): + ctx.role = Role.ROOT # type: ignore[misc] + + +def test_defaults_are_least_privilege() -> None: + ctx = AuthContext(actor=Scope(org="acme", user="alice")) + assert ctx.role is Role.USER + assert ctx.acting_user == "" + assert ctx.from_oauth is False + assert ctx.authorizing_key_fp == "" + + +def test_role_is_str_for_audit_detail() -> None: + """Role 要能直接进 AuditEvent.detail(dict[str, str]),无需转换。""" + detail: dict[str, str] = {"role": Role.ADMIN} + assert detail["role"] == "admin" + + +def test_role_rank_is_ordered() -> None: + assert ROLE_RANK[Role.USER] < ROLE_RANK[Role.ADMIN] < ROLE_RANK[Role.ROOT] + + +def test_unknown_role_rejected_at_construction() -> None: + """拼错的角色名在构造点就炸,而不是在权限判断时静默走 else 分支。""" + with pytest.raises(ValueError): + Role("superuser") + + +# --- ContextVar 传播 --- + + +def test_get_current_is_none_without_authentication() -> None: + """未认证返回 None 而非默认 AuthContext——后者是 fail-open。""" + assert get_current() is None + + +def test_set_then_reset_restores_none() -> None: + ctx = AuthContext(actor=Scope(org="acme", user="alice")) + token = set_current(ctx) + try: + assert get_current() is ctx + finally: + reset_current(token) + assert get_current() is None + + +def test_nested_set_restores_outer_context() -> None: + outer = AuthContext(actor=Scope(org="acme", user="alice")) + inner = AuthContext(actor=Scope(org="evil", user="mallory")) + outer_token = set_current(outer) + inner_token = set_current(inner) + assert get_current() is inner + reset_current(inner_token) + assert get_current() is outer + reset_current(outer_token) + assert get_current() is None + + +def test_threads_do_not_share_context() -> None: + """ThreadingHTTPServer 每请求一线程:一个线程的身份绝不能被另一个看到。""" + seen: dict[str, AuthContext | None] = {} + started = threading.Event() + + def worker() -> None: + seen["before_main_set"] = get_current() + token = set_current(AuthContext(actor=Scope(org="evil", user="mallory"))) + started.set() + seen["own"] = get_current() + reset_current(token) + + main_token = set_current(AuthContext(actor=Scope(org="acme", user="alice"))) + try: + thread = threading.Thread(target=worker) + thread.start() + thread.join() + assert seen["before_main_set"] is None # 主线程的身份不泄漏进子线程 + assert seen["own"].actor == Scope(org="evil", user="mallory") + assert get_current().actor == Scope(org="acme", user="alice") # 未被子线程污染 + finally: + reset_current(main_token) + + +# --- AuthenticationError --- + + +def test_authentication_error_is_distinct_from_permission_denied() -> None: + """401「不知道你是谁」与 403「知道但不许」必须可分,否则 HTTP 层无法映射。""" + assert issubclass(AuthenticationError, AgentMemoryError) + assert not issubclass(AuthenticationError, PermissionDeniedError) + assert not issubclass(PermissionDeniedError, AuthenticationError) diff --git a/tests/unit/common/test_local_security_provider.py b/tests/unit/common/test_local_security_provider.py index 0c12464d..fa1e1bb1 100644 --- a/tests/unit/common/test_local_security_provider.py +++ b/tests/unit/common/test_local_security_provider.py @@ -76,6 +76,18 @@ def test_local_security_provider_can_reject_plaintext_in_strict_mode() -> None: provider.decrypt(b"legacy plaintext", context=_context()) +def test_local_security_provider_defaults_to_strict_not_plaintext() -> None: + """审计 P2-3:默认 fail-closed,不静默放行明文。 + + 默认 True 时,拥有底层存储写权限的攻击者可用任意明文替换密文,绕过 AES-GCM + tag 与 AAD。迁移期读旧明文须显式 opt-in(allow_plaintext=true)。 + """ + provider = LocalEnvelopeSecurityProvider(LocalKeyProvider(key_hex=_KEY_HEX)) + + with pytest.raises(InvalidMagicError): + provider.decrypt(b"legacy plaintext", context=_context()) + + def test_local_security_provider_rejects_aad_or_context_mismatch() -> None: provider = _provider_from_hex() ciphertext = provider.encrypt(b"secret payload", context=_context(user="alice"), aad=b"kv:a") diff --git a/tests/unit/security/test_authenticator.py b/tests/unit/security/test_authenticator.py new file mode 100644 index 00000000..8882d1c7 --- /dev/null +++ b/tests/unit/security/test_authenticator.py @@ -0,0 +1,93 @@ +"""security.authenticator: 抽象契约与工厂注册。""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from common.factory.factory import Factory +from security.authenticator import Authenticator, AuthProducer +from security.bootstrap import register_security +from security.key_store import KeyStoreProducer, PrincipalKeyStore +from security.types import AuthMode, Credentials + +pytestmark = pytest.mark.unit + + +def test_registration_is_idempotent() -> None: + register_security() + first = AuthProducer.known() + register_security() + assert AuthProducer.known() == first + + +def test_all_three_modes_registered() -> None: + register_security() + assert AuthProducer.known() == ["api_key", "dev", "trusted"] + assert KeyStoreProducer.known() == ["memory"] + + +def test_top_names_enter_config_validation() -> None: + """顶层段名要进 Factory.known_top_names(),否则配置解析期会拒掉这两段。""" + register_security() + tops = Factory.known_top_names() + assert "authenticator" in tops + assert "key_store" in tops + + +def test_abstract_contract_cannot_be_partially_implemented() -> None: + class Incomplete(Authenticator): + def authenticate(self, credentials: Credentials): # 缺 mode / health + raise NotImplementedError + + with pytest.raises(TypeError): + Incomplete() # type: ignore[abstract] + + +def test_key_store_abstract_contract() -> None: + class Incomplete(PrincipalKeyStore): + def issue(self, actor, role): + raise NotImplementedError + + with pytest.raises(TypeError): + Incomplete() # type: ignore[abstract] + + +def test_credentials_is_frozen() -> None: + creds = Credentials(api_key="k") + with pytest.raises(FrozenInstanceError): + creds.api_key = "other" # type: ignore[misc] + + +def test_credentials_defaults_are_empty() -> None: + creds = Credentials() + assert creds.api_key == "" + assert creds.headers == {} + assert creds.peer_address == "" + + +def test_oauth_mode_not_defined() -> None: + """OAuth 是第二期:定义一个没有实现的枚举值只会让配置错误变成间接报错。""" + assert {m.value for m in AuthMode} == {"dev", "trusted", "api_key"} + + +def test_interface_module_does_not_import_impl() -> None: + """顶层 .py 是纯抽象,不 import *_impl/(与 control 同规)。 + + 检查 AST 的 import 节点,不是文本匹配——docstring 里提到实现包名是正常的。 + """ + import ast + + import security.authenticator as auth_mod + import security.key_store as ks_mod + + for mod in (auth_mod, ks_mod): + tree = ast.parse(open(mod.__file__, encoding="utf-8").read()) + imported: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported += [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + assert not [name for name in imported if "_impl" in name], mod.__name__ diff --git a/tests/unit/security/test_authenticator_impl.py b/tests/unit/security/test_authenticator_impl.py new file mode 100644 index 00000000..9715c3d7 --- /dev/null +++ b/tests/unit/security/test_authenticator_impl.py @@ -0,0 +1,232 @@ +"""security.authenticator_impl: 三个实现的正反路径与错误消息一致性。""" + +from __future__ import annotations + +import pytest + +from common.errors import AuthenticationError, ValidationError +from common.type_def.auth import AuthContext, Role +from common.type_def.scope import Scope +from config.context import AssemblyContext +from security.authenticator import AuthProducer +from security.authenticator_impl.api_key_authenticator import ApiKeyAuthenticator +from security.authenticator_impl.dev_authenticator import DevAuthenticator +from security.authenticator_impl.trusted_authenticator import TrustedAuthenticator +from security.bootstrap import register_security +from security.key_store import KeyStoreProducer, PrincipalKeyStore +from security.types import AuthMode, Credentials + +pytestmark = pytest.mark.unit + +_ROOT_KEY = "root-key-for-tests" + + +@pytest.fixture(scope="module") +def key_store() -> PrincipalKeyStore: + register_security() + return KeyStoreProducer.build("memory", {}, AssemblyContext()) + + +@pytest.fixture(scope="module") +def alice_key(key_store) -> str: + return key_store.issue(Scope(org="acme", user="alice"), Role.USER) + + +# -- DevAuthenticator ------------------------------------------------------- # + + +def test_dev_returns_root_with_empty_scope() -> None: + """ROOT 的 actor 必须是空 Scope()。 + + security.md §2.2.1 示例写 Scope(org="*"),那在本主干会被 + SQLitePermissionManager.check 的「跨 org 拒绝」规则挡住——ROOT 反而寸步难行。 + """ + ctx = DevAuthenticator().authenticate(Credentials()) + assert ctx.actor == Scope() + assert ctx.role is Role.ROOT + + +def test_dev_ignores_all_credentials() -> None: + dev = DevAuthenticator() + assert dev.authenticate(Credentials(api_key="anything")).role is Role.ROOT + assert dev.mode() is AuthMode.DEV + assert dev.health() is None + + +# -- TrustedAuthenticator --------------------------------------------------- # + + +def _gateway_headers(**overrides) -> dict[str, str]: + headers = { + "x-org-id": "acme", + "x-principal-type": "user", + "x-principal-id": "alice", + } + headers.update(overrides) + return headers + + +def test_trusted_accepts_registered_principal(key_store, alice_key) -> None: + auth = TrustedAuthenticator(key_store=key_store) + ctx = auth.authenticate(Credentials(headers=_gateway_headers())) + assert ctx.actor == Scope(org="acme", user="alice") + assert ctx.role is Role.USER + assert auth.mode() is AuthMode.TRUSTED + + +def test_trusted_ignores_role_header(key_store, alice_key) -> None: + """§2.2.2 关键设计:header 说「你是谁」,框架自己查「你能干什么」。 + + 这条防的是网关被攻破或误配时的任意提权。 + """ + auth = TrustedAuthenticator(key_store=key_store) + ctx = auth.authenticate( + Credentials(headers=_gateway_headers(**{"x-role": "root", "x-principal-role": "root"})) + ) + assert ctx.role is Role.USER + + +def test_trusted_rejects_unregistered_principal(key_store) -> None: + """未注册主体一律拒绝,不默认给 USER 放行。""" + auth = TrustedAuthenticator(key_store=key_store) + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(headers=_gateway_headers(**{"x-principal-id": "nobody"}))) + + +@pytest.mark.parametrize( + "overrides", + [ + {"x-principal-type": "admin"}, # 非 user/agent + {"x-principal-type": ""}, + {"x-org-id": ""}, + {"x-principal-id": ""}, + {"x-org-id": " "}, # 只有空白 + ], +) +def test_trusted_rejects_malformed_headers(key_store, overrides) -> None: + auth = TrustedAuthenticator(key_store=key_store) + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(headers=_gateway_headers(**overrides))) + + +def test_trusted_requires_gateway_key_when_configured(key_store, alice_key) -> None: + auth = TrustedAuthenticator(key_store=key_store, gateway_key="shared-secret") + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(headers=_gateway_headers())) # 没带 + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key="wrong", headers=_gateway_headers())) + ctx = auth.authenticate(Credentials(api_key="shared-secret", headers=_gateway_headers())) + assert ctx.actor == Scope(org="acme", user="alice") + + +def test_trusted_gateway_key_survives_non_ascii(key_store, alice_key) -> None: + """compare_digest 的 str 版对非 ASCII 抛 TypeError → 500 而非 401。""" + auth = TrustedAuthenticator(key_store=key_store, gateway_key="shared-secret") + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key="密钥", headers=_gateway_headers())) + + +# -- ApiKeyAuthenticator ---------------------------------------------------- # + + +def test_api_key_root_returns_empty_scope(key_store) -> None: + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + ctx = auth.authenticate(Credentials(api_key=_ROOT_KEY)) + assert ctx.actor == Scope() + assert ctx.role is Role.ROOT + assert auth.mode() is AuthMode.API_KEY + + +def test_api_key_resolves_principal(key_store, alice_key) -> None: + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + ctx = auth.authenticate(Credentials(api_key=alice_key)) + assert ctx.actor == Scope(org="acme", user="alice") + assert ctx.role is Role.USER + + +@pytest.mark.parametrize("bad", ["", "wrong-key", "密钥非ascii", "a" * 500]) +def test_api_key_rejects_bad_keys(key_store, bad) -> None: + """非 ASCII 必须走 AuthenticationError(401),不能是 TypeError(500)。""" + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key=bad)) + + +def test_api_key_works_without_root_key(key_store, alice_key) -> None: + """root key 已轮换掉、只留主体 key 的部署是合法的。""" + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + assert auth.authenticate(Credentials(api_key=alice_key)).role is Role.USER + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key=_ROOT_KEY)) + + +# -- 跨实现的一致性 ---------------------------------------------------------- # + + +def test_all_failures_share_one_message(key_store) -> None: + """错误消息若区分「主体不存在」与「凭据错误」,就成了主体枚举侧信道。 + + 这条是防止后续维护者「好心」加详细错误消息的护栏。 + """ + api_key_auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + trusted_auth = TrustedAuthenticator(key_store=key_store, gateway_key="s") + + messages = set() + for auth, creds in ( + (api_key_auth, Credentials()), # 凭据缺失 + (api_key_auth, Credentials(api_key="wrong")), # 凭据错误 + (trusted_auth, Credentials(headers={})), # 声明缺失 + (trusted_auth, Credentials(headers=_gateway_headers())), # 网关密钥缺失 + ( + trusted_auth, + Credentials(api_key="s", headers=_gateway_headers(**{"x-principal-id": "ghost"})), + ), # 主体不存在 + ): + with pytest.raises(AuthenticationError) as exc: + auth.authenticate(creds) + messages.add(str(exc.value)) + + assert messages == {"authentication failed"} + + +def test_authenticate_never_returns_none(key_store, alice_key) -> None: + """认证只有成功与失败两种结果——返回 None 会诱导 fail-open 分支。""" + for auth, creds in ( + (DevAuthenticator(), Credentials()), + (TrustedAuthenticator(key_store=key_store), Credentials(headers=_gateway_headers())), + ( + ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY), + Credentials(api_key=alice_key), + ), + ): + assert isinstance(auth.authenticate(creds), AuthContext) + + +def test_producer_builds_each_mode() -> None: + register_security() + ctx = AssemblyContext() + assert AuthProducer.build("dev", {}, ctx).mode() is AuthMode.DEV + assert ( + AuthProducer.build("trusted", {"allow_no_gateway_key": True}, ctx).mode() + is AuthMode.TRUSTED + ) + assert ( + AuthProducer.build("api_key", {"root_api_key": _ROOT_KEY}, ctx).mode() is AuthMode.API_KEY + ) + + +def test_trusted_build_requires_gateway_key_by_default() -> None: + """审计 P1-2:未配 gateway_key 时默认拒绝装配,fail-closed。 + + 未配置时全部身份 header 可被任意调用方伪造;让它默认启动等于把信任边界 + 留给「配没配网关」这个隐含假设。显式 opt-in 才放行。 + """ + register_security() + ctx = AssemblyContext() + with pytest.raises(ValidationError): + AuthProducer.build("trusted", {}, ctx) + # 显式 opt-in 后可装配 + built = AuthProducer.build("trusted", {"allow_no_gateway_key": True}, ctx) + assert built.mode() is AuthMode.TRUSTED + # 配了 gateway_key 自然可装配 + assert AuthProducer.build("trusted", {"gateway_key": "k"}, ctx).mode() is AuthMode.TRUSTED diff --git a/tests/unit/security/test_binding.py b/tests/unit/security/test_binding.py new file mode 100644 index 00000000..dc9e6786 --- /dev/null +++ b/tests/unit/security/test_binding.py @@ -0,0 +1,60 @@ +"""security.binding: DEV 模式的 localhost 强制绑定。""" + +from __future__ import annotations + +import pytest + +from common.errors import ValidationError +from security.binding import check_dev_binding + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1", "[::1]", "LOCALHOST"]) +def test_loopback_accepted(host) -> None: + check_dev_binding(host) + + +@pytest.mark.parametrize("host", ["0.0.0.0", "::", "", "*", None]) +def test_wildcard_rejected(host) -> None: + """容器化场景下最危险的情况:以为只是没配,实际暴露给了整个网络。""" + with pytest.raises(ValidationError): + check_dev_binding(host) + + +@pytest.mark.parametrize("host", ["192.168.1.10", "10.0.0.1", "example.com"]) +def test_non_loopback_rejected(host) -> None: + with pytest.raises(ValidationError): + check_dev_binding(host) + + +def test_any_dangerous_host_in_sequence_rejects() -> None: + """多网卡:任一 host 危险即拒绝,不是「有一个安全就放行」。""" + with pytest.raises(ValidationError): + check_dev_binding(["127.0.0.1", "0.0.0.0"]) + + +def test_all_loopback_sequence_accepted() -> None: + check_dev_binding(["127.0.0.1", "::1"]) + + +def test_empty_sequence_rejected() -> None: + with pytest.raises(ValidationError): + check_dev_binding([]) + + +def test_message_names_the_remedy() -> None: + """错误消息要能自解释:告诉运维改绑哪里、或改用哪个模式。""" + with pytest.raises(ValidationError) as exc: + check_dev_binding("0.0.0.0") + message = str(exc.value) + assert "127.0.0.1" in message + assert "api_key" in message + + +def test_container_only_warns(monkeypatch, caplog) -> None: + """容器里绑 127.0.0.1 是合法的:是否暴露取决于 port mapping,框架无法检查。""" + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.96.0.1") + with caplog.at_level("WARNING"): + check_dev_binding("127.0.0.1") # 不抛 + assert any("容器" in r.message for r in caplog.records) diff --git a/tests/unit/security/test_key_store.py b/tests/unit/security/test_key_store.py new file mode 100644 index 00000000..5655fd53 --- /dev/null +++ b/tests/unit/security/test_key_store.py @@ -0,0 +1,342 @@ +"""security.key_store: 签发、解析、撤销、常时间与「不存明文」回归防线。""" + +from __future__ import annotations + +# The plaintext-retention assertion must inspect the in-memory registry directly. +# pylint: disable=protected-access +import json +import time +from statistics import median + +import pytest + +from common.errors import PermissionDeniedError, ValidationError +from common.type_def.auth import Role +from common.type_def.scope import Scope +from config.context import AssemblyContext +from security.bootstrap import register_security +from security.key_store import KeyStoreProducer, fingerprint, generate_api_key + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def store(): + """module 作用域:Argon2 的 dummy hash 每次构造约 200ms,不必每条测试重算。""" + register_security() + return KeyStoreProducer.build("memory", {}, AssemblyContext()) + + +# -- issue ------------------------------------------------------------------ # + + +def test_cannot_issue_root_key(store) -> None: + """§3.2 明确禁止:ROOT 只能来自配置声明的 Root API Key。""" + with pytest.raises(PermissionDeniedError): + store.issue(Scope(org="acme", user="alice"), Role.ROOT) + + +@pytest.mark.parametrize( + "actor", + [ + Scope(org="acme"), # 既非 user 也非 agent + Scope(org="acme", user="alice", agent="a1"), # 两者都有 + Scope(user="alice"), # 无 org + ], +) +def test_issue_rejects_malformed_principal_scope(store, actor) -> None: + with pytest.raises(ValidationError): + store.issue(actor, Role.USER) + + +def test_issued_keys_are_high_entropy_and_unique(store) -> None: + keys = {store.issue(Scope(org="acme", user=f"u{i}"), Role.USER) for i in range(5)} + assert len(keys) == 5 + assert all(len(k) == 43 for k in keys) # token_urlsafe(32) → 256 bit + + +def test_generate_api_key_is_unique() -> None: + assert len({generate_api_key() for _ in range(100)}) == 100 + + +# -- resolve ---------------------------------------------------------------- # + + +def test_resolve_returns_bound_identity(store) -> None: + key = store.issue(Scope(org="acme", user="alice"), Role.ADMIN) + ctx = store.resolve(key) + assert ctx is not None + assert ctx.actor == Scope(org="acme", user="alice") + assert ctx.role is Role.ADMIN + assert ctx.authorizing_key_fp == fingerprint(key) + assert ctx.acting_user == "alice" + + +def test_agent_principal_has_no_acting_user(store) -> None: + """agent 主体未经 OAuth 授权时无委托目标,acting_user 应为空。""" + key = store.issue(Scope(org="acme", agent="bot1"), Role.USER) + ctx = store.resolve(key) + assert ctx.acting_user == "" + + +def test_resolve_misses_on_wrong_key(store) -> None: + store.issue(Scope(org="acme", user="wrong-key-probe"), Role.USER) + assert store.resolve(generate_api_key()) is None + + +def test_resolve_does_not_raise_on_garbage(store) -> None: + for garbage in ("", "x", "中文密钥", "a" * 500): + assert store.resolve(garbage) is None + + +# -- revoke ----------------------------------------------------------------- # + + +def test_revoke_takes_effect_immediately_and_is_idempotent(store) -> None: + actor = Scope(org="acme", user="revoked-user") + key = store.issue(actor, Role.USER) + assert store.resolve(key) is not None + + store.revoke(fingerprint(key)) + assert store.resolve(key) is None + assert store.get_role(actor) is None + + store.revoke(fingerprint(key)) # 幂等 + store.revoke("nonexistent-fingerprint") + + +# -- get_role --------------------------------------------------------------- # + + +def test_get_role_backs_trusted_mode(store) -> None: + """TRUSTED 模式据此实现「role 不从 header 读」。""" + actor = Scope(org="acme", agent="gateway-bot") + assert store.get_role(actor) is None + store.issue(actor, Role.ADMIN) + assert store.get_role(actor) is Role.ADMIN + + +def test_role_is_principal_scoped_not_session_scoped(store) -> None: + """role 按 principal 索引(§3.1),不含 session。 + + 同一 principal 换 session 登录仍应查到同一 role;session 进 role_key 会让 + TRUSTED 的 get_role(actor 来自网关、不带 session)查不到已注册主体。 + """ + actor = Scope(org="acme", user="sess-user") + key = store.issue(actor, Role.USER) + + # 网关声明的 actor 不带 session,但能查到 role + assert store.get_role(Scope(org="acme", user="sess-user")) is Role.USER + store.revoke(fingerprint(key)) + + +def test_revoking_one_key_keeps_role_for_other_keys_of_same_principal(store) -> None: + """同 principal 多 key 共用一个 role 条目:revoke 一把不能让另一把失效。 + + 回归审计 P2-2:此前 revoke 无条件 pop ``_roles``,导致同 principal 的其它 + 有效 key 一起失去角色。 + """ + actor = Scope(org="acme", user="multi-key") + key_a = store.issue(actor, Role.USER) + key_b = store.issue(actor, Role.USER) # 同 role,允许多 key + + store.revoke(fingerprint(key_a)) + # key_b 仍有效,role 仍在 + assert store.resolve(key_b) is not None + assert store.get_role(actor) is Role.USER + + store.revoke(fingerprint(key_b)) + assert store.get_role(actor) is None + + +def test_issue_rejects_conflicting_role_for_same_principal(store) -> None: + """审计验收 P2-role:同 principal 已有不同 role 的 key 时拒绝签发。 + + role 是 principal 唯一权威状态,不是每把 key 的可冲突副本。否则 issue 覆盖 + _roles 后 resolve(读 record.role)与 get_role(读 _roles)返回不一致。 + """ + actor = Scope(org="acme", user="role-conflict") + key = store.issue(actor, Role.USER) + try: + with pytest.raises(ValidationError): + store.issue(actor, Role.ADMIN) + finally: + store.revoke(fingerprint(key)) + # revoke 全部后可重新签发不同 role + store.issue(actor, Role.ADMIN) + assert store.get_role(actor) is Role.ADMIN + + +def test_revoke_recomputes_role_order_independent(store) -> None: + """审计验收 P2-role:revoke 按剩余有效 key 重算 role,与撤销顺序无关。 + + 覆盖「USER+ADMIN 两 key 分别按两种顺序撤销」--但 issue 禁止同 principal 不同 + role,故此处验证同 role 多 key 的撤销:revoke 任一把,剩余 key 的 role 仍在; + revoke 全部后 role 清空。重点是不再有「残留被撤销 key 的 role」。 + """ + actor = Scope(org="acme", user="revoke-order") + key_a = store.issue(actor, Role.USER) + key_b = store.issue(actor, Role.USER) # 同 role,允许多 key + + # revoke 一把,另一把仍撑住 role + store.revoke(fingerprint(key_a)) + assert store.get_role(actor) is Role.USER + assert store.resolve(key_b) is not None + + # revoke 第二把,role 清空 + store.revoke(fingerprint(key_b)) + assert store.get_role(actor) is None + + +def test_concurrent_issue_conflicting_role_is_atomic(store) -> None: + """验收第三次 P3:两线程并发为同 principal 签 USER/ADMIN,恰好一个成功一个冲突。 + + 严格断言(审计第三次):join(timeout) 确认线程退出、捕获非 ValidationError 异常 + 上抛、结果数 2 / 成功 1 / 冲突 1。实现本身经审计 20 轮强制同拍攻击验证。 + """ + import threading + + actor = Scope(org="acme", user="race-principal-strict") + barrier = threading.Barrier(2) + results: list = [] + errors: list = [] + + def attempt(role): + try: + barrier.wait(timeout=5) # 两线程同时通过,最大化竞态窗口 + except threading.BrokenBarrierError as exc: + errors.append(exc) + return + try: + key = store.issue(actor, role) + results.append(("ok", role, key)) + except ValidationError: + results.append(("conflict", role, None)) + except Exception as exc: # 非 ValidationError 不该发生,上抛 + errors.append(exc) + + t1 = threading.Thread(target=attempt, args=(Role.USER,)) + t2 = threading.Thread(target=attempt, args=(Role.ADMIN,)) + t1.start() + t2.start() + t1.join(timeout=10) + t2.join(timeout=10) + assert not t1.is_alive() and not t2.is_alive(), "线程未在限时内退出" + assert not errors, f"非预期异常: {errors}" + + # 恰好一个成功、一个冲突 + assert len(results) == 2, f"结果数应为 2,得到 {results}" + successes = [r for r in results if r[0] == "ok"] + conflicts = [r for r in results if r[0] == "conflict"] + assert len(successes) == 1, f"应恰好一个成功,得到 {results}" + assert len(conflicts) == 1, f"应恰好一个冲突,得到 {results}" + + # _roles 与 _records 一致:_roles 是赢家的 role + winner_role = successes[0][1] + assert store.get_role(actor) is winner_role + # 清理 + store.revoke(fingerprint(successes[0][2])) + assert store.get_role(actor) is None + + +def test_issued_identity_is_immutable_to_original_scope_mutation(store) -> None: + """验收第三次 P2-1:签发后改原 actor,不影响 resolve 的身份。 + + Scope 现为 frozen 值对象;_Record.actor 保存的是不可变值,原对象后续修改 + (若调用方仍持旧可变引用)不会改变已签发 key 的 principal。防的是「签发后 + 把 actor 改成受害者 org」的越权。 + """ + from dataclasses import FrozenInstanceError + + actor = Scope(org="tenant-a", user="alice") + key = store.issue(actor, Role.USER) + # 原 actor 已 frozen,无法原地改;确认身份未变 + with pytest.raises(FrozenInstanceError): + actor.org = "tenant-victim" + ctx = store.resolve(key) + assert ctx.actor.org == "tenant-a" + assert ctx.actor.user == "alice" + store.revoke(fingerprint(key)) + + +def test_resolved_auth_context_actor_is_deeply_immutable(store) -> None: + """验收第三次 P2-1:resolved AuthContext.actor 不可原地修改。 + + AuthContext(frozen=True) 此前只是浅冻结,actor 是可变 Scope;frozen Scope 后 + 深度不可变,请求生命周期内身份不可篡改。 + """ + from dataclasses import FrozenInstanceError + + actor = Scope(org="acme", user="immutable-ctx-probe") + key = store.issue(actor, Role.USER) + ctx = store.resolve(key) + with pytest.raises(FrozenInstanceError): + ctx.actor.org = "tenant-attacker" + with pytest.raises(FrozenInstanceError): + ctx.actor.user = "bob" + store.revoke(fingerprint(key)) + + +def test_role_does_not_partition_by_space(store) -> None: + """§3.1 role 是 principal 级,不按 space 分:同 principal 不同 space 同 role。 + + 审计 P2-2 建议给 role_key 加 space;但 §3.1 角色是 principal 级(USER/ADMIN/ + ROOT 不随 space 变),space 入索引会让「同 principal 同 role」变成两条互覆 + 记录。本条钉住 principal 级语义。若业务需要 space 级 role,需先演进 §3.1。 + """ + actor = Scope(org="acme", space="s1", user="space-probe") + key = store.issue(actor, Role.USER) + + # 网关声明的 actor 不带 space,仍能查到该 principal 的 role + assert store.get_role(Scope(org="acme", user="space-probe")) is Role.USER + store.revoke(fingerprint(key)) + + +# -- 安全属性 ---------------------------------------------------------------- # + + +def test_registry_never_holds_plaintext(store) -> None: + """最重要的回归防线:注册表里存的必须是哈希,不是明文。""" + key = store.issue(Scope(org="acme", user="plaintext-check"), Role.USER) + dumped = json.dumps( + [ + {"fp": r.key_fp, "hash": r.key_hash, "org": r.actor.org, "revoked": r.revoked} + for r in store._records.values() + ] + ) + assert key not in dumped + assert dumped.count("$argon2id$") >= 1 + + +def test_resolve_pads_time_on_miss(store) -> None: + """未命中不得比「命中前缀但 key 错」快一整个 Argon2 verify。 + + 差异若存在是 ~100x 量级(差一整个 verify),故区间给到 [0.5, 2.0] 足以检出, + 同时容忍 CI 抖动。取中位数而非平均,避免单次 GC 抖动主导。 + """ + key = store.issue(Scope(org="acme", user="timing"), Role.USER) + # 同前缀但内容不同 → 走「候选存在但 verify 失败」路径 + wrong_same_prefix = key[:8] + generate_api_key()[8:] + + def elapsed(candidate: str) -> float: + start = time.perf_counter() + store.resolve(candidate) + return time.perf_counter() - start + + no_candidate = median(elapsed(generate_api_key()) for _ in range(5)) + wrong_key = median(elapsed(wrong_same_prefix) for _ in range(5)) + + ratio = no_candidate / wrong_key + assert 0.5 < ratio < 2.0, f"timing side channel: ratio={ratio:.2f}" + + +def test_single_resolve_stays_under_budget(store) -> None: + """性能基线:防止 Argon2 参数被误配成更离谱的值。 + + 实测单次约 200ms(128 MiB × time_cost=4),对应 5~20 QPS/核——这是已知 + 限制,不是本测试要防的;本测试只防「参数配错一个数量级」。 + """ + key = store.issue(Scope(org="acme", user="perf"), Role.USER) + start = time.perf_counter() + store.resolve(key) + assert (time.perf_counter() - start) < 1.0 diff --git a/tests/unit/security/test_rate_limit.py b/tests/unit/security/test_rate_limit.py new file mode 100644 index 00000000..0485731a --- /dev/null +++ b/tests/unit/security/test_rate_limit.py @@ -0,0 +1,188 @@ +"""security.rate_limit:令牌桶限流(§8.1)。 + +测的是行为而非内部状态:桶的 tokens 字段是实现细节,「第 N 个请求被拒、 +等一会儿又能过」才是契约。时间相关的断言全部注入假时钟,不用 sleep—— +sleep 会让测试又慢又 flaky。 +""" + +from __future__ import annotations + +# The bounded-table assertions are intentional white-box checks of the LRU state. +# pylint: disable=protected-access +import threading + +import pytest + +from common.errors import ValidationError +from config.context import AssemblyContext +from security.bootstrap import register_security +from security.rate_limit import RateLimitProducer +from security.rate_limit_impl.token_bucket_limiter import TokenBucketLimiter + +pytestmark = pytest.mark.unit + +_MONOTONIC = "security.rate_limit_impl.token_bucket_limiter.time.monotonic" + + +@pytest.fixture(autouse=True, scope="module") +def _registered(): + register_security() + + +def _limiter(capacity=3, refill_per_sec=1.0, max_tracked=100) -> TokenBucketLimiter: + return TokenBucketLimiter( + capacity=capacity, refill_per_sec=refill_per_sec, max_tracked=max_tracked + ) + + +# -- 准入 -------------------------------------------------------------------- # + + +def test_burst_up_to_capacity_then_denied() -> None: + limiter = _limiter(capacity=3) + assert [limiter.allow("10.0.0.1") for _ in range(3)] == [True, True, True] + assert limiter.allow("10.0.0.1") is False + + +def test_peers_have_independent_buckets() -> None: + """一个调用方打满不该影响别人——否则单个攻击者就能拒绝全部服务。""" + limiter = _limiter(capacity=2) + assert limiter.allow("10.0.0.1") and limiter.allow("10.0.0.1") + assert limiter.allow("10.0.0.1") is False + assert limiter.allow("10.0.0.2") is True + + +def test_empty_peer_is_never_limited() -> None: + """进程内直连 / MCP stdio 没有网络对端:没有攻击面,限流只会卡住本地 CLI。""" + limiter = _limiter(capacity=1) + assert all(limiter.allow("") for _ in range(50)) + + +# -- 补充 -------------------------------------------------------------------- # + + +def test_tokens_refill_over_time(monkeypatch) -> None: + """桶空之后等够时间要能再放行——不然限流等于永久拉黑。""" + now = [1000.0] + monkeypatch.setattr(_MONOTONIC, lambda: now[0]) + + limiter = _limiter(capacity=2, refill_per_sec=1.0) + assert limiter.allow("10.0.0.1") and limiter.allow("10.0.0.1") + assert limiter.allow("10.0.0.1") is False + + now[0] += 0.5 # 不足一个令牌 + assert limiter.allow("10.0.0.1") is False + + now[0] += 0.5 # 累计 1.0s → 恰好一个令牌 + assert limiter.allow("10.0.0.1") is True + assert limiter.allow("10.0.0.1") is False + + +def test_refill_is_capped_at_capacity(monkeypatch) -> None: + """长时间空闲不该攒出无限额度,否则突发保护形同虚设。""" + now = [1000.0] + monkeypatch.setattr(_MONOTONIC, lambda: now[0]) + + limiter = _limiter(capacity=3, refill_per_sec=1.0) + assert limiter.allow("10.0.0.1") + now[0] += 3600 # 空闲一小时 + + assert [limiter.allow("10.0.0.1") for _ in range(3)] == [True, True, True] + assert limiter.allow("10.0.0.1") is False + + +# -- 桶表有界 ---------------------------------------------------------------- # + + +def test_bucket_table_is_bounded() -> None: + """桶按 peer 建、peer 由远端决定:无界字典会让防耗尽的组件自己成为耗尽入口。""" + limiter = _limiter(capacity=1, max_tracked=10) + for i in range(100): + limiter.allow(f"10.0.0.{i}") + assert len(limiter._buckets) == 10 + + +def test_eviction_drops_least_recently_used() -> None: + """淘汰最久未活跃的那个:活跃 peer 的限流状态不能被一串陌生 IP 冲掉。""" + limiter = _limiter(capacity=1, max_tracked=3) + assert limiter.allow("busy") is True # busy 的桶已耗尽 + limiter.allow("a") + limiter.allow("busy") # 触碰一次,把 busy 移到 LRU 末尾 + limiter.allow("b") + limiter.allow("c") # 超出 3 个 → 淘汰最久未活跃的 "a" + + assert "busy" in limiter._buckets + assert "a" not in limiter._buckets + # busy 仍然被限流——它的状态没被冲掉。 + assert limiter.allow("busy") is False + + +# -- 并发 -------------------------------------------------------------------- # + + +def test_concurrent_requests_do_not_exceed_capacity() -> None: + """「读余量 → 减一 → 写回」在 GIL 下不是原子的:两个线程能同时看到最后一个令牌。 + + 没有锁时本测试会看到 allowed > capacity。 + """ + limiter = _limiter(capacity=50, refill_per_sec=0.0001) + allowed: list[bool] = [] + lock = threading.Lock() + barrier = threading.Barrier(20) + + def hammer() -> None: + barrier.wait() # 尽量让 20 个线程同时进 allow + results = [limiter.allow("10.0.0.1") for _ in range(20)] + with lock: + allowed.extend(results) + + threads = [threading.Thread(target=hammer) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert sum(allowed) == 50 + + +# -- 装配 -------------------------------------------------------------------- # + + +def test_build_uses_defaults() -> None: + limiter = RateLimitProducer.build("token_bucket", {}, AssemblyContext()) + assert isinstance(limiter, TokenBucketLimiter) + limiter.health() + + +def test_build_honours_params() -> None: + limiter = RateLimitProducer.build( + "token_bucket", {"capacity": 2, "refill_per_sec": 7.5}, AssemblyContext() + ) + assert limiter.allow("10.0.0.1") and limiter.allow("10.0.0.1") + assert limiter.allow("10.0.0.1") is False + + +@pytest.mark.parametrize( + "params", + [ + {"capacity": 0}, + {"capacity": -1}, + {"refill_per_sec": 0}, + {"refill_per_sec": -1.0}, + {"max_tracked": 0}, + ], +) +def test_invalid_params_rejected_at_assembly(params) -> None: + """配错了要在启动时炸:capacity=0 会拒绝一切请求,refill=0 会永久拉黑调用方。 + + 这两种「配置写错等于服务下线」的情况,运行期才暴露就是一次生产事故。 + """ + with pytest.raises(ValidationError): + RateLimitProducer.build("token_bucket", params, AssemblyContext()) + + +def test_disabling_is_explicit_not_a_magic_value() -> None: + """关闭限流走 target: unlimited;capacity 不接受反着读的魔法值。""" + limiter = RateLimitProducer.build("unlimited", {}, AssemblyContext()) + assert all(limiter.allow("10.0.0.1") for _ in range(1000)) + limiter.health() diff --git a/tests/unit/storage/test_encrypted_fs_store.py b/tests/unit/storage/test_encrypted_fs_store.py new file mode 100644 index 00000000..e0548289 --- /dev/null +++ b/tests/unit/storage/test_encrypted_fs_store.py @@ -0,0 +1,498 @@ +"""EncryptedFSStore:装饰器契约 + 它的装配。 + +与 ``test_encrypted_kv_store.py`` 同构(同一个假 provider 套路),断言的核心是 +两句:**上层看不出区别,内层看到的全是密文**;以及**装饰器交给 provider 的 +``SecurityContext`` / AAD 到底绑了什么**——后者是加密能否抵抗「密文搬家」的唯一 +依据,只测 roundtrip 的话完全不加密也是绿的。 + +明文兼容(迁移期读加密上线前的老数据)由 provider 的 ``allow_plaintext`` 控制, +本装饰器不重复提供同语义开关,故这里只验「provider 允许则读得出」。 +""" + +from __future__ import annotations + +# Boundary and TOCTOU tests intentionally replace private decorator internals. +# pylint: disable=protected-access +import io +import json + +import pytest + +from common.errors import BackendError, NotFoundError, ValidationError +from common.factory.factory import Factory +from common.security import SecurityContext, SecurityProducer, SecurityProvider +from common.type_def import Scope +from config.context import AssemblyContext +from storage.fs import FsProducer +from storage.fs_impl.encrypted_fs_store import EncryptedFSStore +from storage.fs_impl.local_fs import LocalFSStore + +pytestmark = pytest.mark.unit + +_PREFIX = b"fake1:" +_ALICE = Scope(org="acme", space="product", user="alice") + + +class _FakeSecurity(SecurityProvider): + """把 AAD 编进密文的假 provider:AAD 对不上就解不开,与真信封同性质。""" + + def __init__(self, *, allow_plaintext: bool = True) -> None: + self.allow_plaintext = allow_plaintext + self.fail_decrypt = False + self.encrypt_calls: list[tuple[SecurityContext | None, bytes, bytes]] = [] + self.decrypt_calls: list[tuple[SecurityContext | None, bytes, bytes]] = [] + + def encrypt( + self, + plaintext: bytes, + *, + context: SecurityContext | None = None, + aad: bytes = b"", + ) -> bytes: + self.encrypt_calls.append((context, aad, plaintext)) + return _PREFIX + len(aad).to_bytes(4, "big") + aad + plaintext[::-1] + + def decrypt( + self, + ciphertext: bytes, + *, + context: SecurityContext | None = None, + aad: bytes = b"", + ) -> bytes: + self.decrypt_calls.append((context, aad, ciphertext)) + if self.fail_decrypt: + raise RuntimeError("decrypt failed") + if not ciphertext.startswith(_PREFIX): + if self.allow_plaintext: + return ciphertext + raise RuntimeError("missing encrypted envelope") + offset = len(_PREFIX) + aad_size_end = offset + 4 + aad_len = int.from_bytes(ciphertext[offset:aad_size_end], "big") + offset = aad_size_end + aad_end = offset + aad_len + embedded_aad = ciphertext[offset:aad_end] + if embedded_aad != aad: + raise RuntimeError("aad mismatch") + return ciphertext[aad_end:][::-1] + + +@SecurityProducer.register("fake_encrypted_fs") +def _build_fake_security(config): + return _FakeSecurity(allow_plaintext=bool(config.get("allow_plaintext", True))) + + +def _fs( + tmp_path, security: _FakeSecurity | None = None +) -> tuple[EncryptedFSStore, LocalFSStore, _FakeSecurity]: + inner = LocalFSStore(root=str(tmp_path / "files")) + fake = security or _FakeSecurity() + return EncryptedFSStore(inner, fake, max_plaintext_bytes=64 * 1024 * 1024), inner, fake + + +def _aad_payload(aad: bytes) -> dict: + return json.loads(aad.decode("utf-8")) + + +def test_encrypted_fs_store_encrypts_content_and_decrypts_get(tmp_path) -> None: + fs, inner, security = _fs(tmp_path) + + ref = fs.insert(_ALICE, "a/b/x.bin", io.BytesIO(b"secret payload")) + + with inner.get(_ALICE, ref) as fh: + stored = fh.read() + assert stored.startswith(_PREFIX) + assert b"secret payload" not in stored + with fs.get(_ALICE, ref) as fh: + assert fh.read() == b"secret payload" + + context, aad, plaintext = security.encrypt_calls[0] + assert plaintext == b"secret payload" + assert context is not None + assert context.scope == _ALICE + assert context.purpose == "fs_object" + assert context.metadata["ref"] == "a/b/x.bin" + + +def test_encrypted_fs_store_aad_binds_all_five_scope_dimensions(tmp_path) -> None: + """AAD 少绑一维,那一维就能搬密文。 + + 存储层的 scope 隔离是访问控制、可以被绕过(直接写底层、备份恢复串了); + AAD 是密码学的,绕不过——前提是它真的绑满了。``space`` 是 ``Scope`` 五维化时 + 新加的维度,漏了它同 org 下的两个 space 就能互读。 + """ + fs, _, security = _fs(tmp_path) + scope = Scope(org="acme", space="product", user="alice", agent="bot", session="s1") + + fs.insert(scope, "x.bin", io.BytesIO(b"v")) + + payload = _aad_payload(security.encrypt_calls[0][1]) + assert payload["scope"] == { + "org": "acme", + "space": "product", + "user": "alice", + "agent": "bot", + "session": "s1", + } + assert payload["ref"] == "x.bin" + assert payload["purpose"] == "fs_object" + + +def test_encrypted_fs_store_cross_scope_ciphertext_move_fails(tmp_path) -> None: + """把 alice 的密文直接塞进 bob 的槽位——绕过存储层隔离后仍然读不出来。""" + fs, inner, _ = _fs(tmp_path) + bob = Scope(org="acme", space="product", user="bob") + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"alice-data")) + with inner.get(_ALICE, "x.bin") as fh: + inner.insert(bob, "x.bin", io.BytesIO(fh.read())) + + with pytest.raises(BackendError): # 不是 NotFoundError——是「解不开」 + fs.get(bob, "x.bin") + + +def test_encrypted_fs_store_update_also_encrypts(tmp_path) -> None: + """update 是第二条写路径——只在 insert 上加密是个真实会犯的错。""" + fs, inner, _ = _fs(tmp_path) + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"old")) + fs.update(_ALICE, "x.bin", io.BytesIO(b"newer-secret")) + + with inner.get(_ALICE, "x.bin") as fh: + stored = fh.read() + assert stored.startswith(_PREFIX) + assert b"newer-secret" not in stored + with fs.get(_ALICE, "x.bin") as fh: + assert fh.read() == b"newer-secret" + + +def test_encrypted_fs_store_roundtrips_empty_file(tmp_path) -> None: + fs, _, _ = _fs(tmp_path) + + fs.insert(_ALICE, "empty.bin", io.BytesIO(b"")) + + with fs.get(_ALICE, "empty.bin") as fh: + assert fh.read() == b"" + + +def test_encrypted_fs_store_stat_reports_ciphertext_size(tmp_path) -> None: + """已知代价,显式钉住:size 是密文长度,比明文长。改了要有人主动来改这条。""" + fs, _, _ = _fs(tmp_path) + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"12345")) + + assert fs.stat(_ALICE, "x.bin").size > 5 + + +def test_encrypted_fs_store_passes_through_missing_and_delete(tmp_path) -> None: + fs, _, security = _fs(tmp_path) + + with pytest.raises(NotFoundError): + fs.get(_ALICE, "nope") + fs.insert(_ALICE, "x.bin", io.BytesIO(b"a")) + fs.delete(_ALICE, "x.bin") + fs.delete(_ALICE, "x.bin") # 幂等 + with pytest.raises(NotFoundError): + fs.get(_ALICE, "x.bin") + assert not security.decrypt_calls # delete 不经加解密 + + +def test_encrypted_fs_store_supports_plaintext_compatibility_via_provider(tmp_path) -> None: + """迁移期:加密层上线前写进去的老数据必须还能读,否则上线即全量不可用。""" + fs, inner, _ = _fs(tmp_path, _FakeSecurity(allow_plaintext=True)) + + inner.insert(_ALICE, "legacy.bin", io.BytesIO(b"legacy plaintext")) + + with fs.get(_ALICE, "legacy.bin") as fh: + assert fh.read() == b"legacy plaintext" + + +def test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed(tmp_path) -> None: + """兼容开关只影响**读**。若它顺带放松了写,迁移期写进去的数据就永远是明文。""" + fs, inner, _ = _fs(tmp_path, _FakeSecurity(allow_plaintext=True)) + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"secret")) + + with inner.get(_ALICE, "x.bin") as fh: + assert fh.read().startswith(_PREFIX) + + +def test_encrypted_fs_store_decryption_failure_is_fail_closed(tmp_path) -> None: + fs, _, security = _fs(tmp_path) + fs.insert(_ALICE, "x.bin", io.BytesIO(b"v")) + security.fail_decrypt = True + + with pytest.raises(BackendError): + fs.get(_ALICE, "x.bin") + + +def test_encrypted_fs_store_factory_builds_wrapper_from_named_dependencies(tmp_path) -> None: + Factory.reset_all() + ctx = AssemblyContext.from_dict( + { + "security": {"default": "fake_encrypted_fs"}, + "fs_store": { + "raw": {"target": "local", "params": {"root": str(tmp_path / "files")}}, + "default": { + "target": "encrypted", + "params": {"inner": "raw", "security": "default"}, + }, + }, + } + ) + + fs = FsProducer.build_named("default", ctx) + + assert isinstance(fs, EncryptedFSStore) + ref = fs.insert(_ALICE, "x.bin", io.BytesIO(b"value")) + with fs.get(_ALICE, ref) as fh: + assert fh.read() == b"value" + + +def test_encrypted_fs_store_factory_requires_inner_dependency() -> None: + """没配 inner 时必须报错。给个默认会让「配错了」静默变成「加密了一个内存 + store」——数据写得进去,重启后全没了。 + """ + Factory.reset_all() + ctx = AssemblyContext.from_dict( + { + "security": {"default": "fake_encrypted_fs"}, + "fs_store": {"default": {"target": "encrypted", "params": {"security": "default"}}}, + } + ) + + with pytest.raises(ValidationError): + FsProducer.build_named("default", ctx) + + +def test_encrypted_fs_is_registered_by_storage_bootstrap() -> None: + """``api.build_kernel`` 只调 ``register_backends()``,从不调 ``register_security()``。 + + 装饰器住在 storage 下就是为了这个:注册若挂在别处,不经该装配路径会得到 + 「未注册的实现 'encrypted'」——一个只在部分入口出现的故障。 + """ + from storage.bootstrap import register_backends + + register_backends() + + assert "encrypted" in FsProducer.known() + + +def test_encrypted_fs_store_rejects_oversized_plaintext(tmp_path) -> None: + """审计验收 P2-FS:写入用有界 read,超限即拒,不先整块读入内存。""" + fs, inner, _ = _fs(tmp_path) + fs._max_plaintext_bytes = 4 + with pytest.raises(ValidationError): + fs.insert(_ALICE, "big.bin", io.BytesIO(b"abcdef")) + ref = fs.insert(_ALICE, "ok.bin", io.BytesIO(b"ok")) + fs._max_plaintext_bytes = 4 + with pytest.raises(ValidationError): + fs.update(_ALICE, ref, io.BytesIO(b"oversized")) + + +def test_encrypted_fs_store_bounded_read_does_not_load_oversized(tmp_path) -> None: + """审计验收 P2-FS:超大输入不先全读。用 TrackingReader 证明只读了 max+1。""" + fs, inner, _ = _fs(tmp_path) + fs._max_plaintext_bytes = 4 + + class _TrackingReader(io.BytesIO): + def __init__(self, data): + super().__init__(data) + self.read_calls = [] + + def read(self, n=-1): + self.read_calls.append(n) + return super().read(n) + + reader = _TrackingReader(b"x" * 1024) + with pytest.raises(ValidationError): + fs.insert(_ALICE, "big.bin", reader) + # 只读了 max+1=5 字节就判定超限,没读完整 1024 + assert reader.read_calls == [5] + + +def test_encrypted_fs_store_rejects_oversized_ciphertext_on_read(tmp_path) -> None: + """验收第三次 P3:stat 早拒超大密文,且 decrypt 不被调用。 + + 用显式小 max_ciphertext_bytes,使 1024 字节密文触发 stat 早拒(而非走到 + 解密后被明文复核拒--那是另一条分支)。 + """ + inner = LocalFSStore(root=str(tmp_path / "files")) + fake = _FakeSecurity() + fs = EncryptedFSStore(inner, fake, max_plaintext_bytes=4, max_ciphertext_bytes=8) + big_ciphertext = b"x" * 1024 + ref = inner.insert(_ALICE, "big.enc", io.BytesIO(big_ciphertext)) + with pytest.raises(ValidationError): + fs.get(_ALICE, ref) + # stat 早拒:decrypt 根本没被调用 + assert fake.decrypt_calls == [] + + +def test_encrypted_fs_store_handles_short_reads_without_truncation(tmp_path) -> None: + """验收复验 P2-FS 问题 1:短读流不能被当完整文件静默截断。 + + BinaryIO.read(n) 允许返回 < n 字节而未 EOF。单次 read 会把第一段当完整内容。 + 循环有界读取必须反复 read 到 EOF,否则 b'a' 会被当成整个文件存下。 + """ + + class _ShortReader(io.BytesIO): + """每次只返回 1 字节,模拟短读流。""" + + def read(self, n=-1): + if n is None or n < 0: + return super().read() + return super().read(1) + + fs, inner, fake = _fs(tmp_path) + reader = _ShortReader(b"abcdef") + ref = fs.insert(_ALICE, "short.bin", reader) + # 完整 6 字节都应被读取并加密,不是只存第一段 b'a' + with fs.get(_ALICE, ref) as fh: + assert fh.read() == b"abcdef" + + +def test_encrypted_fs_store_toctou_stat_get_mismatch_still_bounded(tmp_path) -> None: + """验收第四次 P3-test:stat 与 get 不一致时,读取按显式密文上限有界(TOCTOU)。 + + stat 报小、get 返回大,stat 早拒通过后,真正读取仍用循环有界,读到 + max_ciphertext_bytes+1 即止并拒。用 tracking reader 断言**实际读取量**有界-- + 防回归成「全读后再检查长度」(那样 decrypt 也没被调,旧断言测不出退化)。 + """ + + class _TrackingReader(io.BytesIO): + """记录每次 read(n) 的请求大小,用于断言有界读取。""" + + def __init__(self, data): + super().__init__(data) + self.read_calls: list[int] = [] + + def read(self, n=-1): + self.read_calls.append(n) + return super().read(n) + + class _LyingStat: + """stat 永远报 1,get 返回 tracking reader(1024 bytes)--模拟 stat/get 不一致。""" + + def __init__(self, inner): + self._inner = inner + self.last_reader: _TrackingReader | None = None # 供测试断言 + + def __getattr__(self, name): + return getattr(self._inner, name) + + @staticmethod + def stat(scope, ref): + from storage.types import FileStat + + return FileStat(ref=ref, size=1) + + def get(self, scope, ref): + self.last_reader = _TrackingReader(b"x" * 1024) + return self.last_reader + + inner = LocalFSStore(root=str(tmp_path / "files")) + fake = _FakeSecurity() + fs = EncryptedFSStore(inner, fake, max_plaintext_bytes=4, max_ciphertext_bytes=8) + lying = _LyingStat(inner) + fs._inner = lying + ref = "fake-ref" + with pytest.raises(ValidationError): + fs.get(_ALICE, ref) + # 密文流超过 max_ciphertext_bytes 被拒,decrypt 未调用 + assert fake.decrypt_calls == [] + # 实际读取有界(防回归成 fh.read() 全读后检查): + reader = lying.last_reader + assert reader is not None + calls = reader.read_calls + assert calls, "未发生任何 read" + # - 没有 read(-1)(无界全读) + assert -1 not in calls, f"退化成 read(-1) 无界全读:{calls}" + # - 首次请求大小 = max_ciphertext_bytes + 1 = 9 + assert calls[0] == 9, f"首次应请求 max+1=9,得到 {calls[0]}" + # - 循环有界:读到上限即拒,不会有第二次大请求(首次 read(9) 即读到 9 字节超限) + assert len(calls) == 1, f"应在首次 read 即超限拒,不该多次 read:{calls}" + + +def test_encrypted_fs_store_rejects_oversized_plaintext_after_decrypt(tmp_path) -> None: + """验收复验 P2-FS:解密后复核明文上限。 + + stat/密文长度都通过,但解密出的明文超限(密文被替换成另一个合法但解出超大的 + 信封)也要拒。用一个解密时返回超大明文的 fake 触发。 + """ + + class _InflatingSecurity(_FakeSecurity): + def decrypt(self, ciphertext, *, context=None, aad=b""): + return b"y" * 100 # 远超 max_plaintext_bytes=4 + + fs, inner, fake = _fs(tmp_path, security=_InflatingSecurity()) + fs._max_plaintext_bytes = 4 + # 先正常写入一个小文件 + ref = fs.insert(_ALICE, "ok.bin", io.BytesIO(b"ok")) + # 读取时解密返回 100 字节,应被解密后复核拒 + with pytest.raises(ValidationError): + fs.get(_ALICE, ref) + + +def test_encrypted_fs_store_byte_by_byte_stream_does_not_amplify_memory(tmp_path) -> None: + """验收第三次 P2-2:1-byte 短读不按 chunk 数线性增长内存。 + + 此前 list[bytes] + join 会为百万级 1-byte 分片造出 ~700 MiB / 8MiB 内容。 + bytearray 累积使内存与字节数成正比。用小尺寸(8 KiB + 1-byte 读)验证不放大: + 断言峰值增量与内容字节数同量级,而非百倍。 + """ + import tracemalloc + + fs, inner, fake = _fs(tmp_path) + fs._max_plaintext_bytes = 8 * 1024 # 8 KiB,足够看出放大比、不压 CI + + class _ByteByByte(io.BytesIO): + def read(self, n=-1): + if n is None or n < 0: + return super().read() + return super().read(1) + + data = b"x" * (8 * 1024) + tracemalloc.start() + ref = fs.insert(_ALICE, "frag.bin", _ByteByByte(data)) + cur, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + # bytearray 累积:峰值与内容同量级(8 KiB),不应是百倍放大 + assert peak < len(data) * 20, f"内存放大 {peak / len(data):.1f}x,疑似 list 累积" + with fs.get(_ALICE, ref) as fh: + assert fh.read() == data + + +def test_encrypted_fs_store_factory_accepts_max_plaintext_bytes(tmp_path) -> None: + """factory 读取 max_plaintext_bytes 配置;非法值在装配期炸。""" + Factory.reset_all() + ctx = AssemblyContext.from_dict( + { + "security": {"default": "fake_encrypted_fs"}, + "fs_store": { + "raw": {"target": "local", "params": {"root": str(tmp_path / "files")}}, + "default": { + "target": "encrypted", + "params": {"inner": "raw", "security": "default", "max_plaintext_bytes": 8}, + }, + }, + } + ) + fs = FsProducer.build_named("default", ctx) + assert isinstance(fs, EncryptedFSStore) + assert fs._max_plaintext_bytes == 8 + + # 非法值:装配期炸。reset_all 避开上一次 build 的实例缓存。 + Factory.reset_all() + ctx_bad = AssemblyContext.from_dict( + { + "security": {"default": "fake_encrypted_fs"}, + "fs_store": { + "default": { + "target": "encrypted", + "params": {"security": "default", "max_plaintext_bytes": 0}, + } + }, + } + ) + with pytest.raises(ValidationError): + FsProducer.build_named("default", ctx_bad) diff --git a/uv.lock b/uv.lock index afa887b8..365bac17 100644 --- a/uv.lock +++ b/uv.lock @@ -203,6 +203,49 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -1366,6 +1409,9 @@ nlp = [ { name = "hanlp" }, { name = "spacy" }, ] +security = [ + { name = "argon2-cffi" }, +] [package.dev-dependencies] dev = [ @@ -1376,6 +1422,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "argon2-cffi", marker = "extra == 'security'", specifier = ">=23.1" }, { name = "cryptography", specifier = ">=42" }, { name = "elasticsearch", marker = "extra == 'deploy'", specifier = ">=8,<9" }, { name = "flagembedding", marker = "extra == 'embed'", specifier = ">=1.2" }, @@ -1396,7 +1443,7 @@ requires-dist = [ { name = "torch", marker = "extra == 'embed'", specifier = ">=2.0" }, { name = "transformers", marker = "extra == 'embed'", specifier = ">=4.39,<5" }, ] -provides-extras = ["dev", "nlp", "embed", "deploy", "mcp"] +provides-extras = ["dev", "nlp", "embed", "deploy", "mcp", "security"] [package.metadata.requires-dev] dev = [