"""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()