feat(manager): P0 device-binding signature layer for cc-haha desktop clients

Lays down the server side of a per-request Ed25519 signature scheme that
binds a token to a specific desktop install, so the bearer key can't be
extracted from ~/.claude/cc-haha/providers.json and resold. Plan lives
at ~/.claude/plans/peaceful-sprouting-crane.md.

Compatibility: legacy bare-bearer sk- callers (CLI/SDK) pass through
unchanged until P3 (30-day deadline) flips RequireGlobal=true. No
existing token rows are modified — pubkey is nullable and defaults to
null.

Pieces:
- model.Token gains DeviceId, DevicePubkey, DeviceFingerprint, DeviceName,
  DevicePlatform, DeviceAppVersion, DeviceBoundAt, DeviceLastSeenIp,
  DeviceLastUsedAt, RequireDeviceBinding, RevokedAt, RevokedReason.
  Pure additive columns, GORM AutoMigrate handles SQLite/MySQL/PG.
- common.VerifyEd25519Signature: thin wrapper around crypto/ed25519
  stdlib, used by the new middleware. No new external deps.
- service.MarkNonceUsed: Redis SETNX-based nonce store with an
  in-memory sync.Map fallback for single-instance dev. TTL = setting.
- middleware.VerifyDeviceSignatureIfRequired: wired into TokenAuth as
  a fail-fast dispatch right after model.ValidateUserToken. Verifies
  canonical = METHOD\nPATH\nTS_MS\nNONCE\nFINGERPRINT\nSHA256(BODY),
  signed as Ed25519(sha256(canonical)). 120s timestamp window, 300s
  nonce window, fingerprint stored at pair time must match the header.
- controller.PairDevice / ListUserDevices / RenameUserDevice /
  RevokeUserDevice, mounted at /api/devices/* behind UserAuth().
  PairDevice enforces 5-per-user cap and returns the raw sk- once,
  to be stored in the client's OS keychain (not providers.json).
- operation_setting.DeviceBindingSetting: MaxDevicesPerUser=5,
  TimestampWindowMs=120000, NonceTTLSec=300, RequireGlobal=false.

Tests:
- common/crypto_test.go covers round-trip + tamper + malformed inputs.
- middleware/device_signature_test.go covers all error-path branches
  (expired ts, wrong sig, tampered body, fingerprint mismatch, replay,
  revoked, missing headers, legacy fallthrough).
- testdata/device_signature_vectors.json is the cross-language contract
  Rust+TS sides will load to assert byte-identical canonical strings.

Untouched but reserved for follow-up phases:
- Anomaly detection / IP-diversity flagging (P1)
- 30-day deprecation banner + email notifications (P2)
- Hard cutover RequireGlobal=true (P3, day 31)
This commit is contained in:
2026-05-20 12:10:57 +08:00
parent 9d50c09c62
commit 80d0f956d0
11 changed files with 1372 additions and 1 deletions
+19
View File
@@ -376,6 +376,25 @@ func TokenAuth() func(c *gin.Context) {
return
}
// Device-binding signature check. Runs BEFORE the IP-allowlist and
// user-status checks because if the signature is wrong we don't
// need to consult the rest of the policy stack — the bearer alone
// no longer proves identity. See middleware/device_signature.go.
// Legacy bare-bearer tokens (no device_pubkey set) pass through
// transparently until P3 enforcement flips on.
if sigErr := VerifyDeviceSignatureIfRequired(c, token); sigErr != nil {
// Use 401 for ALL signature failures so we don't leak the
// distinction "no signature" vs "bad signature" vs "replayed"
// to outside observers via different status codes. The
// SysLog records the actual cause for ops debugging.
common.SysLog("device-signature reject: " + sigErr.Error() +
" token_id=" + fmt.Sprint(token.Id) +
" client_ip=" + c.ClientIP())
abortWithOpenAiMessage(c, http.StatusUnauthorized,
common.TranslateMessage(c, i18n.MsgTokenInvalid))
return
}
allowIps := token.GetIpLimits()
if len(allowIps) > 0 {
clientIp := c.ClientIP()