diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f81ada..aa42844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Changed +- Provisioning load tests generate an ephemeral admin DID per run instead of + requiring an operator-supplied DID. - VTA-only sessions are marked `running` only after the VTA `/health` readiness probe reports a Ready replica. - Full-stack DID hosting, mediator and VTC deployments now use their HTTP diff --git a/cmd/gen-keypair/main.go b/cmd/gen-keypair/main.go index 0e65531..07a7afb 100644 --- a/cmd/gen-keypair/main.go +++ b/cmd/gen-keypair/main.go @@ -6,36 +6,9 @@ import ( "encoding/base64" "fmt" "log" - "math/big" -) - -// base58btc alphabet used by multibase -const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - -func base58Encode(b []byte) string { - n := new(big.Int).SetBytes(b) - base := big.NewInt(58) - zero := big.NewInt(0) - mod := new(big.Int) - var result []byte - for n.Cmp(zero) > 0 { - n.DivMod(n, base, mod) - result = append(result, alphabet[mod.Int64()]) - } - // leading zero bytes → '1' - for _, byt := range b { - if byt != 0 { - break - } - result = append(result, alphabet[0]) - } - // reverse - for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { - result[i], result[j] = result[j], result[i] - } - return string(result) -} + "github.com/ic3software/vtafarm-api/internal/didkey" +) func main() { pub, priv, err := ed25519.GenerateKey(rand.Reader) @@ -43,10 +16,10 @@ func main() { log.Fatal(err) } - // did:key encoding: multibase base58btc of (multicodec ed25519-pub prefix + pubkey) - // ed25519-pub multicodec varint: 0xed 0x01 - prefixed := append([]byte{0xed, 0x01}, pub...) - didKey := "did:key:z" + base58Encode(prefixed) + didKey, err := didkey.FromPublicKey(pub) + if err != nil { + log.Fatal(err) + } // Store only the 32-byte seed (private key), not the full 64-byte Go representation privB64 := base64.StdEncoding.EncodeToString(priv.Seed()) diff --git a/docs/vta-provisioning-load-test.md b/docs/vta-provisioning-load-test.md index 94fc087..b180e6a 100644 --- a/docs/vta-provisioning-load-test.md +++ b/docs/vta-provisioning-load-test.md @@ -6,10 +6,13 @@ orchestrator path used by the user portal. ## Run lifecycle -`POST /api/v1/admin/load-tests` accepts a count (1–50), one VTA image, and one -`did:key` admin DID. Members use the existing `platform` system account and are -named `load--NNN`. Up to ten member records are created in parallel; -each recorded session then runs independently in the ordinary orchestrator. +`POST /api/v1/admin/load-tests` accepts a count (1–50) and one VTA image. The API +generates one ephemeral `did:key` for the run, discards its private key, and +supplies the DID to every member so the ordinary pipeline continues through +deployment without operator input. Members use the existing `platform` system +account and are named `load--NNN`. Up to ten member records are created +in parallel; each recorded session then runs independently in the ordinary +orchestrator. Only one run may own active resources at a time. A partial run remains active until it is deleted so its successfully created members cannot be forgotten. diff --git a/internal/apidocs/openapi.yaml b/internal/apidocs/openapi.yaml index 7f3622f..5fd8549 100644 --- a/internal/apidocs/openapi.yaml +++ b/internal/apidocs/openapi.yaml @@ -1560,8 +1560,8 @@ paths: description: | Records a run and asynchronously creates 1–50 ordinary VTA-only sessions against the platform stack. Names are generated as - `load--NNN`; one admin DID is supplied to every session so each - pipeline automatically continues through deployment. + `load--NNN`; the API generates one ephemeral admin DID for the + run so every member pipeline automatically continues through deployment. tags: [Admin] security: - CookieAuthAdmin: [] @@ -1573,14 +1573,13 @@ paths: type: object properties: count: { type: integer, minimum: 1, maximum: 50 } - admin_did: { type: string, example: "did:key:z6Mk..." } vta_image: { type: string } - required: [count, admin_did, vta_image] + required: [count, vta_image] responses: "202": description: Run recorded and member creation started "400": - description: Invalid count, DID, or image + description: Invalid count or image content: application/json: schema: { $ref: "#/components/schemas/Error" } diff --git a/internal/didkey/didkey.go b/internal/didkey/didkey.go new file mode 100644 index 0000000..0d537d6 --- /dev/null +++ b/internal/didkey/didkey.go @@ -0,0 +1,55 @@ +package didkey + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "math/big" +) + +const base58BTCAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// Generate creates a valid did:key and deliberately discards its private key. +func Generate() (string, error) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + return FromPublicKey(publicKey) +} + +// FromPublicKey encodes an Ed25519 public key as a did:key identifier. +func FromPublicKey(publicKey ed25519.PublicKey) (string, error) { + if len(publicKey) != ed25519.PublicKeySize { + return "", errors.New("ed25519 public key must be 32 bytes") + } + + // ed25519-pub multicodec varint: 0xed 0x01. + prefixed := make([]byte, 2+len(publicKey)) + prefixed[0], prefixed[1] = 0xed, 0x01 + copy(prefixed[2:], publicKey) + return "did:key:z" + base58Encode(prefixed), nil +} + +func base58Encode(value []byte) string { + n := new(big.Int).SetBytes(value) + base := big.NewInt(58) + zero := big.NewInt(0) + mod := new(big.Int) + + result := make([]byte, 0, len(value)*2) + for n.Cmp(zero) > 0 { + n.DivMod(n, base, mod) + result = append(result, base58BTCAlphabet[mod.Int64()]) + } + for _, b := range value { + if b != 0 { + break + } + result = append(result, base58BTCAlphabet[0]) + } + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + return string(result) +} diff --git a/internal/didkey/didkey_test.go b/internal/didkey/didkey_test.go new file mode 100644 index 0000000..f399248 --- /dev/null +++ b/internal/didkey/didkey_test.go @@ -0,0 +1,23 @@ +package didkey + +import ( + "crypto/ed25519" + "strings" + "testing" +) + +func TestGenerate(t *testing.T) { + did, err := Generate() + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + if !strings.HasPrefix(did, "did:key:z6Mk") { + t.Fatalf("Generate() = %q, want an Ed25519 did:key", did) + } +} + +func TestFromPublicKeyRejectsInvalidLength(t *testing.T) { + if _, err := FromPublicKey(ed25519.PublicKey{1, 2, 3}); err == nil { + t.Fatal("FromPublicKey() accepted an invalid public key") + } +} diff --git a/internal/handler/admin_load_testing.go b/internal/handler/admin_load_testing.go index 8f5e7c0..7044060 100644 --- a/internal/handler/admin_load_testing.go +++ b/internal/handler/admin_load_testing.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/ic3software/vtafarm-api/internal/capacity" + "github.com/ic3software/vtafarm-api/internal/didkey" "github.com/ic3software/vtafarm-api/internal/k8s" "github.com/ic3software/vtafarm-api/internal/middleware" "github.com/ic3software/vtafarm-api/internal/model" @@ -26,7 +27,6 @@ const ( type createLoadTestRequest struct { Count int `json:"count"` - AdminDid string `json:"admin_did"` VtaImage string `json:"vta_image"` } @@ -75,16 +75,11 @@ func (h *SetupHandler) AdminCreateLoadTest(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - req.AdminDid = strings.TrimSpace(req.AdminDid) req.VtaImage = strings.TrimSpace(req.VtaImage) if req.Count < 1 || req.Count > maxLoadTestSessions { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("count must be between 1 and %d", maxLoadTestSessions)}) return } - if !didKeyRe.MatchString(req.AdminDid) { - c.JSON(http.StatusBadRequest, gin.H{"error": "admin_did must be a did:key value produced by pnm setup"}) - return - } if req.VtaImage == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "vta_image is required"}) return @@ -119,6 +114,11 @@ func (h *SetupHandler) AdminCreateLoadTest(c *gin.Context) { return } } + adminDid, err := didkey.Generate() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate load-test admin DID"}) + return + } adminID := c.MustGet(middleware.ContextUserID).(uint) run := model.LoadTestRun{ @@ -145,13 +145,14 @@ func (h *SetupHandler) AdminCreateLoadTest(c *gin.Context) { return } - go h.startLoadTest(run.ID, provider.UserID, req, infra, provider) + go h.startLoadTest(run.ID, provider.UserID, req, adminDid, infra, provider) c.JSON(http.StatusAccepted, gin.H{"id": run.ID, "status": run.Status}) } func (h *SetupHandler) startLoadTest( runID, userID uint, request createLoadTestRequest, + adminDid string, infra sharedInfra, provider *model.SetupSession, ) { @@ -172,7 +173,7 @@ func (h *SetupHandler) startLoadTest( Mode: model.ModeVtaOnly, VtaName: name, VtaImage: request.VtaImage, - AdminDid: request.AdminDid, + AdminDid: adminDid, }, infra, provider, &runID) cancel() results <- createResult{err: err}