- 新测试 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 清单
312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""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()
|