仓库权限除 metadata 外全部拉到最高档(contents/pull_requests/ issues/actions/checks/workflows/administration 等全部 write, members 只读),事件补齐 push/check_run/workflow_run 等; 旧权限 contents/pull_requests write + issues read 不再是上限。 风险明示:administration=write 含删库/转移,secrets=write 含管理 secret,配 All repositories 时影响面为全账号仓库。 注意:改代码只改以后新建 App 的 manifest,线上 App (id 5476837) 仍需在 GitHub 设置里改权限并接受新权限。
738 lines
28 KiB
Python
738 lines
28 KiB
Python
"""GitHub App 一键安装:manifest 建 App + 安装记录 + App webhook。
|
||
|
||
范围(诚实边界):本模块只做“安装”这一半——manifest 生成、
|
||
建 App 回调换凭证、安装事件记录、App webhook 验签。App 的
|
||
pem 虽然存下来了,但本模块不用它签 JWT、不用它换 installation
|
||
token:推分支、建 PR 目前仍走 gh + PAT(见 cloud/pr.py),
|
||
App-token API 动作是下一阶段,不在本模块假装已做。
|
||
|
||
流程(GitHub manifest 流程,见 GitHub 文档):
|
||
POST /github-app/setup/state(持本站 key)-> state + setup_url
|
||
-> 用户打开 setup 页(表单含 manifest JSON + state)点提交
|
||
-> GitHub 建 App 页(用户确认 Create)
|
||
-> GitHub 跳回 /github-app/callback?code=...&state=...
|
||
-> server 验 setup state(一次性、1 小时过期)-> 换 code
|
||
(POST api.github.com/app-manifests/{code}/conversions,
|
||
无需认证,1 小时内有效)-> 存 App 配置
|
||
-> 成功页给安装链接(https://github.com/apps/<slug>/
|
||
installations/new),用户点安装 -> installation 事件
|
||
到 /webhooks/github -> 记安装记录。
|
||
|
||
凭证存放:App 配置存数据库 github_app_config 表(单行),
|
||
不进仓库、不进日志、不出现在任何 API 响应里;webhook_secret
|
||
与 pem 只在 server 进程内读(验签用 secret,JWT 阶段用 pem)。
|
||
与 env 里 GITHUB_WEBHOOK_SECRET 的关系:手动 webhook 仍用
|
||
env secret,App webhook 用库里 secret,验签时两个都试(过渡
|
||
期共存);App 装好后应删掉手动 webhook,避免同一事件送两
|
||
遍(/fix-review 另有内容去重兜底,见 claim_fix_review)。
|
||
|
||
零第三方依赖,只用 Python 标准库;换 code 与 userinfo 均可
|
||
注入 fake http 客户端做单测。
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import secrets
|
||
import time
|
||
import urllib.request
|
||
from cloud.db import connect as _db_connect
|
||
|
||
# setup state 有效期:与 manifest code 的 1 小时对齐。
|
||
SETUP_STATE_TTL_SECONDS = 3600
|
||
|
||
# manifest 默认值:App 名与主页;hook / 回调地址由部署的公网
|
||
# 域名(GITHUB_APP_PUBLIC_BASE)拼出,不硬编码 diante.me。
|
||
DEFAULT_APP_NAME = "codex-app-server"
|
||
DEFAULT_APP_URL = "https://github.com/openqj/codex-app-server"
|
||
|
||
# App 权限与事件:主机最大权限(用户 2026-10-27 明确要求扩到
|
||
# 最高、所有能力)。这是 manifest 的默认值,只影响以后新建的
|
||
# App;已存在的线上 App(id 5476837)不会因改代码自动变权限,
|
||
# 必须在 GitHub App 设置里改权限并由安装方接受新权限后才生效。
|
||
# 风险明示:administration=write 含改仓库设置/删库/转移,
|
||
# secrets=write 含管理仓库 secret,配 All repositories 安装会把
|
||
# 影响面扩到该账号全部仓库——只装在自己信任的账号上。
|
||
# organization/enterprise/billing 类权限不在此列:它们不属于
|
||
# 主机跑任务的能力,且需组织/企业管理员另行审批,不应由一个
|
||
# 代码托管 App 默认索取。
|
||
APP_PERMISSIONS = {
|
||
# 仓库:除 metadata(GitHub 自动只读)外全部拉到最高档。
|
||
"actions": "write",
|
||
"actions_variables": "write",
|
||
"administration": "write",
|
||
"artifact_metadata": "write",
|
||
"attestations": "write",
|
||
"checks": "write",
|
||
"codespaces": "write",
|
||
"codespaces_metadata": "read",
|
||
"codespaces_secrets": "write",
|
||
"contents": "write",
|
||
"dependabot_secrets": "write",
|
||
"deployments": "write",
|
||
"discussions": "write",
|
||
"environments": "write",
|
||
"issues": "write",
|
||
"merge_queues": "write",
|
||
"packages": "write",
|
||
"pages": "write",
|
||
"pull_requests": "write",
|
||
"repository_advisories": "write",
|
||
"repository_custom_properties": "write",
|
||
"repository_hooks": "write",
|
||
"secret_scanning_alerts": "write",
|
||
"secrets": "write",
|
||
"security_events": "write",
|
||
"statuses": "write",
|
||
"vulnerability_alerts": "write",
|
||
"workflows": "write",
|
||
# 组织:只读成员,用于列协作者;不索取组织管理权。
|
||
"members": "read",
|
||
}
|
||
APP_EVENTS = [
|
||
"push",
|
||
"create",
|
||
"delete",
|
||
"pull_request",
|
||
"pull_request_review",
|
||
"pull_request_review_comment",
|
||
"issue_comment",
|
||
"issues",
|
||
"check_run",
|
||
"check_suite",
|
||
"workflow_run",
|
||
"release",
|
||
"deployment",
|
||
"deployment_status",
|
||
"discussion",
|
||
]
|
||
|
||
|
||
class AppError(Exception):
|
||
"""App 流程失败:换 code 失败、响应缺字段等。
|
||
|
||
reason 取值:``exchange_failed`` / ``config_incomplete`` /
|
||
``state_invalid`` / ``state_expired``。
|
||
"""
|
||
|
||
def __init__(self, message, reason=None):
|
||
super().__init__(message)
|
||
self.reason = reason
|
||
|
||
|
||
def build_app_manifest(public_base, app_name=DEFAULT_APP_NAME,
|
||
app_url=DEFAULT_APP_URL, public=False):
|
||
"""拼 manifest dict:GitHub 建 App 表单直接提交这份 JSON。
|
||
|
||
public=False 是单租户部署的默认(只有 App 所有者能安装);
|
||
多租户 SaaS 形态才需要 public=True(改环境变量翻转)。
|
||
"""
|
||
if not public_base or not isinstance(public_base, str):
|
||
raise ValueError("public_base required")
|
||
base = public_base.rstrip("/")
|
||
return {
|
||
"name": app_name,
|
||
"url": app_url,
|
||
"hook_attributes": {"url": base + "/webhooks/github"},
|
||
"redirect_url": base + "/github-app/callback",
|
||
"callback_urls": [base + "/github-app/callback"],
|
||
"public": bool(public),
|
||
"default_permissions": dict(APP_PERMISSIONS),
|
||
"default_events": list(APP_EVENTS),
|
||
}
|
||
|
||
|
||
class SetupStateStore:
|
||
"""setup state 存储:一次性、1 小时过期,防回调被冒用。
|
||
|
||
表 github_app_setup_states:state(主键)、created_at、
|
||
issued_by(签发人用户 id,P0 安全批新增)。新表建表即
|
||
含该列;老库在 __init__ 里用 ALTER 补列(列已存在时
|
||
报错直接忽略,老库不炸,见 except 分支)。
|
||
state 只由全局管理员调 POST /github-app/setup/state
|
||
签发;回调验到即删,同一 state 第二次回调必然失败。
|
||
"""
|
||
|
||
def __init__(self, db_path):
|
||
self.db_path = str(db_path)
|
||
self.conn = _db_connect(self.db_path)
|
||
self.conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS github_app_setup_states("
|
||
"state TEXT PRIMARY KEY, created_at REAL,"
|
||
" issued_by TEXT)"
|
||
)
|
||
self.conn.commit()
|
||
try:
|
||
self.conn.execute(
|
||
"ALTER TABLE github_app_setup_states"
|
||
" ADD COLUMN issued_by TEXT")
|
||
self.conn.commit()
|
||
except Exception:
|
||
try:
|
||
self.conn.rollback()
|
||
except Exception:
|
||
pass
|
||
|
||
def create_state(self, issued_by=None):
|
||
state = secrets.token_urlsafe(32)
|
||
self.conn.execute(
|
||
"INSERT INTO github_app_setup_states"
|
||
"(state, created_at, issued_by)"
|
||
" VALUES(?, ?, ?)",
|
||
(state, time.time(), issued_by),
|
||
)
|
||
self.conn.commit()
|
||
return state
|
||
|
||
def state_issuer(self, state):
|
||
"""查 state 的签发人(回调审计用,不消耗 state)。"""
|
||
if not state:
|
||
return None
|
||
try:
|
||
row = self.conn.execute(
|
||
"SELECT issued_by FROM github_app_setup_states"
|
||
" WHERE state = ?",
|
||
(state,),
|
||
).fetchone()
|
||
except Exception:
|
||
return None
|
||
if row is None:
|
||
return None
|
||
try:
|
||
return row[0]
|
||
except Exception:
|
||
return None
|
||
|
||
def consume_state(self, state, now=None,
|
||
ttl_seconds=SETUP_STATE_TTL_SECONDS,
|
||
allowed_issuers=None):
|
||
"""校验并消耗 state:成功返回 True,失败返回 False。
|
||
|
||
不存在 -> False;过期 -> 删行并返回 False;成功 -> 删行
|
||
并返回 True。``now`` 只为单测注入时间。
|
||
P0 安全批后:allowed_issuers 非空时,签发人不在该
|
||
集合里一律返回 False 且不消耗 state(fail-closed);
|
||
签发人为空(升级前的旧 state)同样不在集合里,等
|
||
于升级后旧 state 全部作废,需管理员重新签发。注意:
|
||
回调本身是浏览器无认证重定向,服务器无法从回调请求
|
||
里认出调用方就是签发人,所以这里核对的是“签发人仍
|
||
在管理员清单里”,不是“回调者就是签发人”——后者
|
||
在本流程里做不到,文档与 PR 描述里必须如实写清,
|
||
不能谎称回调做了身份认证。
|
||
"""
|
||
if not state:
|
||
return False
|
||
if now is None:
|
||
now = time.time()
|
||
row = self.conn.execute(
|
||
"SELECT created_at, issued_by FROM github_app_setup_states"
|
||
" WHERE state = ?",
|
||
(state,),
|
||
).fetchone()
|
||
if row is None:
|
||
return False
|
||
if allowed_issuers is not None:
|
||
try:
|
||
issuer = row[1]
|
||
except Exception:
|
||
issuer = None
|
||
if issuer not in allowed_issuers:
|
||
return False
|
||
self.conn.execute(
|
||
"DELETE FROM github_app_setup_states WHERE state = ?",
|
||
(state,),
|
||
)
|
||
self.conn.commit()
|
||
if now > row[0] + ttl_seconds:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _urllib_post_json(url, headers):
|
||
"""默认的换 code 实现:POST 空 body,返回解析后的 dict。"""
|
||
request = urllib.request.Request(url, data=b"", method="POST",
|
||
headers=headers)
|
||
with urllib.request.urlopen(request, timeout=30) as response:
|
||
raw = response.read().decode("utf-8", "replace")
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except ValueError as exc:
|
||
raise AppError("manifest conversion response is not JSON: %s"
|
||
% (exc,), reason="exchange_failed") from exc
|
||
if not isinstance(parsed, dict):
|
||
raise AppError("manifest conversion response is not an object",
|
||
reason="exchange_failed")
|
||
return parsed
|
||
|
||
|
||
def exchange_manifest_code(code, http_client=None):
|
||
"""用 manifest code 换 App 配置:返回 {id, slug, client_id,
|
||
webhook_secret, pem, html_url}(缺关键字段即 AppError)。
|
||
|
||
http_client 注入约定(单测用):``http_client(url, headers)
|
||
-> dict``。真实路径无需认证(code 本身就是一次性凭证),
|
||
1 小时内必须完成。
|
||
"""
|
||
if not code or not isinstance(code, str):
|
||
raise AppError("code required", reason="exchange_failed")
|
||
url = "https://api.github.com/app-manifests/%s/conversions" % code
|
||
headers = {"Accept": "application/vnd.github+json",
|
||
"User-Agent": "codex-app-server"}
|
||
client = http_client if http_client is not None else _urllib_post_json
|
||
try:
|
||
result = client(url, headers)
|
||
except AppError:
|
||
raise
|
||
except Exception as exc:
|
||
raise AppError(
|
||
"manifest conversion failed: %s" % (exc,),
|
||
reason="exchange_failed",
|
||
) from exc
|
||
if not isinstance(result, dict) or result.get("error"):
|
||
raise AppError("manifest conversion failed: %r" % (result,),
|
||
reason="exchange_failed")
|
||
missing = [k for k in ("id", "webhook_secret", "pem")
|
||
if not result.get(k)]
|
||
if missing:
|
||
raise AppError(
|
||
"manifest conversion response missing: %s" % ",".join(missing),
|
||
reason="config_incomplete")
|
||
return {
|
||
"app_id": result.get("id"),
|
||
"slug": result.get("slug"),
|
||
"client_id": result.get("client_id"),
|
||
"webhook_secret": result.get("webhook_secret"),
|
||
"pem": result.get("pem"),
|
||
"html_url": result.get("html_url"),
|
||
}
|
||
|
||
|
||
def save_app_config(conn, config):
|
||
"""存 App 配置(单行,id 恒为 1;重复 setup 直接覆盖)。
|
||
|
||
调用方须保证 config 含 webhook_secret 与 pem;本函数不校验
|
||
它们的值,只负责落库。secret/pem 绝不出现在返回值里。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS github_app_config("
|
||
"id INTEGER PRIMARY KEY CHECK(id = 1), "
|
||
"app_id TEXT, slug TEXT, client_id TEXT, "
|
||
"webhook_secret TEXT, pem TEXT, html_url TEXT, created_at REAL)"
|
||
)
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO github_app_config(id, app_id, slug,"
|
||
" client_id, webhook_secret, pem, html_url, created_at)"
|
||
" VALUES(1, ?, ?, ?, ?, ?, ?, ?)",
|
||
(config.get("app_id"), config.get("slug"),
|
||
config.get("client_id"), config.get("webhook_secret"),
|
||
config.get("pem"), config.get("html_url"), time.time()),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def get_app_config(conn):
|
||
"""取 App 配置 dict,未配置返回 None。
|
||
|
||
返回里含 webhook_secret 与 pem——调用方只许在 server 进程内
|
||
用(验签 / JWT),绝不放进任何 API 响应;公开状态接口须
|
||
另外拼不含 secret 的视图。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS github_app_config("
|
||
"id INTEGER PRIMARY KEY CHECK(id = 1), "
|
||
"app_id TEXT, slug TEXT, client_id TEXT, "
|
||
"webhook_secret TEXT, pem TEXT, html_url TEXT, created_at REAL)"
|
||
)
|
||
row = conn.execute(
|
||
"SELECT app_id, slug, client_id, webhook_secret, pem, html_url,"
|
||
" created_at FROM github_app_config WHERE id = 1",
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return {
|
||
"app_id": row[0], "slug": row[1], "client_id": row[2],
|
||
"webhook_secret": row[3], "pem": row[4], "html_url": row[5],
|
||
"created_at": row[6],
|
||
}
|
||
|
||
|
||
def public_app_status(conn):
|
||
"""公开状态视图:不含 secret/pem,只给配置与否 + 身份信息。"""
|
||
config = get_app_config(conn)
|
||
if config is None:
|
||
return {"configured": False, "app_id": None, "slug": None,
|
||
"html_url": None}
|
||
return {"configured": True, "app_id": config["app_id"],
|
||
"slug": config["slug"], "html_url": config["html_url"]}
|
||
|
||
|
||
def _ensure_installations_table(conn):
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS github_app_installations("
|
||
"installation_id TEXT, account_login TEXT, "
|
||
"repo_full_name TEXT, created_at REAL, "
|
||
"UNIQUE(installation_id, repo_full_name))"
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def record_installation_repos(conn, installation_id, account_login, repos):
|
||
"""记安装的仓库行(按 (installation_id, repo) 去重,多次
|
||
同步不翻倍)。repos 为 full_name 字符串列表。"""
|
||
_ensure_installations_table(conn)
|
||
now = time.time()
|
||
for repo in repos or []:
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO github_app_installations("
|
||
"installation_id, account_login, repo_full_name, created_at)"
|
||
" VALUES(?, ?, ?, ?)",
|
||
(str(installation_id), account_login, repo, now),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def remove_installation_repos(conn, installation_id, repos):
|
||
"""删安装的部分仓库行(removed 事件用)。"""
|
||
_ensure_installations_table(conn)
|
||
for repo in repos or []:
|
||
conn.execute(
|
||
"DELETE FROM github_app_installations"
|
||
" WHERE installation_id = ? AND repo_full_name = ?",
|
||
(str(installation_id), repo),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def remove_installation(conn, installation_id):
|
||
"""删整个安装的全部仓库行(deleted / suspended 后的清理用,
|
||
返回删掉的行数)。"""
|
||
_ensure_installations_table(conn)
|
||
cur = conn.execute(
|
||
"DELETE FROM github_app_installations WHERE installation_id = ?",
|
||
(str(installation_id),),
|
||
)
|
||
conn.commit()
|
||
return cur.rowcount
|
||
|
||
|
||
def list_installations(conn):
|
||
"""列出全部安装行(按 installation_id / repo 排序)。"""
|
||
_ensure_installations_table(conn)
|
||
rows = conn.execute(
|
||
"SELECT installation_id, account_login, repo_full_name, created_at"
|
||
" FROM github_app_installations"
|
||
" ORDER BY installation_id, repo_full_name",
|
||
).fetchall()
|
||
return [{"installation_id": r[0], "account_login": r[1],
|
||
"repo_full_name": r[2], "created_at": r[3]} for r in rows]
|
||
|
||
|
||
def parse_installation_event(payload):
|
||
"""解析 installation 事件,返回动作 dict 或 None。
|
||
|
||
只认 action 在 created / deleted / added / removed /
|
||
new_permissions_accepted 且带 installation 的 payload;其它
|
||
事件返回 None(调用方继续走原有解析)。repositories 取
|
||
full_name:created 用 payload repositories,added 用
|
||
repositories_added,removed 用 repositories_removed。
|
||
"""
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
action = payload.get("action")
|
||
if action not in ("created", "deleted", "added", "removed",
|
||
"new_permissions_accepted"):
|
||
return None
|
||
installation = payload.get("installation")
|
||
if not isinstance(installation, dict):
|
||
return None
|
||
account = installation.get("account") or {}
|
||
|
||
def names(key):
|
||
out = []
|
||
for repo in payload.get(key) or []:
|
||
if isinstance(repo, dict) and repo.get("full_name"):
|
||
out.append(repo["full_name"])
|
||
return out
|
||
|
||
if action == "added":
|
||
repos = names("repositories_added")
|
||
elif action == "removed":
|
||
repos = names("repositories_removed")
|
||
elif action == "deleted":
|
||
repos = []
|
||
else:
|
||
repos = names("repositories")
|
||
return {
|
||
"action": action,
|
||
"installation_id": installation.get("id"),
|
||
"account_login": account.get("login"),
|
||
"repos": repos,
|
||
}
|
||
|
||
|
||
def claim_fix_review(conn, repo, pr_number, comment_id):
|
||
""" /fix-review 内容去重:同一仓库 + PR + 评论只建一次任务。
|
||
|
||
手动 webhook 与 App webhook 共存时,同一评论会送两遍,
|
||
delivery_id 不同、内容相同——delivery 去重拦不住,内容去重
|
||
才能拦。返回 (is_new, task_id):首次认领 True;已认领过
|
||
返回 False 与当初建的 task_id(可能为 None:认领方还没来
|
||
得及把 task_id 写回,任务正在建)。comment_id 为 None 时
|
||
无法去重,直接放行(不阻塞合法任务)。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS github_fix_review_dedup("
|
||
"repo TEXT, pr_number INTEGER, comment_id TEXT, "
|
||
"task_id TEXT, created_at REAL, "
|
||
"UNIQUE(repo, pr_number, comment_id))"
|
||
)
|
||
if comment_id is None:
|
||
return True, None
|
||
cur = conn.execute(
|
||
"INSERT OR IGNORE INTO github_fix_review_dedup("
|
||
"repo, pr_number, comment_id, task_id, created_at)"
|
||
" VALUES(?, ?, ?, NULL, ?)",
|
||
(repo, pr_number, str(comment_id), time.time()),
|
||
)
|
||
conn.commit()
|
||
if cur.rowcount > 0:
|
||
return True, None
|
||
row = conn.execute(
|
||
"SELECT task_id FROM github_fix_review_dedup"
|
||
" WHERE repo = ? AND pr_number = ? AND comment_id = ?",
|
||
(repo, pr_number, str(comment_id)),
|
||
).fetchone()
|
||
return False, row[0] if row else None
|
||
|
||
|
||
def set_fix_review_task_id(conn, repo, pr_number, comment_id, task_id):
|
||
"""认领后把 task_id 写回内容去重行(供重复送达时回读)。"""
|
||
if comment_id is None:
|
||
return
|
||
conn.execute(
|
||
"UPDATE github_fix_review_dedup SET task_id = ?"
|
||
" WHERE repo = ? AND pr_number = ? AND comment_id = ?",
|
||
(task_id, repo, pr_number, str(comment_id)),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
# -- App-token 阶段:JWT 与 installation token ---------------------------
|
||
# 纯标准库 RS256:解析 PEM(PKCS#1 / PKCS#8)取 (n, e, d),
|
||
# PKCS#1 v1.5 + SHA-256 签名。不依赖 openssl CLI、不落临时文件;
|
||
# JWT 与 installation token 只在进程内出现,绝不打印、不进日志。
|
||
# GitHub App 的 pem 格式:manifest 换回的通常是 PKCS#1
|
||
#("BEGIN RSA PRIVATE KEY"),openssl 3 默认生成的是 PKCS#8
|
||
#("BEGIN PRIVATE KEY"),两者都支持。
|
||
|
||
import base64 as _base64
|
||
|
||
_SHA256_DIGESTINFO_PREFIX = bytes.fromhex(
|
||
"3031300d060960864801650304020105000420")
|
||
|
||
|
||
def _b64url(data):
|
||
if isinstance(data, str):
|
||
data = data.encode("utf-8")
|
||
return _base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||
|
||
|
||
def _der_read(data, pos):
|
||
"""读一个 DER TLV:返回 (tag, value, next_pos)。"""
|
||
tag = data[pos]
|
||
pos += 1
|
||
length = data[pos]
|
||
pos += 1
|
||
if length & 0x80:
|
||
nbytes = length & 0x7F
|
||
if nbytes == 0 or nbytes > 4:
|
||
raise AppError("unsupported DER length",
|
||
reason="bad_key")
|
||
length = int.from_bytes(data[pos:pos + nbytes], "big")
|
||
pos += nbytes
|
||
return tag, data[pos:pos + length], pos + length
|
||
|
||
|
||
def _der_sequence_items(der):
|
||
"""SEQUENCE 的子项列表(每项为 (tag, value))。"""
|
||
tag, value, _ = _der_read(der, 0)
|
||
if tag != 0x30:
|
||
raise AppError("expected DER SEQUENCE", reason="bad_key")
|
||
items = []
|
||
pos = 0
|
||
while pos < len(value):
|
||
t, v, pos = _der_read(value, pos)
|
||
items.append((t, v))
|
||
return items
|
||
|
||
|
||
def _parse_pkcs1_der(der):
|
||
"""PKCS#1 RSAPrivateKey DER -> (n, e, d)。"""
|
||
items = _der_sequence_items(der)
|
||
if len(items) < 4:
|
||
raise AppError("short PKCS#1 key", reason="bad_key")
|
||
n = int.from_bytes(items[1][1], "big")
|
||
e = int.from_bytes(items[2][1], "big")
|
||
d = int.from_bytes(items[3][1], "big")
|
||
if n <= 0 or e <= 0 or d <= 0:
|
||
raise AppError("invalid RSA parameters", reason="bad_key")
|
||
return n, e, d
|
||
|
||
|
||
def _parse_rsa_private_key(pem):
|
||
"""解析 PEM 私钥 -> (n, e, d),PKCS#1 与 PKCS#8 都支持。"""
|
||
if not pem or not isinstance(pem, str):
|
||
raise AppError("private key required", reason="bad_key")
|
||
body = "".join(
|
||
line.strip() for line in pem.splitlines()
|
||
if line.strip() and not line.strip().startswith("-----"))
|
||
try:
|
||
der = _base64.b64decode(body)
|
||
except Exception as exc:
|
||
raise AppError("private key is not valid base64: %s" % (exc,),
|
||
reason="bad_key") from exc
|
||
if "RSA PRIVATE KEY" in pem:
|
||
return _parse_pkcs1_der(der)
|
||
if "PRIVATE KEY" in pem:
|
||
items = _der_sequence_items(der)
|
||
if len(items) < 3 or items[2][0] != 0x04:
|
||
raise AppError("unexpected PKCS#8 shape", reason="bad_key")
|
||
return _parse_pkcs1_der(items[2][1])
|
||
raise AppError("unsupported private key format", reason="bad_key")
|
||
|
||
|
||
def rsa_sign_sha256(pem, message):
|
||
"""RS256 签名:返回签名 bytes(长度 = 模长字节数)。"""
|
||
n, _e, d = _parse_rsa_private_key(pem)
|
||
if isinstance(message, str):
|
||
message = message.encode("utf-8")
|
||
digest = hashlib.sha256(message).digest()
|
||
t = _SHA256_DIGESTINFO_PREFIX + digest
|
||
em_len = (n.bit_length() + 7) // 8
|
||
if em_len < len(t) + 11:
|
||
raise AppError("RSA key too small for SHA-256", reason="bad_key")
|
||
em = b"\x00\x01" + b"\xff" * (em_len - len(t) - 3) + b"\x00" + t
|
||
m = int.from_bytes(em, "big")
|
||
s = pow(m, d, n)
|
||
return s.to_bytes(em_len, "big")
|
||
|
||
|
||
def mint_app_jwt(app_id, pem, now=None, ttl_seconds=540):
|
||
"""签发 App JWT:header alg=RS256,有效期默认 9 分钟
|
||
(GitHub 上限 10 分钟,iat 回拨 60 秒防时钟 skew)。
|
||
|
||
now 只为单测注入时间。pem 非法时抛 AppError(调用方据此
|
||
回落 PAT,不静默用坏 token 去撞 GitHub)。
|
||
"""
|
||
if app_id is None or str(app_id) == "":
|
||
raise AppError("app_id required", reason="bad_key")
|
||
if now is None:
|
||
now = time.time()
|
||
header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"},
|
||
separators=(",", ":")))
|
||
payload = _b64url(json.dumps(
|
||
{"iat": int(now) - 60, "exp": int(now) + int(ttl_seconds),
|
||
"iss": str(app_id)},
|
||
separators=(",", ":")))
|
||
signing_input = header + "." + payload
|
||
signature = rsa_sign_sha256(pem, signing_input.encode("ascii"))
|
||
return signing_input + "." + _b64url(signature)
|
||
|
||
|
||
def _github_api_request(method, url, token, payload, http_client):
|
||
"""调 GitHub API:返回 (status, dict)。
|
||
|
||
http_client 注入约定(单测用):``http_client(method, url,
|
||
headers, body_bytes_or_None) -> (status, dict)``。真实路径
|
||
用 urllib,HTTPError 也解析 body(GitHub 的错误体是 JSON,
|
||
调用方按 status 判断,不靠异常)。
|
||
"""
|
||
headers = {"Accept": "application/vnd.github+json",
|
||
"User-Agent": "codex-app-server"}
|
||
if token:
|
||
headers["Authorization"] = "Bearer " + token
|
||
body = None
|
||
if payload is not None:
|
||
body = json.dumps(payload).encode("utf-8")
|
||
if http_client is not None:
|
||
result = http_client(method, url, headers, body)
|
||
if not (isinstance(result, tuple) and len(result) == 2):
|
||
raise AppError("http_client must return (status, dict)",
|
||
reason="token_exchange_failed")
|
||
return result
|
||
request = urllib.request.Request(url, data=body, method=method,
|
||
headers=headers)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=30) as response:
|
||
raw = response.read().decode("utf-8", "replace")
|
||
status = response.status
|
||
except urllib.error.HTTPError as e:
|
||
raw = e.read().decode("utf-8", "replace")
|
||
status = e.code
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except ValueError:
|
||
parsed = {}
|
||
if not isinstance(parsed, dict):
|
||
parsed = {}
|
||
return status, parsed
|
||
|
||
|
||
def exchange_installation_token(app_id, pem, installation_id,
|
||
http_client=None, now=None):
|
||
"""换 installation token:返回 {token, expires_at, permissions,
|
||
repository_selection}。token 有效期 1 小时,调用方现换现用,
|
||
不缓存、不存库。换失败抛 AppError(调用方回落 PAT)。"""
|
||
if not installation_id:
|
||
raise AppError("installation_id required",
|
||
reason="token_exchange_failed")
|
||
jwt = mint_app_jwt(app_id, pem, now=now)
|
||
url = ("https://api.github.com/app/installations/%s/access_tokens"
|
||
% installation_id)
|
||
status, data = _github_api_request("POST", url, jwt, None, http_client)
|
||
if status != 201 or not data.get("token"):
|
||
raise AppError(
|
||
"installation token exchange failed: %s %r"
|
||
% (status, data.get("message")),
|
||
reason="token_exchange_failed")
|
||
return {"token": data["token"], "expires_at": data.get("expires_at"),
|
||
"permissions": data.get("permissions"),
|
||
"repository_selection": data.get("repository_selection")}
|
||
|
||
|
||
def find_installation_for_repo(conn, repo_full_name):
|
||
"""查仓库对应的 App installation id:无记录返回 None
|
||
(调用方回落 PAT,不报错——没装 App 的仓库走老路)。"""
|
||
if not repo_full_name:
|
||
return None
|
||
_ensure_installations_table(conn)
|
||
row = conn.execute(
|
||
"SELECT installation_id FROM github_app_installations"
|
||
" WHERE repo_full_name = ? ORDER BY created_at LIMIT 1",
|
||
(repo_full_name,),
|
||
).fetchone()
|
||
return row[0] if row else None
|
||
|
||
|
||
def resolve_app_token(conn, repo_full_name, http_client=None):
|
||
"""给仓库解析 App installation token:成功返回 token 字符串,
|
||
任一步缺失或失败返回 None(调用方回落 PAT,不抛错)。
|
||
|
||
步骤:App 配置(库里)-> 安装记录(仓库名查 installation
|
||
id)-> JWT -> 换 token。token 与 JWT 只在进程内出现。
|
||
"""
|
||
try:
|
||
config = get_app_config(conn)
|
||
if config is None or not config.get("pem"):
|
||
return None
|
||
installation_id = find_installation_for_repo(conn, repo_full_name)
|
||
if installation_id is None:
|
||
return None
|
||
result = exchange_installation_token(
|
||
config.get("app_id"), config.get("pem"), installation_id,
|
||
http_client=http_client)
|
||
return result["token"]
|
||
except AppError:
|
||
return None
|
||
except Exception:
|
||
return None
|