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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user