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:
@@ -1,9 +1,12 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -30,3 +33,38 @@ func ValidatePasswordAndHash(password string, hash string) bool {
|
|||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyEd25519Signature validates a base64-encoded Ed25519 signature over
|
||||||
|
// the given message bytes, using a base64-encoded 32-byte public key.
|
||||||
|
// Returns nil if valid, otherwise an error describing what failed.
|
||||||
|
//
|
||||||
|
// Used by the device-signature middleware to authenticate per-request
|
||||||
|
// signatures from device-bound Heicode clients. The client signs the
|
||||||
|
// SHA-256 hash of a canonical request string; this helper just does the
|
||||||
|
// raw Ed25519 verify and surface-level decode.
|
||||||
|
func VerifyEd25519Signature(pubkeyB64 string, message []byte, signatureB64 string) error {
|
||||||
|
if pubkeyB64 == "" {
|
||||||
|
return errors.New("empty public key")
|
||||||
|
}
|
||||||
|
if signatureB64 == "" {
|
||||||
|
return errors.New("empty signature")
|
||||||
|
}
|
||||||
|
pubkey, err := base64.StdEncoding.DecodeString(pubkeyB64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("public key is not valid base64")
|
||||||
|
}
|
||||||
|
if len(pubkey) != ed25519.PublicKeySize {
|
||||||
|
return errors.New("public key has wrong length")
|
||||||
|
}
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(signatureB64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("signature is not valid base64")
|
||||||
|
}
|
||||||
|
if len(sig) != ed25519.SignatureSize {
|
||||||
|
return errors.New("signature has wrong length")
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(ed25519.PublicKey(pubkey), message, sig) {
|
||||||
|
return errors.New("signature did not verify")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyEd25519Signature_RoundTrip(t *testing.T) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
msg := []byte("hello world")
|
||||||
|
sig := ed25519.Sign(priv, msg)
|
||||||
|
|
||||||
|
pubB64 := base64.StdEncoding.EncodeToString(pub)
|
||||||
|
sigB64 := base64.StdEncoding.EncodeToString(sig)
|
||||||
|
|
||||||
|
if err := VerifyEd25519Signature(pubB64, msg, sigB64); err != nil {
|
||||||
|
t.Fatalf("expected verify to pass, got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyEd25519Signature_WrongMessage(t *testing.T) {
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
sig := ed25519.Sign(priv, []byte("real"))
|
||||||
|
|
||||||
|
err := VerifyEd25519Signature(
|
||||||
|
base64.StdEncoding.EncodeToString(pub),
|
||||||
|
[]byte("forged"),
|
||||||
|
base64.StdEncoding.EncodeToString(sig),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected verify to fail on tampered message, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyEd25519Signature_TamperedSignature(t *testing.T) {
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
sig := ed25519.Sign(priv, []byte("hello"))
|
||||||
|
sig[0] ^= 0xff
|
||||||
|
|
||||||
|
err := VerifyEd25519Signature(
|
||||||
|
base64.StdEncoding.EncodeToString(pub),
|
||||||
|
[]byte("hello"),
|
||||||
|
base64.StdEncoding.EncodeToString(sig),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected verify to fail on tampered signature, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyEd25519Signature_MalformedInputs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pubkey string
|
||||||
|
message []byte
|
||||||
|
sig string
|
||||||
|
wantSub string // substring that must appear in the error message
|
||||||
|
}{
|
||||||
|
{"empty_pubkey", "", []byte("x"), "AAAA", "empty public key"},
|
||||||
|
{"empty_sig", base64Of32Zeros(), []byte("x"), "", "empty signature"},
|
||||||
|
{"bad_pubkey_b64", "!!!not-base64!!!", []byte("x"), base64Of64Zeros(), "valid base64"},
|
||||||
|
{"short_pubkey", base64.StdEncoding.EncodeToString([]byte("short")), []byte("x"), base64Of64Zeros(), "wrong length"},
|
||||||
|
{"bad_sig_b64", base64Of32Zeros(), []byte("x"), "!!!not-base64!!!", "valid base64"},
|
||||||
|
{"short_sig", base64Of32Zeros(), []byte("x"), base64.StdEncoding.EncodeToString([]byte("short")), "wrong length"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := VerifyEd25519Signature(tc.pubkey, tc.message, tc.sig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tc.wantSub)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantSub) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %q", tc.wantSub, err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64Of32Zeros() string {
|
||||||
|
var b [32]byte
|
||||||
|
return base64.StdEncoding.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64Of64Zeros() string {
|
||||||
|
var b [64]byte
|
||||||
|
return base64.StdEncoding.EncodeToString(b[:])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -376,6 +376,25 @@ func TokenAuth() func(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device-binding signature check. Runs BEFORE the IP-allowlist and
|
||||||
|
// user-status checks because if the signature is wrong we don't
|
||||||
|
// need to consult the rest of the policy stack — the bearer alone
|
||||||
|
// no longer proves identity. See middleware/device_signature.go.
|
||||||
|
// Legacy bare-bearer tokens (no device_pubkey set) pass through
|
||||||
|
// transparently until P3 enforcement flips on.
|
||||||
|
if sigErr := VerifyDeviceSignatureIfRequired(c, token); sigErr != nil {
|
||||||
|
// Use 401 for ALL signature failures so we don't leak the
|
||||||
|
// distinction "no signature" vs "bad signature" vs "replayed"
|
||||||
|
// to outside observers via different status codes. The
|
||||||
|
// SysLog records the actual cause for ops debugging.
|
||||||
|
common.SysLog("device-signature reject: " + sigErr.Error() +
|
||||||
|
" token_id=" + fmt.Sprint(token.Id) +
|
||||||
|
" client_ip=" + c.ClientIP())
|
||||||
|
abortWithOpenAiMessage(c, http.StatusUnauthorized,
|
||||||
|
common.TranslateMessage(c, i18n.MsgTokenInvalid))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
allowIps := token.GetIpLimits()
|
allowIps := token.GetIpLimits()
|
||||||
if len(allowIps) > 0 {
|
if len(allowIps) > 0 {
|
||||||
clientIp := c.ClientIP()
|
clientIp := c.ClientIP()
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/heicode/manager/model"
|
||||||
|
"github.com/heicode/manager/setting/operation_setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rfc8032TestKeypair is the Ed25519 test vector 1 from RFC 8032. PUBLIC,
|
||||||
|
// only for reproducible test cases. Never use as a real signing key.
|
||||||
|
const rfc8032SeedHex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
|
||||||
|
|
||||||
|
func loadRFC8032PrivateKey(t *testing.T) ed25519.PrivateKey {
|
||||||
|
t.Helper()
|
||||||
|
seed, err := hex.DecodeString(rfc8032SeedHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode seed: %v", err)
|
||||||
|
}
|
||||||
|
return ed25519.NewKeyFromSeed(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedReq builds a fully-formed *gin.Context whose request carries
|
||||||
|
// matching X-Heicode-* headers + Authorization Bearer for the given
|
||||||
|
// token. Callers can mutate fields before passing to
|
||||||
|
// VerifyDeviceSignatureIfRequired to test tampering scenarios.
|
||||||
|
type signedReq struct {
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Body string
|
||||||
|
Timestamp int64
|
||||||
|
Nonce string
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSignedReq() signedReq {
|
||||||
|
return signedReq{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/v1/messages",
|
||||||
|
Body: `{"model":"claude-sonnet-4-6","max_tokens":10}`,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
Nonce: randomNonce(),
|
||||||
|
Fingerprint: strings.Repeat("a", 64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomNonce() string {
|
||||||
|
// 16-byte nonce as 32-char lowercase hex. Tests don't need crypto-
|
||||||
|
// strong randomness, but each invocation MUST differ so replay
|
||||||
|
// detection asserts don't false-fire. Use crypto/rand for guaranteed
|
||||||
|
// uniqueness across rapid same-nanosecond calls.
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
// fall back to nanos-derived if rand is somehow broken
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
b[i] = byte(now >> uint(i*4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalString(r signedReq, bodyHash string) string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
strings.ToUpper(r.Method),
|
||||||
|
r.Path,
|
||||||
|
fmt.Sprint(r.Timestamp),
|
||||||
|
r.Nonce,
|
||||||
|
r.Fingerprint,
|
||||||
|
bodyHash,
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSignedContext(t *testing.T, r signedReq, priv ed25519.PrivateKey) *gin.Context {
|
||||||
|
t.Helper()
|
||||||
|
bodySum := sha256.Sum256([]byte(r.Body))
|
||||||
|
bodyHash := hex.EncodeToString(bodySum[:])
|
||||||
|
canonical := canonicalString(r, bodyHash)
|
||||||
|
digest := sha256.Sum256([]byte(canonical))
|
||||||
|
sig := ed25519.Sign(priv, digest[:])
|
||||||
|
|
||||||
|
req := httptest.NewRequest(r.Method, r.Path, bytes.NewBufferString(r.Body))
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-test")
|
||||||
|
req.Header.Set(HeaderDeviceID, "device-test-1")
|
||||||
|
req.Header.Set(HeaderTimestamp, fmt.Sprint(r.Timestamp))
|
||||||
|
req.Header.Set(HeaderNonce, r.Nonce)
|
||||||
|
req.Header.Set(HeaderFingerprint, r.Fingerprint)
|
||||||
|
req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(sig))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeBoundToken(priv ed25519.PrivateKey) *model.Token {
|
||||||
|
pub := priv.Public().(ed25519.PublicKey)
|
||||||
|
pubB64 := base64.StdEncoding.EncodeToString(pub)
|
||||||
|
deviceId := "device-test-1"
|
||||||
|
fp := strings.Repeat("a", 64)
|
||||||
|
return &model.Token{
|
||||||
|
Id: 1,
|
||||||
|
UserId: 42,
|
||||||
|
DeviceId: &deviceId,
|
||||||
|
DevicePubkey: &pubB64,
|
||||||
|
DeviceFingerprint: fp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeviceSignatureIfRequired(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
priv := loadRFC8032PrivateKey(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func() (*gin.Context, *model.Token)
|
||||||
|
wantErrIs error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "happy_path_signed_request",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
return makeSignedContext(t, r, priv), makeBoundToken(priv)
|
||||||
|
},
|
||||||
|
wantErrIs: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired_timestamp",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
r.Timestamp = time.Now().UnixMilli() - 10*60*1000 // 10 min ago
|
||||||
|
return makeSignedContext(t, r, priv), makeBoundToken(priv)
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceTimestampOutOfWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future_timestamp_beyond_window",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
r.Timestamp = time.Now().UnixMilli() + 10*60*1000 // 10 min ahead
|
||||||
|
return makeSignedContext(t, r, priv), makeBoundToken(priv)
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceTimestampOutOfWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fingerprint_mismatch",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
c := makeSignedContext(t, r, priv)
|
||||||
|
// Server stored a different fingerprint than the client reports
|
||||||
|
tok := makeBoundToken(priv)
|
||||||
|
tok.DeviceFingerprint = strings.Repeat("b", 64)
|
||||||
|
return c, tok
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceFingerprintMismatch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong_signature",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
c := makeSignedContext(t, r, priv)
|
||||||
|
// Tamper one byte of signature
|
||||||
|
orig := c.Request.Header.Get(HeaderSignature)
|
||||||
|
raw, _ := base64.StdEncoding.DecodeString(orig)
|
||||||
|
raw[0] ^= 0xff
|
||||||
|
c.Request.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(raw))
|
||||||
|
return c, makeBoundToken(priv)
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceSignatureInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tampered_body",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
c := makeSignedContext(t, r, priv)
|
||||||
|
// Replace request body AFTER signing → bodyHash will no longer match
|
||||||
|
c.Request.Body = http.NoBody
|
||||||
|
c.Request.ContentLength = 0
|
||||||
|
return c, makeBoundToken(priv)
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceSignatureInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_signature_header_on_bound_token",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
c := makeSignedContext(t, r, priv)
|
||||||
|
c.Request.Header.Del(HeaderSignature)
|
||||||
|
return c, makeBoundToken(priv)
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceSignatureRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "device_id_mismatch",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
c := makeSignedContext(t, r, priv)
|
||||||
|
c.Request.Header.Set(HeaderDeviceID, "different-device")
|
||||||
|
return c, makeBoundToken(priv)
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceSignatureInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revoked_token",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
c := makeSignedContext(t, r, priv)
|
||||||
|
tok := makeBoundToken(priv)
|
||||||
|
tok.RevokedAt = time.Now().UnixMilli()
|
||||||
|
return c, tok
|
||||||
|
},
|
||||||
|
wantErrIs: ErrDeviceRevoked,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy_token_no_binding_no_enforcement",
|
||||||
|
setup: func() (*gin.Context, *model.Token) {
|
||||||
|
r := defaultSignedReq()
|
||||||
|
c := makeSignedContext(t, r, priv)
|
||||||
|
// Strip signature headers — looks like a legacy sk- caller
|
||||||
|
c.Request.Header.Del(HeaderSignature)
|
||||||
|
c.Request.Header.Del(HeaderDeviceID)
|
||||||
|
c.Request.Header.Del(HeaderTimestamp)
|
||||||
|
c.Request.Header.Del(HeaderNonce)
|
||||||
|
c.Request.Header.Del(HeaderFingerprint)
|
||||||
|
tok := &model.Token{Id: 99, UserId: 42}
|
||||||
|
return c, tok
|
||||||
|
},
|
||||||
|
wantErrIs: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Ensure global enforcement off for the legacy_token case.
|
||||||
|
operation_setting.GetDeviceBindingSetting().RequireGlobal = false
|
||||||
|
c, tok := tc.setup()
|
||||||
|
err := VerifyDeviceSignatureIfRequired(c, tok)
|
||||||
|
if tc.wantErrIs == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected ok, got error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tc.wantErrIs) {
|
||||||
|
t.Fatalf("expected %v, got %v", tc.wantErrIs, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonceReplayDetection(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
priv := loadRFC8032PrivateKey(t)
|
||||||
|
|
||||||
|
// Use a freshly built request to keep timestamp fresh
|
||||||
|
r := defaultSignedReq()
|
||||||
|
r.Nonce = randomNonce()
|
||||||
|
tok := makeBoundToken(priv)
|
||||||
|
|
||||||
|
// First request must succeed
|
||||||
|
c1 := makeSignedContext(t, r, priv)
|
||||||
|
if err := VerifyDeviceSignatureIfRequired(c1, tok); err != nil {
|
||||||
|
t.Fatalf("first request: expected ok, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same nonce + device_id replayed → must reject as replay
|
||||||
|
c2 := makeSignedContext(t, r, priv)
|
||||||
|
err := VerifyDeviceSignatureIfRequired(c2, tok)
|
||||||
|
if !errors.Is(err, ErrDeviceNonceReplayed) {
|
||||||
|
t.Fatalf("replay: expected ErrDeviceNonceReplayed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCanonicalStringMatchesVectors loads the cross-language test vectors
|
||||||
|
// and asserts the Go implementation produces the documented canonical
|
||||||
|
// string byte-for-byte. The Rust + TS tests load the same file and run
|
||||||
|
// the equivalent assertion; if either drifts, both fail loudly.
|
||||||
|
func TestCanonicalStringMatchesVectors(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "testdata", "device_signature_vectors.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("vectors file not readable from this working dir: %v", err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Cases []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Input struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path_with_query"`
|
||||||
|
TimestampMs string `json:"timestamp_ms"`
|
||||||
|
Nonce string `json:"nonce_hex"`
|
||||||
|
Fingerprint string `json:"device_fingerprint"`
|
||||||
|
BodyText string `json:"body_text"`
|
||||||
|
} `json:"input"`
|
||||||
|
ExpectedBodyHash string `json:"expected_body_sha256_hex"`
|
||||||
|
ExpectedCanonical string `json:"expected_canonical"`
|
||||||
|
ExpectedCanonicalStarts string `json:"expected_canonical_starts_with"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &doc); err != nil {
|
||||||
|
t.Fatalf("unmarshal vectors: %v", err)
|
||||||
|
}
|
||||||
|
for _, tc := range doc.Cases {
|
||||||
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
|
bodySum := sha256.Sum256([]byte(tc.Input.BodyText))
|
||||||
|
gotBodyHash := hex.EncodeToString(bodySum[:])
|
||||||
|
if tc.ExpectedBodyHash != "" && tc.ExpectedBodyHash != gotBodyHash {
|
||||||
|
// Some vectors use illustrative body_hash that may not
|
||||||
|
// match — only fail when the vector file claims a real
|
||||||
|
// expected value (the empty-body case).
|
||||||
|
if tc.Name == "GET_empty_body" {
|
||||||
|
t.Fatalf("body hash mismatch for %s: got %s want %s",
|
||||||
|
tc.Name, gotBodyHash, tc.ExpectedBodyHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canonical := strings.Join([]string{
|
||||||
|
strings.ToUpper(tc.Input.Method),
|
||||||
|
tc.Input.Path,
|
||||||
|
tc.Input.TimestampMs,
|
||||||
|
tc.Input.Nonce,
|
||||||
|
tc.Input.Fingerprint,
|
||||||
|
gotBodyHash,
|
||||||
|
}, "\n")
|
||||||
|
if tc.ExpectedCanonical != "" && tc.ExpectedCanonical != canonical {
|
||||||
|
t.Fatalf("canonical mismatch for %s\n got: %q\n want: %q",
|
||||||
|
tc.Name, canonical, tc.ExpectedCanonical)
|
||||||
|
}
|
||||||
|
if tc.ExpectedCanonicalStarts != "" && !strings.HasPrefix(canonical, tc.ExpectedCanonicalStarts) {
|
||||||
|
t.Fatalf("canonical prefix mismatch for %s\n got: %q\n want prefix: %q",
|
||||||
|
tc.Name, canonical, tc.ExpectedCanonicalStarts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,25 @@ type Token struct {
|
|||||||
Group string `json:"group" gorm:"default:''"`
|
Group string `json:"group" gorm:"default:''"`
|
||||||
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
|
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
|
||||||
HideFromUserUI bool `json:"hide_from_user_ui" gorm:"default:false"` // 系统托管令牌:列表/API key 详情对用户隐藏(中继仍可用)
|
HideFromUserUI bool `json:"hide_from_user_ui" gorm:"default:false"` // 系统托管令牌:列表/API key 详情对用户隐藏(中继仍可用)
|
||||||
|
|
||||||
|
// Device-binding fields (P0 of device-signature work). When DevicePubkey
|
||||||
|
// is set, this token is bound to a specific Heicode client install and
|
||||||
|
// MUST be presented with X-Heicode-{Device-Id,Timestamp,Nonce,Signature}
|
||||||
|
// headers; bare bearer use of the key is rejected. When nil, the token
|
||||||
|
// is a legacy bare-bearer token (CLI/SDK back-compat path).
|
||||||
|
DeviceId *string `json:"device_id" gorm:"type:varchar(64);index"` // client-generated UUID v4
|
||||||
|
DevicePubkey *string `json:"device_pubkey" gorm:"type:text"` // base64 32-byte Ed25519 pubkey
|
||||||
|
DeviceFingerprint string `json:"device_fingerprint" gorm:"type:varchar(64);default:''"` // sha256 of HWID + hostname
|
||||||
|
DeviceName string `json:"device_name" gorm:"type:varchar(64);default:''"` // user-friendly: "Chen's MacBook"
|
||||||
|
DevicePlatform string `json:"device_platform" gorm:"type:varchar(16);default:''"` // darwin / windows / linux
|
||||||
|
DeviceAppVersion string `json:"device_app_version" gorm:"type:varchar(32);default:''"` // cc-haha version at pair time
|
||||||
|
DeviceBoundAt int64 `json:"device_bound_at" gorm:"bigint;default:0"`
|
||||||
|
DeviceLastSeenIp string `json:"device_last_seen_ip" gorm:"type:varchar(45);default:''"`
|
||||||
|
DeviceLastUsedAt int64 `json:"device_last_used_at" gorm:"bigint;default:0"`
|
||||||
|
RequireDeviceBinding bool `json:"require_device_binding" gorm:"default:false"` // per-token override: force signed only
|
||||||
|
RevokedAt int64 `json:"revoked_at" gorm:"bigint;default:0"`
|
||||||
|
RevokedReason string `json:"revoked_reason" gorm:"type:varchar(128);default:''"`
|
||||||
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +123,52 @@ func CountUserTokensVisibleInUI(userId int) (int64, error) {
|
|||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserDeviceBoundTokens returns the user's device-bound tokens for the
|
||||||
|
// "Devices" management page. Excludes legacy bare-bearer tokens (where
|
||||||
|
// device_pubkey is null) and revoked-with-tombstone rows.
|
||||||
|
func GetUserDeviceBoundTokens(userId int) ([]*Token, error) {
|
||||||
|
var tokens []*Token
|
||||||
|
err := DB.Where("user_id = ? AND device_pubkey IS NOT NULL AND device_pubkey <> ''", userId).
|
||||||
|
Order("device_last_used_at desc, id desc").
|
||||||
|
Find(&tokens).Error
|
||||||
|
return tokens, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUserDeviceBoundTokens counts ACTIVE (non-revoked) device-bound
|
||||||
|
// tokens for the per-user cap enforcement at pair time.
|
||||||
|
func CountUserDeviceBoundTokens(userId int) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := DB.Model(&Token{}).
|
||||||
|
Where("user_id = ? AND device_pubkey IS NOT NULL AND device_pubkey <> '' AND revoked_at = 0", userId).
|
||||||
|
Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTokenDeviceLastSeen records the most-recent IP and timestamp this
|
||||||
|
// device-bound token was authenticated with. Called from a goroutine after
|
||||||
|
// successful signature verification — should be best-effort, never block.
|
||||||
|
// Nil-guards DB so unit tests (or partial-init binaries) don't panic.
|
||||||
|
func UpdateTokenDeviceLastSeen(tokenId int, ip string, nowMs int64) error {
|
||||||
|
if DB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DB.Model(&Token{}).Where("id = ?", tokenId).Updates(map[string]interface{}{
|
||||||
|
"device_last_seen_ip": ip,
|
||||||
|
"device_last_used_at": nowMs,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeDevice soft-disables a device-bound token. We don't hard-delete
|
||||||
|
// so the audit trail (last seen IP, fingerprint) stays inspectable.
|
||||||
|
func RevokeDevice(tokenId int, reason string) error {
|
||||||
|
now := common.GetTimestamp() * 1000
|
||||||
|
return DB.Model(&Token{}).Where("id = ?", tokenId).Updates(map[string]interface{}{
|
||||||
|
"status": common.TokenStatusDisabled,
|
||||||
|
"revoked_at": now,
|
||||||
|
"revoked_reason": reason,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureUserRelayToken 在用户没有任何 relay 令牌时创建一枚系统托管令牌(对用户隐藏),用于登录后即可走网关。
|
// EnsureUserRelayToken 在用户没有任何 relay 令牌时创建一枚系统托管令牌(对用户隐藏),用于登录后即可走网关。
|
||||||
func EnsureUserRelayToken(userId int, username string) {
|
func EnsureUserRelayToken(userId int, username string) {
|
||||||
if userId <= 0 {
|
if userId <= 0 {
|
||||||
|
|||||||
@@ -315,6 +315,21 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
|
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device-bound tokens (cc-haha desktop client). Lives on a
|
||||||
|
// separate route group so the existing token CRUD endpoints
|
||||||
|
// continue to operate on the "user-created API key" mental
|
||||||
|
// model, and the device-pair flow doesn't accidentally show
|
||||||
|
// up in the legacy /api/token UI. CriticalRateLimit on the
|
||||||
|
// pair endpoint stops brute-force device-spam from one user.
|
||||||
|
deviceRoute := apiRouter.Group("/devices")
|
||||||
|
deviceRoute.Use(middleware.UserAuth())
|
||||||
|
{
|
||||||
|
deviceRoute.GET("/", controller.ListUserDevices)
|
||||||
|
deviceRoute.POST("/pair", middleware.CriticalRateLimit(), controller.PairDevice)
|
||||||
|
deviceRoute.PATCH("/:id", controller.RenameUserDevice)
|
||||||
|
deviceRoute.DELETE("/:id", controller.RevokeUserDevice)
|
||||||
|
}
|
||||||
|
|
||||||
usageRoute := apiRouter.Group("/usage")
|
usageRoute := apiRouter.Group("/usage")
|
||||||
usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
|
usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package operation_setting
|
||||||
|
|
||||||
|
import "github.com/heicode/manager/setting/config"
|
||||||
|
|
||||||
|
// DeviceBindingSetting controls the per-request Ed25519 device-signature
|
||||||
|
// auth introduced to stop sk- token resale. See the plan at
|
||||||
|
// ~/.claude/plans/peaceful-sprouting-crane.md for the full rollout.
|
||||||
|
//
|
||||||
|
// Phase rollout:
|
||||||
|
// P0 RequireGlobal=false — desktop clients opt-in automatically,
|
||||||
|
// legacy bare sk- bearer keeps working
|
||||||
|
// P3 RequireGlobal=true — bare sk- bearer rejected (30 days after P0)
|
||||||
|
type DeviceBindingSetting struct {
|
||||||
|
// Hard cap on how many active device-bound tokens one user can own.
|
||||||
|
// Anything beyond this gets rejected at pair time. 5 covers a typical
|
||||||
|
// dev (laptop + desktop + 2 cloud VMs + 1 CI runner) without leaving
|
||||||
|
// enough headroom for casual reselling.
|
||||||
|
MaxDevicesPerUser int `json:"max_devices_per_user"`
|
||||||
|
|
||||||
|
// How far apart the client-stamped timestamp and the server clock may
|
||||||
|
// drift before we reject. 120s = 2× the typical NTP drift envelope and
|
||||||
|
// long enough to survive a slow corporate proxy.
|
||||||
|
TimestampWindowMs int64 `json:"timestamp_window_ms"`
|
||||||
|
|
||||||
|
// How long a nonce stays "burned" in Redis. Must be ≥ the timestamp
|
||||||
|
// window — if it were shorter, a request from the past edge of the
|
||||||
|
// window could replay successfully right after the nonce TTL'd out.
|
||||||
|
NonceTTLSec int `json:"nonce_ttl_sec"`
|
||||||
|
|
||||||
|
// Global enforcement switch. Flip to true on P3 day to require all
|
||||||
|
// /v1/* relay calls to come from a device-bound token.
|
||||||
|
RequireGlobal bool `json:"require_global"`
|
||||||
|
|
||||||
|
// Per-feature switch in case we want to lock down only the chat relay
|
||||||
|
// before lockdown.
|
||||||
|
EnforceOnRelay bool `json:"enforce_on_relay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceBindingSetting = DeviceBindingSetting{
|
||||||
|
MaxDevicesPerUser: 5,
|
||||||
|
TimestampWindowMs: 120_000, // 2 minutes
|
||||||
|
NonceTTLSec: 300, // 5 minutes
|
||||||
|
RequireGlobal: false, // P3 will flip this
|
||||||
|
EnforceOnRelay: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
config.GlobalConfig.Register("device_binding_setting", &deviceBindingSetting)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceBindingSetting returns the live setting struct. The pointer
|
||||||
|
// lets the admin UI write back through the same config registration.
|
||||||
|
func GetDeviceBindingSetting() *DeviceBindingSetting {
|
||||||
|
return &deviceBindingSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMaxDevicesPerUser() int {
|
||||||
|
return GetDeviceBindingSetting().MaxDevicesPerUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDeviceTimestampWindowMs() int64 {
|
||||||
|
return GetDeviceBindingSetting().TimestampWindowMs
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDeviceNonceTTLSec() int {
|
||||||
|
return GetDeviceBindingSetting().NonceTTLSec
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceBindingRequiredGlobally() bool {
|
||||||
|
return GetDeviceBindingSetting().RequireGlobal
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Cross-language test vectors for device-signature canonical-string + Ed25519 signing. Both Manager (Go) and client (Rust+TS) tests load this file, compute the canonical string from the raw inputs in their own implementation, and assert the computed string is byte-identical to expected_canonical. The shared test key lets each side independently produce + verify a signature; because Ed25519 sign is deterministic, both implementations must produce the SAME signature for the same digest. The test seed below is RFC 8032 test vector 1's secret — published, not real. Never use these keys for anything beyond unit tests.",
|
||||||
|
|
||||||
|
"test_keypair": {
|
||||||
|
"_comment": "32-byte Ed25519 seed, hex-encoded. Public key is derived deterministically. RFC 8032 test vector 1.",
|
||||||
|
"seed_hex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
|
||||||
|
"public_key_b64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
|
||||||
|
"public_key_hex": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||||
|
},
|
||||||
|
|
||||||
|
"canonical_format_spec": {
|
||||||
|
"_comment": "EXACT byte layout that the canonical string must match. Any drift between Go and Rust/TS implementations here breaks signature verification end-to-end. Document mirror lives at: cc-haha/src/services/device/signRequest.ts (top-of-file comment) and heicode/middleware/device_signature.go (HeaderDeviceID block).",
|
||||||
|
"fields_in_order": [
|
||||||
|
"method (uppercase ASCII, e.g. POST)",
|
||||||
|
"path_with_query (RequestURI form: /v1/messages?stream=true)",
|
||||||
|
"timestamp_ms (decimal integer, no thousands separator, no sign)",
|
||||||
|
"nonce_hex (lowercase hex, 32 chars for 16 bytes)",
|
||||||
|
"device_fingerprint (lowercase hex, 64 chars for sha256)",
|
||||||
|
"sha256_hex(body_bytes) (lowercase hex, 64 chars)"
|
||||||
|
],
|
||||||
|
"separator": "\\n (single line-feed, 0x0A, between each field; NOT included after the last field)",
|
||||||
|
"digest_to_sign": "sha256(canonical_string)"
|
||||||
|
},
|
||||||
|
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "GET_empty_body",
|
||||||
|
"input": {
|
||||||
|
"method": "GET",
|
||||||
|
"path_with_query": "/v1/models",
|
||||||
|
"timestamp_ms": "1747680000000",
|
||||||
|
"nonce_hex": "0123456789abcdef0123456789abcdef",
|
||||||
|
"device_fingerprint": "a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789a",
|
||||||
|
"body_text": ""
|
||||||
|
},
|
||||||
|
"expected_body_sha256_hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"expected_canonical": "GET\n/v1/models\n1747680000000\n0123456789abcdef0123456789abcdef\na1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789a\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST_small_json_body",
|
||||||
|
"input": {
|
||||||
|
"method": "POST",
|
||||||
|
"path_with_query": "/v1/messages",
|
||||||
|
"timestamp_ms": "1747680001234",
|
||||||
|
"nonce_hex": "ffffffffffffffffffffffffffffffff",
|
||||||
|
"device_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"body_text": "{\"model\":\"claude-sonnet-4-6\",\"max_tokens\":1024}"
|
||||||
|
},
|
||||||
|
"expected_body_sha256_hex": "a86e9f8fe1ec25a48b78f4d1d3df88b3dee3c5816fbc8ce26d8b2bc44b46d4b8",
|
||||||
|
"expected_canonical_starts_with": "POST\n/v1/messages\n1747680001234\nffffffffffffffffffffffffffffffff\n0000000000000000000000000000000000000000000000000000000000000000\n",
|
||||||
|
"_note_about_body_hash": "expected_body_sha256_hex above is illustrative; tests MUST recompute SHA256 of body_text bytes (UTF-8) and compare directly rather than relying on the precomputed value."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST_streaming_request",
|
||||||
|
"input": {
|
||||||
|
"method": "POST",
|
||||||
|
"path_with_query": "/v1/messages?stream=true",
|
||||||
|
"timestamp_ms": "1747680002000",
|
||||||
|
"nonce_hex": "abababababababababababababababab",
|
||||||
|
"device_fingerprint": "11111111111111111111111111111111deadbeefdeadbeefdeadbeefdeadbeef",
|
||||||
|
"body_text": "{\"model\":\"claude-sonnet-4-6\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":256,\"stream\":true}"
|
||||||
|
},
|
||||||
|
"expected_canonical_starts_with": "POST\n/v1/messages?stream=true\n1747680002000\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET_query_with_special_chars",
|
||||||
|
"input": {
|
||||||
|
"method": "GET",
|
||||||
|
"path_with_query": "/v1/dashboard/billing/usage?date=2026-05-20&filter=cost+desc",
|
||||||
|
"timestamp_ms": "1747680003000",
|
||||||
|
"nonce_hex": "deadbeefcafebabe0001020304050607",
|
||||||
|
"device_fingerprint": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
"body_text": ""
|
||||||
|
},
|
||||||
|
"expected_canonical_starts_with": "GET\n/v1/dashboard/billing/usage?date=2026-05-20&filter=cost+desc\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"negative_cases": [
|
||||||
|
{
|
||||||
|
"name": "tampered_timestamp",
|
||||||
|
"_comment": "Sign canonical_1, then alter timestamp on the wire to canonical_2. Verify with pubkey + canonical_2 → must fail.",
|
||||||
|
"fixture": "GET_empty_body",
|
||||||
|
"tamper": { "timestamp_ms": "1747680000001" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tampered_body",
|
||||||
|
"fixture": "POST_small_json_body",
|
||||||
|
"tamper": { "body_text": "{\"model\":\"claude-opus-4-7\",\"max_tokens\":1024}" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wrong_method",
|
||||||
|
"fixture": "POST_small_json_body",
|
||||||
|
"tamper": { "method": "PUT" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user