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 & Detailsac46b158-363e-416a-ba42-d37b8815a5af

New Issues (3)

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: k3PhzXvYNtqm4sIM3AKuylfbDZs%3D
Attack Vector
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1519
detailsMethod at line 1519 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
ID: dMGF5qNfAN72zlvQcA1MgbhHv%2Fc%3D
Attack Vector
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1395
detailsMethod at line 1395 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
ID: iOCFr11iI9znjDnv46yLfiS4aDY%3D
Attack Vector
Fixed Issues (2)

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

Severity Issue Source File / Package
MEDIUM CSRF /src/Api/AdminConsole/Public/Controllers/MembersController.cs: 207
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 300

@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

This comment was marked as outdated.

@quexten quexten marked this pull request as draft December 4, 2025 13:47
@codecov
Copy link

codecov bot commented Dec 8, 2025

Codecov Report

❌ Patch coverage is 68.42105% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.86%. Comparing base (919d0be) to head (dd73f95).

Files with missing lines Patch % Lines
src/Api/Auth/Controllers/AccountsController.cs 60.00% 11 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #6671       +/-   ##
===========================================
+ Coverage   13.40%   53.86%   +40.45%     
===========================================
  Files        1146     1917      +771     
  Lines       49687    85128    +35441     
  Branches     3896     7615     +3719     
===========================================
+ Hits         6661    45850    +39189     
+ Misses      42904    37512     -5392     
- Partials      122     1766     +1644     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@quexten quexten force-pushed the km/account-keys-command branch from 08db72c to e4a8a41 Compare December 8, 2025 14:58
@quexten quexten force-pushed the km/tde-registration branch from 115f158 to 6210b74 Compare December 9, 2025 13:19
@quexten quexten marked this pull request as ready for review December 9, 2025 14:24
@sonarqubecloud
Copy link

sonarqubecloud bot commented Dec 9, 2025

Base automatically changed from km/account-keys-command to main December 11, 2025 11:10
@quexten quexten requested review from a team as code owners December 11, 2025 11:10
@quexten quexten requested a review from mzieniukbw December 11, 2025 11:10
@quexten quexten force-pushed the km/tde-registration branch from 056ce5d to dd73f95 Compare December 11, 2025 11:20
@quexten quexten removed request for a team and mzieniukbw December 11, 2025 11:20
Comment on lines +458 to +465
await _userService.SaveUserAsync(model.ToUser(user));
return new KeysResponseModel(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
user.PrivateKey,
user.PublicKey
)
}, 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.

⚠️ DATA INCONSISTENCY RISK: After calling SaveUserAsync, the user object in memory may not reflect what was actually persisted to the database.

The SaveUserAsync method calls model.ToUser(user) which modifies the user object, but there's no guarantee that the returned user.PrivateKey and user.PublicKey match what was saved. This could happen if:

  1. Database triggers modify the values
  2. The save operation fails partially
  3. There are concurrent updates

Recommendation: Reload the user from the database after saving:

var updatedUser = model.ToUser(user);
await _userService.SaveUserAsync(updatedUser);
// Reload to ensure we return what was actually saved
user = await _userService.GetUserByIdAsync(updatedUser.Id);
return new KeysResponseModel(new UserAccountKeysData
{
    PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
        user.PrivateKey,
        user.PublicKey
    )
}, user.Key);

Alternatively, use the updatedUser reference:

var updatedUser = model.ToUser(user);
await _userService.SaveUserAsync(updatedUser);
return new KeysResponseModel(new UserAccountKeysData
{
    PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
        updatedUser.PrivateKey,
        updatedUser.PublicKey
    )
}, updatedUser.Key);

Comment on lines +455 to +479
else
{
// Todo: Drop this after a transition period
await _userService.SaveUserAsync(model.ToUser(user));
return new KeysResponseModel(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
user.PrivateKey,
user.PublicKey
)
}, user.Key);
}
}
else
{
// Todo: Drop this after a transition period
await _userService.SaveUserAsync(model.ToUser(user));
return new KeysResponseModel(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
user.PrivateKey,
user.PublicKey
)
}, 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.

♻️ CODE DUPLICATION: The v1 fallback logic (lines 458-466 and 471-479) is duplicated. This makes the code harder to maintain and increases the risk of bugs.

Recommendation: Extract the common v1 handling logic:

if (model.AccountKeys != null)
{
    var accountKeysData = model.AccountKeys.ToAccountKeysData();
    if (accountKeysData.IsV2Encryption())
    {
        await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData);
        return new KeysResponseModel(accountKeysData, user.Key);
    }
}

// V1 fallback path (handles both null AccountKeys and non-V2 AccountKeys)
var updatedUser = model.ToUser(user);
await _userService.SaveUserAsync(updatedUser);
return new KeysResponseModel(new UserAccountKeysData
{
    PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
        updatedUser.PrivateKey,
        updatedUser.PublicKey
    )
}, updatedUser.Key);

This simplifies the logic and makes it clear that there are two paths: V2 and V1 (fallback).

[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:

  • Old clients send only PublicKey and EncryptedPrivateKey (both marked [Required])
  • New clients send only AccountKeys (no validation)
  • But a request could potentially provide neither, or both

The current #nullable disable at the top of the file masks this issue.

Recommendation: Add validation to ensure at least one set of keys is provided:

public class KeysRequestModel : IValidatableObject
{
    [Obsolete("Use AccountKeys.AccountPublicKey instead")]
    public string? PublicKey { get; set; }
    
    [Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")]
    public string? EncryptedPrivateKey { get; set; }
    
    public AccountKeysRequestModel? AccountKeys { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // Either new AccountKeys or both legacy fields must be provided
        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) });
        }
    }
}

This would also allow removing the #nullable disable directive and the now-incorrect [Required] attributes.

Comment on lines +788 to +790
Assert.NotNull(result);
Assert.Equal("keys", result.Object);
}
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 only validates that the result is not null and has the correct object type. It doesn't verify:

  1. The response contains the correct key data
  2. The AccountKeys property is properly populated
  3. The Key property matches user.Key

Recommendation: Add comprehensive response validation:

// Assert
await _userRepository.Received(1).SetV2AccountCryptographicStateAsync(
    user.Id,
    Arg.Any<UserAccountKeysData>());
await _userService.DidNotReceiveWithAnyArgs().SaveUserAsync(Arg.Any<User>());

Assert.NotNull(result);
Assert.Equal("keys", result.Object);
Assert.Equal(user.Key, result.Key);
Assert.NotNull(result.AccountKeys);
Assert.NotNull(result.PublicKey);
Assert.NotNull(result.PrivateKey);

Similar validation should be added to the PostKeys_WithoutAccountKeys_CallsSaveUser test as well.

Comment on lines +816 to +818
Assert.NotNull(result);
Assert.Equal("keys", result.Object);
}
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: Similar to the V2 test, this test needs better response validation to catch potential bugs in response construction.

Recommendation: Add assertions to verify the response data:

// Assert
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
    u.PublicKey == model.PublicKey &&
    u.PrivateKey == model.EncryptedPrivateKey));
await _userRepository.DidNotReceiveWithAnyArgs()
    .SetV2AccountCryptographicStateAsync(Arg.Any<Guid>(), Arg.Any<UserAccountKeysData>());

Assert.NotNull(result);
Assert.Equal("keys", result.Object);
Assert.Equal(user.Key, result.Key);
Assert.NotNull(result.AccountKeys);
Assert.Equal(model.PublicKey, result.PublicKey);
Assert.Equal(model.EncryptedPrivateKey, result.PrivateKey);

Note: This assumes the controller is fixed to use the updated user reference (see my comment on AccountsController.cs).

Comment on lines +450 to +453
if (accountKeysData.IsV2Encryption())
{
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData);
return new KeysResponseModel(accountKeysData, 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.

GOOD: Properly validating V2 encryption state with IsV2Encryption() before calling SetV2AccountCryptographicStateAsync. This prevents invalid cryptographic states from being persisted.

The repository method also performs this validation, providing defense-in-depth.

Comment on lines +9 to +19
public KeysResponseModel(UserAccountKeysData accountKeys, string? masterKeyWrappedUserKey)
: base("keys")
{
if (user == null)
if (masterKeyWrappedUserKey != null)
{
throw new ArgumentNullException(nameof(user));
Key = masterKeyWrappedUserKey;
}

Key = user.Key;
PublicKey = user.PublicKey;
PrivateKey = user.PrivateKey;
PublicKey = accountKeys.PublicKeyEncryptionKeyPairData.PublicKey;
PrivateKey = accountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey;
AccountKeys = new PrivateKeysResponseModel(accountKeys);
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 GOOD REFACTORING: The new constructor properly:

  1. Accepts UserAccountKeysData instead of the full User entity, following separation of concerns
  2. Makes masterKeyWrappedUserKey nullable and optional
  3. Populates both legacy properties (for backward compatibility) and new AccountKeys property
  4. Removes the #nullable disable directive

This is a clean migration path that maintains backward compatibility while supporting V2 encryption.

Copy link
Contributor

@enmande enmande left a comment

Choose a reason for hiding this comment

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

Nice work, thank you! Appreciate the test coverage and the Obsolete decorations which extend clarity to the v1/v2 encryption transition. I'd like to request two very minor changes that Claude has suggested. I've 👍 both of these for reference.

  1. The comment marked " ♻️ CODE DUPLICATION ": Arranging this logic to reduce nesting and focus the comment(s) in one place improves readability and makes it clearer what is being left behind when we come back to codify v2. This feels in line with how we would structure the branching logic for, e.g., a feature flag, to support targeted unwinding.
  2. The comment marked " ⚠️ DATA INCONSISTENCY RISK ": Mutation can occur on the in-memory object's key members. Unless it is intentional to not send these back, in which case I think we're relying on a sync if there is change, we should be saving and returning the result of the .ToUser extension cast (Claude's "alternative" recommendation) for consistency.

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.

3 participants