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 清单
This commit is contained in:
host committed 2026-11-06 21:21:55 +00:00
1 parent 06c3e172fe
commit a90cf78d84
15 files changed
+2370 -93

No files matched your search

+311
View File
@@ -0,0 +1,311 @@
"""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()