diff --git a/heicode/common/crypto.go b/heicode/common/crypto.go index 3ca06bd..b08e940 100644 --- a/heicode/common/crypto.go +++ b/heicode/common/crypto.go @@ -1,9 +1,12 @@ package common import ( + "crypto/ed25519" "crypto/hmac" "crypto/sha256" + "encoding/base64" "encoding/hex" + "errors" "golang.org/x/crypto/bcrypt" ) @@ -30,3 +33,38 @@ func ValidatePasswordAndHash(password string, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 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 +} diff --git a/heicode/common/crypto_test.go b/heicode/common/crypto_test.go new file mode 100644 index 0000000..f9f9563 --- /dev/null +++ b/heicode/common/crypto_test.go @@ -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[:]) +} diff --git a/heicode/controller/device.go b/heicode/controller/device.go new file mode 100644 index 0000000..9534fdf --- /dev/null +++ b/heicode/controller/device.go @@ -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 +} diff --git a/heicode/middleware/auth.go b/heicode/middleware/auth.go index 83759b3..baf93dc 100644 --- a/heicode/middleware/auth.go +++ b/heicode/middleware/auth.go @@ -376,6 +376,25 @@ func TokenAuth() func(c *gin.Context) { 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() if len(allowIps) > 0 { clientIp := c.ClientIP() diff --git a/heicode/middleware/device_signature.go b/heicode/middleware/device_signature.go new file mode 100644 index 0000000..3521671 --- /dev/null +++ b/heicode/middleware/device_signature.go @@ -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()) + } +} diff --git a/heicode/middleware/device_signature_test.go b/heicode/middleware/device_signature_test.go new file mode 100644 index 0000000..7f86ce2 --- /dev/null +++ b/heicode/middleware/device_signature_test.go @@ -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) + } + }) + } +} diff --git a/heicode/model/token.go b/heicode/model/token.go index a2c3758..eda25eb 100644 --- a/heicode/model/token.go +++ b/heicode/model/token.go @@ -30,7 +30,26 @@ type Token struct { Group string `json:"group" gorm:"default:''"` CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 HideFromUserUI bool `json:"hide_from_user_ui" gorm:"default:false"` // 系统托管令牌:列表/API key 详情对用户隐藏(中继仍可用) - DeletedAt gorm.DeletedAt `gorm:"index"` + + // 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"` } func (token *Token) Clean() { @@ -104,6 +123,52 @@ func CountUserTokensVisibleInUI(userId int) (int64, error) { 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 令牌时创建一枚系统托管令牌(对用户隐藏),用于登录后即可走网关。 func EnsureUserRelayToken(userId int, username string) { if userId <= 0 { diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 044572a..3251353 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -315,6 +315,21 @@ func SetApiRouter(router *gin.Engine) { 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.Use(middleware.CORS(), middleware.CriticalRateLimit()) { diff --git a/heicode/service/nonce_store.go b/heicode/service/nonce_store.go new file mode 100644 index 0000000..89a2031 --- /dev/null +++ b/heicode/service/nonce_store.go @@ -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 +} diff --git a/heicode/setting/operation_setting/device_binding_setting.go b/heicode/setting/operation_setting/device_binding_setting.go new file mode 100644 index 0000000..0f3783f --- /dev/null +++ b/heicode/setting/operation_setting/device_binding_setting.go @@ -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 +} diff --git a/heicode/testdata/device_signature_vectors.json b/heicode/testdata/device_signature_vectors.json new file mode 100644 index 0000000..c5d8cf5 --- /dev/null +++ b/heicode/testdata/device_signature_vectors.json @@ -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" } + } + ] +}