diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 046733719..5ac903c95 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -138,7 +138,7 @@ func main() { twoFactorRepo := &repo.TwoFactorRepository{DB: db, SecretEncryptionKey: secretEncryptionKeyBytes} userAuthRepo := &repo.UserAuthRepository{DB: db} - twoFactorRecoveryRepo := &two_factor_recovery.Repository{Db: db} + twoFactorRecoveryRepo := &two_factor_recovery.Repository{Db: db, SecretEncryptionKey: secretEncryptionKeyBytes} billingRepo := &repo.BillingRepository{DB: db} userEntityRepo := &userEntityRepo.Repository{DB: db} locationTagRepository := &locationtagRepo.Repository{DB: db} diff --git a/server/ente/enc_data.go b/server/ente/enc_data.go deleted file mode 100644 index db54deeb2..000000000 --- a/server/ente/enc_data.go +++ /dev/null @@ -1,28 +0,0 @@ -package ente - -import ( - "database/sql/driver" - "encoding/json" - "fmt" -) - -// EncData is a struct that holds an encrypted data and related nonce. -type EncData struct { - Data string `json:"data"` - Nonce string `json:"nonce"` -} - -// Value implements the driver.Valuer interface, allowing EncString to be used as a SQL value. -func (e EncData) Value() (driver.Value, error) { - return json.Marshal(e) -} - -// Scan implements the sql.Scanner interface, allowing EncString to be scanned from SQL queries. -func (e *EncData) Scan(value interface{}) error { - // Convert to bytes if necessary (depends on the driver, pq returns []byte for JSONB) - b, ok := value.([]byte) - if !ok { - return fmt.Errorf("type assertion to []byte failed") - } - return json.Unmarshal(b, &e) -} diff --git a/server/ente/passkey.go b/server/ente/passkey.go index e4ddb6e52..b780e8c06 100644 --- a/server/ente/passkey.go +++ b/server/ente/passkey.go @@ -13,23 +13,29 @@ type Passkey struct { var MaxPasskeys = 10 -type ConfigurePassKeySkipRequest struct { - PassKeySkipSecret string `json:"passKeySkipSecret" binding:"required"` - EncPassKeySkipSecret EncData `json:"encPassKeySkipSecret" binding:"required"` +type ConfigurePassKeyRecoveryRequest struct { + SkipSecret string `json:"resetSecret" binding:"required"` + // The UserSecretCipher has SkipSecret encrypted with the user's recoveryKey + // If the user sends the correct UserSecretCipher, we can be sure that the user has the recoveryKey, + // and we can allow the user to recover their MFA. + UserSecretCipher string `json:"userSecretCipher" binding:"required"` + UserSecretNonce string `json:"userSecretNonce" binding:"required"` } type TwoFactorRecoveryStatus struct { // AllowAdminReset is a boolean that determines if the admin can reset the user's MFA. // If true, in the event that the user loses their MFA device, the admin can reset the user's MFA. AllowAdminReset bool `json:"allowAdminReset" binding:"required"` - IsPassKeySkipEnabled bool `json:"isPassKeySkipEnabled" binding:"required"` + IsPassKeySkipEnabled bool `json:"isPassKeyResetEnabled" binding:"required"` } type PasseKeySkipChallengeResponse struct { - EncData + // The PassKeyEncSecret has SkipSecret encrypted with the user's recoveryKey + UserSecretCipher string `json:"userSecretCipher" binding:"required"` + UserSecretNonce string `json:"userSecretNonce" binding:"required"` } type SkipPassKeyRequest struct { - SessionID string `json:"sessionID" binding:"required"` - PassKeySkipSecret string `json:"passKeySkipSecret" binding:"required"` + SessionID string `json:"sessionID" binding:"required"` + SkipSecret string `json:"resetSecret" binding:"required"` } diff --git a/server/migrations/80_two_factor_recovery.down.sql b/server/migrations/80_two_factor_recovery.down.sql index f89f1689f..14bfd3aa9 100644 --- a/server/migrations/80_two_factor_recovery.down.sql +++ b/server/migrations/80_two_factor_recovery.down.sql @@ -1,2 +1,2 @@ -DROP TABLE IF NOT EXISTS two_factor_recovery; +DROP TABLE IF EXISTS two_factor_recovery; DROP TRIGGER IF EXISTS update_two_factor_recovery_updated_at ON two_factor_recovery; \ No newline at end of file diff --git a/server/migrations/80_two_factor_recovery.up.sql b/server/migrations/80_two_factor_recovery.up.sql index 670fb74d1..c3eacdc37 100644 --- a/server/migrations/80_two_factor_recovery.up.sql +++ b/server/migrations/80_two_factor_recovery.up.sql @@ -2,10 +2,10 @@ CREATE TABLE IF NOT EXISTS two_factor_recovery ( user_id bigint NOT NULL, -- if false, the support team team will not be able to reset the MFA for the user enable_admin_mfa_reset boolean NOT NULL DEFAULT true, - pass_key_reset_key uuid, - pass_key_reset_enc_data jsonb, - twofa_key_reset_key uuid, - twofa_key_reset_enc_data jsonb, + server_passkey_secret_data bytea, + server_passkey_secret_nonce bytea, + user_passkey_secret_data text, + user_passkey_secret_nonce text, created_at bigint NOT NULL DEFAULT now_utc_micro_seconds(), updated_at bigint NOT NULL DEFAULT now_utc_micro_seconds() ); diff --git a/server/pkg/api/user.go b/server/pkg/api/user.go index d8a73291b..e278e3e0d 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -256,7 +256,7 @@ func (h *UserHandler) GetAccountRecoveryStatus(c *gin.Context) { // ConfigurePassKeySkipChallenge configures the passkey skip challenge for a user. In case the user does not // have access to passkey, the user can bypass the passkey by providing the recovery key func (h *UserHandler) ConfigurePassKeySkipChallenge(c *gin.Context) { - var request ente.ConfigurePassKeySkipRequest + var request ente.ConfigurePassKeyRecoveryRequest if err := c.ShouldBindJSON(&request); err != nil { handler.Error(c, stacktrace.Propagate(err, "")) return diff --git a/server/pkg/controller/user/passkey.go b/server/pkg/controller/user/passkey.go index b92baf649..373be92c6 100644 --- a/server/pkg/controller/user/passkey.go +++ b/server/pkg/controller/user/passkey.go @@ -13,12 +13,12 @@ func (c *UserController) GetAccountRecoveryStatus(ctx *gin.Context) (*ente.TwoFa return c.TwoFactorRecoveryRepo.GetStatus(userID) } -func (c *UserController) ConfigurePassKeySkip(ctx *gin.Context, req *ente.ConfigurePassKeySkipRequest) error { +func (c *UserController) ConfigurePassKeySkip(ctx *gin.Context, req *ente.ConfigurePassKeyRecoveryRequest) error { userID := auth.GetUserID(ctx.Request.Header) - return c.TwoFactorRecoveryRepo.ConfigurePassKeyRecovery(ctx, userID, req) + return c.TwoFactorRecoveryRepo.ConfigurePassKeySkipChallenge(ctx, userID, req) } -func (c *UserController) GetPasskeySkipChallenge(ctx *gin.Context, passKeySessionID string) (*ente.EncData, error) { +func (c *UserController) GetPasskeySkipChallenge(ctx *gin.Context, passKeySessionID string) (*ente.PasseKeySkipChallengeResponse, error) { userID, err := c.PasskeyRepo.GetUserIDWithPasskeyTwoFactorSession(passKeySessionID) if err != nil { return nil, err @@ -31,7 +31,7 @@ func (c *UserController) GetPasskeySkipChallenge(ctx *gin.Context, passKeySessio return nil, ente.NewBadRequestWithMessage("Passkey reset is not configured") } - result, err := c.TwoFactorRecoveryRepo.GetPasskeyResetChallenge(ctx, userID) + result, err := c.TwoFactorRecoveryRepo.GetPasskeySkipChallenge(ctx, userID) if err != nil { return nil, err } @@ -46,7 +46,7 @@ func (c *UserController) SkipPassKey(context *gin.Context, req *ente.SkipPassKey if err != nil { return nil, stacktrace.Propagate(err, "") } - exists, err := c.TwoFactorRecoveryRepo.VerifyRecoveryKeyForPassKey(userID, req.PassKeySkipSecret) + exists, err := c.TwoFactorRecoveryRepo.VerifyPasskeySkipSecret(userID, req.SkipSecret) if err != nil { return nil, stacktrace.Propagate(err, "") } diff --git a/server/pkg/repo/two_factor_recovery/repository.go b/server/pkg/repo/two_factor_recovery/repository.go index fa2ecb0a6..02f7c8138 100644 --- a/server/pkg/repo/two_factor_recovery/repository.go +++ b/server/pkg/repo/two_factor_recovery/repository.go @@ -4,18 +4,21 @@ import ( "context" "database/sql" "github.com/ente-io/museum/ente" + "github.com/ente-io/museum/pkg/utils/crypto" "github.com/ente-io/stacktrace" + "github.com/sirupsen/logrus" ) type Repository struct { - Db *sql.DB + Db *sql.DB + SecretEncryptionKey []byte } // GetStatus returns `ente.TwoFactorRecoveryStatus` for a user func (r *Repository) GetStatus(userID int64) (*ente.TwoFactorRecoveryStatus, error) { var isAdminResetEnabled bool - var resetKey sql.NullString - row := r.Db.QueryRow("SELECT enable_admin_mfa_reset, pass_key_reset_key FROM two_factor_recovery WHERE user_id = $1", userID) + var resetKey sql.NullByte + row := r.Db.QueryRow(`SELECT enable_admin_mfa_reset, server_passkey_secret_data FROM two_factor_recovery WHERE user_id = $1`, userID) err := row.Scan(&isAdminResetEnabled, &resetKey) if err != nil { if err == sql.ErrNoRows { @@ -30,30 +33,45 @@ func (r *Repository) GetStatus(userID int64) (*ente.TwoFactorRecoveryStatus, err return &ente.TwoFactorRecoveryStatus{AllowAdminReset: isAdminResetEnabled, IsPassKeySkipEnabled: resetKey.Valid}, nil } -func (r *Repository) ConfigurePassKeyRecovery(ctx context.Context, userID int64, req *ente.ConfigurePassKeySkipRequest) error { - _, err := r.Db.ExecContext(ctx, `INSERT INTO two_factor_recovery (user_id, pass_key_reset_key, pass_key_reset_enc_data) - VALUES ($1, $2,$3) ON CONFLICT (user_id) - DO UPDATE SET pass_key_reset_key = $2, pass_key_reset_enc_data = $3`, userID, req.PassKeySkipSecret, - req.EncPassKeySkipSecret) +func (r *Repository) ConfigurePassKeySkipChallenge(ctx context.Context, userID int64, req *ente.ConfigurePassKeyRecoveryRequest) error { + serveEncPassKey, encRrr := crypto.Encrypt(req.SkipSecret, r.SecretEncryptionKey) + if encRrr != nil { + return stacktrace.Propagate(encRrr, "failed to encrypt passkey secret") + } + _, err := r.Db.ExecContext(ctx, `INSERT INTO two_factor_recovery + (user_id, server_passkey_secret_data, server_passkey_secret_nonce, user_passkey_secret_data, user_passkey_secret_nonce)) + VALUES ($1, $2,$3,$4,$5) ON CONFLICT (user_id) + DO UPDATE SET server_passkey_secret_data = $2, server_passkey_secret_nonce = $3, user_passkey_secret_data=$4,user_passkey_secret_nonce=$5`, + userID, serveEncPassKey.Cipher, serveEncPassKey.Nonce, req.UserSecretCipher, req.UserSecretNonce) return err } -func (r *Repository) GetPasskeyResetChallenge(ctx context.Context, userID int64) (*ente.EncData, error) { - var encData *ente.EncData - err := r.Db.QueryRowContext(ctx, "SELECT pass_key_reset_enc_data FROM two_factor_recovery WHERE user_id= $1", userID).Scan(encData) +func (r *Repository) GetPasskeySkipChallenge(ctx context.Context, userID int64) (*ente.PasseKeySkipChallengeResponse, error) { + var result *ente.PasseKeySkipChallengeResponse + err := r.Db.QueryRowContext(ctx, "SELECT user_passkey_secret_data, user_passkey_secret_nonce FROM two_factor_recovery WHERE user_id= $1", userID).Scan(result.UserSecretCipher, result.UserSecretNonce) if err != nil { return nil, err } - return encData, nil + return result, nil } -// VerifyRecoveryKeyForPassKey checks if the passkey reset key is valid for a user -func (r *Repository) VerifyRecoveryKeyForPassKey(userID int64, passKeyResetKey string) (bool, error) { - var exists bool - row := r.Db.QueryRow(`SELECT EXISTS( SELECT 1 FROM two_factor_recovery WHERE user_id = $1 AND pass_key_reset_key = $2)`, userID, passKeyResetKey) - err := row.Scan(&exists) +// VerifyPasskeySkipSecret checks if the passkey skip secret is valid for a user +func (r *Repository) VerifyPasskeySkipSecret(userID int64, skipSecret string) (bool, error) { + // get server_passkey_secret_data and server_passkey_secret_nonce for given user id + var severSecreteData, serverSecretNonce []byte + row := r.Db.QueryRow(`SELECT server_passkey_secret_data, server_passkey_secret_nonce FROM two_factor_recovery WHERE user_id = $1`, userID) + err := row.Scan(&severSecreteData, &serverSecretNonce) if err != nil { return false, stacktrace.Propagate(err, "") } - return exists, nil + // decrypt server_passkey_secret_data + serverSkipSecretKey, decErr := crypto.Decrypt(severSecreteData, serverSecretNonce, r.SecretEncryptionKey) + if decErr != nil { + return false, stacktrace.Propagate(decErr, "failed to decrypt passkey reset key") + } + if skipSecret != serverSkipSecretKey { + logrus.Warn("invalid passkey skip secret") + return false, nil + } + return true, nil }