Skip to content

Conversation

@quexten
Copy link
Contributor

@quexten quexten commented Dec 2, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-27279

📔 Objective

Adds registration with V2 keys for TDE flows. TDE flows use the postKeys method to set the keys. In this PR, this is extended to set the keys via a newly introduced command instead. The command supports both v1 and v2 under the hood.

📸 Screenshots

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@quexten quexten changed the base branch from main to km/account-keys-command December 2, 2025 16:10
@github-actions
Copy link
Contributor

github-actions bot commented Dec 2, 2025

Logo
Checkmarx One – Scan Summary & Details84d5b4d0-2648-4102-87bb-666732e49e59

New Issues (1)

Checkmarx found the following issues in this Pull Request

Severity Issue Source File / Package Checkmarx Insight
MEDIUM CSRF /src/Api/Auth/Controllers/AccountsController.cs: 431
detailsMethod at line 431 of /src/Api/Auth/Controllers/AccountsController.cs gets a parameter from a user request from model. This parameter value flow...
ID: EKcBxFzmcb3JFkiDJ0ObAngp2go%3D
Attack Vector
Fixed Issues (1)

Great job! The following issues were fixed in this Pull Request

Severity Issue Source File / Package
MEDIUM CSRF /src/Api/Auth/Controllers/AccountsController.cs: 536

@quexten quexten changed the title km/tde registration Implement Registration with V2 Keys Dec 2, 2025
@quexten quexten changed the title Implement Registration with V2 Keys [PM-27279] Implement Registration with V2 Keys Dec 2, 2025
@quexten quexten changed the title [PM-27279] Implement Registration with V2 Keys [PM-27279] Implement TDE Registration with V2 Keys Dec 4, 2025
@quexten quexten marked this pull request as ready for review December 4, 2025 13:01
@quexten quexten requested a review from a team as a code owner December 4, 2025 13:01
@quexten quexten requested a review from enmande December 4, 2025 13:01
@claude
Copy link
Contributor

claude bot commented Dec 4, 2025

Claude finished @quexten's task —— View job


Code Review Complete ✅

I've completed a thorough review of this PR implementing TDE registration with V2 keys. Here are my findings:


Critical Issues Found

1. 🚨 NULL REFERENCE BUG - Line 456 in AccountsController.cs

Severity: CRITICAL - Runtime Exception

Location: src/Api/Auth/Controllers/AccountsController.cs:456

Issue:

return new KeysResponseModel(model.AccountKeys.ToAccountKeysData(), user.Key);

This line ALWAYS calls model.AccountKeys.ToAccountKeysData() but model.AccountKeys can be null (as handled in line 447). When the legacy path is used (model.AccountKeys == null), this will throw a NullReferenceException.

Evidence from test:
The test PostKeys_WithoutAccountKeys_CallsSaveUser explicitly sets model.AccountKeys = null but the test doesn't validate the response construction, so it doesn't catch this bug.

Impact:

  • All legacy v1 key registration flows will fail with NullReferenceException
  • Breaks backwards compatibility
  • Zero test coverage detected this because tests don't verify response construction

Recommended Fix:

if (model.AccountKeys != null)
{
    await _setAccountKeysForUserCommand.SetAccountKeysForUserAsync(user, model.AccountKeys);
    return new KeysResponseModel(model.AccountKeys.ToAccountKeysData(), user.Key);
}
else
{
    await _userService.SaveUserAsync(model.ToUser(user));
    var accountKeys = await _userAccountKeysQuery.Run(user);
    return new KeysResponseModel(accountKeys, user.Key);
}

This mirrors the pattern used in the GetKeys() method at lines 468-469.


2. 🔒 CSRF CONCERN - Line 431 (Checkmarx Finding)

Severity: LOW (likely false positive)

Location: src/Api/Auth/Controllers/AccountsController.cs:431

Analysis:
The Checkmarx CSRF warning is likely a false positive for the following reasons:

  1. Authentication Required: The endpoint is protected by authorization at the controller level
  2. Bearer Token Authentication: ASP.NET Core API endpoints using Bearer token authentication are inherently protected against CSRF since tokens are sent in headers, not cookies
  3. Consistent with Other Endpoints: All other POST endpoints in this controller follow the same pattern without explicit CSRF tokens

Recommendation:

  • Mark as false positive in Checkmarx
  • No code changes required
  • Bearer token authentication provides CSRF protection

Security Concerns

3. ⚠️ Missing Input Validation

Location: src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs:19

Issue: The AccountKeys property has no validation attributes, but the obsolete properties have [Required] attributes.

[Obsolete("Use AccountKeys.AccountPublicKey instead")]
[Required]
public string PublicKey { get; set; }

[Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")]
[Required]
public string EncryptedPrivateKey { get; set; }

public AccountKeysRequestModel AccountKeys { get; set; }  // ⚠️ No validation

Impact:

  • When AccountKeys is provided but contains invalid data, validation may not trigger appropriately
  • Could lead to cryptic error messages from deeper in the call stack

Recommendation:
Consider adding validation to ensure either the legacy properties OR AccountKeys are provided:

public class KeysRequestModel : IValidatableObject
{
    // ... existing properties ...
    
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (AccountKeys == null && (string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey)))
        {
            yield return new ValidationResult(
                "Either AccountKeys or both PublicKey and EncryptedPrivateKey must be provided.",
                new[] { nameof(AccountKeys), nameof(PublicKey), nameof(EncryptedPrivateKey) });
        }
    }
}

Test Coverage Issues

4. 📊 Insufficient Test Coverage (SonarQube: 0% on new code)

Issue: Only 2 tests added but critical paths are not fully validated.

Missing Test Scenarios:

  1. PostKeys with null AccountKeys should return correct response

    • Current test verifies command calls but doesn't validate response construction
    • Should add assertions to verify the response data
  2. PostKeys with AccountKeys should return correct response structure

    • Test doesn't verify the response contains the correct data from AccountKeys
  3. PostKeys when user already has keys and feature flag enabled

    • Need test coverage for this scenario
  4. PostKeys with null AccountKeys and null legacy properties

    • Should handle gracefully or throw meaningful error

Recommended Additional Tests:

[Theory, BitAutoData]
public async Task PostKeys_WithoutAccountKeys_ReturnsCorrectResponse(
    User user, KeysRequestModel model)
{
    // Arrange
    user.PublicKey = null;
    user.PrivateKey = null;
    user.Key = "master-key-wrapped-user-key";
    model.AccountKeys = null;
    model.PublicKey = "public-key";
    model.EncryptedPrivateKey = "encrypted-private-key";
    
    var expectedAccountKeys = new UserAccountKeysData { /* ... */ };
    
    _userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
    _userAccountKeysQuery.Run(user).Returns(expectedAccountKeys);
    _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false);

    // Act
    var result = await _sut.PostKeys(model);

    // Assert
    Assert.NotNull(result);
    Assert.Equal(user.Key, result.Key);
    Assert.Equal(expectedAccountKeys.PublicKeyEncryptionKeyPairData.PublicKey, result.PublicKey);
}

[Theory, BitAutoData]
public async Task PostKeys_WithExistingKeysAndFeatureFlagEnabled_ThrowsBadRequest(
    User user, KeysRequestModel model)
{
    // Arrange
    user.PublicKey = "existing-public-key";
    user.PrivateKey = "existing-private-key";
    model.AccountKeys = new AccountKeysRequestModel { /* ... */ };
    
    _userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
    _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(true);

    // Act & Assert
    await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKeys(model));
}

Code Quality Issues

5. 📝 Inconsistent Null Handling

Location: AccountsController.cs

Issue: File likely has nullable reference types disabled, but the codebase is moving toward nullable reference types (ADR 0024).

Recommendation:

  • Enable nullable reference types for this file
  • Properly annotate nullable parameters
  • This would have caught the null reference bug at compile time

6. 🔄 Code Duplication

Location: GetKeys vs PostKeys

Issue: The response construction logic should be consistent. Currently:

  • GetKeys correctly uses _userAccountKeysQuery.Run(user) then constructs response
  • PostKeys attempts to use model.AccountKeys.ToAccountKeysData() unconditionally

Recommendation: Extract response construction to a helper method:

private async Task<KeysResponseModel> BuildKeysResponseAsync(User user, UserAccountKeysData? accountKeys = null)
{
    accountKeys ??= await _userAccountKeysQuery.Run(user);
    return new KeysResponseModel(accountKeys, user.Key);
}

7. 📖 Missing Documentation

Issue: The PostKeys method has complex branching logic but no XML documentation comments explaining:

  • When to use AccountKeys vs legacy properties
  • Migration path for v1 to v2 keys
  • Feature flag behavior

Recommendation: Add comprehensive XML documentation:

/// <summary>
/// Sets the cryptographic keys for the current user. Supports both legacy v1 keys and new v2 account keys.
/// </summary>
/// <param name="model">Key request containing either legacy PublicKey/EncryptedPrivateKey or new AccountKeys</param>
/// <returns>Response containing the user's key information</returns>
/// <remarks>
/// For v1 clients: Send PublicKey and EncryptedPrivateKey properties
/// For v2 clients: Send AccountKeys property with full key hierarchy
/// Feature flag ReturnErrorOnExistingKeypair controls whether key replacement is allowed
/// </remarks>
[HttpPost("keys")]
public async Task<KeysResponseModel> PostKeys([FromBody] KeysRequestModel model)

Summary

Severity Count Description
🚨 CRITICAL 1 Null reference bug causing runtime failures
⚠️ HIGH 2 Input validation, Test coverage
📝 MEDIUM 4 Code quality, documentation, consistency
ℹ️ LOW 1 CSRF false positive

Required Changes Before Merge:

  1. FIX CRITICAL BUG: Line 456 null reference - handle both code paths correctly
  2. ADD TESTS: Cover response construction for both v1 and v2 paths
  3. ADD VALIDATION: Ensure proper validation of input data

Recommended Changes:

  1. Refactor response construction into helper method
  2. Enable nullable reference types
  3. Add XML documentation explaining dual-path behavior

Verdict:CHANGES REQUESTED - Critical null reference bug must be fixed before merge. The security architecture is sound, but the implementation has a significant defect in the legacy compatibility path that will break v1 clients.

I've also added inline comments on the specific issues for easy navigation.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Dec 4, 2025

await _userService.SaveUserAsync(model.ToUser(user));
}

return new KeysResponseModel(model.AccountKeys.ToAccountKeysData(), user.Key);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 CRITICAL BUG: This will throw a NullReferenceException when model.AccountKeys is null (the legacy v1 path).

The code at line 447-454 branches on model.AccountKeys != null, but the response construction at line 456 always calls model.AccountKeys.ToAccountKeysData().

Fix:

Suggested change
return new KeysResponseModel(model.AccountKeys.ToAccountKeysData(), user.Key);
if (model.AccountKeys != null)
{
await _setAccountKeysForUserCommand.SetAccountKeysForUserAsync(user, model.AccountKeys);
return new KeysResponseModel(model.AccountKeys.ToAccountKeysData(), user.Key);
}
else
{
await _userService.SaveUserAsync(model.ToUser(user));
var accountKeys = await _userAccountKeysQuery.Run(user);
return new KeysResponseModel(accountKeys, user.Key);
}

This mirrors the pattern used in GetKeys() at lines 468-469.

model.EncryptedPrivateKey = "encrypted-private-key";

_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 TEST COVERAGE GAP: This test doesn't validate the response construction, which would have caught the null reference bug at line 456 in the controller.

Recommendation: Add assertions to verify the response data:

// Assert
Assert.NotNull(result);
Assert.Equal("keys", result.Object);
Assert.Equal(user.Key, result.Key);
Assert.NotNull(result.AccountKeys);
// Verify response contains data from the saved user, not null model.AccountKeys

You'll also need to mock _userAccountKeysQuery.Run(user) to return the expected data.

[Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")]
[Required]
public string EncryptedPrivateKey { get; set; }
public AccountKeysRequestModel AccountKeys { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MISSING VALIDATION: The AccountKeys property has no validation attributes, while the obsolete properties have [Required].

This creates an ambiguous state where neither old nor new properties might be provided. Consider adding validation:

public class KeysRequestModel : IValidatableObject
{
    // ... existing properties ...
    
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (AccountKeys == null && (string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey)))
        {
            yield return new ValidationResult(
                "Either AccountKeys or both PublicKey and EncryptedPrivateKey must be provided.",
                new[] { nameof(AccountKeys), nameof(PublicKey), nameof(EncryptedPrivateKey) });
        }
    }
}

@quexten quexten marked this pull request as draft December 4, 2025 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants