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)
319 lines
9.8 KiB
Go
319 lines
9.8 KiB
Go
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
|
|
}
|