Address the desktop client team gap analysis on the unified /api/heicode/*
surface:
- P0-1 GET device auth: no-body V2 signed-GET path (fetch forbids GET body),
same Ed25519 canonical with empty-body hash; UserOrV2DeviceAuth +
OptionalV2DeviceAuth dispatch on signature headers. Unit test added.
- P0-2 approval inbox: GET .../tasks/{id}/approvals?status=pending.
- P0-3 project_folder: artifacts list normalizes the primary code deliverable
to display_artifact_type=project_folder + is_project + manifest/files/
archive/revisions subpaths.
- P0-4 archive contract: real application/zip + Content-Disposition +
Content-Length; ARTIFACT_ARCHIVE_NOT_READY (retryable) when no files yet.
- P1-1 file path: GET .../files?path=<url-encoded> (no segment ambiguity).
- P1-2/P1-3 revision: local edits stored as accepted baseline; /messages and
/execute consume the latest accepted revision (-> applied), return
active_project_revision.
- P1-4 doc: Swarm same-shape routes stated explicitly.
Doc updated to match. Build + middleware/controller tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
398 lines
12 KiB
Go
398 lines
12 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
"github.com/heicode/manager/service"
|
|
"golang.org/x/crypto/chacha20poly1305"
|
|
"golang.org/x/crypto/curve25519"
|
|
"golang.org/x/crypto/hkdf"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestUserOrV2DeviceAuthRejectsMalformedEncryptedRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.POST("/api/agent/user/tasks/:task_id/deployment-draft", UserOrV2DeviceAuth(), func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
})
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/agent/user/tasks/task-v2/deployment-draft",
|
|
nil,
|
|
)
|
|
req.Header.Set("Content-Encoding", V2ContentEncoding)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get(HeaderAuthError); got != V2AuthErrorEphPubkeyMissing {
|
|
t.Fatalf("%s = %q, want %q", HeaderAuthError, got, V2AuthErrorEphPubkeyMissing)
|
|
}
|
|
if rec.Header().Get(HeaderServerTime) == "" {
|
|
t.Fatalf("%s was not set", HeaderServerTime)
|
|
}
|
|
}
|
|
|
|
func TestOptionalV2DeviceAuthAllowsPlainRequests(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.POST("/api/heicode-auth/api/user/tasks/intent", OptionalV2DeviceAuth(), func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/heicode-auth/api/user/tasks/intent", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUserOrV2DeviceAuthDecryptsValidEncryptedRequest(t *testing.T) {
|
|
setupV2UserAuthTestDB(t)
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
deviceID := "device-v2-success"
|
|
fingerprint := strings.Repeat("c", 64)
|
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate ed25519 key: %v", err)
|
|
}
|
|
publicKeyB64 := base64.StdEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))
|
|
deviceIDCopy := deviceID
|
|
token := model.Token{
|
|
UserId: 42,
|
|
Key: "sk-v2-success-test",
|
|
Status: common.TokenStatusEnabled,
|
|
Name: "v2 success test",
|
|
ExpiredTime: -1,
|
|
UnlimitedQuota: true,
|
|
DeviceId: &deviceIDCopy,
|
|
DevicePubkey: &publicKeyB64,
|
|
DeviceFingerprint: fingerprint,
|
|
RequireDeviceBinding: true,
|
|
}
|
|
if err := token.Insert(); err != nil {
|
|
t.Fatalf("insert token: %v", err)
|
|
}
|
|
|
|
router := gin.New()
|
|
router.POST("/api/agent/user/tasks/:task_id/deployment-draft", UserOrV2DeviceAuth(), func(c *gin.Context) {
|
|
if got := c.GetInt("id"); got != 42 {
|
|
t.Fatalf("id context = %d, want 42", got)
|
|
}
|
|
var payload struct {
|
|
Objective string `json:"objective"`
|
|
}
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
t.Fatalf("bind decrypted body: %v", err)
|
|
}
|
|
if payload.Objective != "encrypted sub task" {
|
|
t.Fatalf("objective = %q", payload.Objective)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
})
|
|
|
|
path := "/api/agent/user/tasks/task-v2/deployment-draft"
|
|
body := []byte(`{"objective":"encrypted sub task"}`)
|
|
req := newEncryptedV2Request(t, http.MethodPost, path, body, deviceID, fingerprint, privateKey)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUserOrV2DeviceAuthDecryptsEncryptedSwarmRequest(t *testing.T) {
|
|
setupV2UserAuthTestDB(t)
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
deviceID := "device-v2-swarm"
|
|
fingerprint := strings.Repeat("d", 64)
|
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate ed25519 key: %v", err)
|
|
}
|
|
publicKeyB64 := base64.StdEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))
|
|
deviceIDCopy := deviceID
|
|
token := model.Token{
|
|
UserId: 42,
|
|
Key: "sk-v2-swarm-test",
|
|
Status: common.TokenStatusEnabled,
|
|
Name: "v2 swarm test",
|
|
ExpiredTime: -1,
|
|
UnlimitedQuota: true,
|
|
DeviceId: &deviceIDCopy,
|
|
DevicePubkey: &publicKeyB64,
|
|
DeviceFingerprint: fingerprint,
|
|
RequireDeviceBinding: true,
|
|
}
|
|
if err := token.Insert(); err != nil {
|
|
t.Fatalf("insert token: %v", err)
|
|
}
|
|
|
|
router := gin.New()
|
|
router.POST("/api/swarms", UserOrV2DeviceAuth(), func(c *gin.Context) {
|
|
if got := c.GetInt("id"); got != 42 {
|
|
t.Fatalf("id context = %d, want 42", got)
|
|
}
|
|
var payload struct {
|
|
OrchestrationPlan struct {
|
|
Objective string `json:"objective"`
|
|
} `json:"orchestration_plan"`
|
|
}
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
t.Fatalf("bind decrypted body: %v", err)
|
|
}
|
|
if payload.OrchestrationPlan.Objective != "encrypted swarm task" {
|
|
t.Fatalf("objective = %q", payload.OrchestrationPlan.Objective)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
})
|
|
|
|
body := []byte(`{"orchestration_plan":{"objective":"encrypted swarm task"}}`)
|
|
req := newEncryptedV2Request(t, http.MethodPost, "/api/swarms", body, deviceID, fingerprint, privateKey)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUserOrV2DeviceAuthVerifiesNoBodySignedGet covers the GET path: fetch
|
|
// forbids a body on GET, so the client signs with the same canonical but an
|
|
// empty body hash and no Content-Encoding (gap P0-1).
|
|
func TestUserOrV2DeviceAuthVerifiesNoBodySignedGet(t *testing.T) {
|
|
setupV2UserAuthTestDB(t)
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
deviceID := "device-v2-get"
|
|
fingerprint := strings.Repeat("e", 64)
|
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate ed25519 key: %v", err)
|
|
}
|
|
publicKeyB64 := base64.StdEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))
|
|
deviceIDCopy := deviceID
|
|
token := model.Token{
|
|
UserId: 42,
|
|
Key: "sk-v2-get-test",
|
|
Status: common.TokenStatusEnabled,
|
|
Name: "v2 get test",
|
|
ExpiredTime: -1,
|
|
UnlimitedQuota: true,
|
|
DeviceId: &deviceIDCopy,
|
|
DevicePubkey: &publicKeyB64,
|
|
DeviceFingerprint: fingerprint,
|
|
RequireDeviceBinding: true,
|
|
}
|
|
if err := token.Insert(); err != nil {
|
|
t.Fatalf("insert token: %v", err)
|
|
}
|
|
|
|
router := gin.New()
|
|
router.GET("/api/heicode/sub-agile/tasks/:task_id/workflow", UserOrV2DeviceAuth(), func(c *gin.Context) {
|
|
if got := c.GetInt("id"); got != 42 {
|
|
t.Fatalf("id context = %d, want 42", got)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
})
|
|
|
|
path := "/api/heicode/sub-agile/tasks/dep_x/workflow"
|
|
req := newSignedV2GetRequest(t, path, deviceID, fingerprint, privateKey)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// newSignedV2GetRequest builds a no-body device-signed GET: ephemeral pubkey
|
|
// header + signature over the canonical with sha256("") as the body hash, and
|
|
// NO Content-Encoding (no encrypted body).
|
|
func newSignedV2GetRequest(t *testing.T, path, deviceID, fingerprint string, signingKey ed25519.PrivateKey) *http.Request {
|
|
t.Helper()
|
|
method := http.MethodGet
|
|
ephPriv := make([]byte, curve25519.ScalarSize)
|
|
if _, err := rand.Read(ephPriv); err != nil {
|
|
t.Fatalf("generate eph private key: %v", err)
|
|
}
|
|
ephPriv[0] &= 248
|
|
ephPriv[31] &= 127
|
|
ephPriv[31] |= 64
|
|
ephPub, err := curve25519.X25519(ephPriv, curve25519.Basepoint)
|
|
if err != nil {
|
|
t.Fatalf("derive eph public key: %v", err)
|
|
}
|
|
ephPubB64 := base64.StdEncoding.EncodeToString(ephPub)
|
|
headerNonce := randomNonce()
|
|
timestamp := fmt.Sprint(time.Now().UnixMilli())
|
|
emptyHash := sha256.Sum256(nil)
|
|
canonical := strings.Join([]string{
|
|
method,
|
|
path,
|
|
timestamp,
|
|
headerNonce,
|
|
fingerprint,
|
|
ephPubB64,
|
|
hex.EncodeToString(emptyHash[:]),
|
|
}, "\n")
|
|
digest := sha256.Sum256([]byte(canonical))
|
|
signature := ed25519.Sign(signingKey, digest[:])
|
|
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.Header.Set(HeaderEphPubkey, ephPubB64)
|
|
req.Header.Set(HeaderDeviceID, deviceID)
|
|
req.Header.Set(HeaderTimestamp, timestamp)
|
|
req.Header.Set(HeaderNonce, headerNonce)
|
|
req.Header.Set(HeaderFingerprint, fingerprint)
|
|
req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(signature))
|
|
req.Header.Set("Accept", "application/json")
|
|
return req
|
|
}
|
|
|
|
func setupV2UserAuthTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
common.UsingSQLite = true
|
|
common.UsingMySQL = false
|
|
common.UsingPostgreSQL = false
|
|
common.RedisEnabled = false
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
model.DB = db
|
|
model.LOG_DB = db
|
|
if err := db.AutoMigrate(&model.User{}, &model.Token{}, &model.ServerKey{}); err != nil {
|
|
t.Fatalf("automigrate: %v", err)
|
|
}
|
|
if err := db.Create(&model.User{
|
|
Id: 42,
|
|
Username: "v2-user",
|
|
Password: "not-used",
|
|
Status: common.UserStatusEnabled,
|
|
Group: "default",
|
|
Quota: 1000000,
|
|
}).Error; err != nil {
|
|
t.Fatalf("insert user: %v", err)
|
|
}
|
|
if err := service.EnsureServerECDHKey(); err != nil {
|
|
t.Fatalf("ensure server ecdh key: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if model.DB == db {
|
|
model.DB = nil
|
|
}
|
|
if model.LOG_DB == db {
|
|
model.LOG_DB = nil
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
return db
|
|
}
|
|
|
|
func newEncryptedV2Request(t *testing.T, method, path string, plaintext []byte, deviceID, fingerprint string, signingKey ed25519.PrivateKey) *http.Request {
|
|
t.Helper()
|
|
serverPubB64, err := service.GetServerECDHPublicKeyB64()
|
|
if err != nil {
|
|
t.Fatalf("get server pubkey: %v", err)
|
|
}
|
|
serverPub, err := base64.StdEncoding.DecodeString(serverPubB64)
|
|
if err != nil {
|
|
t.Fatalf("decode server pubkey: %v", err)
|
|
}
|
|
ephPriv := make([]byte, curve25519.ScalarSize)
|
|
if _, err := rand.Read(ephPriv); err != nil {
|
|
t.Fatalf("generate eph private key: %v", err)
|
|
}
|
|
ephPriv[0] &= 248
|
|
ephPriv[31] &= 127
|
|
ephPriv[31] |= 64
|
|
ephPub, err := curve25519.X25519(ephPriv, curve25519.Basepoint)
|
|
if err != nil {
|
|
t.Fatalf("derive eph public key: %v", err)
|
|
}
|
|
shared, err := curve25519.X25519(ephPriv, serverPub)
|
|
if err != nil {
|
|
t.Fatalf("derive shared key: %v", err)
|
|
}
|
|
r := hkdf.New(sha256.New, shared, nil, []byte("heicode-aead-v1"))
|
|
key := make([]byte, chacha20poly1305.KeySize)
|
|
if _, err := io.ReadFull(r, key); err != nil {
|
|
t.Fatalf("derive aead key: %v", err)
|
|
}
|
|
aead, err := chacha20poly1305.New(key)
|
|
if err != nil {
|
|
t.Fatalf("new aead: %v", err)
|
|
}
|
|
headerNonce := randomNonce()
|
|
timestamp := fmt.Sprint(time.Now().UnixMilli())
|
|
aad := strings.Join([]string{deviceID, timestamp, headerNonce, method, path}, "|")
|
|
aeadNonce := make([]byte, aead.NonceSize())
|
|
if _, err := rand.Read(aeadNonce); err != nil {
|
|
t.Fatalf("generate aead nonce: %v", err)
|
|
}
|
|
ciphertext := aead.Seal(nil, aeadNonce, plaintext, []byte(aad))
|
|
encryptedBody := append(append([]byte{}, aeadNonce...), ciphertext...)
|
|
|
|
ephPubB64 := base64.StdEncoding.EncodeToString(ephPub)
|
|
bodyHash := sha256.Sum256(plaintext)
|
|
canonical := strings.Join([]string{
|
|
method,
|
|
path,
|
|
timestamp,
|
|
headerNonce,
|
|
fingerprint,
|
|
ephPubB64,
|
|
hex.EncodeToString(bodyHash[:]),
|
|
}, "\n")
|
|
digest := sha256.Sum256([]byte(canonical))
|
|
signature := ed25519.Sign(signingKey, digest[:])
|
|
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(encryptedBody))
|
|
req.Header.Set("Content-Encoding", V2ContentEncoding)
|
|
req.Header.Set(HeaderEphPubkey, ephPubB64)
|
|
req.Header.Set(HeaderDeviceID, deviceID)
|
|
req.Header.Set(HeaderTimestamp, timestamp)
|
|
req.Header.Set(HeaderNonce, headerNonce)
|
|
req.Header.Set(HeaderFingerprint, fingerprint)
|
|
req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(signature))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
return req
|
|
}
|