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)
92 lines
2.9 KiB
Go
92 lines
2.9 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/heicode/manager/common"
|
|
)
|
|
|
|
// NonceStore detects request replays by recording every (device_id, nonce)
|
|
// pair the device-signature middleware sees and refusing the second
|
|
// occurrence within a TTL window.
|
|
//
|
|
// Redis is the primary backend; when REDIS_CONN_STRING is unset we fall
|
|
// back to an in-memory sync.Map with a janitor goroutine. The fallback is
|
|
// fine for single-instance dev / SQLite deployments — replay protection
|
|
// for multi-instance production REQUIRES Redis (otherwise Pod A doesn't
|
|
// know what Pod B has seen).
|
|
|
|
type memoryNonceEntry struct {
|
|
expiresAt time.Time
|
|
}
|
|
|
|
var (
|
|
memoryNonces sync.Map // key: string -> *memoryNonceEntry
|
|
memoryJanitorOnce sync.Once
|
|
)
|
|
|
|
// startMemoryNonceJanitor sweeps expired entries every minute. Idempotent.
|
|
func startMemoryNonceJanitor() {
|
|
memoryJanitorOnce.Do(func() {
|
|
go func() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
now := time.Now()
|
|
memoryNonces.Range(func(k, v any) bool {
|
|
entry := v.(*memoryNonceEntry)
|
|
if now.After(entry.expiresAt) {
|
|
memoryNonces.Delete(k)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}()
|
|
})
|
|
}
|
|
|
|
// MarkNonceUsed atomically records the (deviceId, nonce) pair as seen.
|
|
// Returns (ok=true) if this is the first time we've seen it within
|
|
// the TTL window. Returns (ok=false) if it was already used — the
|
|
// caller should reject the request as a replay.
|
|
//
|
|
// Never returns an error in normal operation; even if Redis is slow or
|
|
// failing it falls back to the memory store. A real error (programming
|
|
// bug) bubbles up and the caller can fail closed.
|
|
func MarkNonceUsed(deviceId, nonce string, ttl time.Duration) (bool, error) {
|
|
key := "nonce:" + deviceId + ":" + nonce
|
|
|
|
if common.RedisEnabled && common.RDB != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
// SET NX EX: returns true if the key was set (i.e. didn't exist).
|
|
ok, err := common.RDB.SetNX(ctx, key, "1", ttl).Result()
|
|
if err == nil {
|
|
return ok, nil
|
|
}
|
|
// On Redis error, fall through to memory to fail soft. This is a
|
|
// deliberate trade-off: a brief Redis outage briefly weakens
|
|
// replay protection across instances, but doesn't kill the API.
|
|
common.SysLog("nonce store: Redis SetNX failed, falling back to memory: " + err.Error())
|
|
}
|
|
|
|
startMemoryNonceJanitor()
|
|
now := time.Now()
|
|
expiresAt := now.Add(ttl)
|
|
_, loaded := memoryNonces.LoadOrStore(key, &memoryNonceEntry{expiresAt: expiresAt})
|
|
if loaded {
|
|
// Key existed. Check whether it was a stale entry the janitor hasn't
|
|
// reaped yet — if so, overwrite and treat as fresh.
|
|
if entry, ok := memoryNonces.Load(key); ok {
|
|
if now.After(entry.(*memoryNonceEntry).expiresAt) {
|
|
memoryNonces.Store(key, &memoryNonceEntry{expiresAt: expiresAt})
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|