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:
2026-03-13 00:48:31 -07:00
parent 586d4e3355
commit 8545473703
13 changed files with 192 additions and 17 deletions

View File

@@ -304,9 +304,12 @@ func (x *RenewTokenResponse) GetExpiresAt() *timestamppb.Timestamp {
return nil
}
// EnrollTOTPRequest carries no body; the acting account is from the JWT.
// EnrollTOTPRequest carries the current password for re-authentication.
// Security (SEC-01): password is required to prevent a stolen session token
// from being used to enroll attacker-controlled TOTP on the victim's account.
type EnrollTOTPRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` // security: current password required; never logged
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -341,6 +344,13 @@ func (*EnrollTOTPRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{6}
}
func (x *EnrollTOTPRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
// EnrollTOTPResponse returns the TOTP secret and otpauth URI for display.
// Security: the secret is shown once; it is stored only in encrypted form.
type EnrollTOTPResponse struct {
@@ -578,8 +588,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\x12RenewTokenResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x129\n" +
"\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x13\n" +
"\x11EnrollTOTPRequest\"M\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"/\n" +
"\x11EnrollTOTPRequest\x12\x1a\n" +
"\bpassword\x18\x01 \x01(\tR\bpassword\"M\n" +
"\x12EnrollTOTPResponse\x12\x16\n" +
"\x06secret\x18\x01 \x01(\tR\x06secret\x12\x1f\n" +
"\votpauth_uri\x18\x02 \x01(\tR\n" +