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)
This commit is contained in:
2026-05-20 12:10:57 +08:00
parent 9d50c09c62
commit 80d0f956d0
11 changed files with 1372 additions and 1 deletions
+318
View File
@@ -0,0 +1,318 @@
package controller
import (
"encoding/base64"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/i18n"
"github.com/heicode/manager/model"
"github.com/heicode/manager/setting/operation_setting"
)
// Public DTO sent to the web dashboard. Never includes Key (the raw sk-
// value) — that's only returned once at pair time. Subsequent listings
// show only metadata so a curious admin can't reseed someone's device
// just by reading the API response.
type deviceListItem struct {
Id int `json:"id"`
DeviceId string `json:"device_id"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
DeviceAppVersion string `json:"device_app_version"`
DeviceBoundAt int64 `json:"device_bound_at"`
DeviceLastUsedAt int64 `json:"device_last_used_at"`
DeviceLastSeenIp string `json:"device_last_seen_ip"`
Status int `json:"status"`
RevokedAt int64 `json:"revoked_at"`
RevokedReason string `json:"revoked_reason"`
}
func toDeviceListItem(t *model.Token) deviceListItem {
deviceId := ""
if t.DeviceId != nil {
deviceId = *t.DeviceId
}
return deviceListItem{
Id: t.Id,
DeviceId: deviceId,
DeviceName: t.DeviceName,
DevicePlatform: t.DevicePlatform,
DeviceAppVersion: t.DeviceAppVersion,
DeviceBoundAt: t.DeviceBoundAt,
DeviceLastUsedAt: t.DeviceLastUsedAt,
DeviceLastSeenIp: t.DeviceLastSeenIp,
Status: t.Status,
RevokedAt: t.RevokedAt,
RevokedReason: t.RevokedReason,
}
}
// ListUserDevices serves GET /api/devices — the "Devices" tab on the
// user's Profile page. Each row maps 1:1 to a device-bound token row.
func ListUserDevices(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "unauthorized"})
return
}
tokens, err := model.GetUserDeviceBoundTokens(userId)
if err != nil {
common.SysLog("ListUserDevices: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
items := make([]deviceListItem, 0, len(tokens))
for _, t := range tokens {
items = append(items, toDeviceListItem(t))
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
}
type pairDeviceRequest struct {
DeviceId string `json:"device_id"` // client-generated UUID v4
PublicKey string `json:"public_key"` // base64 32-byte Ed25519 pubkey
Fingerprint string `json:"fingerprint"` // sha256 hex of HWID + hostname
DeviceName string `json:"device_name"` // free-form, e.g. "Chen's MacBook"
Platform string `json:"platform"` // darwin / windows / linux
AppVersion string `json:"app_version"` // cc-haha version at pair time
}
// PairDevice serves POST /api/devices/pair — the very first thing a
// new cc-haha install does after the user logs into Heicode. The
// session cookie (set by /api/user/login) provides the user identity.
// We create a NEW hidden token row whose key the client must store in
// OS keychain — that's the bearer for subsequent calls, paired with a
// signature header.
func PairDevice(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "unauthorized"})
return
}
var req pairDeviceRequest
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
if !isValidPairRequest(req) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
// Per-user device cap. Enforce BEFORE inserting so racing pair calls
// from a malicious script can't sneak past.
count, err := model.CountUserDeviceBoundTokens(userId)
if err != nil {
common.SysLog("PairDevice count: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
if int(count) >= operation_setting.GetMaxDevicesPerUser() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "device limit reached",
})
return
}
// Refuse if same device_id already paired for this user — caller
// should DELETE first if they want to re-pair.
var existing model.Token
dup := model.DB.Where("user_id = ? AND device_id = ?", userId, req.DeviceId).
First(&existing).Error
if dup == nil {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": "device already paired",
})
return
}
rawKey, err := common.GenerateKey()
if err != nil {
common.SysLog("PairDevice GenerateKey: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
now := common.GetTimestamp()
nowMs := now * 1000
deviceIdCopy := req.DeviceId
pubkeyCopy := req.PublicKey
tok := model.Token{
UserId: userId,
Name: deviceNameFromRequest(req),
Key: rawKey,
Status: common.TokenStatusEnabled,
CreatedTime: now,
AccessedTime: now,
ExpiredTime: -1, // never naturally; signature validity is the gate
UnlimitedQuota: false,
HideFromUserUI: true, // device tokens only show on Devices page
DeviceId: &deviceIdCopy,
DevicePubkey: &pubkeyCopy,
DeviceFingerprint: req.Fingerprint,
DeviceName: req.DeviceName,
DevicePlatform: req.Platform,
DeviceAppVersion: req.AppVersion,
DeviceBoundAt: nowMs,
RequireDeviceBinding: true,
}
if err := tok.Insert(); err != nil {
common.SysLog("PairDevice Insert: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
// Return the raw key ONCE. Client puts it in OS keychain immediately
// and never persists to providers.json. Manager log only records the
// masked form via the token controller's existing helpers.
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": tok.Id,
"device_id": deviceIdCopy,
"sk": "sk-" + rawKey,
"device_bound_at": nowMs,
},
})
}
// RevokeUserDevice serves DELETE /api/devices/:id — flips the device's
// token to disabled. Client side discards the keychain entry and
// prompts for re-pair.
func RevokeUserDevice(c *gin.Context) {
userId := c.GetInt("id")
tokenId, err := strconv.Atoi(c.Param("id"))
if err != nil || tokenId <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "bad id"})
return
}
// Look up + ownership check in one query to prevent users from
// guessing token ids belonging to other users.
var tok model.Token
q := model.DB.Where("id = ? AND user_id = ?", tokenId, userId).First(&tok)
if q.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
if tok.DevicePubkey == nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "not a device-bound token"})
return
}
if err := model.RevokeDevice(tok.Id, "user_initiated"); err != nil {
common.SysLog("RevokeUserDevice: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
type renameDeviceRequest struct {
DeviceName string `json:"device_name"`
}
// RenameUserDevice serves PATCH /api/devices/:id — lets the user update
// the friendly label without re-pairing. Trims to 64 chars to match the
// DB column.
func RenameUserDevice(c *gin.Context) {
userId := c.GetInt("id")
tokenId, err := strconv.Atoi(c.Param("id"))
if err != nil || tokenId <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "bad id"})
return
}
var req renameDeviceRequest
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
name := strings.TrimSpace(req.DeviceName)
if len(name) > 64 {
name = name[:64]
}
res := model.DB.Model(&model.Token{}).
Where("id = ? AND user_id = ? AND device_pubkey IS NOT NULL", tokenId, userId).
Update("device_name", name)
if res.Error != nil {
common.SysLog("RenameUserDevice: " + res.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// --- validation helpers ---
func isValidPairRequest(r pairDeviceRequest) bool {
if r.DeviceId == "" || r.PublicKey == "" || r.Fingerprint == "" {
return false
}
if len(r.DeviceId) > 64 {
return false
}
if len(r.Fingerprint) != 64 { // sha256 hex
return false
}
// Ed25519 pubkey must be exactly 32 bytes once base64-decoded.
pk, err := base64.StdEncoding.DecodeString(r.PublicKey)
if err != nil || len(pk) != 32 {
return false
}
if len(r.DeviceName) > 64 || len(r.Platform) > 16 || len(r.AppVersion) > 32 {
return false
}
return true
}
func deviceNameFromRequest(r pairDeviceRequest) string {
// `Token.Name` is what shows up in legacy admin tools and consume
// logs. Make it visually obvious these rows are devices, not
// user-created API keys, so admins can tell them apart at a glance.
label := strings.TrimSpace(r.DeviceName)
if label == "" {
label = "device"
}
return "device:" + label
}