package http import ( "context" "encoding/hex" "net/http" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/rwadurian/mpc-system/pkg/logger" "github.com/rwadurian/mpc-system/services/account/adapters/output/grpc" "github.com/rwadurian/mpc-system/services/account/application/ports" "github.com/rwadurian/mpc-system/services/account/application/use_cases" "github.com/rwadurian/mpc-system/services/account/domain/value_objects" "go.uber.org/zap" ) // AccountHTTPHandler handles HTTP requests for accounts type AccountHTTPHandler struct { createAccountUC *use_cases.CreateAccountUseCase getAccountUC *use_cases.GetAccountUseCase updateAccountUC *use_cases.UpdateAccountUseCase listAccountsUC *use_cases.ListAccountsUseCase getAccountSharesUC *use_cases.GetAccountSharesUseCase deactivateShareUC *use_cases.DeactivateShareUseCase loginUC *use_cases.LoginUseCase refreshTokenUC *use_cases.RefreshTokenUseCase generateChallengeUC *use_cases.GenerateChallengeUseCase initiateRecoveryUC *use_cases.InitiateRecoveryUseCase completeRecoveryUC *use_cases.CompleteRecoveryUseCase getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase cancelRecoveryUC *use_cases.CancelRecoveryUseCase sessionCoordinatorClient *grpc.SessionCoordinatorClient } // NewAccountHTTPHandler creates a new AccountHTTPHandler func NewAccountHTTPHandler( createAccountUC *use_cases.CreateAccountUseCase, getAccountUC *use_cases.GetAccountUseCase, updateAccountUC *use_cases.UpdateAccountUseCase, listAccountsUC *use_cases.ListAccountsUseCase, getAccountSharesUC *use_cases.GetAccountSharesUseCase, deactivateShareUC *use_cases.DeactivateShareUseCase, loginUC *use_cases.LoginUseCase, refreshTokenUC *use_cases.RefreshTokenUseCase, generateChallengeUC *use_cases.GenerateChallengeUseCase, initiateRecoveryUC *use_cases.InitiateRecoveryUseCase, completeRecoveryUC *use_cases.CompleteRecoveryUseCase, getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase, cancelRecoveryUC *use_cases.CancelRecoveryUseCase, sessionCoordinatorClient *grpc.SessionCoordinatorClient, ) *AccountHTTPHandler { return &AccountHTTPHandler{ createAccountUC: createAccountUC, getAccountUC: getAccountUC, updateAccountUC: updateAccountUC, listAccountsUC: listAccountsUC, getAccountSharesUC: getAccountSharesUC, deactivateShareUC: deactivateShareUC, loginUC: loginUC, refreshTokenUC: refreshTokenUC, generateChallengeUC: generateChallengeUC, initiateRecoveryUC: initiateRecoveryUC, completeRecoveryUC: completeRecoveryUC, getRecoveryStatusUC: getRecoveryStatusUC, cancelRecoveryUC: cancelRecoveryUC, sessionCoordinatorClient: sessionCoordinatorClient, } } // RegisterRoutes registers HTTP routes func (h *AccountHTTPHandler) RegisterRoutes(router *gin.RouterGroup) { accounts := router.Group("/accounts") { accounts.POST("", h.CreateAccount) accounts.GET("", h.ListAccounts) accounts.GET("/:id", h.GetAccount) accounts.PUT("/:id", h.UpdateAccount) accounts.GET("/:id/shares", h.GetAccountShares) accounts.DELETE("/:id/shares/:shareId", h.DeactivateShare) } auth := router.Group("/auth") { auth.POST("/challenge", h.GenerateChallenge) auth.POST("/login", h.Login) auth.POST("/refresh", h.RefreshToken) } recovery := router.Group("/recovery") { recovery.POST("", h.InitiateRecovery) recovery.GET("/:id", h.GetRecoveryStatus) recovery.POST("/:id/complete", h.CompleteRecovery) recovery.POST("/:id/cancel", h.CancelRecovery) } // MPC session management mpc := router.Group("/mpc") { mpc.POST("/keygen", h.CreateKeygenSession) mpc.POST("/sign", h.CreateSigningSession) mpc.GET("/sessions/:id", h.GetSessionStatus) } } // CreateAccountRequest represents the request for creating an account type CreateAccountRequest struct { Username string `json:"username" binding:"required"` Email string `json:"email" binding:"omitempty,email"` Phone *string `json:"phone"` PublicKey string `json:"publicKey" binding:"required"` KeygenSessionID string `json:"keygenSessionId" binding:"required"` ThresholdN int `json:"thresholdN" binding:"required,min=1"` ThresholdT int `json:"thresholdT" binding:"required,min=1"` Shares []ShareInput `json:"shares" binding:"required,min=1"` } // ShareInput represents a share in the request type ShareInput struct { ShareType string `json:"shareType" binding:"required"` PartyID string `json:"partyId" binding:"required"` PartyIndex int `json:"partyIndex" binding:"required,min=0"` DeviceType *string `json:"deviceType"` DeviceID *string `json:"deviceId"` } // CreateAccount handles account creation func (h *AccountHTTPHandler) CreateAccount(c *gin.Context) { var req CreateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } keygenSessionID, err := uuid.Parse(req.KeygenSessionID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen session ID"}) return } shares := make([]ports.ShareInput, len(req.Shares)) for i, s := range req.Shares { shares[i] = ports.ShareInput{ ShareType: value_objects.ShareType(s.ShareType), PartyID: s.PartyID, PartyIndex: s.PartyIndex, DeviceType: s.DeviceType, DeviceID: s.DeviceID, } } // Decode hex-encoded public key publicKeyBytes, err := hex.DecodeString(req.PublicKey) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public key format"}) return } output, err := h.createAccountUC.Execute(c.Request.Context(), ports.CreateAccountInput{ Username: req.Username, Email: req.Email, Phone: req.Phone, PublicKey: publicKeyBytes, KeygenSessionID: keygenSessionID, ThresholdN: req.ThresholdN, ThresholdT: req.ThresholdT, Shares: shares, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "account": output.Account, "shares": output.Shares, }) } // GetAccount handles getting account by ID func (h *AccountHTTPHandler) GetAccount(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } output, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ AccountID: &accountID, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "account": output.Account, "shares": output.Shares, }) } // UpdateAccountRequest represents the request for updating an account type UpdateAccountRequest struct { Phone *string `json:"phone"` } // UpdateAccount handles account updates func (h *AccountHTTPHandler) UpdateAccount(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } var req UpdateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } output, err := h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ AccountID: accountID, Phone: req.Phone, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, output.Account) } // ListAccounts handles listing accounts func (h *AccountHTTPHandler) ListAccounts(c *gin.Context) { var offset, limit int if o := c.Query("offset"); o != "" { // Parse offset } if l := c.Query("limit"); l != "" { // Parse limit } output, err := h.listAccountsUC.Execute(c.Request.Context(), use_cases.ListAccountsInput{ Offset: offset, Limit: limit, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "accounts": output.Accounts, "total": output.Total, }) } // GetAccountShares handles getting account shares func (h *AccountHTTPHandler) GetAccountShares(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } output, err := h.getAccountSharesUC.Execute(c.Request.Context(), accountID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "shares": output.Shares, }) } // DeactivateShare handles share deactivation func (h *AccountHTTPHandler) DeactivateShare(c *gin.Context) { idStr := c.Param("id") accountID, err := value_objects.AccountIDFromString(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } shareID := c.Param("shareId") err = h.deactivateShareUC.Execute(c.Request.Context(), ports.DeactivateShareInput{ AccountID: accountID, ShareID: shareID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "share deactivated"}) } // GenerateChallengeRequest represents the request for generating a challenge type GenerateChallengeRequest struct { Username string `json:"username" binding:"required"` } // GenerateChallenge handles challenge generation func (h *AccountHTTPHandler) GenerateChallenge(c *gin.Context) { var req GenerateChallengeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } output, err := h.generateChallengeUC.Execute(c.Request.Context(), use_cases.GenerateChallengeInput{ Username: req.Username, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "challengeId": output.ChallengeID, "challenge": hex.EncodeToString(output.Challenge), "expiresAt": output.ExpiresAt, }) } // LoginRequest represents the request for login type LoginRequest struct { Username string `json:"username" binding:"required"` Challenge string `json:"challenge" binding:"required"` Signature string `json:"signature" binding:"required"` } // Login handles user login func (h *AccountHTTPHandler) Login(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Decode hex-encoded challenge and signature // Return 401 Unauthorized for invalid formats (treated as invalid credentials) challengeBytes, err := hex.DecodeString(req.Challenge) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } signatureBytes, err := hex.DecodeString(req.Signature) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } output, err := h.loginUC.Execute(c.Request.Context(), ports.LoginInput{ Username: req.Username, Challenge: challengeBytes, Signature: signatureBytes, }) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "account": output.Account, "accessToken": output.AccessToken, "refreshToken": output.RefreshToken, }) } // RefreshTokenRequest represents the request for refreshing tokens type RefreshTokenRequest struct { RefreshToken string `json:"refreshToken" binding:"required"` } // RefreshToken handles token refresh func (h *AccountHTTPHandler) RefreshToken(c *gin.Context) { var req RefreshTokenRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } output, err := h.refreshTokenUC.Execute(c.Request.Context(), use_cases.RefreshTokenInput{ RefreshToken: req.RefreshToken, }) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "accessToken": output.AccessToken, "refreshToken": output.RefreshToken, }) } // InitiateRecoveryRequest represents the request for initiating recovery type InitiateRecoveryRequest struct { AccountID string `json:"accountId" binding:"required"` RecoveryType string `json:"recoveryType" binding:"required"` OldShareType *string `json:"oldShareType"` } // InitiateRecovery handles recovery initiation func (h *AccountHTTPHandler) InitiateRecovery(c *gin.Context) { var req InitiateRecoveryRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } accountID, err := value_objects.AccountIDFromString(req.AccountID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } input := ports.InitiateRecoveryInput{ AccountID: accountID, RecoveryType: value_objects.RecoveryType(req.RecoveryType), } if req.OldShareType != nil { st := value_objects.ShareType(*req.OldShareType) input.OldShareType = &st } output, err := h.initiateRecoveryUC.Execute(c.Request.Context(), input) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "recoverySession": output.RecoverySession, }) } // GetRecoveryStatus handles getting recovery status func (h *AccountHTTPHandler) GetRecoveryStatus(c *gin.Context) { id := c.Param("id") output, err := h.getRecoveryStatusUC.Execute(c.Request.Context(), use_cases.GetRecoveryStatusInput{ RecoverySessionID: id, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, output.RecoverySession) } // CompleteRecoveryRequest represents the request for completing recovery type CompleteRecoveryRequest struct { NewPublicKey string `json:"newPublicKey" binding:"required"` NewKeygenSessionID string `json:"newKeygenSessionId" binding:"required"` NewShares []ShareInput `json:"newShares" binding:"required,min=1"` } // CompleteRecovery handles recovery completion func (h *AccountHTTPHandler) CompleteRecovery(c *gin.Context) { id := c.Param("id") var req CompleteRecoveryRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } newKeygenSessionID, err := uuid.Parse(req.NewKeygenSessionID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen session ID"}) return } newShares := make([]ports.ShareInput, len(req.NewShares)) for i, s := range req.NewShares { newShares[i] = ports.ShareInput{ ShareType: value_objects.ShareType(s.ShareType), PartyID: s.PartyID, PartyIndex: s.PartyIndex, DeviceType: s.DeviceType, DeviceID: s.DeviceID, } } // Decode hex-encoded public key newPublicKeyBytes, err := hex.DecodeString(req.NewPublicKey) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public key format"}) return } output, err := h.completeRecoveryUC.Execute(c.Request.Context(), ports.CompleteRecoveryInput{ RecoverySessionID: id, NewPublicKey: newPublicKeyBytes, NewKeygenSessionID: newKeygenSessionID, NewShares: newShares, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, output.Account) } // CancelRecovery handles recovery cancellation func (h *AccountHTTPHandler) CancelRecovery(c *gin.Context) { id := c.Param("id") err := h.cancelRecoveryUC.Execute(c.Request.Context(), use_cases.CancelRecoveryInput{ RecoverySessionID: id, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "recovery cancelled"}) } // ============================================ // MPC Session Management Endpoints // ============================================ // CreateKeygenSessionRequest represents the request for creating a keygen session type CreateKeygenSessionRequest struct { ThresholdN int `json:"threshold_n" binding:"required,min=2"` ThresholdT int `json:"threshold_t" binding:"required,min=1"` Participants []ParticipantRequest `json:"participants" binding:"required,min=2"` } // ParticipantRequest represents a participant in the request type ParticipantRequest struct { PartyID string `json:"party_id" binding:"required"` DeviceType string `json:"device_type"` DeviceID string `json:"device_id"` } // CreateKeygenSession handles creating a new keygen session func (h *AccountHTTPHandler) CreateKeygenSession(c *gin.Context) { var req CreateKeygenSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate threshold if req.ThresholdT > req.ThresholdN { c.JSON(http.StatusBadRequest, gin.H{"error": "threshold_t cannot be greater than threshold_n"}) return } if len(req.Participants) != req.ThresholdN { c.JSON(http.StatusBadRequest, gin.H{"error": "number of participants must equal threshold_n"}) return } // Convert participants to gRPC format participants := make([]grpc.ParticipantInfo, len(req.Participants)) for i, p := range req.Participants { participants[i] = grpc.ParticipantInfo{ PartyID: p.PartyID, DeviceType: p.DeviceType, DeviceID: p.DeviceID, } } // Call session coordinator via gRPC ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() logger.Info("Calling CreateKeygenSession via gRPC", zap.Int("threshold_n", req.ThresholdN), zap.Int("threshold_t", req.ThresholdT), zap.Int("num_participants", len(participants))) resp, err := h.sessionCoordinatorClient.CreateKeygenSession( ctx, int32(req.ThresholdN), int32(req.ThresholdT), participants, 600, // 10 minutes expiry ) if err != nil { logger.Error("gRPC CreateKeygenSession failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } logger.Info("gRPC CreateKeygenSession succeeded", zap.String("session_id", resp.SessionID), zap.Int("num_join_tokens", len(resp.JoinTokens))) c.JSON(http.StatusCreated, gin.H{ "session_id": resp.SessionID, "session_type": "keygen", "threshold_n": req.ThresholdN, "threshold_t": req.ThresholdT, "join_tokens": resp.JoinTokens, "status": "created", }) } // CreateSigningSessionRequest represents the request for creating a signing session type CreateSigningSessionRequest struct { AccountID string `json:"account_id" binding:"required"` MessageHash string `json:"message_hash" binding:"required"` Participants []ParticipantRequest `json:"participants" binding:"required,min=2"` } // CreateSigningSession handles creating a new signing session func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) { var req CreateSigningSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate account ID accountID, err := value_objects.AccountIDFromString(req.AccountID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) return } // Decode message hash messageHash, err := hex.DecodeString(req.MessageHash) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message_hash format (expected hex)"}) return } if len(messageHash) != 32 { c.JSON(http.StatusBadRequest, gin.H{"error": "message_hash must be 32 bytes (SHA-256)"}) return } // Get account to verify it exists and get threshold info output, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ AccountID: &accountID, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) return } // Validate participant count against threshold if len(req.Participants) < output.Account.ThresholdT { c.JSON(http.StatusBadRequest, gin.H{ "error": "insufficient participants", "required": output.Account.ThresholdT, "provided": len(req.Participants), }) return } // Convert participants to gRPC format participants := make([]grpc.ParticipantInfo, len(req.Participants)) for i, p := range req.Participants { participants[i] = grpc.ParticipantInfo{ PartyID: p.PartyID, DeviceType: p.DeviceType, DeviceID: p.DeviceID, } } // Call session coordinator via gRPC ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() resp, err := h.sessionCoordinatorClient.CreateSigningSession( ctx, int32(output.Account.ThresholdT), participants, messageHash, 600, // 10 minutes expiry ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "session_id": resp.SessionID, "session_type": "sign", "account_id": req.AccountID, "message_hash": req.MessageHash, "threshold_t": output.Account.ThresholdT, "join_tokens": resp.JoinTokens, "status": "created", }) } // GetSessionStatus handles getting session status func (h *AccountHTTPHandler) GetSessionStatus(c *gin.Context) { sessionID := c.Param("id") // Validate session ID format if _, err := uuid.Parse(sessionID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID format"}) return } // Call session coordinator via gRPC ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() resp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } response := gin.H{ "session_id": sessionID, "status": resp.Status, "completed_parties": resp.CompletedParties, "total_parties": resp.TotalParties, } if len(resp.PublicKey) > 0 { response["public_key"] = hex.EncodeToString(resp.PublicKey) } if len(resp.Signature) > 0 { response["signature"] = hex.EncodeToString(resp.Signature) } c.JSON(http.StatusOK, response) }