feat(manager): P0 device-binding signature layer for cc-haha desktop clients

Lays down the server side of a per-request Ed25519 signature scheme that
binds a token to a specific desktop install, so the bearer key can't be
extracted from ~/.claude/cc-haha/providers.json and resold. Plan lives
at ~/.claude/plans/peaceful-sprouting-crane.md.

Compatibility: legacy bare-bearer sk- callers (CLI/SDK) pass through
unchanged until P3 (30-day deadline) flips RequireGlobal=true. No
existing token rows are modified — pubkey is nullable and defaults to
null.

Pieces:
- model.Token gains DeviceId, DevicePubkey, DeviceFingerprint, DeviceName,
  DevicePlatform, DeviceAppVersion, DeviceBoundAt, DeviceLastSeenIp,
  DeviceLastUsedAt, RequireDeviceBinding, RevokedAt, RevokedReason.
  Pure additive columns, GORM AutoMigrate handles SQLite/MySQL/PG.
- common.VerifyEd25519Signature: thin wrapper around crypto/ed25519
  stdlib, used by the new middleware. No new external deps.
- service.MarkNonceUsed: Redis SETNX-based nonce store with an
  in-memory sync.Map fallback for single-instance dev. TTL = setting.
- middleware.VerifyDeviceSignatureIfRequired: wired into TokenAuth as
  a fail-fast dispatch right after model.ValidateUserToken. Verifies
  canonical = METHOD\nPATH\nTS_MS\nNONCE\nFINGERPRINT\nSHA256(BODY),
  signed as Ed25519(sha256(canonical)). 120s timestamp window, 300s
  nonce window, fingerprint stored at pair time must match the header.
- controller.PairDevice / ListUserDevices / RenameUserDevice /
  RevokeUserDevice, mounted at /api/devices/* behind UserAuth().
  PairDevice enforces 5-per-user cap and returns the raw sk- once,
  to be stored in the client's OS keychain (not providers.json).
- operation_setting.DeviceBindingSetting: MaxDevicesPerUser=5,
  TimestampWindowMs=120000, NonceTTLSec=300, RequireGlobal=false.

Tests:
- common/crypto_test.go covers round-trip + tamper + malformed inputs.
- middleware/device_signature_test.go covers all error-path branches
  (expired ts, wrong sig, tampered body, fingerprint mismatch, replay,
  revoked, missing headers, legacy fallthrough).
- testdata/device_signature_vectors.json is the cross-language contract
  Rust+TS sides will load to assert byte-identical canonical strings.

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