Files
heicode-win/heicode/service/nonce_store.go
T
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

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
}