# Encryption Design

> 博士，放心——这份文档是我写的，不是凯尔希医生审核过的医疗报告。  
> —— 可露希尔，罗德岛总工程师

## TL;DR

```
你的密码  →  [Argon2id, t=4]  →  256-bit key  →  [AES-256-GCM]  →  providers.enc
                                                        ↑
                                             每次加密随机 96-bit nonce
```

- `.enc` 文件在强密码前提下**可以公开发布**（放 GitHub、U 盘里到处拷、用飞鸽传书都没问题）。
- 唯一需要保密的是你的密码。选个强的。
- 加密算法是 AES-256-GCM。密钥派生用 Argon2id，参数 m=19456 KiB, t=4, p=1。
- 如果你用的密码是 `123456` 或者 `password`，那当我没说上面的话。

---

## Algorithm Selection

### 为什么是 AES-256-GCM

AES-256 用了二十多年了，是目前**最广泛审计、硬件加速支持最好的对称加密算法**。256-bit 密钥空间是 2^256，即便是量子计算机在可预见的将来也没法暴力破解。

GCM（Galois/Counter Mode）是一种**认证加密**（AEAD）模式。它同时提供：

| 属性 | 含义 |
|---|---|
| **机密性** | 不知道密钥的人读不出内容 |
| **完整性** | 篡改过哪怕一个比特都会被发现 |
| **认证** | 你可以确认解密出来的东西确实是你当初加密的那个 |

这三样缺一不可。只加密不认证的模式（比如 AES-CTR 裸奔）可以被攻击者修改密文而不被发现——那种东西我不允许出现在我的工程部里。

### 为什么是 Argon2id

密钥不是直接从密码来的——人记不住 256 个随机比特。所以需要 **KDF（Key Derivation Function）** 把密码拉伸成密钥。

Argon2 是 2015 年 Password Hashing Competition 的冠军，有三种变体：

- **Argon2d** — 最快，抗 GPU，但不抗 side-channel
- **Argon2i** — 抗 side-channel，但不抗 GPU
- **Argon2id** ← 我们用的这个，两者兼顾

Argon2id 的暴力破解成本由三个参数控制：

| 参数 | 我们的值 | 作用 |
|---|---|---|
| 内存 (m) | 19456 KiB (19 MiB) | 每次尝试必须占用 19 MiB 内存 |
| 迭代 (t) | 4 | 每尝试一次跑 4 轮 |
| 并行 (p) | 1 | 单线程 |

这意味着攻击者想用 GPU 并行暴力破解时，每张卡同一时间能跑多少次尝试受到显存大小限制。一张 24 GiB 的显卡最多同时跑 ~1200 个尝试——对于高熵密码来说微不足道。

**关于 t=4**：OWASP（开放 Web 应用安全项目）推荐的最低值是 t=2。我们选了 4，比最低多一倍。每次 auth 多花 ~200ms，换来对暴力破解的有效防御翻倍。这个 trade-off 我认为值。

---

## Ciphertext Format

```
┌───┬──────────────────┬──────────────┬──────────────────────────┐
│ 1B│   ~22 B          │    12 B      │    variable + 16 B       │
│   │   (base64)       │              │                          │
│len│   salt           │   nonce      │  ciphertext + GCM tag    │
└───┴──────────────────┴──────────────┴──────────────────────────┘
      ←── 明文存储 ──→                  ←── 加密 + 认证 ──→
```

### salt (salt_len + base64 编码的盐值)

- 每次加密随机生成（32 bytes，base64 编码后约 22 字符），存储在密文头部。
- **不是秘密**。盐的作用是让同一个密码在不同加密中产生不同的密钥。
- 没有盐 → 同一个密码永远产生同一个密钥 → 攻击者可以预先计算 rainbow table。

### nonce (12 bytes, 96-bit)

- 每次加密随机生成，来自操作系统加密级随机源（`/dev/urandom` 或等效）。
- **不是秘密**。nonce 是 AES-GCM 的初始化向量，必须唯一但不能重复。
- 96-bit 随机 nonce：重复概率 = 生日悖论计算，在 ~2^48 次加密后碰撞概率约 50%。我们一天最多加密几次，到宇宙热寂之前碰不上。

### ciphertext + tag

- 加密后的 provider 数据 + 16-byte GCM 认证标签。由 `aes-gcm` crate 自动处理。
- **这是真正需要保密的部分。**

---

## Encrypt & Decrypt Flow

### 加密

```
输入: plaintext (bytes), password (str)

1. OsRng 生成 32-byte 随机 salt
2. salt + password → Argon2id (m=19456, t=4, p=1) → 256-bit key
3. OsRng 生成 12-byte 随机 nonce
4. key + nonce + plaintext → AES-256-GCM → ciphertext + 16B tag
5. 输出: salt_len(1B) || salt || nonce || ciphertext || tag
```

### 解密

```
输入: ciphertext (bytes), password (str)

1. 读取 salt_len，按偏移切出 salt、nonce、encrypted_payload
2. salt + password → Argon2id (同上参数) → 256-bit key
3. key + nonce + encrypted_payload → AES-256-GCM 解密
4. GCM tag 验证:
   - 通过 → 返回 plaintext
   - 失败 → 返回 InvalidPassword（密码错，或密文被篡改）
```

解密时 Argon2id 参数必须与加密时完全一致，否则会导出不同的密钥。

---

## Threat Model & Assurance

### `.enc` 文件可以公开发布吗？

**可以**，前提是密码足够强。

`.enc` 文件包含的公开信息（salt、nonce）不提供任何解密捷径。攻击者唯一的途径是**暴力破解密码**：

```
对每个候选密码:
  1. Argon2id 派生密钥 (19 MiB × 4 轮，~200ms)
  2. AES-256-GCM 解密并验证 tag
  3. 如果 tag 不匹配，换下一个密码
```

攻击成本 = (密码空间) × (200ms + 19 MiB)。如果你的密码是一个 6 词 diceware 短语（~77 bits 熵），那么攻击者在 1000 张 A100 显卡上也需要 >10^12 年才能破解。

如果你的密码是 `closure123`，那大概 3 分钟就没了。

### 不会发生的事

- **Nonce 重用**：不可能。每次加密随机 96-bit nonce。
- **篡改密文而不被发现**：不可能。GCM 提供认证，篡改建检测。
- **从 salt/nonce 反向推导密钥**：不可能。KDF 是单向的。
- **时序攻击泄漏密码**：不会。Argon2id 和 AES-GCM 都是常量时间实现。
- **内存转储泄漏密钥**：密钥只在 `Store` 的 `RefCell` 中保存到进程退出。不会落盘。

### 不会发生的事（但你应该知道）

- **密码太弱**：Argon2id 只能减慢暴力破解，不能把弱密码变强。选个强密码。
- **忘记密码**：没有恢复机制。密钥是从密码派生的，忘了就真没了。我不是凯尔希，变不出备份。

---

## 关于 OTP（为什么没做）

有人问我：要不要加个 TOTP 两步验证？

我的回答是：**不加。加了我工程部这个月预算就超了。**

要理解为什么，先看 OTP 种子存哪的问题：

| 种子存储位置 | 能算 2FA 吗 | 问题 |
|---|---|---|
| 存在 `.enc` 文件里，密码加密 | 不是 2FA | 密码解出种子 → 种子算 OTP → OTP 跟自己比 → 永远通过。等于多输了一行字，安全性零提升。 |
| 存在 `.enc` 外面 | 理论上是 | 博士在另一台机器上只拷了 `.enc` 过去，解开后发现 OTP 种子留在旧机器上——人被锁在门外了。 |
| 硬件 key（YubiKey 等） | 是 | 为了管几个 API key，在终端里每次切 provider 掏一次硬件 key——谁会这么干？ |

**核心问题是我们的威胁模型不需要它。**

1. `.enc` 被偷 → 离线暴力破解 → 强密码 + Argon2id t=4 已经防住。
2. 攻击者进了你的机器 → 键盘记录器能抓到密码也能抓到 OTP。OTP 不提供额外防御。
3. 你把密码告诉了坏人 → 如果种子是密码解出来的，坏人也能拿到种子。

没有一个场景是加了 OTP 能从"不安全"变成"安全"的。

如果哪天 closure 变成多用户服务（不太可能，但万一呢），那再议。现在的 single-user、local-first、password-KDF 设计在工程上是完整的。

> 我的工程部预算有限，不花在没用的东西上。
> —— 可露希尔

---

## Code Reference

实现文件：`src/core/crypto.rs`

```rust
fn kdf() -> Argon2<'static> {
    let params = argon2::Params::new(19456, 4, 1, Some(32))
        .expect("valid argon2 params");
    Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
}

pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>> { ... }
pub fn decrypt(ciphertext: &[u8], password: &str) -> Result<Vec<u8>> { ... }
```

依赖：
- [`aes-gcm`](https://crates.io/crates/aes-gcm) — AES-256-GCM 实现
- [`argon2`](https://crates.io/crates/argon2) — Argon2id KDF 实现
- [`rand`](https://crates.io/crates/rand) — `OsRng` 系统熵源

测试文件：`tests/crypto_test.rs`

```
test_encrypt_decrypt_roundtrip  — 加解密往返验证
test_decrypt_wrong_password_fails — 错误密码被拒绝
```

---

## Changelog

| 版本 | 日期 | 变更 |
|---|---|---|
| 0.1.0 | 2026-05 | 初始实现：Argon2id (default, t=2) + AES-256-GCM |
| 0.3.1 | 2026-05 | 显式 Argon2id 参数 (t=4)，不再使用 `Argon2::default()` |
