Files
chenchen 80d0f956d0 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)
2026-05-20 12:10:57 +08:00

72 lines
2.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package operation_setting
import "github.com/heicode/manager/setting/config"
// DeviceBindingSetting controls the per-request Ed25519 device-signature
// auth introduced to stop sk- token resale. See the plan at
// ~/.claude/plans/peaceful-sprouting-crane.md for the full rollout.
//
// Phase rollout:
// P0 RequireGlobal=false — desktop clients opt-in automatically,
// legacy bare sk- bearer keeps working
// P3 RequireGlobal=true — bare sk- bearer rejected (30 days after P0)
type DeviceBindingSetting struct {
// Hard cap on how many active device-bound tokens one user can own.
// Anything beyond this gets rejected at pair time. 5 covers a typical
// dev (laptop + desktop + 2 cloud VMs + 1 CI runner) without leaving
// enough headroom for casual reselling.
MaxDevicesPerUser int `json:"max_devices_per_user"`
// How far apart the client-stamped timestamp and the server clock may
// drift before we reject. 120s = 2× the typical NTP drift envelope and
// long enough to survive a slow corporate proxy.
TimestampWindowMs int64 `json:"timestamp_window_ms"`
// How long a nonce stays "burned" in Redis. Must be ≥ the timestamp
// window — if it were shorter, a request from the past edge of the
// window could replay successfully right after the nonce TTL'd out.
NonceTTLSec int `json:"nonce_ttl_sec"`
// Global enforcement switch. Flip to true on P3 day to require all
// /v1/* relay calls to come from a device-bound token.
RequireGlobal bool `json:"require_global"`
// Per-feature switch in case we want to lock down only the chat relay
// before lockdown.
EnforceOnRelay bool `json:"enforce_on_relay"`
}
var deviceBindingSetting = DeviceBindingSetting{
MaxDevicesPerUser: 5,
TimestampWindowMs: 120_000, // 2 minutes
NonceTTLSec: 300, // 5 minutes
RequireGlobal: false, // P3 will flip this
EnforceOnRelay: false,
}
func init() {
config.GlobalConfig.Register("device_binding_setting", &deviceBindingSetting)
}
// GetDeviceBindingSetting returns the live setting struct. The pointer
// lets the admin UI write back through the same config registration.
func GetDeviceBindingSetting() *DeviceBindingSetting {
return &deviceBindingSetting
}
func GetMaxDevicesPerUser() int {
return GetDeviceBindingSetting().MaxDevicesPerUser
}
func GetDeviceTimestampWindowMs() int64 {
return GetDeviceBindingSetting().TimestampWindowMs
}
func GetDeviceNonceTTLSec() int {
return GetDeviceBindingSetting().NonceTTLSec
}
func DeviceBindingRequiredGlobally() bool {
return GetDeviceBindingSetting().RequireGlobal
}