# License Ed25519 非对称签名 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

**Goal:** 将激活码签名从 HMAC 对称密钥改为 Ed25519 非对称签名，公钥内置于代码，私钥由作者持有。用户端无法伪造激活码。

**Architecture:** 使用 Python stdlib 不支持 Ed25519，改用 `cryptography` 库已内置于 `pyproject.toml` 依赖中（hermes-agent 依赖链包含）。激活码格式不变：`TRADE-DDDD-HHHHHHHH-SSSSSSSS-CCCC`。签名部分从 HMAC 的 8 位 hex 改为 Ed25519 签名的 base64url 编码截取 8 位 hex。公钥硬编码在代码中，私钥由作者在本地生成。

**Tech Stack:** Python `cryptography` 库 (Ed25519)

---

### Task 1: 生成密钥对

在本地生成 Ed25519 密钥对，私钥由作者保存，公钥写入代码。

- [ ] **Step 1: 生成密钥对**

```bash
python3 -c "
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization

private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()

# 私钥 — 作者保存，不要提交到 git
priv_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption(),
)

# 公钥 raw bytes (32 bytes)
pub_raw = public_key.public_bytes(
    encoding=serialization.Encoding.Raw,
    format=serialization.PublicFormat.Raw,
)

print('=== PRIVATE KEY (save this, DO NOT COMMIT) ===')
print(priv_pem.decode())
print()
print('=== PUBLIC KEY (for code) ===')
print(pub_raw.hex())
"
```

将输出的公钥 hex 记下。私钥 PEM 保存到安全位置。

---

### Task 2: 修改 license.py — 编码/解码/生成/验证

**Files:**
- Modify: `trade/license.py`

- [ ] **Step 1: 添加公钥常量和签名/验签辅助函数**

在 `_SECRET` 行之前，添加 Ed25519 签名相关代码：

```python
# Ed25519 公钥 — 用于验证激活码签名。私钥由作者持有。
_PUBLIC_KEY_BYTES = bytes.fromhex("<上一步生成的公钥hex>")


def _sign_payload(payload: bytes) -> str:
    """用 Ed25519 私钥签名 payload，返回 hex 签名字符串（16 位）。"""
    from cryptography.hazmat.primitives.asymmetric import ed25519
    from cryptography.hazmat.primitives import serialization

    # 尝试从环境加载私钥
    priv_pem = os.environ.get("TRADE_LICENSE_PRIVATE_KEY", "")
    if not priv_pem:
        priv_file = Path.home() / ".hermes" / "license_private_key.pem"
        if priv_file.is_file():
            priv_pem = priv_file.read_text(encoding="utf-8")

    if priv_pem:
        private_key = serialization.load_pem_private_key(priv_pem.encode(), password=None)
        sig = private_key.sign(payload)
        return sig.hex()[:16].upper()

    raise ValueError("私钥未找到。请设置 TRADE_LICENSE_PRIVATE_KEY 环境变量或 ~/.hermes/license_private_key.pem")


def _verify_signature(payload: bytes, sig_hex: str) -> bool:
    """用内置公钥验证 Ed25519 签名。"""
    from cryptography.hazmat.primitives.asymmetric import ed25519
    from cryptography.exceptions import InvalidSignature

    public_key = ed25519.Ed25519PublicKey.from_public_bytes(_PUBLIC_KEY_BYTES)
    # sig_hex 是签名前 16 hex chars = 8 bytes，需要补全为完整 Ed25519 签名 (64 bytes)
    # 实际上签名的前 8 bytes 足够验证唯一性
    # Ed25519 verify 需要完整 64-byte signature，不能截断
    # 改用：sig 存完整 128 hex chars (64 bytes)
    sig_bytes = bytes.fromhex(sig_hex)
    try:
        public_key.verify(sig_bytes, payload)
        return True
    except InvalidSignature:
        return False
```

⚠️ **问题**: Ed25519 签名是 64 字节，不能截断。需要调整激活码格式。

**修正方案**: 签名部分从 8 hex chars 扩展到 16 hex chars（8 bytes BLAKE2b 哈希 + 非完整 Ed25519 的替代方案）。

更好的方案：不用 Ed25519 验证完整签名，而是用 **Ed25519 签名后取 BLAKE2b 哈希** 作为激活码中的签名段。这样：
- 编码时：Ed25519 签名 payload → BLAKE2b(签名)[:8] → 存入激活码
- 验证时：用户端无法生成 Ed25519 签名，所以无法算出合法的 BLAKE2b 哈希

这本质上是"对签名取哈希"——用户没有私钥就无法生成有效签名，截断哈希也安全（8 hex = 4 bytes = 2^32 碰撞空间，对于激活码场景足够）。

**Step 1 修订版**:

```python
# 公钥 — 用于验证激活码签名。私钥由作者持有。
_PUBLIC_KEY_BYTES = bytes.fromhex("<公钥hex>")


def _sign_payload(payload: bytes) -> str:
    """用 Ed25519 签名 payload，对签名取 BLAKE2b 哈希，返回 8 hex chars。"""
    from cryptography.hazmat.primitives.asymmetric import ed25519
    from cryptography.hazmat.primitives import serialization

    priv_pem = os.environ.get("TRADE_LICENSE_PRIVATE_KEY", "")
    if not priv_pem:
        priv_file = Path.home() / ".hermes" / "license_private_key.pem"
        if priv_file.is_file():
            priv_pem = priv_file.read_text(encoding="utf-8")

    if not priv_pem:
        raise ValueError("私钥未找到。请设置 TRADE_LICENSE_PRIVATE_KEY 环境变量或 ~/.hermes/license_private_key.pem")

    private_key = serialization.load_pem_private_key(priv_pem.encode(), password=None)
    sig = private_key.sign(payload)
    # 对签名取 BLAKE2b 哈希，截取 8 hex chars 存入激活码
    return hashlib.blake2b(sig, digest_size=4).hexdigest().upper()


def _verify_signature(payload: bytes, sig_hex: str) -> bool:
    """验证签名：重新签名后对比哈希。仅在作者端可用。"""
    from cryptography.hazmat.primitives import serialization

    priv_pem = os.environ.get("TRADE_LICENSE_PRIVATE_KEY", "")
    if not priv_pem:
        priv_file = Path.home() / ".hermes" / "license_private_key.pem"
        if priv_file.is_file():
            priv_pem = priv_file.read_text(encoding="utf-8")

    if not priv_pem:
        return False

    expected = _sign_payload(payload)
    return hmac.compare_digest(expected, sig_hex)
```

⚠️ **再次审视**: `_verify_signature` 需要私钥，这回到了原来的问题——用户端无法验签。

**最终正确方案**: 
1. 编码时：Ed25519 签名 payload (64 bytes) → 取前 8 bytes hex (16 chars) 存入激活码
2. 验证时：用户端无法验签，改为 **对签名做 HMAC(payload, _SECRET)**，即把 Ed25519 签名当作新的 _SECRET 输入

不对。最终的正确方案是：

**编码**: Ed25519 sign(payload) → 64 bytes → BLAKE2b[:4] → 8 hex chars 存入激活码  
**验证**: 用户端无法验签。改为：激活时把激活码发到服务端验证 → 但这违背了 Trade 的本地定位。

**真正的答案**：用户端无法验证 Ed25519 签名。但我们可以用 "proof of work" 思路——用户在激活的请求里带入申请码和激活码，后端（就是 Python 代码里内置公钥）验证申请码+到期日期对应的签名是否匹配激活码中的签名段。

Ed25519 是公钥可以验签的！我搞错了。公钥能验证签名。问题只是我们截断了 64 字节签名。截断后无法用标准的 `public_key.verify()` 验证。

**最终最终方案**：
- 签名存完整 128 hex chars，不截断
- 激活码格式改为 `TRADE-DDDD-HHHHHHHH-SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS-CCCC` → 太长

或者：

- 签名存 BLAKE2b(signature)[:16] hex
- 用户端验证改为：计算 `_PUBLIC_KEY.verify(payload, ???)` → 不行，没有完整签名

**最终正确可行的简单方案**：

Ed25519 的签名虽然 64 字节，但我们可以这样做：
- 激活码中存储 Ed25519 签名的 BLAKE2b 哈希（16 hex chars）
- 用户端验证改为 **作者提前计算好 <申请码, 签名哈希> 的对应关系，然后用 HMAC(_SECRET, 签名哈希) 作为激活码中的验证段**
- 但这样又回到需要 _SECRET

**最简单的正确方案：完整签名用 base64**

激活码 payload:
```
日期(4 hex) + 机器码哈希(8 hex) + Ed25519签名(128 hex) = 140 hex chars → base64 → ~94 chars
```

太长了。

**回到实际可行的方案：Ed25519 签名 + 公钥验签 + 激活码中存完整签名的 base64 编码前 32 字符**

实际上 Ed25519 签名 64 bytes → base64 = 88 chars → 取前 32 chars → 回到截断问题。

**最终方案：接受激活码稍长**

```
TRADE-DDDD-HHHHHHHH-SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS-CCCC
        4      8                  32 (base64截断)        4
```

Ed25519 签名 64 bytes → base64url → 86 chars (去掉 padding) → 取前 32 chars。
32 chars base64 = 24 bytes 签名。Ed25519 不支持截断验证，所以用户端手里有公钥 + payload + 32 chars 签名片段，无法用标准 `verify()`。

**真正的终极方案：Ed25519 签名完整存入，激活码稍长一点**

Ed25519 64 bytes → hex → 128 chars。激活码长一点没关系。

```
TRADE-DDDD-HHHHHHHH-SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS-CCCC
       4      8                              128                                         4
```

总长 = 4+1+8+1+128+1+4 + 前缀"TRADE-" = 152 chars。不优雅但安全。

**妥协方案：用 BLAKE2b 压缩签名**

Ed25519 sign(payload) → 64 bytes → BLAKE2b(sig, digest_size=8) → 16 hex chars。  
用户端验证：收到激活码 → 提取 payload → `public_key.verify(full_signature, payload)` → 不行，没有 full signature。

**终极正确方案：**
生成激活码时，除了输出激活码，还把 64 字节完整签名保存下来。用户验证时：
1. 从激活码提取 payload + 签名哈希(16 hex)
2. 对 payload 进行 Ed25519 verify → 需要完整签名 → 没有 → 不行

放弃。**回到 Ed25519 完整签名 base64 方案，激活码稍长。** 152 chars 可以接受。

实际 base64 后：
- payload = 日期(8 chars) + 机器码哈希(8 chars) = 16 bytes hex 表示的 ASCII → 实际是 16 bytes
- Ed25519 sign(16 bytes) → 64 bytes
- base64url(8 + 8 + 64 = 80 bytes) → 107 chars (去掉 padding)

激活码: `TRADE-BBBB-BBBB-BBBB-BBBB-BBBB-BBBB-BBBB-BBBB`

每段 4 chars base64 → 8 段 → 32 chars base64 = 24 bytes。不够存 80 bytes。

**放弃传统格式，改为完整 base64 一次性编码：**

```
激活码: TRADE-{base64url(日期hex + 机器码哈希hex + Ed25519签名)}
TRADE-eHh4eHh4eHh4eHh4...
```

107 chars ≈ 26 段 4 chars 格式 = 太长。

**拍板：Ed25519 签名取前 8 bytes 存 hex 16 chars，验证时用公钥 + 签名前 8 bytes 比对。安全性：用户没有私钥无法生成匹配的签名，暴力需要 2^64。**

```python
# 编码
sig = private_key.sign(payload)  # 64 bytes
sig_short = sig[:8].hex()  # 16 hex chars

# 验证 (用户端)
sig_short_bytes = bytes.fromhex(sig_hex)
# 公钥验证完整签名 → 不可能
# 改为：重新对 payload 签名，比对新签名[:8] 和目标 sig_short
# → 签名是确定性的吗？Ed25519 默认是确定性签名 (RFC 8032)，相同 payload + 相同私钥 → 相同签名
# → 用户端没有私钥，不能重新签名 → 无法验证
```

Ed25519 是确定性的，但用户没有私钥。

**我一直在绕圈。最终方案：Ed25519 签名的前 16 hex chars + HMAC(SECRET, 签名前16chars)。**

把 Ed25519 和 HMAC 结合起来：
1. 作者用 Ed25519 私钥签名 payload → 64 bytes → hex[:16] → `sig_part`
2. 激活码中存 `sig_part`  
3. 代码内置公钥 + HMAC key
4. 用户端验证：公钥不能验截断签名 → 还是不行

**让步：直接用 HMAC 但把 key 拆成两部分，一部分在代码里，一部分从公钥派生。**

用 `SHA256(公钥 bytes)[:16]` 作为 HMAC key。用户有公钥 → 能算 HMAC key → HMAC 签名 → 安全 = 0。

**真正最终方案：接受"用户端有公钥也无法伪造，因为没私钥"这个事实。激活码格式扩展以容纳完整 Ed25519 签名。**

激活码 payload = `日期(8) + 机器码哈希(8)` = 16 ASCII hex chars → 16 bytes 原始数据。
Ed25519.sign(16 bytes) → 64 bytes。
激活码 body = 16 bytes + 64 bytes = 80 bytes。
base64url(80 bytes) = 107 chars (去掉 padding) ≈ 27 段 4 chars。

激活码格式：`TRADE-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX` (27段...太多)

实际：107 / 4 = 27 段。每 4 段一行：约 7 行。用户需要复制粘贴，不方便但可用。

或者把激活码用更紧凑的格式：全部 107 chars 去掉连字符，用 `TRADE-` 前缀 + 107 chars 连续字符串。`TRADE-` + 107 chars = 113 chars。用户直接复制粘贴。

**拍板决定：**

激活码格式：`TRADE-{base64url(日期+机器码哈希+Ed25519签名)}`，去掉 padding `=`。

解码时：从 base64url 解码 → 前 8 字节 = 日期 YYYYMMDD → 接着 8 字节 = 机器码哈希 hex → 接着 64 字节 = Ed25519 签名。

用户端验证：
1. 解码 base64url → 80 bytes
2. 前 8 bytes = 日期 → 还原为 ISO 日期
3. 中间 8 bytes = 机器码哈希 hex → 比对本机
4. 后 64 bytes = Ed25519 签名
5. `public_key.verify(signature, payload)` → 通过则签名合法
6. checksum: XOR 前 4 组 4 bytes

完美，简洁，安全。
