Metadata-Version: 2.4
Name: theid
Version: 2.0.0
Summary: AK/SK + JWT identity for services of https://devnull.cn
Author-email: Xizhen Du <xizhendu@gmail.com>
License: Proprietary
Project-URL: Homepage, https://devnull.cn
Project-URL: Source, https://github.com/xizhendu/theid-sdk-python
Keywords: identity,authentication,jwt,access-key,devnull
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Topic :: Security
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.28
Provides-Extra: verify
Requires-Dist: PyJWT[crypto]>=2.8; extra == "verify"
Provides-Extra: dev
Requires-Dist: PyJWT[crypto]>=2.8; extra == "dev"

# theid 2.0

身份客户端 for [devnull.cn](https://devnull.cn)：用 **access_key / secret_key** 换取
**短时 EdDSA JWT**。与 AWS 的 AK/SK 模型一致 —— secret key 不上网、只用于签名；与
AWS SigV4 的区别是签名的产物是一次性断言（assertion），换来的是可缓存的 Bearer 令牌。

服务端契约见 [identity/docs/auth-2.0.md](https://github.com/xizhendu/identity/blob/main/docs/auth-2.0.md)。

```
client                          identity                      resource service
  │  POST /oauth/token            │                                  │
  │  assertion = HS256(JWT, sk)   │                                  │
  ├──────────────────────────────▶│  ① 验签 + jti 单次消费           │
  │◀──────────────────────────────┤  ② EdDSA access token (15m)      │
  │  Authorization: Bearer ...    │                                  │
  ├─────────────────────────────────────────────────────────────────▶│ ③ 本地验签（JWKS 公钥）
```

## 安装

```bash
pip install theid              # 客户端：只有 requests
pip install "theid[verify]"    # 资源服务：额外装 PyJWT[crypto] 用于本地验签
```

## 凭据

优先级：**显式参数 > 环境变量 > `~/.devnull/credentials`**。

```bash
export THEID_ACCESS_KEY=DK...
export THEID_SECRET_KEY=...
```

```ini
# ~/.devnull/credentials   (chmod 600)
[default]
access_key = DK...
secret_key = ...
```

没有任何内置兜底凭据：缺失时抛 `CredentialsError`，不会静默降级。
文件权限宽于 0600 会得到一条 `warnings.warn`。

## 使用

```python
import theid

client = theid.Client()                 # 读环境变量或凭据文件
headers = client.authorization_headers()
# {'Authorization': 'Bearer eyJ...', 'Content-Type': 'application/json'}

client.whoami()                         # {'account_id': '016869757', 'roles': ['user'], ...}
client.get('/me')                       # 自动带 token；401 时刷新一次
client.token(force_refresh=True)        # 强制换新
```

令牌在进程内缓存，**到期前 60 秒**（或 TTL 的 1/4，取小者）才重新换取；多线程安全。
换 token 的 POST 在 429/5xx/网络错误上按指数退避重试（默认 2 次），**每次重试都重新签名
一个新的 jti**，所以不会撞上服务端的重放检测。

### 显式指定

```python
client = theid.Client(
    access_key='DK...',
    secret_key='...',
    base_url='https://devnull.cn/identity',   # 断言 audience 默认同此
    audience='thedns',                        # 令牌 aud
    scope='profile:read device:read',         # 只能下放，不能提权
    timeout=5.0,
    retries=2,
)
```

### 模块级快捷方式

```python
import theid
theid.token()
theid.authorization_headers()
```

进程内单例，行为与 `Client()` 相同。

## 资源服务：本地验签

```python
from theid import JWKSClient

jwks = JWKSClient()          # 全局建一次：公钥缓存 300s，未知 kid 触发一次刷新

def require_auth(request):
    token = request.headers['Authorization'].split(' ', 1)[1]
    claims = jwks.verify(token, audience='thedns', issuer='https://devnull.cn/identity')
    if 'device:read' not in claims['scope'].split():
        raise PermissionError
    return claims['sub']     # account_id，或 'device:<device_id>'
```

`verify_token()` 是同样功能的一次性便捷函数。注意 **必须**显式传 `issuer`（生产环境），
否则 base_url 配错会扩大信任面。

Go 服务端可用任意 JWKS 库（`MicahParks/keyfunc` 或 `lestrrat-go/jwx`）：
只需 `GET /.well-known/jwks.json`，算法 `EdDSA`，校验 `iss`/`aud`/`exp`/`nbf`。

## 错误

| 异常 | 何时 | 是否重试 |
|---|---|---|
| `CredentialsError` | 没配凭据 / 只配了一半 / 文件格式错 | 否 |
| `AuthenticationError` | 401/403：签名错、断言重放、token 过期、账号不存在 | 否（换凭据/修时钟） |
| `ScopeError` | 400 `invalid_scope`：请求了没授予的 scope | 否 |
| `RequestError` | 400/422：请求格式错 | 否 |
| `RateLimitError` | 429 且重试耗尽 | 是（看 `retry_after`） |
| `ServiceError` | 5xx 或响应体不合法 | 是 |
| `NetworkError` | DNS/TLS/超时/连接重置 | 是 |
| `InvalidTokenError` | 验签失败（资源服务侧） | 否 |

所有异常都继承 `TheIDError`。`str(exc)` 里带服务端的稳定错误码，但服务端不会告诉你
access_key 是否存在——这是刻意的。

## 主机信息（可选）

```python
theid.host.system_id()     # sha256(/etc/machine-id)；macOS 用 IOPlatformUUID
theid.host.hostname()
theid.host.private_ip()    # 无路由时返回 None，不再抛异常
theid.host.is_private_ip('fd00::1')   # 支持 IPv6；非法输入抛 ValueError
theid.host.public_ip(client)          # 走 identity 的 /ip，不再依赖 ipinfo
```

## 从 1.x 迁移

1.x 的 API 大部分已删除，因为它们的语义不可靠：

| 1.x | 2.0 |
|---|---|
| 硬编码兜底凭据 | 删除；缺凭据直接报错 |
| `token()` 失败返回 `{'rc':205}` | 抛 `AuthenticationError` |
| 每次调用都重新登录、无超时 | 缓存 + 超时 + 退避重试 |
| `nickname()`（返回明文密码） | 删除 |
| `ip_regulated()`（硬编码 ipinfo token、失败即 False） | 删除；`public_ip()` 改走 identity |
| `is_private_ip()` 只支持 IPv4、非法返回 False | 支持 v6，非法抛 `ValueError` |
| `system_id()` = md5(machine-id 含换行) | sha256(machine-id strip) |
| `email()` | 删除（账号只有 9 位 account_id，不再收集邮箱） |

**1.x 老账号**需要先轮换一次凭据才能用 2.0（服务端当时只存了哈希，无法反推 SK）。
新注册的账号本身就是 2.0 凭据，跳过这一步：

```bash
python3 - <<'EOF'
import hashlib, requests
ak, sk = '你的旧 AK', '你的旧 SK'
password = hashlib.sha256((hashlib.sha256(ak.encode()).hexdigest()
                           + hashlib.sha256(sk.encode()).hexdigest()).encode()).hexdigest()
r = requests.post('https://devnull.cn/identity/keys/rekey',
                  json={'access_key': ak, 'password': password}, timeout=10)
print(r.status_code, r.json())   # 新 AK/SK 只显示这一次
EOF
```

## 发布

2.0 是破坏性升级，发布前必须处理依赖它的三个包（`thedns`/`theca`/`meshing` 在模块顶层
import 了已删除的 `email()`/`ip_regulated()`）。完整检查清单见 `docs/RELEASE-2.0.md`。

## 开发

```bash
python3 -m unittest discover -s tests -v
```

测试全部离线：HTTP 层是一个 `requests.Session` 形状的桩，断言/缓存/重试/错误映射都被覆盖。
与真实服务端的端到端契约测试在 identity 仓库的 `test/test_e2e_sdk.py`。
