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

215 lines
7.5 KiB
Go

package middleware
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"github.com/heicode/manager/service"
"github.com/heicode/manager/setting/operation_setting"
)
// Header names exchanged between the Heicode desktop client and Manager.
// Names and exact byte order of the canonical string are part of the
// public client/server contract — DO NOT rename these without bumping the
// client protocol version (cc-haha/src/services/device/signRequest.ts).
const (
HeaderDeviceID = "X-Heicode-Device-Id"
HeaderTimestamp = "X-Heicode-Timestamp"
HeaderNonce = "X-Heicode-Nonce"
HeaderSignature = "X-Heicode-Signature"
HeaderFingerprint = "X-Heicode-Fingerprint"
HeaderClientVer = "X-Heicode-Client-Version"
canonicalSeparator = "\n"
)
// Sentinel error values let callers distinguish "no headers present"
// (legacy bare-bearer client, fall through to old path) from
// "headers present but invalid" (reject the request).
var (
ErrDeviceSignatureMissing = errors.New("device signature headers missing")
ErrDeviceSignatureRequired = errors.New("device signature required for this token")
ErrDeviceTimestampOutOfWindow = errors.New("device signature timestamp out of allowed window")
ErrDeviceNonceReplayed = errors.New("device signature nonce already used")
ErrDeviceRevoked = errors.New("device has been revoked")
ErrDeviceFingerprintMismatch = errors.New("device fingerprint mismatch")
ErrDeviceSignatureInvalid = errors.New("device signature did not verify")
ErrDeviceSignatureBodyReadFail = errors.New("failed to buffer request body for signature verification")
)
// VerifyDeviceSignatureIfRequired is the policy decision point for whether
// a bound token's request carries a valid Ed25519 signature.
//
// Returns nil when the caller should proceed (either the token isn't
// device-bound and global enforcement is off, or signature is valid).
// Returns a sentinel error when the caller MUST reject. The caller
// (TokenAuth) decides the HTTP response code — typically 401.
//
// Side effects on success: c.Set("device_id", ...), c.Set("device_bound",
// true), and the token's last_seen_ip/last_used_at are updated async.
func VerifyDeviceSignatureIfRequired(c *gin.Context, token *model.Token) error {
cfg := operation_setting.GetDeviceBindingSetting()
signedHeader := c.GetHeader(HeaderSignature)
tokenIsBound := token.DevicePubkey != nil && *token.DevicePubkey != ""
// CASE 1: token has no device binding.
if !tokenIsBound {
// If global enforcement is off, legacy bare-bearer is fine.
if !cfg.RequireGlobal && !token.RequireDeviceBinding {
return nil
}
// Global enforcement is on but this token was never paired.
if signedHeader == "" {
return ErrDeviceSignatureRequired
}
// Headers present but token has no pubkey to verify against — reject.
return ErrDeviceSignatureRequired
}
// CASE 2: token is bound. Signature headers are required.
if signedHeader == "" {
return ErrDeviceSignatureRequired
}
deviceIdHeader := c.GetHeader(HeaderDeviceID)
timestampHeader := c.GetHeader(HeaderTimestamp)
nonce := c.GetHeader(HeaderNonce)
fingerprintHeader := c.GetHeader(HeaderFingerprint)
if deviceIdHeader == "" || timestampHeader == "" || nonce == "" || fingerprintHeader == "" {
return ErrDeviceSignatureMissing
}
// device_id in headers must match what we stored at pair time.
if token.DeviceId == nil || *token.DeviceId != deviceIdHeader {
return ErrDeviceSignatureInvalid
}
// Revoked tokens fail closed even if signature would otherwise verify.
if token.RevokedAt != 0 {
return ErrDeviceRevoked
}
// Fingerprint binding: the server-stored fingerprint from pair time
// must match what the client now reports. Mismatch usually means the
// private key was copied to another machine.
if token.DeviceFingerprint != "" && token.DeviceFingerprint != fingerprintHeader {
return ErrDeviceFingerprintMismatch
}
// Timestamp window check. We parse defensively because clients
// occasionally ship trailing whitespace or a stray '+' sign.
tsMs, err := strconv.ParseInt(strings.TrimSpace(timestampHeader), 10, 64)
if err != nil {
return ErrDeviceSignatureInvalid
}
nowMs := time.Now().UnixMilli()
skew := nowMs - tsMs
if skew < 0 {
skew = -skew
}
if skew > cfg.TimestampWindowMs {
return ErrDeviceTimestampOutOfWindow
}
// Nonce replay check. Atomic SETNX in Redis (or memory fallback). If
// this returned false the same (device_id, nonce) was used recently
// — that's a replay.
ttl := time.Duration(cfg.NonceTTLSec) * time.Second
ok, err := service.MarkNonceUsed(deviceIdHeader, nonce, ttl)
if err != nil {
// Internal error — fail closed (don't silently let through).
return ErrDeviceSignatureInvalid
}
if !ok {
return ErrDeviceNonceReplayed
}
// Buffer the request body so the downstream relay can still read it.
// Tee the bytes, hash them, and stash back into c.Request.Body so
// later handlers see a fresh reader. For empty-body requests (GET)
// the SHA-256 hash is just the hash of empty string.
bodyHash, err := bufferAndHashBody(c)
if err != nil {
return ErrDeviceSignatureBodyReadFail
}
// Compose canonical string. ORDER MATTERS — both sides must use the
// same byte sequence. Document this in
// cc-haha/src/services/device/signRequest.ts.
canonical := strings.Join([]string{
strings.ToUpper(c.Request.Method),
c.Request.URL.RequestURI(), // path + raw query
timestampHeader,
nonce,
fingerprintHeader,
bodyHash,
}, canonicalSeparator)
// Sign target: sha256(canonical). This adds one extra layer beyond
// raw Ed25519's built-in hashing but matches what most asym-sign
// REST APIs do (e.g. webhook signatures) and keeps the message-to-
// sign at a fixed 32 bytes regardless of body size.
digest := sha256.Sum256([]byte(canonical))
if err := common.VerifyEd25519Signature(*token.DevicePubkey, digest[:], signedHeader); err != nil {
return ErrDeviceSignatureInvalid
}
// Stash device info in context so logs / billing can show it.
c.Set("device_id", deviceIdHeader)
c.Set("device_bound", true)
// Update last-seen async to avoid blocking the request.
go updateDeviceLastSeen(token.Id, c.ClientIP())
return nil
}
// bufferAndHashBody reads the entire request body into memory, computes
// SHA-256 hex, and replaces c.Request.Body with a fresh reader so the
// downstream handlers (relay, distribute, controller) still work.
//
// Pre-existing TokenAuth has not consumed the body yet — Distribute()
// does that later via GetAndValidateRequest. Reading + replacing here
// is safe.
func bufferAndHashBody(c *gin.Context) (string, error) {
if c.Request.Body == nil {
return hexHashOf([]byte{}), nil
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return "", err
}
_ = c.Request.Body.Close()
c.Request.Body = io.NopCloser(strings.NewReader(string(body)))
c.Request.ContentLength = int64(len(body))
return hexHashOf(body), nil
}
func hexHashOf(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// updateDeviceLastSeen runs in a goroutine. We don't care if this
// occasionally races; it's monitoring-grade data, not auth-critical.
func updateDeviceLastSeen(tokenId int, ip string) {
if tokenId <= 0 {
return
}
now := time.Now().UnixMilli()
if err := model.UpdateTokenDeviceLastSeen(tokenId, ip, now); err != nil {
common.SysLog("updateDeviceLastSeen: " + err.Error())
}
}