Fix SEC-01: require password for TOTP enroll
- REST handleTOTPEnroll now requires password field in request body - gRPC EnrollTOTP updated with password field in proto message - Both handlers check lockout status and record failures on bad password - Updated Go, Python, and Rust client libraries to pass password - Updated OpenAPI specs with new requestBody schema - Added TestTOTPEnrollRequiresPassword with no-password, wrong-password, and correct-password sub-tests Security: TOTP enrollment now requires the current password to prevent session-theft escalation to persistent account takeover. Lockout and failure recording use the same Argon2id constant-time path as login. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -195,13 +195,39 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke
|
||||
}
|
||||
|
||||
// EnrollTOTP begins TOTP enrollment for the calling account.
|
||||
func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
|
||||
//
|
||||
// Security (SEC-01): the current password is required to prevent a stolen
|
||||
// session token from being used to enroll attacker-controlled TOTP on the
|
||||
// victim's account. Lockout is checked and failures are recorded.
|
||||
func (a *authServiceServer) EnrollTOTP(ctx context.Context, req *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
|
||||
claims := claimsFromContext(ctx)
|
||||
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "account not found")
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "password is required")
|
||||
}
|
||||
|
||||
// Security: check lockout before verifying (same as login flow).
|
||||
locked, lockErr := a.s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
a.s.logger.Error("lockout check (gRPC TOTP enroll)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"locked"}`) //nolint:errcheck
|
||||
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
|
||||
}
|
||||
|
||||
// Security: verify the current password with Argon2id (constant-time).
|
||||
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, &acct.ID, peerIP(ctx), `{"result":"wrong_password"}`) //nolint:errcheck
|
||||
return nil, status.Error(codes.Unauthenticated, "password is incorrect")
|
||||
}
|
||||
|
||||
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
|
||||
Reference in New Issue
Block a user