Skip to content

[PM-39454] feat(km): expose the user key id on sync and add the backfill endpoint - #8112

Draft
quexten wants to merge 3 commits into
km/user-key-id-06-validate-key-idfrom
km/user-key-id-07-sync-and-backfill
Draft

[PM-39454] feat(km): expose the user key id on sync and add the backfill endpoint#8112
quexten wants to merge 3 commits into
km/user-key-id-06-validate-key-idfrom
km/user-key-id-07-sync-and-backfill

Conversation

@quexten

@quexten quexten commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Add the user id on sync in the user decryption options, and add the endpoint for back-filling it for v1 users.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.90%. Comparing base (0c52d78) to head (fc604e4).

Additional details and impacted files
@@                          Coverage Diff                          @@
##           km/user-key-id-06-validate-key-id    #8112      +/-   ##
=====================================================================
+ Coverage                              62.88%   62.90%   +0.02%     
=====================================================================
  Files                                   2304     2305       +1     
  Lines                                 100407   100426      +19     
  Branches                                9041     9043       +2     
=====================================================================
+ Hits                                   63137    63170      +33     
+ Misses                                 35083    35069      -14     
  Partials                                2187     2187              

☔ View full report in Codecov by Harness.
📢 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/user-key-id-07-sync-and-backfill branch from 7a89c6f to f9f46d1 Compare July 31, 2026 13:33
@quexten
quexten force-pushed the km/user-key-id-07-sync-and-backfill branch from f9f46d1 to da9b23f Compare July 31, 2026 15:20
@quexten quexten added ai-review Request a Claude code review t:feature Change Type - Feature Development labels Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Reviewed the new POST /accounts/key-management/user-key-id backfill endpoint, SetUserKeyIdCommand, the UserKeyId field added to the sync UserDecryption payload, and the key-id validation changes now included in this PR's diff (MasterPasswordService, ChangeKdfCommand, MasterPasswordUnlockDataTests). The endpoint itself remains sound: fill-only semantics are enforced as a single conditional at the repository layer, malformed key ids are rejected by [UserKeyId] model validation before ToKeyId() runs, and DI registration plus test structure match sibling code in AccountsKeyManagementController. However, MasterPasswordService.SaveUpdateExistingKdfConfigurationAsync calls a method name that does not exist, which breaks the build.

Code Review Details
  • ❌ : Call to non-existent ValidateUserKeyUnchangedForUser breaks the build; the check is already performed by ValidateDataForUser on line 179
    • src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs:198
  • ♻️ : Test rewrite drops the only coverage of the empty-string key id column, a branch still handled in User.GetUserKeyId()
    • test/Core.Test/KeyManagement/Models/Data/MasterPasswordUnlockDataTests.cs:44

Previously raised threads remain addressed by the author (same-key-id 400 handled in the SDK; sync-field assertion thread resolved).

Note: this PR targets the stacked branch km/user-key-id-06-validate-key-id, and its diff currently also contains the key-id validation commit's changes, so those were reviewed as in-scope rather than as pre-existing context. The KeyId type, TrySetUserKeyIdAsync, and SetUserKeyIdRequestModel are pre-existing.

Comment thread src/Core/KeyManagement/Commands/SetUserKeyIdCommand.cs
Comment thread src/Api/Vault/Models/Response/SyncResponseModel.cs
@quexten
quexten force-pushed the km/user-key-id-07-sync-and-backfill branch from da9b23f to fc604e4 Compare July 31, 2026 15:39
quexten added 3 commits August 1, 2026 02:31
…tch the stored one

Now that the key id is recorded, use it. Two checks, for two different mistakes.

ValidateUserKeyUnchangedForUser guards the flows that re-wrap an existing user key without
replacing it — changing a master password, changing a KDF. If the client reports a key id
that is not the one the account has, the client is working from a user key the server does
not believe is current, and the write would leave the account wrapped under a key nothing
else agrees on. Reject rather than persist that.

ValidateContainedKeyIdMatches guards rotation, which does replace the user key. There the
request carries the new key id, and the unlock data must name that same key rather than
the one being retired.

Both are no-ops when either side is absent, so accounts predating the key id and clients
that do not send one are unaffected.
…ill endpoint

Accounts that predate the key id have none stored, and the server cannot derive one — only
the client knows which key it holds. Close that gap from both ends.

Sync now returns the stored key id, so a client can tell whether the server already knows
its key. When it does not, POST /accounts/key-management/user-key-id reports it.

The backfill is deliberately fill-only: TrySetUserKeyIdAsync writes just when the column is
still null, and the command turns a refusal into a 400 rather than silently ignoring it.
Reporting a key id must never be able to rename the key an account is already known to use.

Adds end-to-end coverage of the endpoint: successful backfill, the rejected second attempt,
malformed input, and unauthenticated access.
@quexten
quexten force-pushed the km/user-key-id-07-sync-and-backfill branch from fc604e4 to ff87137 Compare July 31, 2026 17:31

user.Key = updateExistingData.MasterPasswordUnlock.MasterKeyWrappedUserKey;
ApplyKdfStateOnUser(user, updateExistingData.MasterPasswordUnlock.Kdf);
updateExistingData.MasterPasswordUnlock.ValidateUserKeyUnchangedForUser(user);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CRITICAL: ValidateUserKeyUnchangedForUser does not exist — this does not compile, and the check is already performed above.

Details and fix

The only method on MasterPasswordUnlockData is ValidateUserKeyIdUnchangedForUser (src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs:42); ValidateUserKeyUnchangedForUser (no Id) is not defined anywhere in the repo, so this is a CS1061 build break. The failing Run tests check on this PR is consistent with that.

The line is also redundant: updateExistingData.ValidateDataForUser(user) on line 179 already calls MasterPasswordUnlock.ValidateUserKeyIdUnchangedForUser(user) (src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs:67), and it runs there before user.Key and the KDF state are mutated, which is the correct ordering for a validation. Commit c9360ddb already removed the identical stray call from PrepareUpdateExistingMasterPasswordAsync; this second occurrence appears to have been missed.

        user.Key = updateExistingData.MasterPasswordUnlock.MasterKeyWrappedUserKey;
        ApplyKdfStateOnUser(user, updateExistingData.MasterPasswordUnlock.Kdf);

The test names in test/Core.Test/KeyManagement/Models/Data/MasterPasswordUnlockDataTests.cs also use the ValidateUserKeyUnchangedForUser_* prefix while invoking ValidateUserKeyIdUnchangedForUser, suggesting a rename that was only half applied — worth aligning those names in whichever direction you settle on.

Comment on lines 44 to 56
[Fact]
public void ValidateUserKeyIdUnchangedForUser_Accepts_WhenStoredKeyIdIsEmptyString()
public void ValidateUserKeyUnchangedForUser_WhenServerHasNoKeyId_DoesNotThrow()
{
// An empty column value is treated as unset rather than as a malformed key id, so a legacy
// row does not fail every password change.
var user = BuildUser(string.Empty);

BuildUnlockData(DifferentKeyId).ValidateUserKeyIdUnchangedForUser(user);
// Backfill case: nothing to compare against yet.
MakeData(_keyIdA).ValidateUserKeyIdUnchangedForUser(MakeUser(null));
}

[Fact]
public void ValidateUserKeyIdUnchangedForUser_DoesNotMutateTheUser()
public void ValidateUserKeyUnchangedForUser_WhenRequestOmitsKeyId_DoesNotThrow()
{
var user = BuildUser(StoredKeyId);

BuildUnlockData(StoredKeyId).ValidateUserKeyIdUnchangedForUser(user);

Assert.Equal(StoredKeyId, user.UserKeyId);
// Clients that predate the field must keep working.
MakeData(null).ValidateUserKeyIdUnchangedForUser(MakeUser(_keyIdA));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ DEBT: The rewrite drops the only test covering the empty-string key id column, a branch that is still deliberately handled in production code.

Details and fix

User.GetUserKeyId() (src/Core/Entities/User.cs:137) treats an empty UserKeyId column as unset rather than as a malformed key id:

public KeyId? GetUserKeyId() =>
    KeyId.FromHexEncodedString(string.IsNullOrEmpty(UserKeyId) ? null : UserKeyId);

That guard is what keeps a legacy row with '' from failing every password change through ValidateUserKeyIdUnchangedForUser. The deleted ValidateUserKeyIdUnchangedForUser_Accepts_WhenStoredKeyIdIsEmptyString test was the only coverage of it — grepping GetUserKeyId across test/ now returns a single unrelated assertion in RegisterFinishRequestModelTests, so the branch is untested after this PR.

Re-adding the case keeps the defensive behavior pinned:

[Fact]
public void ValidateUserKeyIdUnchangedForUser_WhenStoredKeyIdIsEmptyString_DoesNotThrow()
{
    // An empty column value is treated as unset, so a legacy row does not fail every password change.
    MakeData(_keyIdA).ValidateUserKeyIdUnchangedForUser(MakeUser(string.Empty));
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant