Skip to content

Conversation

@shane-melton
Copy link
Member

@shane-melton shane-melton commented Oct 9, 2025

🎟️ Tracking

PM-24468

📔 Objective

Implement the cipher risk evaluation logic in the Vault SDK as a separate CipherRiskClient so that it can be re-used in mobile and gain potential performance improvements.

The CipherRiskClient contains logic to check if a multiple login ciphers' passwords are exposed (via HIBP), weak (via zxcvbn), or reused.

⏰ 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

@claude
Copy link

claude bot commented Oct 9, 2025

Claude finished @shane-melton's task —— View job


Claude finished @shane-melton's task —— View job


Code Review Complete ✅

I've completed a comprehensive review of the CipherRiskClient implementation. Overall, this is a well-structured addition with good separation of concerns, comprehensive testing, and thoughtful error handling.


📊 Summary of Changes Since Last Review

The PR has addressed previous feedback well:

  • ✅ Removed unused pub(super) visibility modifiers (commit bc1808f)
  • ✅ Moved dependencies to workspace configuration (commit c039308)
  • ✅ Refactored into focused sub-modules (HIBP, password strength)
  • ✅ Cleaned up module exports to expose only necessary types
  • ✅ Applied iterator chaining improvements from nikwithak's review

🔴 Critical Issues

1. SHA-1 Algorithm Lacks Required Documentation (crates/bitwarden-vault/src/cipher_risk/hibp.rs:16)

Per CLAUDE.md guidelines: "New crypto algorithms or key derivation: Detailed description, review and audit trail required. Document algorithm choice rationale and test vectors."

Issue: SHA-1 is cryptographically broken for security purposes. While it's the correct choice here (HIBP API requirement + k-anonymity protection), this needs explicit documentation.

Required: Add a comment explaining:

/// Hash password with SHA-1 for HIBP k-anonymity lookup.
///
/// NOTE: SHA-1 is cryptographically broken for collision resistance, but is the correct
/// choice here because:
/// 1. HIBP API requires SHA-1 for backwards compatibility with their existing database
/// 2. The k-anonymity model (sending only first 5 characters) protects the password itself
/// 3. This is used purely as a hash function for lookup, not for cryptographic security
/// 4. An attacker cannot reverse the hash or learn the password from the 5-char prefix
fn hash_password_for_hibp(password: &str) -> (String, String) {

2. Potential Password Leakage in Error Messages (crates/bitwarden-vault/src/cipher_risk/cipher_risk_client.rs:81)

Per CLAUDE.md: "Do not log keys, passwords, or vault data in logs or error paths. Redact sensitive data."

Issue: When HIBP API calls fail, we store the error message in ExposedPasswordResult::Error(e.to_string()). Reqwest errors can sometimes include URL parameters or request details. While the code does use .without_url() in hibp.rs (good!), we should verify this is sufficient.

Verification needed: Test that reqwest::Error::to_string() after .without_url() never leaks password hash prefixes in error messages.

Recommendation: Add explicit test case:

#[tokio::test]
async fn test_error_messages_dont_leak_password_data() {
    // Verify that error messages don't contain hash prefixes or passwords
}

3. Missing Documentation on Concurrency Limit Rationale (crates/bitwarden-vault/src/cipher_risk/cipher_risk_client.rs:29)

Issue: MAX_CONCURRENT_REQUESTS: usize = 100 lacks justification for why 100 was chosen.

Required: Document the rationale:

/// Maximum concurrent HIBP API requests.
/// 
/// Limits concurrent requests to balance performance with:
/// - API courtesy (avoid overwhelming HIBP service)
/// - Client connection pool limits (typical HTTP/2 allows ~100 streams)
/// - Memory usage for concurrent futures
/// 
/// Value chosen based on typical HTTP client defaults and HIBP API guidelines.
const MAX_CONCURRENT_REQUESTS: usize = 100;

⚠️ Significant Concerns

4. Error Handling Could Mask Total Failures (crates/bitwarden-vault/src/cipher_risk/cipher_risk_client.rs:147)

Issue: If ALL HIBP checks fail (e.g., network outage), the operation still returns Ok(results) with every cipher having ExposedPasswordResult::Error(...). Callers might not realize the entire batch failed.

Recommendation: Consider returning an error if ALL checks fail:

// After collecting results
if options.check_exposed && !results.is_empty() {
    let all_failed = results.iter().all(|r| matches!(r.exposed_result, ExposedPasswordResult::Error(_)));
    if all_failed {
        return Err(CipherRiskError::AllHibpChecksFailed);
    }
}

Alternative: Document this behavior clearly in the method docs so callers know to check for this scenario.

5. Password Reuse Map Doesn't Filter Empty Passwords Consistently (types.rs:52)

Good: PasswordReuseMap::new() filters empty passwords (line 52).

Issue: The logic in to_cipher_risk (line 61-68) returns early for empty passwords, setting reuse_count: None. This is correct, but the asymmetry could be confusing.

Recommendation: Add a comment in to_cipher_risk explaining why empty passwords return reuse_count: None:

if details.password.is_empty() {
    // Skip empty passwords. Note: empty passwords are also excluded from 
    // PasswordReuseMap, so reuse_count would be None anyway.
    return CipherRiskResult { ... };
}

6. Missing Validation for CipherId (types.rs:29)

Observation: CipherId is required (good change from previous review!), but there's no validation that it's non-empty.

Risk: If callers pass empty/default CipherIds, results become unmatchable to input ciphers.

Recommendation: Either:

  • Document that callers MUST provide valid CipherIds
  • Add validation and return an error for invalid IDs

🎨 Suggestions for Improvement

7. Type Safety: Use NewType for Hash Prefix/Suffix (hibp.rs:13-19)

Current: Returns (String, String) - callers could accidentally swap prefix/suffix.

Better:

struct HashPrefix(String);
struct HashSuffix(String);

fn hash_password_for_hibp(password: &str) -> (HashPrefix, HashSuffix) {
    // ...
    (HashPrefix(prefix.to_string()), HashSuffix(suffix.to_string()))
}

This would make the API harder to misuse.

8. Consider Caching Password Hashes (cipher_risk_client.rs:71)

Observation: For password strength calculation, the same password is hashed multiple times if checking multiple ciphers with the same password.

Optimization: Could cache zxcvbn results for identical (password, username) pairs within a batch.

Trade-off: Adds complexity. Only worthwhile if profiling shows it's a bottleneck.

9. Extract Magic Number for Hash Prefix Length (hibp.rs:18)

const HIBP_HASH_PREFIX_LENGTH: usize = 5;

fn hash_password_for_hibp(password: &str) -> (String, String) {
    let hash = Sha1::digest(password.as_bytes());
    let hash_hex = format!("{:X}", hash);
    let (prefix, suffix) = hash_hex.split_at(HIBP_HASH_PREFIX_LENGTH);
    (prefix.to_string(), suffix.to_string())
}

10. Test Coverage: Add Edge Cases (cipher_risk_client.rs:156-545)

Good: Comprehensive test suite covering concurrency, partial failures, empty passwords.

Missing test cases:

  • Very long passwords (e.g., >1000 chars)
  • Unicode passwords (emoji, non-ASCII)
  • Passwords with null bytes or special characters
  • Username/password combinations with HTML/script injection patterns (ensure zxcvbn handles them)

11. Consider Adding Batch Size Limits (cipher_risk_client.rs:122)

Issue: compute_risk() accepts Vec<CipherLoginDetails> with no size limit.

Risk: Calling with 10,000+ ciphers could cause memory issues or extremely long execution times.

Recommendation: Document expected limits or add validation:

const MAX_BATCH_SIZE: usize = 10_000;

if login_details.len() > MAX_BATCH_SIZE {
    return Err(CipherRiskError::BatchTooLarge);
}

12. HIBP Rate Limiting Not Implemented (hibp.rs:46-67)

Observation: No rate limiting or backoff for HIBP API calls.

Risk: Rapid batches could hit rate limits.

Consideration: The 100 concurrent limit provides some natural throttling, but explicit rate limiting would be more robust. Document if this is intentional.


👍 Good Practices Observed

  • Clean module organization with focused sub-modules
  • Comprehensive test coverage including integration and concurrency tests
  • Per-cipher error capture prevents batch failures
  • Proper use of k-anonymity for HIBP
  • Good use of Arc to avoid cloning password map
  • .without_url() to prevent URL leakage in errors
  • Empty password handling is sensible
  • WASM bindings properly implemented

📋 Action Items for Author

Must Address (Before Merge)

  1. ✅ Add SHA-1 algorithm justification comment (CLAUDE.md requirement)
  2. ✅ Add concurrency limit rationale comment
  3. ✅ Verify error messages don't leak password data (add test if needed)
  4. ⚠️ Document or add validation for CipherId requirement

Should Consider

  1. 🤔 Add error when ALL HIBP checks fail (or document current behavior)
  2. 🤔 Add comment about empty password handling asymmetry
  3. 💭 Consider batch size limits and document max expected usage

Nice to Have

  1. 🌱 Extract hash prefix length constant
  2. 🌱 Add edge case tests (unicode, very long passwords)
  3. 🌱 Consider type-safe newtypes for hash prefix/suffix

📖 CLAUDE.md Compliance Check

Reviewed against repo guidelines:

  • No business logic in WASM: Only bindings in WASM attributes
  • No unsafe blocks: No unsafe code present
  • ⚠️ Crypto algorithm documentation: SHA-1 needs justification comment (see Issue Dependency Dashboard #1)
  • No secrets in logs: Using .without_url() for error messages (verify with test)
  • Backward compatibility: New feature, no breaking changes
  • Error handling: Custom error type with proper propagation
  • Testing: Comprehensive test coverage

@github-actions
Copy link
Contributor

github-actions bot commented Oct 9, 2025

Logo
Checkmarx One – Scan Summary & Details2558bc66-eda0-4b40-a3f7-02c3508aeeaf

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

@codecov
Copy link

codecov bot commented Oct 9, 2025

Codecov Report

❌ Patch coverage is 96.92058% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.74%. Comparing base (6838370) to head (5f76d84).

Files with missing lines Patch % Lines
...warden-vault/src/cipher_risk/cipher_risk_client.rs 98.39% 7 Missing ⚠️
crates/bitwarden-vault/src/cipher_risk/types.rs 57.14% 6 Missing ⚠️
crates/bitwarden-vault/src/vault_client.rs 0.00% 5 Missing ⚠️
...twarden-vault/src/cipher_risk/password_strength.rs 97.82% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #499      +/-   ##
==========================================
+ Coverage   78.36%   78.74%   +0.38%     
==========================================
  Files         291      295       +4     
  Lines       29343    29960     +617     
==========================================
+ Hits        22994    23592     +598     
- Misses       6349     6368      +19     

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

@shane-melton shane-melton force-pushed the vault/pm-24468/cipher-risk-client branch from 915fe76 to a10fef6 Compare October 13, 2025 17:54
@shane-melton shane-melton marked this pull request as ready for review October 14, 2025 21:17
@shane-melton shane-melton requested review from a team as code owners October 14, 2025 21:18
Copy link
Contributor

@nikwithak nikwithak left a comment

Choose a reason for hiding this comment

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

Looks good! A few minor suggestions.

Comment on lines 77 to 126
let http_client = self.client.internal.get_http_client().clone();
let password_map = password_map.clone();
let base_url = options
.hibp_base_url
.clone()
.unwrap_or_else(|| HIBP_DEFAULT_BASE_URL.to_string());

async move {
if details.password.is_empty() {
// Skip empty passwords, return default risk values
return CipherRisk {
id: details.id,
password_strength: 0,
exposed_result: ExposedPasswordResult::NotChecked,
reuse_count: None,
};
}

let password_strength = Self::calculate_password_strength(
&details.password,
details.username.as_deref(),
);

// Check exposure via HIBP API if enabled
// Capture errors per-cipher instead of propagating them
let exposed_result = if options.check_exposed {
match Self::check_password_exposed(&http_client, &details.password, &base_url)
.await
{
Ok(count) => ExposedPasswordResult::Found(count),
Err(e) => ExposedPasswordResult::Error(e.to_string()),
}
} else {
ExposedPasswordResult::NotChecked
};

// Check reuse from provided map
let reuse_count = if let Some(map) = &password_map {
map.map.get(&details.password).copied()
} else {
None
};

CipherRisk {
id: details.id,
password_strength,
exposed_result,
reuse_count,
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⛏️ Could this be moved to a separate function async fn to_cipher_risk(..) or similar?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done in b7d3077

@Hinton
Copy link
Member

Hinton commented Oct 15, 2025

Will this replace the existing password_strength under the auth namespace? https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/auth/password/strength.rs#L5-L17

@shane-melton
Copy link
Member Author

@Hinton I originally planned on utilizing their implementation, however it is adding Bitwarden specific inputs as it was being used to specifically check the user's Bitwarden password. It also assumed there was always an email (a requirement for Bitwarden accounts).

Not opposed to making this more generic so it can be reused by Auth. However, at that point I'm not sure the Vault crate is the proper spot for a generic password strength checking utility, especially if it means the Auth crate would depend on Vault.

@shane-melton shane-melton requested a review from Hinton October 21, 2025 17:59
uuid = { workspace = true }
wasm-bindgen = { workspace = true, optional = true }
wasm-bindgen-futures = { workspace = true, optional = true }
zxcvbn = ">=3.0.1, <4.0"
Copy link
Member

Choose a reason for hiding this comment

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

issue: These should be moved to the root workspace and referenced as workspace dependencies since they are consumed by multiple crates.

Copy link
Member Author

Choose a reason for hiding this comment

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

Moved to the root workspace in c039308

Comment on lines 35 to 42
/// Error type for cipher risk evaluation operations
#[allow(missing_docs)]
#[bitwarden_error(flat)]
#[derive(Debug, Error)]
pub enum CipherRiskError {
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
}
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: Having a root error.rs is a bit of an anti pattern we're slowly moving away from. Defining it where it's used will result in less file jumping.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done! 5d0e1d2

///
/// Returns `Some(CipherLoginDetails)` if this is a login cipher with a password,
/// otherwise returns `None`.
pub fn to_login_details(&self) -> Option<crate::cipher::cipher_risk::CipherLoginDetails> {
Copy link
Member

Choose a reason for hiding this comment

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

question: Is this used anywhere?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nope, it was a leftover. Removed d201755

#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
pub struct CipherLoginDetails {
/// Cipher ID to identify which cipher in results.
pub id: Option<CipherId>,
Copy link
Member

Choose a reason for hiding this comment

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

question: Can CipherId be empty?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point, it really doesn't make sense for it to ever be null in this context. Made required in 9b8b001

Copy link
Member

Choose a reason for hiding this comment

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

suggestion: This feature seems completely decoupled from ciphers. Maybe move it to a separate dedicated module, and/or crate.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea, moved to a separate cipher_risk module in 5d0e1d2

Copy link
Member

Choose a reason for hiding this comment

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

issue: We generally only use clients to expose a public interface, this file seems to currently do a lot of various things.

suggestion:

  1. Extract two sub-modules, HIBP and reuse detection. These can be unit tested in isolation quite nicely which will also significantly decrease the filesize.
  2. Consider what public interface you need, is it sufficient to just accept a single struct containing a list of ciphers to check? If so everything except this struct and response can be made internal to the module or crate.

Copy link
Member Author

Choose a reason for hiding this comment

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

Refactored the HIBP and password strength into their own sub-modules to reduce the complexity of the cipher_risk_client. (the reuse detection is fairly small and didn't seem worth the same overhead).

I also cleaned up the public interface to only export the required input/output structs, the error, and the client itself.

ac5c2f0

@sonarqubecloud
Copy link

nikwithak
nikwithak previously approved these changes Oct 23, 2025
Copy link
Contributor

@nikwithak nikwithak left a comment

Choose a reason for hiding this comment

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

A couple of non-blocking observations, but otherwise looks good!

/// Hash password with SHA-1 and split into prefix/suffix for k-anonymity.
///
/// Returns a tuple of (prefix: first 5 chars, suffix: remaining chars).
pub(super) fn hash_password_for_hibp(password: &str) -> (String, String) {
Copy link
Contributor

Choose a reason for hiding this comment

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

⛏️ It looks like these are only called from this mod in check_password_exposed, so I think you can do away with the pub(super) here and on parse_hibp_response()

Copy link
Member Author

Choose a reason for hiding this comment

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

Nice catch! Removed in bc1808f

/// - For emails: extracts and tokenizes the local part (before @)
/// - For usernames: tokenizes the entire string
/// - Splits on non-alphanumeric characters and converts to lowercase
pub(super) fn extract_user_inputs(username: &str) -> Vec<String> {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: This is only used in calculate_password_strength, so you can ditch the pub(super)

Copy link
Member Author

Choose a reason for hiding this comment

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

Removed in bc1808f

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