Files
codex-app-server/cloud/chv/authd.py
T
host a90cf78d84 feat(chv): 对齐 Muse P3 收尾——authd 替身凭证 + Sentinel 出口审批
- 新测试 cloud/tests/test_chv_muse_p3.py 30 项:替身过期、真 token
  泄露面、SSRF 挡截、deny/ask 路径、authd-seed 拆分、wake 预审、
  客内 authd 守护进程、git credential helper 端到端(真 git fill)
- 8 个旧测试按新 authd 行为改写(未删测试、未放宽断言):
  GITEA_TOKEN 改走 authd-seed,build_ssh_command 不再 source 进
  codex 环境;wake 测试补 Sentinel 白名单 + 假解析器
- 7 处超 50 行函数拆到 50 行内(不改行为);补 authd/sentinel
  公共 API 注解,mypy 在改动文件上干净
- 真问题修复:git 默认不把 path 发给 helper,chv-sync boot 加
  credential.useHttpPath=true,否则 authd 路径每次都拒绝
- 全量 cloud 套件 1897 项 OK(skipped=8)

真机待验证(未做,等第三棒让出 WSL 测试机):见
docs/chv-muse-p3-gap.md §3.6 清单
2026-11-06 21:21:55 +00:00

312 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""authd 式替身凭证服务(feat-chv-muse-p3,叶子模块)。
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
docs/chv-muse-p3-gap.md):凭证由 VM 内独立服务 authd 保管
(含 OAuth token,存于用户 VM 而非集中基础设施),代理只见
替身令牌;内置连接器的执行分离——每个 privsep worker 有明确
凭证白名单(日历 worker 不能仅改参数就索取邮件凭证);登录
凭证存入运行时单元之外的安全凭证存储,代理不可见,仅在需要
时注入。
本模块是纯逻辑(只用标准库,不依赖 cloud.*),宿主侧与客内
authd 守护进程共用同一份代码(见 chv-guest/authd.py):
- `CredentialVault`:只活内存的保险库;`store()` 只接受白名单
内的(连接器,凭证键);`release()` 按次发放,绑定
{连接器, 主机, 仓库路径, 操作},单次有效 + 300 秒 TTL,
过期即废;`__repr__` 与审计记录里绝无凭证值。
- 替身的两档(见差距表 §1):本期默认是“按次释放”——释放给
git 子进程的值仍是真 token,但只进 git 进程内存、不进 agent
环境/argv/日志/落盘;真铸 token(Gitea API 铸短期专用 token)
由 `mint_gitea_token()` 实现,默认关闭(须真 token 有建 token
权限,现有 write:repository 测试 token 铸 token 会失败)。
- 位置决策:authd 跑在客内、单元外(root 身份的 systemd 服务),
不在宿主侧——Muse 的 authd 就在用户 VM 里;宿主侧方案要走
vsock(P1 范围、未就绪),且会把全部 VM 的 token 集中到宿主。
"""
import hashlib
import hmac
import json
import secrets
import time
import urllib.error
import urllib.request
from collections.abc import Callable, Iterable
# 连接器白名单(privsep 口径):本期只认 gitea 一个连接器。
# 未知连接器一律拒绝,不得以“默认放行”扩展。
ALLOWED_CONNECTORS = frozenset({"gitea"})
# 每连接器的凭证白名单:连接器 → 允许的凭证键。gitea 只允许
# GITEA_TOKEN;其它键(哪怕调用方传进来)一律拒绝入库。
CONNECTOR_CREDENTIALS = {
"gitea": frozenset({"GITEA_TOKEN"}),
}
# 按次释放的默认有效期(秒)与次数:单次、300 秒,过期即废。
DEFAULT_RELEASE_TTL_SECONDS = 300
DEFAULT_RELEASE_MAX_USES = 1
class AuthdRefusal(Exception):
"""authd 拒绝:kind + 原因,不含任何凭证值。"""
def __init__(self, kind: str, message: str) -> None:
super().__init__("authd_refused:%s: %s" % (kind, message))
self.kind = kind
class SurrogateRelease:
"""一次按次释放(替身):绑定到一次具体的凭证使用。
release_id 是不透明 id(审计与吊销用,不含值);value 只在
释放对象里出现一次,由调用方(客内守护进程)经 Unix socket
交给 git credential helper、再由 helper 交给 git 子进程——
除此之外不进任何环境、argv、日志、审计。`consume()` 校验
绑定与有效期,单次有效:用过一次即废,过期即废。"""
def __init__(self, release_id: str, connector: str, key: str,
host: str, repo: str, operation: str, value: str,
expires_at: float, max_uses: int = 1) -> None:
self.release_id = release_id
self.connector = connector
self.key = key
self.host = host
self.repo = repo
self.operation = operation
self.value = value
self.expires_at = expires_at
self.uses_left = max_uses
def describe(self) -> dict:
"""审计用描述(只含绑定,不含值)。"""
return {"release_id": self.release_id,
"connector": self.connector, "key": self.key,
"host": self.host, "repo": self.repo,
"operation": self.operation,
"expires_at": self.expires_at,
"uses_left": self.uses_left}
def __repr__(self) -> str: # 绝不含 value
return ("SurrogateRelease(id=%s connector=%s host=%s "
"repo=%s operation=%s)"
% (self.release_id, self.connector, self.host,
self.repo, self.operation))
class CredentialVault:
"""只活内存的凭证保险库(一个 VM 一个实例,客内 authd 持有)。
- 值只存在 `self._store` 的内存 dict 里,不落盘、不进日志;
- `store()` 校验白名单:未知连接器 / 该连接器不允许的键 /
空值一律拒绝入库(AuthdRefusal),绝不静默收下;
- `release()` 按次发放:调用方须给出完整绑定(主机、仓库、
操作),绑定与入库时的作用域不一致即拒绝;
- `consume()` 单次兑现:release 兑现一次即从库里删除,过期
也兑现不了;兑现失败不留可重试的残值。
clock 可注入(测试用假时钟测过期),默认 time.time。"""
def __init__(self, clock: Callable[[], float] | None = None) -> None:
self._store: dict[tuple[str, str], dict] = {}
self._releases: dict[str, SurrogateRelease] = {}
self._clock = clock or time.time
def __repr__(self) -> str: # 绝不含任何凭证值
return "CredentialVault(connectors=%s)" % sorted(
{c for (c, _k) in self._store})
def store(self, connector: str, key: str, value: str,
host: str | None = None, repo: str | None = None) -> None:
"""入库:白名单校验 + 作用域记录(host/repo 是该凭证
允许使用的范围,release 时须一致)。"""
if connector not in ALLOWED_CONNECTORS:
raise AuthdRefusal(
"unknown_connector",
"连接器 %s 不在 authd 白名单内" % connector)
allowed = CONNECTOR_CREDENTIALS.get(connector, frozenset())
if key not in allowed:
raise AuthdRefusal(
"credential_not_allowlisted",
"连接器 %s 不允许凭证键 %s" % (connector, key))
if not isinstance(value, str) or not value.strip():
raise AuthdRefusal(
"empty_credential", "凭证值为空,拒绝入库")
self._store[(connector, key)] = {
"value": value, "host": host, "repo": repo}
def has(self, connector: str, key: str) -> bool:
"""该(连接器,键)是否已入库(不返回值)。"""
return (connector, key) in self._store
def forget(self, connector: str, key: str) -> bool:
"""删一条入库凭证(VM 停止时调用);同时作废其全部未兑现
释放。未知键返回 False,不抛错。"""
gone = self._store.pop((connector, key), None) is not None
if gone:
doomed = [rid for rid, rel in self._releases.items()
if rel.connector == connector
and rel.key == key]
for rid in doomed:
self._releases.pop(rid, None)
return gone
def release(self, connector: str, key: str, host: str, repo: str,
operation: str,
ttl_seconds: int = DEFAULT_RELEASE_TTL_SECONDS,
max_uses: int = DEFAULT_RELEASE_MAX_USES
) -> SurrogateRelease:
"""按次发放:绑定须与入库作用域一致(host/repo 有记录时
须一致,无记录时本次绑定即定作用域),否则拒绝。发放的
释放单次有效、ttl 后过期;发放本身记入库内待兑现表,
须经 consume() 兑现一次才交出值。"""
entry = self._store.get((connector, key))
if entry is None:
raise AuthdRefusal(
"credential_not_stored",
"连接器 %s 的凭证 %s 未入库" % (connector, key))
if entry["host"] is not None and entry["host"] != host:
raise AuthdRefusal(
"scope_mismatch",
"凭证作用域主机不一致,拒绝释放")
if entry["repo"] is not None and entry["repo"] != repo:
raise AuthdRefusal(
"scope_mismatch",
"凭证作用域仓库不一致,拒绝释放")
if operation not in ("clone", "pull", "push"):
raise AuthdRefusal(
"operation_not_allowed",
"操作 %s 不在允许集内" % operation)
release = SurrogateRelease(
release_id=secrets.token_hex(8),
connector=connector, key=key, host=host, repo=repo,
operation=operation, value=entry["value"],
expires_at=self._clock() + ttl_seconds,
max_uses=max_uses)
self._releases[release.release_id] = release
return release
def consume(self, release_id: str) -> str:
"""兑现一次释放:过期/用完/未知 id 一律拒绝(AuthdRefusal,
不含值)。兑现成功返回凭证值,并把该释放从库里删除
(单次有效,用过即废)。"""
release = self._releases.get(release_id)
if release is None:
raise AuthdRefusal(
"release_unknown", "释放记录不存在或已兑现")
if self._clock() >= release.expires_at:
self._releases.pop(release_id, None)
raise AuthdRefusal("release_expired", "释放已过期")
if release.uses_left <= 0:
self._releases.pop(release_id, None)
raise AuthdRefusal("release_exhausted", "释放已用完")
release.uses_left -= 1
value = release.value
self._releases.pop(release_id, None)
return value
def audit_release(self, release: SurrogateRelease) -> dict:
"""发放审计记录(只含绑定,不含值)。"""
record = release.describe()
record["event"] = "authd_credential_released"
return record
def audit_refusal(self, kind: str, connector: str,
key: str | None = None) -> dict:
"""拒绝审计记录(只含 kind 与名,不含值)。"""
return {"event": "authd_credential_refused", "kind": kind,
"connector": connector, "key": key}
def _default_post(url: str, headers: dict, payload: dict):
"""默认 HTTP 客户端(urllib):返回 (status, body_dict)。
只用于 mint_gitea_token,测试注入假 post,不联网。"""
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers,
method="POST")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status, json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read() or b"{}")
except ValueError:
body = {}
return exc.code, body
def mint_gitea_token(api_url: str, user: str, master_token: str,
name: str, scopes: Iterable[str] = ("write:repository",),
post: Callable[[str, dict, dict], tuple[int, dict]]
| None = None) -> str:
"""经 Gitea API 铸短期专用 token(真替身路径,默认关闭)。
调 `POST {api_url}/users/{user}/tokens`(Gitea 官方 API),
新 token 与 master token 是不同的值、可独立吊销(DELETE 同
路径),到期由调用方吊销。返回新 token 值;失败即
AuthdRefusal(不含任何 token 值)。
前提(见差距表 §3.2):master token 须有建 token 的权限;
现有 write:repository 测试 token 铸 token 会失败(403),
开这条路须另配有铸 token 权限的 token 并做真机验证。
post 可注入(测试用假 post),默认走 urllib。"""
if not api_url or not user or not master_token or not name:
raise AuthdRefusal(
"mint_args_missing", "铸 token 参数不全")
base = api_url.rstrip("/")
url = "%s/users/%s/tokens" % (base, user)
headers = {"Authorization": "token %s" % master_token,
"Content-Type": "application/json"}
payload = {"name": name, "scopes": list(scopes)}
post_fn = post or _default_post
status, body = post_fn(url, headers, payload)
if status not in (200, 201):
raise AuthdRefusal(
"mint_failed",
"Gitea 铸 token 失败:status=%s" % status)
token = (body or {}).get("sha1") or (body or {}).get("token")
if not token:
raise AuthdRefusal(
"mint_empty", "Gitea 铸 token 未返回 token 值")
return token
def revoke_gitea_token(api_url: str, user: str, master_token: str,
token_id: object,
delete: Callable[[str, dict], int] | None = None
) -> bool:
"""吊销一个铸出的 token(`DELETE {api}/users/{user}/tokens/{id}`)。
成功返回 True;404 视为已不在(也返回 True,不报错);
其它失败返回 False(调用方审计,不抛错——吊销失败不能炸任务,
但须留审计)。delete 可注入,默认走 urllib。"""
if not api_url or not user or not master_token \
or token_id is None:
return False
base = api_url.rstrip("/")
url = "%s/users/%s/tokens/%s" % (base, user, token_id)
headers = {"Authorization": "token %s" % master_token}
if delete is None:
def _do_delete(url: str, headers: dict) -> int:
req = urllib.request.Request(url, headers=headers,
method="DELETE")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status
except urllib.error.HTTPError as exc:
return exc.code
delete = _do_delete
status = delete(url, headers)
return status in (200, 204, 404)
def surrogate_fingerprint(value: str) -> str:
"""凭证值的指纹(审计/日志里唯一允许出现的凭证派生形式):
SHA-256 前 8 位 hex。指纹不可逆推出原值,审计里只记指纹、
不记值。"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:8]
def hmac_sign(key: str, message: str) -> str:
"""HMAC-SHA256 签名(释放记录的防篡改绑定,客内守护进程
用它签发 release_id 的校验码;与 Gitea 无关)。"""
return hmac.new(key.encode("utf-8"), message.encode("utf-8"),
hashlib.sha256).hexdigest()