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)
72 lines
2.6 KiB
Go
72 lines
2.6 KiB
Go
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
|
||
}
|