Files
group_fqcd_jr/tools/generate_jwt_keys.py
lzf_0626 c32d3dbd06 chore: 开发专用 JWT 密钥、密钥生成脚本与轮换文档
背景:此前全环境共用一把 JWT 密钥(config/jwt/jwt-private.pem)。它相当于
"能冒充 9001/9002/9003 的万能钥匙"(实测边界:签名有效 + 用户存在且启用才通过,
伪造新用户与使用禁用账号都会被拒)。为避免同一把密钥将来又变成生产密钥,
本次引入开发专用密钥,并把签发侧收敛到配置。

改动:
1. 新增 tools/generate_jwt_keys.py:可复现地生成 RS256 密钥对(PKCS#8 / SPKI),
   打印公钥 SHA-256 指纹便于核对服务端加载的是否同一把;密钥已存在时默认拒绝
   覆盖,避免误操作导致所有已签发令牌立即失效。
2. 生成开发专用密钥到 config/jwt/dev/(该目录整体已被 .gitignore 忽略,不入库)。
3. 三个工具脚本不再硬编码私钥路径,改为读配置:acceptance_check 与 demo_agent_e2e
   走 get_settings().jwt_private_key_path,smoke_check 因刻意不依赖 app 包而读
   JWT_PRIVATE_KEY_PATH 环境变量。今后轮换密钥只需改 .env 一处。
4. .env、.env.example 与 Settings 默认值统一指向 config/jwt/dev/。
5. 新增 docs/21-JWT密钥管理与轮换.md:密钥分工(服务端只读公钥,
   JWT_PRIVATE_KEY_PATH 在 app/ 中无任何读取点,故生产机可只挂公钥)、
   克隆后必须自行生成、多人共用一个服务时必须共用同一把私钥、
   轮换的影响面与生产部署要点、安全红线。
6. 记录一处易被忽略的问题:生产环境的 JWT_ISSUER / JWT_AUDIENCE 也应与开发不同,
   否则开发环境签发的令牌在生产上依然有效——这比换密钥更容易漏。

说明:本次提交不含任何密钥文件(.env 与 config/jwt/ 均在 .gitignore 中)。
旧密钥 config/jwt/jwt-private.pem 已退役但保留未删,配置不再引用它,
用它签发的令牌会被拒绝。

验证:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、integration 29 passed、
acceptance_check --production 7 PASS、demo_agent_e2e 9/9 PASS——均使用新密钥完成
签发与验签。
2026-09-10 18:14:03 +08:00

126 lines
5.2 KiB
Python
Raw Permalink 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.
"""生成 JWT(RS256)密钥对。
为什么要有这个脚本,而不是让你手敲 `openssl genrsa`:
- **可复现**:任何人拿到这个脚本就能在目标环境生成同一规格的密钥(RSA 2048 / PKCS#8 /
SubjectPublicKeyInfo),不会因为 openssl 版本或参数写法不同而产生互不兼容的密钥。
- **少一个踩坑点**:`openssl` 在 Windows 上经常不可用或来自 Git 自带的旧版本(那套不支持
`genpkey`),手敲命令失败时很难判断是参数问题还是环境问题。
- **自带安全检查**:生成后会打印公钥指纹(用于核对"服务端加载的是不是同一把公钥")并在
私钥已存在时默认拒绝覆盖(避免误操作把正在用的密钥冲掉,导致所有已签发令牌立即失效)。
用法:
```powershell
# 开发环境(会被 .gitignore 忽略,目录本身不进仓库)
D:\\conda\\envs\\jr_py313\\python.exe tools\\generate_jwt_keys.py --out-dir config/jwt/dev
# 生产环境:在**生产机**上生成,不要把开发密钥拿去用
D:\\conda\\envs\\jr_py313\\python.exe tools\\generate_jwt_keys.py --out-dir /etc/jr-agent/keys
```
生成后把路径写进 `.env`:
```ini
JWT_PRIVATE_KEY_PATH=config/jwt/dev/jwt-private.pem
JWT_PUBLIC_KEY_PATH=config/jwt/dev/jwt-public.pem
```
**安全约束(本脚本只能提醒,执行靠人)**:
1. 私钥**永不进仓库**——`config/jwt/` 已在 `.gitignore` 中,换目录存放时请确认新目录同样被忽略。
2. 私钥**永不复用**:开发、预发、生产各一套。开发私钥泄露不影响生产,反之亦然。
3. 生产密钥**在生产机上生成**,不要在本机生成再拷贝过去——拷贝过程本身就是泄露面。
4. 轮换私钥会让**所有已签发的令牌立即失效**(公钥验签失败),需要在低峰期做并通知调用方。
"""
import argparse
import hashlib
import sys
from pathlib import Path
PRIVATE_KEY_FILENAME = "jwt-private.pem"
PUBLIC_KEY_FILENAME = "jwt-public.pem"
def public_key_fingerprint(public_pem: bytes) -> str:
"""公钥 SHA-256 指纹(DER 部分)。用于核对服务端加载的公钥与签发方是否一致。"""
from cryptography.hazmat.primitives import serialization
public_key = serialization.load_pem_public_key(public_pem)
der = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return hashlib.sha256(der).hexdigest()
def generate(out_dir: Path, *, force: bool, key_size: int) -> int:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
private_path = out_dir / PRIVATE_KEY_FILENAME
public_path = out_dir / PUBLIC_KEY_FILENAME
if not force and (private_path.exists() or public_path.exists()):
existing = [str(p) for p in (private_path, public_path) if p.exists()]
print("拒绝覆盖已存在的密钥:")
for path in existing:
print(f" - {path}")
print("覆盖会让所有已签发令牌立即失效。确认要换,请显式加 --force。")
return 1
out_dir.mkdir(parents=True, exist_ok=True)
key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
print(f"已生成 {key_size} 位 RSA 密钥对:")
print(f" 私钥:{private_path}")
print(f" 公钥:{public_path}")
print(f" 公钥 SHA-256 指纹:{public_key_fingerprint(public_pem)}")
print()
print("请把下面两行写进 .env(或在部署环境注入同名变量):")
print(f" JWT_PRIVATE_KEY_PATH={private_path.as_posix()}")
print(f" JWT_PUBLIC_KEY_PATH={public_path.as_posix()}")
print()
print("提醒:私钥不要提交、不要贴进聊天记录、不要与生产密钥混用;")
print(" 服务端只需要公钥即可验签,签发方(调用方)才需要私钥。")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="生成 JWT(RS256) 密钥对")
parser.add_argument(
"--out-dir",
default="config/jwt/dev",
help="输出目录,默认 config/jwt/dev(已被 .gitignore 忽略)",
)
parser.add_argument("--force", action="store_true", help="覆盖已存在的密钥(会让旧令牌立即失效)")
parser.add_argument("--key-size", type=int, default=2048, help="RSA 位数,默认 2048")
args = parser.parse_args()
try:
import cryptography # noqa: F401
except ImportError:
print("缺少 cryptography 依赖。项目依赖 PyJWT[rsa],请先安装依赖:")
print(" D:\\conda\\envs\\jr_py313\\python.exe -m pip install cryptography")
return 2
return generate(Path(args.out_dir), force=args.force, key_size=args.key_size)
if __name__ == "__main__":
sys.exit(main())