Skip to content

Conversation

@iinuwa
Copy link

@iinuwa iinuwa commented Oct 6, 2025

🎟️ Tracking

PM-26354

📔 Objective

In order to set up unlock passkeys on mobile clients, this PR adds a method to create a rotateable key set derived from a PRF value.

This is based on existing code in the TypeScript library and web vault:

⏰ 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

@github-actions
Copy link
Contributor

github-actions bot commented Oct 6, 2025

Logo
Checkmarx One – Scan Summary & Details4559b123-3615-4d77-a58a-8758772b33d4

Great job! No new security vulnerabilities introduced in this pull request

@codecov
Copy link

codecov bot commented Oct 6, 2025

Codecov Report

❌ Patch coverage is 91.47727% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.44%. Comparing base (79eb8c4) to head (fef1668).

Files with missing lines Patch % Lines
crates/bitwarden-core/src/key_management/crypto.rs 0.00% 9 Missing ⚠️
...bitwarden-core/src/key_management/crypto_client.rs 0.00% 3 Missing ⚠️
crates/bitwarden-uniffi/src/crypto.rs 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #494      +/-   ##
==========================================
+ Coverage   78.36%   78.44%   +0.07%     
==========================================
  Files         291      293       +2     
  Lines       29343    29519     +176     
==========================================
+ Hits        22994    23155     +161     
- Misses       6349     6364      +15     

☔ 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.

@iinuwa iinuwa force-pushed the km/PM-26177/create-prf-user-key-set branch from 2514070 to 32ca764 Compare October 6, 2025 18:30
})
}

fn unlock<Ids: KeyIds>(
Copy link
Author

Choose a reason for hiding this comment

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

This method is currently unused since we don't yet have plans to use the rotateable key set on mobile, only to create them. However, it implements functionality that is currently done in TypeScript that could be moved to the SDK.

I can remove it until we decide to either migrate the browser apps to this SDK method or start using it in the mobile apps.

let pub_key = SpkiPublicKeyBytes::from(pub_key_bytes);
let encapsulation_key = AsymmetricPublicCryptoKey::from_der(&pub_key)?;
// TODO: There is no method to store only the public key in the store, so we
// have pull out the encryption key to encapsulate it manually.
Copy link
Contributor

Choose a reason for hiding this comment

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

Note: I think this needs a KM-owned tech debt follow-up ticket. I'll make a jira ticket when this merges, but adding the APIs for this feels out of scope for an external PR.

Copy link
Contributor

@quexten quexten left a comment

Choose a reason for hiding this comment

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

Changes look mostly good, thank you!
Only some minor questions / nits.

One more (optional) suggestion here would be to take test vectors from the TS clients and feed them into the unit tests so we know the implementation works the same way.

@iinuwa
Copy link
Author

iinuwa commented Oct 7, 2025

Thank you for the review!

One more (optional) suggestion here would be to take test vectors from the TS clients and feed them into the unit tests so we know the implementation works the same way.

I looked around the clients codebase, but I don't see a full test vector for an end-to-end (PRF, User Key) -> RotateableKeySet and back. There are mocked values that don't verify the actual cryptography in https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts and https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts. The Rust types guarantee some of the other invariants tested in. Did you have some specific test vectors in mind?

@abergs
Copy link
Member

abergs commented Oct 7, 2025

@iinuwa just a ping that the lint failed and needs a cargo +"nightly-2025-08-18" fmt

@iinuwa
Copy link
Author

iinuwa commented Oct 7, 2025

@iinuwa just a ping that the lint failed and needs a cargo +"nightly-2025-08-18" fmt

Thanks. I'm using jj which doesn't run git hooks, and my IDE formats according to Rust stable formatting rules, but doesn't format comments the same way. I'll work that out in my workflow.

Copy link
Contributor

@quexten quexten Oct 8, 2025

Choose a reason for hiding this comment

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

Note: I'd recommend moving RotateableKeySet into it's own file, since it is a different cryptographic construction. key_rotation.rs is really just meant for "User Key Rotation", which may interact with RotateableKeySets, but is a different use-case.

(As a KM note, key_rotation.rs should probably be renamed to user_key_rotation.rs)

@claude
Copy link

claude bot commented Oct 8, 2025

Claude finished @iinuwa's task —— View job


Code Review Complete

Reviewing PR #494: Add methods to create rotateable key sets from PRF


Summary of Changes

This PR implements methods to create rotateable key sets derived from PRF (Pseudo-Random Function) values for unlock passkeys on mobile clients. The implementation adds:

  1. New derive_symmetric_key_from_prf function (prf.rs) - Derives AES-256-CBC-HMAC keys from PRF output
  2. New RotateableKeySet struct (rotateable_key_set.rs) - Cryptographic construction for key rotation
  3. New make_prf_user_key_set API - Exposed through CryptoClient for mobile consumption
  4. Feature-gating improvements in vault code to reduce WASM bundle size

Critical Issues

🔴 Security: File Organization Violates Repository Guidelines

Location: crates/bitwarden-crypto/src/keys/rotateable_key_set.rs

Issue: Per the review discussion and CLAUDE.md guidelines, RotateableKeySet should be in its own file separate from key_rotation.rs. The reviewer (quexten) explicitly requested:

"I'd recommend moving RotateableKeySet into it's own file, since it is a different cryptographic construction. key_rotation.rs is really just meant for 'User Key Rotation'"

However, I notice the file is in its own file (rotateable_key_set.rs), not in key_rotation.rs. This appears to already address the feedback. ✓


🟡 Test Coverage: Missing Cross-Platform Verification

Location: crates/bitwarden-crypto/src/keys/prf.rs:18-56

Issue: Tests verify internal correctness but lack cross-platform test vectors. The author noted they couldn't find full end-to-end test vectors in the TypeScript codebase.

Recommendation: Create explicit test vectors documenting the expected PRF → key derivation to ensure compatibility between Rust SDK and TypeScript clients. Consider:

#[test]
fn test_prf_compatibility_with_typescript() {
    // Known PRF value from TS implementation
    let prf = hex::decode("...").unwrap();
    let expected_key_bytes = hex::decode("...").unwrap();
    
    let key = derive_symmetric_key_from_prf(&prf).unwrap();
    // Verify key matches TS-derived key
}

This ensures cryptographic compatibility across platforms and serves as regression protection.


Suggested Improvements

📝 Documentation: Function Purpose Unclear

Location: crates/bitwarden-crypto/src/keys/prf.rs:6-16

Suggestion: Enhance documentation to explain the broader context:

/// Takes the output of a PRF and derives a symmetric key for protecting rotateable key sets.
///
/// This is used in WebAuthn PRF-based authentication flows where the PRF output
/// from a security key is used to derive an upstream key that protects the user's
/// encryption key through a RotateableKeySet.
///
/// The PRF output must be at least 32 bytes long. If longer, only the first 32 bytes
/// are used. The key is derived using HKDF-SHA256 to produce an AES-256-CBC-HMAC key.
///
/// # Security
/// - Returns an error if the PRF is all zeros (uninitialized)
/// - Uses the same key stretching as master key and PIN key derivation
///
/// # Arguments
/// * `prf` - Raw PRF output from WebAuthn authenticator (≥32 bytes)
pub fn derive_symmetric_key_from_prf(prf: &[u8]) -> Result<SymmetricCryptoKey, CryptoError>

🎨 Code Quality: Test Helper Naming

Location: crates/bitwarden-crypto/src/keys/prf.rs:52-55

Issue: The test helper pseudorandom_bytes generates deterministic bytes, not pseudorandom ones.

Suggestion: Rename for clarity:

/// Returns deterministic test bytes for a given length.
/// Each byte equals (index % 255).
fn deterministic_test_bytes(len: usize) -> Vec<u8> {
    (0..len).map(|x| (x % 255) as u8).collect()
}

🎨 Code Quality: Inconsistent Test Organization

Location: crates/bitwarden-crypto/src/keys/prf.rs:44-50

Issue: The test test_long_prf_truncated_to_proper_length has a logical flaw - it creates two different PRFs and expects them to produce the same key, which is incorrect.

Current code:

fn test_long_prf_truncated_to_proper_length() {
    let long_prf = pseudorandom_bytes(33);
    let prf = pseudorandom_bytes(32);  // Different bytes!
    let key1 = derive_symmetric_key_from_prf(&long_prf).unwrap();
    let key2 = derive_symmetric_key_from_prf(&prf).unwrap();
    assert_eq!(key1, key2);  // This happens to pass due to deterministic generation
}

Should be:

fn test_long_prf_truncated_to_32_bytes() {
    let long_prf = pseudorandom_bytes(33);
    let truncated_prf = &long_prf[..32];
    
    let key1 = derive_symmetric_key_from_prf(&long_prf).unwrap();
    let key2 = derive_symmetric_key_from_prf(truncated_prf).unwrap();
    
    assert_eq!(key1, key2, "Long PRF should be truncated to 32 bytes");
}

🟡 API Design: Unused Method Should Be Removed or Feature-Gated

Location: crates/bitwarden-crypto/src/keys/rotateable_key_set.rs:75-95

Issue: The unlock method is marked with #[allow(dead_code)] and has a TODO comment indicating it's unused and may be removed. The author asked whether to remove it.

Recommendation: Either:

  1. Remove it now if there are no immediate plans to use it (cleaner)
  2. Keep it with better documentation if migration from TS is planned soon:
/// Unlocks a rotateable key set using the upstream key.
///
/// # Future Migration
/// This method will be used when webauthn-login-strategy is migrated from
/// TypeScript to the SDK. See: [ticket reference]
///
/// Until then, this is tested but unused in production code.
#[cfg(test)]
pub(crate) fn unlock<Ids: KeyIds>(

Given the PR description states this is only for creating key sets (not unlocking), I recommend removing the unused method to keep the PR focused.


🎨 Code Quality: Inconsistent Error Enum Variant Naming

Location: crates/bitwarden-vault/src/cipher/cipher_client/get.rs:19

Observation: Changed RepositoryErrorRepository. This is actually an improvement - it's more concise and follows Rust convention where the variant name doesn't repeat the error type name. Well done! ✓


Good Practices Observed

Proper zero-key validation - Prevents uninitialized PRF values from being used
Comprehensive test coverage - Multiple edge cases tested (zero key, short PRF, long PRF)
Consistent with existing patterns - Uses same stretch_key approach as PIN and master keys
Feature gating improvements - Vault changes properly gate WASM-only imports
Generic implementation - RotateableKeySet::new is properly generic over KeyIds
Dangerous method usage documented - Proper #[allow(deprecated)] annotations with context
Symmetric-only restriction - Correctly limits to symmetric keys per security review feedback


Status of Previous Review Feedback

Addressed:

  • ✅ Added comment explaining zero-check (line 10)
  • ✅ Added test for >32 byte PRF truncation
  • ✅ Named keys as UpstreamKey/DownstreamKey per suggestion
  • ✅ Kept implementation symmetric-only as recommended
  • ✅ Fixed clippy lint (enum variant suffix removal)
  • ✅ Proper formatting applied

Pending:

  • ⏸️ Moving RotateableKeySet to separate file - Already done in rotateable_key_set.rs
  • ⏸️ Test vectors from TS clients - Author noted they don't exist in TS codebase
  • ⏸️ Tech debt ticket for KM-owned APIs - To be created by reviewer after merge

Action Items for Author

  1. Consider adding cross-platform test vectors (see Test Coverage section above) to document expected behavior and ensure TS compatibility
  2. Improve test for PRF truncation to actually test truncation logic (currently tests coincidentally equal deterministic sequences)
  3. Decide on unlock() method - remove it or add better documentation explaining future migration plans
  4. Enhance documentation for derive_symmetric_key_from_prf to explain broader context and security considerations

Overall Assessment

This is a well-structured implementation that correctly ports TypeScript functionality to Rust. The cryptographic approach is sound, following established patterns in the codebase. The code quality is high with good test coverage, proper error handling, and thoughtful use of Rust's type system.

The main concerns are around documentation completeness and cross-platform compatibility verification. The implementation itself appears secure and correct.

Recommendation: Approve with minor improvements suggested above.


@sonarqubecloud
Copy link

sonarqubecloud bot commented Oct 8, 2025

@iinuwa iinuwa force-pushed the km/PM-26177/create-prf-user-key-set branch from b9bb1f0 to d33e203 Compare October 23, 2025 15:06
@iinuwa iinuwa force-pushed the km/PM-26177/create-prf-user-key-set branch from d33e203 to fef1668 Compare October 23, 2025 15:26
@iinuwa iinuwa changed the title [PM-26177] Add methods to create rotateable key sets from PRF [PM-26354] Add methods to create rotateable key sets from PRF Oct 23, 2025
@iinuwa iinuwa marked this pull request as ready for review October 23, 2025 15:44
@iinuwa iinuwa requested review from a team as code owners October 23, 2025 15:44
@iinuwa iinuwa requested a review from Thomas-Avery October 23, 2025 15:44
@iinuwa iinuwa requested review from Jingo88 and addisonbeck October 23, 2025 15:44
@iinuwa
Copy link
Author

iinuwa commented Oct 23, 2025

I rebased and incorporated the changes mentioned by Bernd, squashed the lint fixes, and added some other commits to make CI all happy.

This is ready for review. (I updated the Jira ticket to PM-26354 to separate it from the mobile work.)

@quexten
Copy link
Contributor

quexten commented Oct 27, 2025

Looks like there are some new merge conflicts, other than that LGTM.

Comment on lines +5 to +7
/// The PRF output must be at least 32 bytes long.
pub fn derive_symmetric_key_from_prf(prf: &[u8]) -> Result<SymmetricCryptoKey, CryptoError> {
let (secret, _) = prf.split_at_checked(32).ok_or(CryptoError::InvalidKeyLen)?;
Copy link
Member

Choose a reason for hiding this comment

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

We should probably mention in the docs that only the first 32 bytes of the PRF output are processed:

/// The PRF output must be at least 32 bytes long. If longer, only the first 32 bytes are used and the remainder is discarded.
pub fn derive_symmetric_key_from_prf(prf: &[u8]) -> Result<SymmetricCryptoKey, CryptoError> {

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.

4 participants