背景:此前全环境共用一把 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——均使用新密钥完成 签发与验签。
126 lines
5.2 KiB
Python
126 lines
5.2 KiB
Python
"""生成 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())
|