From 613ed380369c6d00631e8262dda2f97eee4bd53c Mon Sep 17 00:00:00 2001 From: Darnell Andries Date: Fri, 25 Oct 2024 21:12:51 -0700 Subject: [PATCH] Add OPAQUE login --- controllers/accounts.go | 19 +- controllers/auth.go | 231 +++++++++++++++++++++++- controllers/sessions.go | 10 +- datastore/accounts.go | 42 ++++- datastore/ake_states.go | 45 +++-- datastore/registration_states.go | 14 +- datastore/sessions.go | 4 +- docs/docs.go | 183 +++++++++++++++++++ docs/swagger.json | 183 +++++++++++++++++++ docs/swagger.yaml | 121 +++++++++++++ main.go | 11 +- middleware/auth.go | 7 +- migrations/20241025031241_opaque.up.sql | 3 +- misc/test-client/main.go | 5 + services/opaque.go | 115 +++++++++++- util/util.go | 2 +- 16 files changed, 936 insertions(+), 59 deletions(-) diff --git a/controllers/accounts.go b/controllers/accounts.go index 12ad183..8ed9029 100644 --- a/controllers/accounts.go +++ b/controllers/accounts.go @@ -13,10 +13,12 @@ import ( opaqueMsg "github.com/bytemare/opaque/message" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/go-playground/validator/v10" ) type AccountsController struct { opaqueService *services.OpaqueService + validate *validator.Validate jwtUtil *util.JWTUtil ds *datastore.Datastore } @@ -99,7 +101,12 @@ func FromOpaqueRegistrationResponse(opaqueResp *opaqueMsg.RegistrationResponse) } func NewAccountsController(opaqueService *services.OpaqueService, jwtUtil *util.JWTUtil, ds *datastore.Datastore) *AccountsController { - return &AccountsController{opaqueService, jwtUtil, ds} + return &AccountsController{ + opaqueService: opaqueService, + validate: validator.New(validator.WithRequiredStructEnabled()), + jwtUtil: jwtUtil, + ds: ds, + } } func (ac *AccountsController) Router(authMiddleware func(http.Handler) http.Handler, verificationAuthMiddleware func(http.Handler) http.Handler) chi.Router { @@ -120,6 +127,11 @@ func (ac *AccountsController) setupPasswordInitHelper(email string, w http.Respo return } + if err := ac.validate.Struct(requestData); err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + opaqueReq, err := requestData.ToOpaqueRequest(ac.opaqueService) if err != nil { util.RenderErrorResponse(w, r, http.StatusBadRequest, err) @@ -149,6 +161,11 @@ func (ac *AccountsController) setupPasswordFinalizeHelper(email string, w http.R return nil } + if err := ac.validate.Struct(requestData); err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return nil + } + opaqueRecord, err := requestData.ToOpaqueRecord(ac.opaqueService) if err != nil { util.RenderErrorResponse(w, r, http.StatusBadRequest, err) diff --git a/controllers/auth.go b/controllers/auth.go index f94cc4a..bd242c1 100644 --- a/controllers/auth.go +++ b/controllers/auth.go @@ -1,27 +1,129 @@ package controllers import ( + "encoding/hex" + "errors" + "fmt" "net/http" "github.com/brave-experiments/accounts/datastore" "github.com/brave-experiments/accounts/middleware" "github.com/brave-experiments/accounts/services" + "github.com/brave-experiments/accounts/util" + opaqueMsg "github.com/bytemare/opaque/message" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/go-playground/validator/v10" ) type AuthController struct { opaqueService *services.OpaqueService + validate *validator.Validate + jwtUtil *util.JWTUtil + ds *datastore.Datastore } -func NewAuthController(opaqueService *services.OpaqueService) *AuthController { - return &AuthController{opaqueService} +type KE1 struct { + Email string `json:"email" validate:"required,email,ascii" example:"test@example.com"` + BlindedMessage string `json:"blindedMessage" validate:"required"` + EpkU string `json:"clientEphemeralPublicKey" validate:"required"` + NonceU string `json:"clientNonce" validate:"required"` +} + +type KE2 struct { + AkeToken string `json:"akeToken"` + EvaluatedMessage string `json:"evaluatedMessage"` + MaskingNonce string `json:"maskingNonce"` + MaskedResponse string `json:"maskedResponse"` + EpkS string `json:"serverEphemeralPublicKey"` + NonceS string `json:"serverNonce"` + Mac string `json:"serverMac"` +} + +type KE3 struct { + Mac string `json:"clientMac" validate:"required"` + SessionName *string `json:"sessionName"` +} + +type LoginFinalizeResponse struct { + AuthToken string `json:"authToken"` +} + +func (req *KE1) ToOpaqueKE1(opaqueService *services.OpaqueService) (*opaqueMsg.KE1, error) { + blindedMessage, err := hex.DecodeString(req.BlindedMessage) + if err != nil { + return nil, fmt.Errorf("failed to decode blinded message: %w", err) + } + epk, err := hex.DecodeString(req.EpkU) + if err != nil { + return nil, fmt.Errorf("failed to decode epk: %w", err) + } + nonce, err := hex.DecodeString(req.NonceU) + if err != nil { + return nil, fmt.Errorf("failed to decode nonce: %w", err) + } + blindedMessageElement := opaqueService.NewElement() + epkElement := opaqueService.NewElement() + if err = blindedMessageElement.UnmarshalBinary(blindedMessage); err != nil { + return nil, fmt.Errorf("failed to decode blinded message to element: %w", err) + } + if err = epkElement.UnmarshalBinary(epk); err != nil { + return nil, fmt.Errorf("failed to decode epk to element: %w", err) + } + + return &opaqueMsg.KE1{ + CredentialRequest: &opaqueMsg.CredentialRequest{ + BlindedMessage: blindedMessageElement, + }, + EpkU: epkElement, + NonceU: nonce, + }, nil +} + +func FromOpaqueKE2(opaqueResp *opaqueMsg.KE2) (*KE2, error) { + evalMsgBin, err := opaqueResp.EvaluatedMessage.MarshalBinary() + if err != nil { + return nil, fmt.Errorf("failed to serialize evaluated message: %w", err) + } + epkBin, err := opaqueResp.EpkS.MarshalBinary() + if err != nil { + return nil, fmt.Errorf("failed to serialize evaluated message: %w", err) + } + return &KE2{ + EvaluatedMessage: hex.EncodeToString(evalMsgBin), + MaskingNonce: hex.EncodeToString(opaqueResp.MaskingNonce), + MaskedResponse: hex.EncodeToString(opaqueResp.MaskedResponse), + EpkS: hex.EncodeToString(epkBin), + NonceS: hex.EncodeToString(opaqueResp.NonceS), + Mac: hex.EncodeToString(opaqueResp.Mac), + }, nil +} + +func (req *KE3) ToOpaqueKE3() (*opaqueMsg.KE3, error) { + mac, err := hex.DecodeString(req.Mac) + if err != nil { + return nil, fmt.Errorf("failed to decode mac: %w", err) + } + return &opaqueMsg.KE3{ + Mac: mac, + }, nil +} + +func NewAuthController(opaqueService *services.OpaqueService, jwtUtil *util.JWTUtil, ds *datastore.Datastore) *AuthController { + return &AuthController{ + opaqueService: opaqueService, + validate: validator.New(validator.WithRequiredStructEnabled()), + jwtUtil: jwtUtil, + ds: ds, + } } func (ac *AuthController) Router(authMiddleware func(http.Handler) http.Handler) chi.Router { r := chi.NewRouter() r.With(authMiddleware).Get("/validate", ac.Validate) + r.With(authMiddleware).Post("/login/init", ac.LoginInit) + r.With(authMiddleware).Post("/login/finalize", ac.LoginInit) return r } @@ -33,6 +135,7 @@ func (ac *AuthController) Router(authMiddleware func(http.Handler) http.Handler) // @Param Authorization header string true "Bearer + auth token" // @Success 200 {object} ValidateTokenResponse // @Failure 401 {object} util.ErrorResponse +// @Failure 403 {object} util.ErrorResponse // @Failure 500 {object} util.ErrorResponse // @Router /v2/auth/validate [get] func (ac *AuthController) Validate(w http.ResponseWriter, r *http.Request) { @@ -47,3 +150,127 @@ func (ac *AuthController) Validate(w http.ResponseWriter, r *http.Request) { render.Status(r, http.StatusOK) render.JSON(w, r, response) } + +// @Summary Initialize login +// @Description First step of OPAQUE login flow, generates KE2 message +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body KE1 true "KE1 message" +// @Success 200 {object} KE2 +// @Failure 400 {object} util.ErrorResponse +// @Failure 500 {object} util.ErrorResponse +// @Router /v2/auth/login/init [post] +func (ac *AuthController) LoginInit(w http.ResponseWriter, r *http.Request) { + var requestData KE1 + if err := render.DecodeJSON(r.Body, &requestData); err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + + if err := ac.validate.Struct(requestData); err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + + opaqueReq, err := requestData.ToOpaqueKE1(ac.opaqueService) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + + ke2, akeState, err := ac.opaqueService.LoginInit(requestData.Email, opaqueReq) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusInternalServerError, err) + return + } + + akeToken, err := ac.jwtUtil.CreateEphemeralAKEToken(akeState.ID, datastore.AkeStateExpiration) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusInternalServerError, err) + return + } + + response, err := FromOpaqueKE2(ke2) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusInternalServerError, err) + return + } + response.AkeToken = akeToken + + render.Status(r, http.StatusOK) + render.JSON(w, r, response) +} + +// @Summary Finalize login +// @Description Final step of login flow, verifies KE3 message and creates session +// @Tags Auth +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer + ake token" +// @Param request body KE3 true "KE3 message" +// @Success 200 {object} LoginFinalizeResponse +// @Failure 400 {object} util.ErrorResponse +// @Failure 401 {object} util.ErrorResponse +// @Failure 500 {object} util.ErrorResponse +// @Router /v2/auth/login/finalize [post] +func (ac *AuthController) LoginFinalize(w http.ResponseWriter, r *http.Request) { + token, err := util.ExtractAuthToken(r) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + + akeStateID, err := ac.jwtUtil.ValidateEphemeralAKEToken(token) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusUnauthorized, err) + return + } + + var requestData KE3 + if err := render.DecodeJSON(r.Body, &requestData); err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + + if err := ac.validate.Struct(requestData); err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + + opaqueReq, err := requestData.ToOpaqueKE3() + if err != nil { + util.RenderErrorResponse(w, r, http.StatusBadRequest, err) + return + } + + accountID, err := ac.opaqueService.LoginFinalize(akeStateID, opaqueReq) + if err != nil { + if errors.Is(err, services.ErrIncorrectCredentials) || + errors.Is(err, datastore.ErrAKEStateNotFound) || + errors.Is(err, datastore.ErrAKEStateExpired) { + util.RenderErrorResponse(w, r, http.StatusUnauthorized, err) + return + } + util.RenderErrorResponse(w, r, http.StatusInternalServerError, err) + return + } + + session, err := ac.ds.CreateSession(*accountID, requestData.SessionName) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusInternalServerError, err) + return + } + + authToken, err := ac.jwtUtil.CreateAuthToken(session.ID) + if err != nil { + util.RenderErrorResponse(w, r, http.StatusInternalServerError, err) + return + } + response := LoginFinalizeResponse{ + AuthToken: authToken, + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, response) +} diff --git a/controllers/sessions.go b/controllers/sessions.go index e1ddf08..a9552b5 100644 --- a/controllers/sessions.go +++ b/controllers/sessions.go @@ -13,12 +13,14 @@ import ( ) type SessionsController struct { - datastore *datastore.Datastore + datastore *datastore.Datastore + minSessionVersion int } -func NewSessionsController(datastore *datastore.Datastore) *SessionsController { +func NewSessionsController(datastore *datastore.Datastore, minSessionVersion int) *SessionsController { return &SessionsController{ - datastore: datastore, + datastore, + minSessionVersion, } } @@ -34,7 +36,7 @@ func NewSessionsController(datastore *datastore.Datastore) *SessionsController { func (sc *SessionsController) ListSessions(w http.ResponseWriter, r *http.Request) { session := r.Context().Value(middleware.ContextSession).(*datastore.Session) - sessions, err := sc.datastore.ListSessions(session.AccountID) + sessions, err := sc.datastore.ListSessions(session.AccountID, sc.minSessionVersion) if err != nil { util.RenderErrorResponse(w, r, http.StatusInternalServerError, err) return diff --git a/datastore/accounts.go b/datastore/accounts.go index 4e64e12..4e0737a 100644 --- a/datastore/accounts.go +++ b/datastore/accounts.go @@ -17,17 +17,37 @@ type Account struct { CreatedAt time.Time } -func (d *Datastore) GetOrCreateAccount(email string) (*Account, error) { +var ErrAccountNotFound = errors.New("account not found") + +func (d *Datastore) GetAccount(tx *gorm.DB, email string) (*Account, error) { var account Account + if tx == nil { + tx = d.db + } + result := tx.Where("email = ?", email).First(&account) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, ErrAccountNotFound + } + return nil, fmt.Errorf("error fetching account: %w", result.Error) + } + return &account, nil +} + +func (d *Datastore) GetOrCreateAccount(email string) (*Account, error) { + var account *Account err := d.db.Transaction(func(tx *gorm.DB) error { - result := tx.Where("email = ?", email).First(&account) - if result.Error == nil { - return nil - } + account, err := d.GetAccount(tx, email) - if !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return fmt.Errorf("error fetching account: %w", result.Error) + if err != nil { + if errors.Is(err, ErrAccountNotFound) { + err = nil + } else { + return err + } + } else { + return nil } id, err := uuid.NewV7() @@ -35,8 +55,10 @@ func (d *Datastore) GetOrCreateAccount(email string) (*Account, error) { return err } - account.ID = id - account.Email = email + account = &Account{ + ID: id, + Email: email, + } if err := tx.Create(&account).Error; err != nil { return fmt.Errorf("error creating account: %w", err) @@ -49,7 +71,7 @@ func (d *Datastore) GetOrCreateAccount(email string) (*Account, error) { return nil, err } - return &account, nil + return account, nil } // split into two methods for seed id and registration. use the struct for updates! diff --git a/datastore/ake_states.go b/datastore/ake_states.go index 0d68e0b..db80ca5 100644 --- a/datastore/ake_states.go +++ b/datastore/ake_states.go @@ -9,28 +9,30 @@ import ( "gorm.io/gorm" ) -const akeStateExpiration = 30 * time.Second +const AkeStateExpiration = 30 * time.Second var ErrAKEStateNotFound = errors.New("AKE state not found") var ErrAKEStateExpired = errors.New("AKE state has expired") type AKEState struct { - ID uuid.UUID `json:"id"` - AccountID uuid.UUID `json:"-"` - State []byte `json:"-"` - CreatedAt time.Time `json:"createdAt" gorm:"<-:false"` + ID uuid.UUID `json:"id"` + AccountID *uuid.UUID `json:"-"` + OprfSeedID int `json:"-"` + State []byte `json:"-"` + CreatedAt time.Time `json:"createdAt" gorm:"<-:false"` } -func (d *Datastore) CreateAKEState(accountID uuid.UUID, state []byte) (*AKEState, error) { +func (d *Datastore) CreateAKEState(accountID *uuid.UUID, state []byte, oprfSeedID int) (*AKEState, error) { id, err := uuid.NewV7() if err != nil { return nil, err } akeState := AKEState{ - ID: id, - AccountID: accountID, - State: state, + ID: id, + AccountID: accountID, + OprfSeedID: oprfSeedID, + State: state, } if err := d.db.Create(&akeState).Error; err != nil { @@ -40,15 +42,6 @@ func (d *Datastore) CreateAKEState(accountID uuid.UUID, state []byte) (*AKEState return &akeState, nil } -func (d *Datastore) DeleteAKEState(akeStateID uuid.UUID) error { - result := d.db.Delete(&AKEState{}, "id = ?", akeStateID) - if result.Error != nil { - return fmt.Errorf("failed to delete AKE state: %w", result.Error) - } - - return nil -} - func (d *Datastore) GetAKEState(akeStateID uuid.UUID) (*AKEState, error) { var akeState AKEState if err := d.db.First(&akeState, "id = ?", akeStateID).Error; err != nil { @@ -58,12 +51,18 @@ func (d *Datastore) GetAKEState(akeStateID uuid.UUID) (*AKEState, error) { return nil, fmt.Errorf("failed to get AKE state: %w", err) } + var err error // Check if AKE state has expired - if time.Since(akeState.CreatedAt) > akeStateExpiration { - if err := d.DeleteAKEState(akeStateID); err != nil { - return nil, fmt.Errorf("failed to delete expired AKE state: %w", err) - } - return nil, ErrAKEStateExpired + if time.Since(akeState.CreatedAt) > AkeStateExpiration { + err = ErrAKEStateExpired + } + + if dbErr := d.db.Delete(&AKEState{}, "id = ?", akeStateID).Error; dbErr != nil { + return nil, fmt.Errorf("failed to delete AKE state: %w", dbErr) + } + + if err != nil { + return nil, err } return &akeState, nil diff --git a/datastore/registration_states.go b/datastore/registration_states.go index 8853269..9fc85a8 100644 --- a/datastore/registration_states.go +++ b/datastore/registration_states.go @@ -28,15 +28,17 @@ func (d *Datastore) GetRegistrationStateSeedID(email string) (int, error) { return 0, fmt.Errorf("failed to get registration state: %w", err) } + var err error if time.Since(state.CreatedAt) > registrationStateExpiration { - if err := d.db.Delete(&state).Error; err != nil { - return 0, fmt.Errorf("failed to delete expired registration state: %w", err) - } - return 0, ErrRegistrationStateExpired + err = ErrRegistrationStateExpired + } + + if dbErr := d.db.Delete(&state).Error; dbErr != nil { + return 0, fmt.Errorf("failed to delete registration state: %w", dbErr) } - if err := d.db.Delete(&state).Error; err != nil { - return 0, fmt.Errorf("failed to delete registration state: %w", err) + if err != nil { + return 0, err } return state.OprfSeedID, nil diff --git a/datastore/sessions.go b/datastore/sessions.go index a95f10c..5f649aa 100644 --- a/datastore/sessions.go +++ b/datastore/sessions.go @@ -46,9 +46,9 @@ func (d *Datastore) CreateSession(accountID uuid.UUID, sessionName *string) (*Se return &session, nil } -func (d *Datastore) ListSessions(accountID uuid.UUID) ([]Session, error) { +func (d *Datastore) ListSessions(accountID uuid.UUID, minSessionVersion int) ([]Session, error) { var sessions []Session - if err := d.db.Where("account_id = ?", accountID).Find(&sessions).Error; err != nil { + if err := d.db.Where("account_id = ? AND version >= ?", accountID, minSessionVersion).Find(&sessions).Error; err != nil { return nil, fmt.Errorf("failed to list sessions: %w", err) } diff --git a/docs/docs.go b/docs/docs.go index 04b5dae..d7d6b10 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -263,6 +263,111 @@ const docTemplate = `{ } } }, + "/v2/auth/login/finalize": { + "post": { + "description": "Final step of login flow, verifies KE3 message and creates session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Finalize login", + "parameters": [ + { + "type": "string", + "description": "Bearer + ake token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "KE3 message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.KE3" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.LoginFinalizeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + } + } + } + }, + "/v2/auth/login/init": { + "post": { + "description": "First step of OPAQUE login flow, generates KE2 message", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Initialize login", + "parameters": [ + { + "description": "KE1 message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.KE1" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.KE2" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + } + } + } + }, "/v2/auth/validate": { "get": { "description": "Validates an auth token and returns session details", @@ -295,6 +400,12 @@ const docTemplate = `{ "$ref": "#/definitions/util.ErrorResponse" } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -563,6 +674,78 @@ const docTemplate = `{ } }, "definitions": { + "controllers.KE1": { + "type": "object", + "required": [ + "blindedMessage", + "clientEphemeralPublicKey", + "clientNonce", + "email" + ], + "properties": { + "blindedMessage": { + "type": "string" + }, + "clientEphemeralPublicKey": { + "type": "string" + }, + "clientNonce": { + "type": "string" + }, + "email": { + "type": "string", + "example": "test@example.com" + } + } + }, + "controllers.KE2": { + "type": "object", + "properties": { + "akeToken": { + "type": "string" + }, + "evaluatedMessage": { + "type": "string" + }, + "maskedResponse": { + "type": "string" + }, + "maskingNonce": { + "type": "string" + }, + "serverEphemeralPublicKey": { + "type": "string" + }, + "serverMac": { + "type": "string" + }, + "serverNonce": { + "type": "string" + } + } + }, + "controllers.KE3": { + "type": "object", + "required": [ + "clientMac" + ], + "properties": { + "clientMac": { + "type": "string" + }, + "sessionName": { + "type": "string" + } + } + }, + "controllers.LoginFinalizeResponse": { + "type": "object", + "properties": { + "authToken": { + "type": "string" + } + } + }, "controllers.PasswordFinalizeResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 83b1bec..b8df3dc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -253,6 +253,111 @@ } } }, + "/v2/auth/login/finalize": { + "post": { + "description": "Final step of login flow, verifies KE3 message and creates session", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Finalize login", + "parameters": [ + { + "type": "string", + "description": "Bearer + ake token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "KE3 message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.KE3" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.LoginFinalizeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + } + } + } + }, + "/v2/auth/login/init": { + "post": { + "description": "First step of OPAQUE login flow, generates KE2 message", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Initialize login", + "parameters": [ + { + "description": "KE1 message", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.KE1" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.KE2" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + } + } + } + }, "/v2/auth/validate": { "get": { "description": "Validates an auth token and returns session details", @@ -285,6 +390,12 @@ "$ref": "#/definitions/util.ErrorResponse" } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/util.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -553,6 +664,78 @@ } }, "definitions": { + "controllers.KE1": { + "type": "object", + "required": [ + "blindedMessage", + "clientEphemeralPublicKey", + "clientNonce", + "email" + ], + "properties": { + "blindedMessage": { + "type": "string" + }, + "clientEphemeralPublicKey": { + "type": "string" + }, + "clientNonce": { + "type": "string" + }, + "email": { + "type": "string", + "example": "test@example.com" + } + } + }, + "controllers.KE2": { + "type": "object", + "properties": { + "akeToken": { + "type": "string" + }, + "evaluatedMessage": { + "type": "string" + }, + "maskedResponse": { + "type": "string" + }, + "maskingNonce": { + "type": "string" + }, + "serverEphemeralPublicKey": { + "type": "string" + }, + "serverMac": { + "type": "string" + }, + "serverNonce": { + "type": "string" + } + } + }, + "controllers.KE3": { + "type": "object", + "required": [ + "clientMac" + ], + "properties": { + "clientMac": { + "type": "string" + }, + "sessionName": { + "type": "string" + } + } + }, + "controllers.LoginFinalizeResponse": { + "type": "object", + "properties": { + "authToken": { + "type": "string" + } + } + }, "controllers.PasswordFinalizeResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 906d448..342795d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,52 @@ definitions: + controllers.KE1: + properties: + blindedMessage: + type: string + clientEphemeralPublicKey: + type: string + clientNonce: + type: string + email: + example: test@example.com + type: string + required: + - blindedMessage + - clientEphemeralPublicKey + - clientNonce + - email + type: object + controllers.KE2: + properties: + akeToken: + type: string + evaluatedMessage: + type: string + maskedResponse: + type: string + maskingNonce: + type: string + serverEphemeralPublicKey: + type: string + serverMac: + type: string + serverNonce: + type: string + type: object + controllers.KE3: + properties: + clientMac: + type: string + sessionName: + type: string + required: + - clientMac + type: object + controllers.LoginFinalizeResponse: + properties: + authToken: + type: string + type: object controllers.PasswordFinalizeResponse: properties: authToken: @@ -273,6 +321,75 @@ paths: summary: Initialize password setup tags: - Accounts + /v2/auth/login/finalize: + post: + consumes: + - application/json + description: Final step of login flow, verifies KE3 message and creates session + parameters: + - description: Bearer + ake token + in: header + name: Authorization + required: true + type: string + - description: KE3 message + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.KE3' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.LoginFinalizeResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/util.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/util.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/util.ErrorResponse' + summary: Finalize login + tags: + - Auth + /v2/auth/login/init: + post: + consumes: + - application/json + description: First step of OPAQUE login flow, generates KE2 message + parameters: + - description: KE1 message + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.KE1' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.KE2' + "400": + description: Bad Request + schema: + $ref: '#/definitions/util.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/util.ErrorResponse' + summary: Initialize login + tags: + - Auth /v2/auth/validate: get: description: Validates an auth token and returns session details @@ -293,6 +410,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/util.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/util.ErrorResponse' "500": description: Internal Server Error schema: diff --git a/main.go b/main.go index c06ef1a..5fa1ad6 100644 --- a/main.go +++ b/main.go @@ -67,15 +67,20 @@ func main() { log.Panic().Err(err).Msg("Failed to init OPAQUE service") } - authMiddleware := middleware.AuthMiddleware(jwtUtil, datastore) + minSessionVersion := 1 + if passwordAuthEnabled { + minSessionVersion = 2 + } + + authMiddleware := middleware.AuthMiddleware(jwtUtil, datastore, minSessionVersion) verificationAuthMiddleware := middleware.VerificationAuthMiddleware(jwtUtil, datastore) r := chi.NewRouter() - authController := controllers.NewAuthController(opaqueService) + authController := controllers.NewAuthController(opaqueService, jwtUtil, datastore) accountsController := controllers.NewAccountsController(opaqueService, jwtUtil, datastore) verificationController := controllers.NewVerificationController(datastore, jwtUtil, sesUtil, passwordAuthEnabled) - sessionsController := controllers.NewSessionsController(datastore) + sessionsController := controllers.NewSessionsController(datastore, minSessionVersion) r.Use(middleware.LoggerMiddleware) diff --git a/middleware/auth.go b/middleware/auth.go index 76e31cb..eb804e0 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -12,7 +12,7 @@ import ( const ContextSession = "session" const ContextVerification = "verification" -func AuthMiddleware(jwtUtil *util.JWTUtil, ds *datastore.Datastore) func(http.Handler) http.Handler { +func AuthMiddleware(jwtUtil *util.JWTUtil, ds *datastore.Datastore, minSessionVersion int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, err := util.ExtractAuthToken(r) @@ -37,6 +37,11 @@ func AuthMiddleware(jwtUtil *util.JWTUtil, ds *datastore.Datastore) func(http.Ha return } + if session.Version < minSessionVersion { + util.RenderErrorResponse(w, r, http.StatusForbidden, errors.New("outdated session")) + return + } + // Store session in context ctx := context.WithValue(r.Context(), ContextSession, session) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/migrations/20241025031241_opaque.up.sql b/migrations/20241025031241_opaque.up.sql index d309201..e1db1fb 100644 --- a/migrations/20241025031241_opaque.up.sql +++ b/migrations/20241025031241_opaque.up.sql @@ -9,7 +9,8 @@ ALTER TABLE accounts ADD COLUMN opaque_registration BYTEA; CREATE TABLE ake_states ( id UUID PRIMARY KEY, - account_id UUID NOT NULL REFERENCES accounts(id), + account_id UUID REFERENCES accounts(id), + oprf_seed_id INT REFERENCES oprf_seeds(id), state BYTEA NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/misc/test-client/main.go b/misc/test-client/main.go index 633b8ec..6c55828 100644 --- a/misc/test-client/main.go +++ b/misc/test-client/main.go @@ -21,6 +21,8 @@ func postReq(fields map[string]interface{}, url string, verificationToken string log.Fatal(err) } + log.Printf("request body: %+v", fields) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) if err != nil { log.Fatal(err) @@ -49,6 +51,9 @@ func postReq(fields map[string]interface{}, url string, verificationToken string if err := decoder.Decode(&respBody); err != nil { log.Fatal(err) } + + log.Printf("response body: %+v", respBody) + return respBody } diff --git a/services/opaque.go b/services/opaque.go index e021412..a37874c 100644 --- a/services/opaque.go +++ b/services/opaque.go @@ -2,6 +2,7 @@ package services import ( "encoding/hex" + "errors" "fmt" "os" @@ -9,6 +10,7 @@ import ( "github.com/bytemare/crypto" "github.com/bytemare/opaque" opaqueMsg "github.com/bytemare/opaque/message" + "github.com/google/uuid" ) const ( @@ -16,6 +18,8 @@ const ( opaquePublicKeyEnv = "OPAQUE_PUBLIC_KEY" ) +var ErrIncorrectCredentials = errors.New("incorrect credentials") + type OpaqueService struct { ds *datastore.Datastore oprfSeeds map[int][]byte @@ -62,19 +66,34 @@ func NewOpaqueService(ds *datastore.Datastore) (*OpaqueService, error) { return &OpaqueService{ds, oprfSeeds, currentSeedId, secretKey, publicKey, config}, nil } -func (o *OpaqueService) SetupPasswordInit(email string, request *opaqueMsg.RegistrationRequest) (*opaqueMsg.RegistrationResponse, error) { +func (o *OpaqueService) NewElement() *crypto.Element { + return o.config.OPRF.Group().NewElement() +} + +func (o *OpaqueService) newOpaqueServer(seedID int) (*opaque.Server, error) { server, err := opaque.NewServer(o.config) if err != nil { return nil, fmt.Errorf("failed to init opaque server: %w", err) } + if err = server.SetKeyMaterial(nil, o.secretKey, o.publicKey, o.oprfSeeds[seedID]); err != nil { + return nil, fmt.Errorf("failed to set key material for opaque server: %w", err) + } + return server, nil +} + +func (o *OpaqueService) SetupPasswordInit(email string, request *opaqueMsg.RegistrationRequest) (*opaqueMsg.RegistrationResponse, error) { + seedID := o.currentSeedId + + server, err := o.newOpaqueServer(seedID) + if err != nil { + return nil, err + } publicKeyElement := o.NewElement() if err = publicKeyElement.UnmarshalBinary(o.publicKey); err != nil { return nil, fmt.Errorf("failed to decode public key during password init: %w", err) } - seedID := o.currentSeedId - if err = o.ds.UpsertRegistrationState(email, seedID); err != nil { return nil, err } @@ -105,6 +124,92 @@ func (o *OpaqueService) SetupPasswordFinalize(email string, registration *opaque return account, nil } -func (o *OpaqueService) NewElement() *crypto.Element { - return o.config.OPRF.Group().NewElement() +func (o *OpaqueService) LoginInit(email string, ke1 *opaqueMsg.KE1) (*opaqueMsg.KE2, *datastore.AKEState, error) { + account, err := o.ds.GetAccount(nil, email) + if err != nil { + if errors.Is(err, datastore.ErrAccountNotFound) { + err = nil + } else { + return nil, nil, fmt.Errorf("failed to get account during login init: %w", err) + } + } + + useFakeRecord := account == nil || account.OpaqueRegistration == nil || account.OprfSeedID == nil + + seedID := o.currentSeedId + if !useFakeRecord { + seedID = *account.OprfSeedID + } + + server, err := o.newOpaqueServer(seedID) + if err != nil { + return nil, nil, err + } + + var opaqueRecord *opaque.ClientRecord + if useFakeRecord { + // Get fake record and continue with process to prevent + // client enumeration attacks + opaqueRecord, err = o.config.GetFakeRecord([]byte(email)) + if err != nil { + return nil, nil, fmt.Errorf("failed to get fake opaque registration: %w", err) + } + } else { + deserializer, err := o.config.Deserializer() + if err != nil { + return nil, nil, fmt.Errorf("failed to get opaque deserializer: %w", err) + } + opaqueRegistration, err := deserializer.RegistrationRecord(account.OpaqueRegistration) + if err != nil { + return nil, nil, fmt.Errorf("failed to deserialize opaque registration: %w", err) + } + opaqueRecord = &opaque.ClientRecord{ + RegistrationRecord: opaqueRegistration, + CredentialIdentifier: []byte(email), + ClientIdentity: nil, + TestMaskNonce: nil, + } + } + + ke2, err := server.LoginInit(ke1, opaqueRecord) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate ke2: %w", err) + } + + var accountID *uuid.UUID + if !useFakeRecord { + accountID = &account.ID + } + akeState, err := o.ds.CreateAKEState(accountID, server.SerializeState(), seedID) + if err != nil { + return nil, nil, fmt.Errorf("failed to store AKE state: %w", err) + } + + return ke2, akeState, nil +} + +func (o *OpaqueService) LoginFinalize(akeStateID uuid.UUID, ke3 *opaqueMsg.KE3) (*uuid.UUID, error) { + akeState, err := o.ds.GetAKEState(akeStateID) + if err != nil { + return nil, err + } + + server, err := o.newOpaqueServer(akeState.OprfSeedID) + if err != nil { + return nil, err + } + + if err = server.SetAKEState(akeState.State); err != nil { + return nil, fmt.Errorf("failed to set AKE state for login finalize: %w", err) + } + + if err = server.LoginFinish(ke3); err != nil { + return nil, ErrIncorrectCredentials + } + + if akeState.AccountID == nil { + return nil, ErrIncorrectCredentials + } + + return akeState.AccountID, nil } diff --git a/util/util.go b/util/util.go index 70d615e..4b84e5e 100644 --- a/util/util.go +++ b/util/util.go @@ -26,7 +26,7 @@ func (e *ErrorResponse) Render(w http.ResponseWriter, r *http.Request) error { func RenderErrorResponse(w http.ResponseWriter, r *http.Request, status int, err error) { var errStr string - if status != http.StatusBadRequest && status != http.StatusNotFound { + if status != http.StatusBadRequest && status != http.StatusForbidden && status != http.StatusNotFound { errStr = http.StatusText(status) logLevel := zerolog.ErrorLevel if status != http.StatusInternalServerError {