Skip to content

Conversation

@david-livefront
Copy link
Collaborator

@david-livefront david-livefront commented Nov 3, 2025

🎟️ Tracking

PM-27771

📔 Objective

This PR improves the TOTP parsing logic in the TotpUriUtils to handle more edge cases than before.

⏰ 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
Contributor

claude bot commented Nov 3, 2025

Claude finished @david-livefront's task —— View job


Code Review Complete ✅

Summary

This PR significantly improves TOTP parsing logic with better validation, clearer architecture, and enhanced edge case handling. The refactoring moves TOTP utilities to a more appropriate location in the :ui module and implements robust parameter validation.

Key Improvements 👍

  1. Excellent Architecture Refactoring: Validation logic is now split into focused extension functions (getSecret(), getDigits(), getPeriod(), getAlgorithm()), making the code more maintainable and testable.

  2. Proper Algorithm Validation: The new implementation correctly distinguishes between missing algorithms (→ default to SHA_1) and invalid algorithms (→ reject URI). The previous code would incorrectly default invalid algorithms to SHA_1.

  3. Base32 Secret Validation: Added proper validation for Base32 encoding with trimming and empty string handling.

  4. Sensible Validation Bounds: Digits are validated to be between 1-10, and period must be at least 1 second, which aligns with RFC 6238 and prevents malformed TOTP URIs.

Issues Identified

🟡 Medium Priority

  1. Base32 Padding Validation (StringExtensions.kt:245): The regex accepts any number of = padding characters. RFC 4648 specifies that only 0, 1, 3, 4, or 6 padding characters are valid for Base32. Consider whether stricter validation is needed or if lenient parsing is intentional for compatibility. See comment

📝 Minor Issues

  1. Documentation Typo (TotpUriUtils.kt:91): Comment says "default to 6" but should say "default to 30" (this is the period function, not digits). See comment

  2. Base32 Empty String (StringExtensions.kt:245): The isBase32() extension could be more robust by including an isNotEmpty() check to make it self-contained. Currently protected by caller, but would be safer for future reuse. See comment

🌱 Future Improvements

  1. Test Coverage Enhancements: Consider adding test cases for:

    • Whitespace-only secrets
    • Zero digits boundary
    • Zero period boundary
    • See specific suggestions in inline comments
  2. Test Clarity (TotpUriUtilsTest.kt:48): Consider using more explicit invalid Base32 examples like "0189" or "INVALID!" instead of "1234567890qwertyuiop" for clearer test intent. See comment

Security Considerations ✓

  • Base32 validation prevents injection of invalid characters
  • Parameter bounds checking prevents potential overflow/underflow issues
  • Early rejection of malformed URIs is security-positive
  • No security vulnerabilities identified (Checkmarx scan also passed)

Test Coverage 📊

Codecov shows 80% patch coverage with 9 lines missing coverage. The missing coverage appears to be in edge case branches. Consider adding the suggested test cases above to improve coverage.

Architecture Compliance ✓

  • Follows established patterns from docs/ARCHITECTURE.md
  • Proper module organization (:ui module for shared UI utilities)
  • Uses Kotlin idioms appropriately (extension functions, nullable types, early returns)
  • KDoc documentation is clear and helpful

Recommendation

Approve with minor suggestions. The PR delivers substantial improvements to TOTP parsing robustness and code quality. The identified issues are minor and can be addressed in follow-up improvements if desired. The one question to clarify is whether RFC 4648-strict padding validation is needed for Base32 strings.


Todo List

  • Invoke reviewing-changes skill for comprehensive PR analysis
  • Analyze key changes in TotpUriUtils and validation logic
  • Review test coverage and edge cases
  • Check security implications
  • Create inline comments for specific issues
  • Provide overall review summary

val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "1234567890qwertyuiop"
Copy link
Contributor

Choose a reason for hiding this comment

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

🎨 Test improvement opportunity: The test secret "1234567890qwertyuiop" contains invalid Base32 characters (0, 1, 8, 9, and lowercase vowels). Consider using a more explicit test case that clearly shows Base32 validation:

// More explicit examples:
// Invalid: contains '0', '1', '8', '9'
every { getQueryParameter("secret") } returns "0189"
// Or invalid: contains lowercase vowels outside Base32
every { getQueryParameter("secret") } returns "invalid!"

This makes the test intent clearer: we're validating Base32 alphabet, not just rejecting arbitrary strings.

Comment on lines +45 to +52
fun `getTotpDataOrNull with invalid secret returns null`() {
val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "1234567890qwertyuiop"
}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Missing test coverage: Consider adding a test case for an empty secret string after trimming (e.g., " "). This would verify the behavior when getQueryParameter("secret") returns whitespace-only strings.

@Test
fun `getTotpDataOrNull with whitespace-only secret returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "   "
    }

    assertNull(uri.getTotpDataOrNull())
}

Comment on lines 56 to 64
fun `getTotpDataOrNull with invalid digits returns null`() {
val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "secret"
every { getQueryParameter("digits") } returns "55"
}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Test edge cases: Good coverage for out-of-range digits (55 > 10). Consider also testing the lower boundary (0 or negative) to ensure comprehensive validation:

@Test
fun `getTotpDataOrNull with zero digits returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "secret"
        every { getQueryParameter("digits") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

Comment on lines 68 to 77
fun `getTotpDataOrNull with invalid period returns null`() {
val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "secret"
every { getQueryParameter("digits") } returns "5"
every { getQueryParameter("period") } returns "-1"
}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Test edge cases: Consider also testing period = 0 explicitly, since the validation is period < 1:

@Test
fun `getTotpDataOrNull with zero period returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "secret"
        every { getQueryParameter("digits") } returns "6"
        every { getQueryParameter("period") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

return if (digits == null) {
// If no value was provided, then we'll default to 6.
6
} else if (digits < 1 || digits > 10) {
Copy link
Contributor

Choose a reason for hiding this comment

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

ℹ️ Magic number rationale: The upper bound of 10 digits is well-chosen. According to RFC 6238 (TOTP specification), most implementations support 6-8 digits, and 10 is a reasonable maximum. This validation will prevent potential issues with excessive digit counts while allowing flexibility.

Comment on lines +48 to +63
private fun Uri.getAlgorithm(): TotpData.CryptoHashAlgorithm? {
val algorithm = this
.getQueryParameter(PARAM_NAME_ALGORITHM)
?.trim()
?.lowercase()
return if (algorithm == null) {
// If no value was provided, then we'll default to SHA_1.
TotpData.CryptoHashAlgorithm.SHA_1
} else {
// If the value is unidentifiable, then it's invalid.
// If it's identifiable, then we return the valid value.
// We specifically do not use a `let` here, since we do not want to map an unidentified
// value to the default value.
TotpData.CryptoHashAlgorithm.parse(value = algorithm)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Excellent improvement: The refactoring of getAlgorithm() correctly distinguishes between:

  • Missing algorithm → default to SHA_1 ✓
  • Invalid algorithm → return null (reject the entire URI) ✓

The comment explaining why let is intentionally avoided is particularly helpful. This prevents the subtle bug where an invalid algorithm like "sha999" would fall through to the default instead of being rejected.

Comment on lines +23 to +27
val secret = this.getSecret() ?: return null
val digits = this.getDigits() ?: return null
val period = this.getPeriod() ?: return null
val algorithm = this.getAlgorithm() ?: return null
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Better validation structure: Extracting validation into separate methods with early returns makes the code much more maintainable and the validation logic clearer. Each parameter now has explicit validation rules that are easy to test independently.

@github-actions
Copy link
Contributor

github-actions bot commented Nov 3, 2025

Logo
Checkmarx One – Scan Summary & Details7a3f34b3-3c31-47d0-9119-d2af00345579

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

@david-livefront david-livefront force-pushed the PM-27771-improve-totp-parsing branch 3 times, most recently from 5643594 to dd9e13e Compare November 3, 2025 22:55
/**
* Checks if a string is using base32 digits.
*/
private fun String.isBase32(): Boolean = "^[A-Za-z2-7]+=*$".toRegex().matches(this)
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Important - Input Validation: Base32 padding validation could be stricter

The current regex ^[A-Za-z2-7]+=*$ accepts any number of = padding characters. According to RFC 4648, Base32 padding should only result in 0, 1, 3, 4, or 6 padding characters (to make the length a multiple of 8).

Current behavior: AAAA======== would be accepted (8 padding chars - invalid)
Expected: Only specific padding lengths should be valid

Security consideration: Lenient validation may accept malformed Base32 strings that fail during TOTP generation, potentially causing runtime issues.

Stricter validation example (if needed)
private fun String.isBase32(): Boolean {
    // Check Base32 alphabet
    if (!matches("^[A-Za-z2-7]+=*$".toRegex())) return false
    
    // Validate padding length (0, 1, 3, 4, or 6 according to RFC 4648)
    val paddingCount = count { it == '=' }
    return paddingCount in setOf(0, 1, 3, 4, 6)
}

Question: Is this lenient validation intentional for compatibility with various TOTP providers, or should we enforce RFC 4648 compliance more strictly?

Impact: Medium - Could accept invalid Base32 strings that fail at TOTP generation time

this
.getQueryParameter(PARAM_NAME_SECRET)
?.trim()
?.takeIf { it.isBase32() }
Copy link
Contributor

Choose a reason for hiding this comment

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

🟢 Suggestion - Edge Case: Empty string handling after trim

The current implementation trims whitespace and validates Base32, but an empty string after trimming would match the regex ^[A-Za-z2-7]+=*$ (since * means zero or more).

Suggestion: Explicitly reject empty secrets for clarity:

private fun Uri.getSecret(): String? =
    this
        .getQueryParameter(PARAM_NAME_SECRET)
        ?.trim()
        ?.takeIf { it.isNotEmpty() && it.isBase32() }

Impact: Low - Edge case that's unlikely but worth handling explicitly

// We specifically do not use a `let` here, since we do not want to map an unidentified
// value to the default value.
TotpData.CryptoHashAlgorithm.parse(value = algorithm)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Excellent improvement: Proper algorithm validation

This is a significant improvement over the old implementation! The refactored logic correctly distinguishes between:

  • Missing algorithm → default to SHA_1 ✓
  • Invalid algorithm → return null and reject the entire URI ✓

The comment explaining why let is intentionally avoided is particularly valuable. This prevents the subtle bug where invalid algorithms like "sha999" would incorrectly fall through to the default.

Old behavior (from previous code):

algorithm = TotpData.CryptoHashAlgorithm.parse(value = ...) ?: TotpData.CryptoHashAlgorithm.SHA_1

This would accept invalid algorithms and default them to SHA_1.

New behavior: Properly rejects invalid algorithms while defaulting only when none is specified.

val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "1234567890qwertyuiop"
Copy link
Contributor

Choose a reason for hiding this comment

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

🟢 Test improvement: More explicit invalid Base32 test case

The test secret "1234567890qwertyuiop" contains characters outside the Base32 alphabet (0, 1, 8, 9, and lowercase vowels e/i/o/u). Consider using a clearer example that explicitly demonstrates Base32 validation:

// More explicit examples:
every { getQueryParameter("secret") } returns "0189"  // Invalid: contains 0, 1, 8, 9
// or
every { getQueryParameter("secret") } returns "INVALID!"  // Invalid: contains special chars

This makes the test intent clearer: we're validating the Base32 alphabet specifically.

Impact: Low - Test clarity improvement

}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Future improvement: Missing test coverage for whitespace-only secret

Consider adding a test case for whitespace-only secret strings (e.g., " "). This would verify behavior when getQueryParameter("secret") returns only whitespace.

@Test
fun `getTotpDataOrNull with whitespace-only secret returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "   "
    }

    assertNull(uri.getTotpDataOrNull())
}

Impact: Low - Additional edge case coverage

}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Future improvement: Test lower boundary for digits validation

Good coverage for the upper boundary (55 > 10). Consider also testing the lower boundary:

@Test
fun `getTotpDataOrNull with zero digits returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "JBSWY3DPEHPK3PXP"  // Valid Base32
        every { getQueryParameter("digits") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

Impact: Low - Comprehensive boundary testing

}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Future improvement: Test zero period explicitly

Since the validation is period < 1, consider also testing period = 0 explicitly:

@Test
fun `getTotpDataOrNull with zero period returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "JBSWY3DPEHPK3PXP"  // Valid Base32
        every { getQueryParameter("digits") } returns "6"
        every { getQueryParameter("period") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

Impact: Low - Comprehensive boundary testing

@david-livefront david-livefront force-pushed the PM-27771-improve-totp-parsing branch from dd9e13e to 55e62b5 Compare November 4, 2025 15:39
val regex = ("^[A-Z2-7]+=*$").toRegex()
return regex.matches(this)
}
fun String.isBase32(): Boolean = "^[A-Za-z2-7]+=*$".toRegex().matches(this)
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Base32 validation: Empty string edge case

The current regex ^[A-Za-z2-7]+=*$ will match an empty string (since * means zero or more). While the getSecret() function in TotpUriUtils trims the input, an empty string after trimming would still pass this validation.

Recommendation: Consider adding an explicit length check or use + instead of * for non-empty validation:

Option 1: Explicit non-empty check
fun String.isBase32(): Boolean = 
    isNotEmpty() && "^[A-Za-z2-7]+=*$".toRegex().matches(this)
Option 2: Require at least one Base32 character
fun String.isBase32(): Boolean = 
    "^[A-Za-z2-7]+[A-Za-z2-7=]*$".toRegex().matches(this)

Impact: Low - Edge case that's unlikely but worth handling explicitly for robustness

val regex = ("^[A-Z2-7]+=*$").toRegex()
return regex.matches(this)
}
fun String.isBase32(): Boolean = "^[A-Za-z2-7]+=*$".toRegex().matches(this)
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Base32 validation: Padding compliance

According to RFC 4648 Section 6, Base32 padding with = should only result in specific padding lengths to make the total length a multiple of 8. Valid padding counts are: 0, 1, 3, 4, or 6 characters.

Current behavior: AAAA======== (8 padding chars) would be accepted
RFC 4648 compliant: Only 0, 1, 3, 4, or 6 = characters should be valid

Question: Is lenient validation intentional for compatibility with various TOTP providers, or should we enforce stricter RFC 4648 compliance?

Stricter RFC 4648-compliant validation
fun String.isBase32(): Boolean {
    // Check Base32 alphabet
    if (!matches("^[A-Za-z2-7]+=*$".toRegex())) return false
    
    // Validate padding length per RFC 4648
    val paddingCount = count { it == '=' }
    return paddingCount in setOf(0, 1, 3, 4, 6)
}

Impact: Medium - Lenient validation may accept malformed Base32 strings that fail during actual TOTP generation

Note: RFC 3548 (referenced in TotpData KDoc) states "padding...should be omitted", so many TOTP URIs may not have padding at all. The question is whether we should reject invalid padding patterns.

// We specifically do not use a `let` here, since we do not want to map an unidentified
// value to the default value.
TotpData.CryptoHashAlgorithm.parse(value = algorithm)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Excellent algorithm validation logic

This is a significant improvement! The refactored code correctly distinguishes between:

  • Missing algorithm → default to SHA_1 ✓
  • Invalid algorithm → return null and reject the entire URI ✓

The comment explaining why let is intentionally avoided is particularly valuable. This prevents the subtle bug where invalid algorithms like "sha999" would incorrectly fall through to the default.

Old behavior: Would accept invalid algorithms and default them to SHA_1
New behavior: Properly rejects invalid algorithms while defaulting only when none is specified

return if (digits == null) {
// If no value was provided, then we'll default to 6.
6
} else if (digits < 1 || digits > 10) {
Copy link
Contributor

Choose a reason for hiding this comment

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

ℹ️ Validation bounds rationale

The upper bound of 10 digits is well-chosen. According to RFC 6238 (TOTP specification), most implementations support 6-8 digits, and 10 is a reasonable maximum that prevents potential UI/display issues while allowing flexibility.

The lower bound of 1 prevents zero or negative digit counts which would be meaningless for TOTP codes.

val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "1234567890qwertyuiop"
Copy link
Contributor

Choose a reason for hiding this comment

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

🎨 Test clarity: More explicit Base32 validation example

The test secret "1234567890qwertyuiop" contains multiple character types outside Base32:

  • Invalid digits: 0, 1, 8, 9 (Base32 uses 2-7, not 0-1 or 8-9)
  • Invalid letters: e, i, o, u (Base32 alphabet excludes vowels to avoid confusion)

Consider a clearer example that explicitly demonstrates what's being validated:

every { getQueryParameter("secret") } returns "0189"  // Invalid: contains 0, 1, 8, 9

or

every { getQueryParameter("secret") } returns "INVALID!"  // Invalid: special characters

This makes the test intent more obvious: we're specifically validating the Base32 alphabet.

Impact: Low - Test clarity improvement

}

assertNull(uri.getTotpDataOrNull())
}
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: Whitespace-only secret

Consider adding a test case for whitespace-only secret strings (e.g., " "). This would verify behavior when getQueryParameter("secret") returns only whitespace, which gets trimmed to an empty string.

Suggested test case
@Test
fun `getTotpDataOrNull with whitespace-only secret returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "   "
    }

    assertNull(uri.getTotpDataOrNull())
}

Impact: Low - Additional edge case coverage

}

assertNull(uri.getTotpDataOrNull())
}
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: Lower boundary for digits

Good coverage for the upper boundary (55 > 10). Consider also testing the lower boundary to ensure comprehensive validation:

Suggested test case
@Test
fun `getTotpDataOrNull with zero digits returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "JBSWY3DPEHPK3PXP"  // Valid Base32
        every { getQueryParameter("digits") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

This would test the digits < 1 validation in TotpUriUtils.kt:75.

Impact: Low - Comprehensive boundary testing

}

assertNull(uri.getTotpDataOrNull())
}
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: Zero period boundary

Since the validation is period < 1 (line 93 in TotpUriUtils.kt), consider explicitly testing period = 0:

Suggested test case
@Test
fun `getTotpDataOrNull with zero period returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "JBSWY3DPEHPK3PXP"  // Valid Base32
        every { getQueryParameter("digits") } returns "6"
        every { getQueryParameter("period") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

The current test uses -1 which is good, but testing 0 explicitly would provide complete boundary coverage.

Impact: Low - Comprehensive boundary testing

@codecov
Copy link

codecov bot commented Nov 4, 2025

Codecov Report

❌ Patch coverage is 80.00000% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.90%. Comparing base (4b7fcdb) to head (a43f021).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...lin/com/bitwarden/ui/platform/util/TotpUriUtils.kt 78.57% 0 Missing and 9 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #6119   +/-   ##
=======================================
  Coverage   84.90%   84.90%           
=======================================
  Files         724      724           
  Lines       52780    52798   +18     
  Branches     7672     7679    +7     
=======================================
+ Hits        44811    44829   +18     
  Misses       5277     5277           
  Partials     2692     2692           

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

@david-livefront david-livefront force-pushed the PM-27771-improve-totp-parsing branch from 55e62b5 to a43f021 Compare November 4, 2025 19:19
val regex = ("^[A-Z2-7]+=*$").toRegex()
return regex.matches(this)
}
fun String.isBase32(): Boolean = "^[A-Za-z2-7]+=*$".toRegex().matches(this)
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Base32 validation: Empty string edge case

The current regex ^[A-Za-z2-7]+=*$ will match an empty string (since * means zero or more). While getSecret() in TotpUriUtils trims the input, an empty string would still pass this validation.

Recommendation: The current implementation in TotpUriUtils.kt:109 handles this correctly by checking it.isNotEmpty() && it.isBase32(), so this is already protected. However, for the isBase32() extension function itself to be more robust for future use, consider:

Suggested change
fun String.isBase32(): Boolean = "^[A-Za-z2-7]+=*$".toRegex().matches(this)
fun String.isBase32(): Boolean = isNotEmpty() && "^[A-Za-z2-7]+=*$".toRegex().matches(this)

This makes the function self-contained and safe to use anywhere without requiring callers to remember the empty string check.

val regex = ("^[A-Z2-7]+=*$").toRegex()
return regex.matches(this)
}
fun String.isBase32(): Boolean = "^[A-Za-z2-7]+=*$".toRegex().matches(this)
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Base32 validation: RFC 4648 padding compliance

According to RFC 4648 Section 6, Base32 padding with = should only result in specific padding lengths to make the total length a multiple of 8. Valid padding counts are: 0, 1, 3, 4, or 6 characters.

Current behavior: AAAA======== (8 padding chars) would be accepted
RFC 4648 compliant: Only 0, 1, 3, 4, or 6 = characters should be valid

Question for review: Is lenient validation intentional for compatibility with various TOTP providers, or should we enforce stricter RFC 4648 compliance?

Stricter RFC 4648-compliant validation (if desired)
fun String.isBase32(): Boolean {
    // Check Base32 alphabet
    if (!matches("^[A-Za-z2-7]+=*$".toRegex())) return false
    
    // Validate padding length per RFC 4648
    val paddingCount = count { it == '=' }
    return paddingCount in setOf(0, 1, 3, 4, 6)
}

Note: RFC 3548 (referenced in TotpData KDoc at line 14) states "padding...should be omitted", so many TOTP URIs may not have padding at all. The question is whether we should reject invalid padding patterns when padding is present.

Impact: Medium - Lenient validation may accept malformed Base32 strings that could fail during actual TOTP generation, though most providers omit padding entirely.

private fun Uri.getPeriod(): Int? {
val period = this.getQueryParameter(PARAM_NAME_PERIOD)?.trim()?.toIntOrNull()
return if (period == null) {
// If no value was provided, then we'll default to 30.
Copy link
Contributor

Choose a reason for hiding this comment

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

📝 Documentation correction

The comment says "default to 6" but this is the getPeriod() function which correctly defaults to 30 seconds (not the getDigits() function which defaults to 6).

Suggested change
// If no value was provided, then we'll default to 30.
// If no value was provided, then we'll default to 30.

Comment on lines +49 to +63
private fun Uri.getAlgorithm(): TotpData.CryptoHashAlgorithm? {
val algorithm = this
.getQueryParameter(PARAM_NAME_ALGORITHM)
?.trim()
?.lowercase()
return if (algorithm == null) {
// If no value was provided, then we'll default to SHA_1.
TotpData.CryptoHashAlgorithm.SHA_1
} else {
// If the value is unidentifiable, then it's invalid.
// If it's identifiable, then we return the valid value.
// We specifically do not use a `let` here, since we do not want to map an unidentified
// value to the default value.
TotpData.CryptoHashAlgorithm.parse(value = algorithm)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Excellent algorithm validation logic

This is a significant improvement over the previous implementation! The refactored code correctly distinguishes between:

  • Missing algorithm → default to SHA_1 ✓
  • Invalid algorithm → return null and reject the entire URI ✓

The comment at lines 60-62 explaining why let is intentionally avoided is particularly valuable. This prevents the subtle bug where invalid algorithms like "sha999" would incorrectly fall through to the default.

Previous behavior: Would accept invalid algorithms and default them to SHA_1
New behavior: Properly rejects invalid algorithms while defaulting only when none is specified

This makes the validation much more robust and prevents malformed URIs from being accepted.

Comment on lines +66 to +81
/**
* Attempts to extract the digits from the given totp [Uri].
*/
@Suppress("MagicNumber")
private fun Uri.getDigits(): Int? {
val digits = this.getQueryParameter(PARAM_NAME_DIGITS)?.trim()?.toIntOrNull()
return if (digits == null) {
// If no value was provided, then we'll default to 6.
6
} else if (digits < 1 || digits > 10) {
// If the value is less than 1 or greater than 10, then it's invalid.
null
} else {
// If the value is valid, then we'll return it.
digits
}
Copy link
Contributor

Choose a reason for hiding this comment

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

ℹ️ Validation bounds rationale

The validation bounds are well-chosen:

  • Upper bound of 10 digits: According to RFC 6238 (TOTP specification), most implementations support 6-8 digits. The upper limit of 10 is a reasonable maximum that prevents potential UI/display issues while allowing flexibility for edge cases.
  • Lower bound of 1 digit: Prevents zero or negative digit counts which would be meaningless for TOTP codes.

This strikes a good balance between flexibility and preventing malformed TOTP URIs.

Comment on lines +15 to +44
/**
* Checks if the given [Uri] contains valid data for a TOTP. The [TotpData] will be returned when
* the correct data is present or `null` if data is invalid or missing.
*/
fun Uri.getTotpDataOrNull(): TotpData? {
// Must be a "otpauth" scheme
if (!this.scheme.equals(other = TOTP_SCHEME_NAME, ignoreCase = true)) return null
// Must be a "totp" host
if (!this.host.equals(other = TOTP_HOST_NAME, ignoreCase = true)) return null
val secret = this.getSecret() ?: return null
val digits = this.getDigits() ?: return null
val period = this.getPeriod() ?: return null
val algorithm = this.getAlgorithm() ?: return null
val segments = this.pathSegments?.firstOrNull()?.split(":")
val segmentCount = segments?.size ?: 0
return TotpData(
uri = this.toString(),
issuer = this.getQueryParameter(PARAM_NAME_ISSUER)
?: segments?.firstOrNull()?.trim()?.takeIf { segmentCount > 1 },
accountName = if (segmentCount > 1) {
segments?.getOrNull(index = 1)?.trim()
} else {
segments?.firstOrNull()?.trim()
},
secret = secret,
digits = digits,
period = period,
algorithm = algorithm,
)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Improved validation architecture

The refactoring of validation into separate extension functions (getSecret(), getDigits(), getPeriod(), getAlgorithm()) is excellent! This approach provides:

Clear separation of concerns - Each parameter has its own validation logic
Better maintainability - Easy to modify validation rules for individual parameters
Improved testability - Each validation rule can be tested independently
Early returns - Invalid data is rejected immediately with clear null returns
Self-documenting code - The structure makes it obvious when defaults are applied vs. when validation fails

This is a significant architectural improvement over the previous monolithic parsing approach.

val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "1234567890qwertyuiop"
Copy link
Contributor

Choose a reason for hiding this comment

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

🎨 Test clarity: More explicit Base32 validation example

The test secret "1234567890qwertyuiop" contains multiple character types outside Base32:

  • Invalid digits: 0, 1, 8, 9 (Base32 uses 2-7, not 0-1 or 8-9)
  • Invalid letters: e, i, o, u (lowercase vowels - Base32 alphabet is A-Z excluding vowels to avoid confusion)

Consider using a clearer example that explicitly demonstrates what's being validated:

every { getQueryParameter("secret") } returns "0189"  // Invalid: contains 0, 1, 8, 9

or

every { getQueryParameter("secret") } returns "INVALID!"  // Invalid: special characters

This would make the test intent more obvious: we're specifically validating the Base32 alphabet.

Impact: Low - Test works correctly, this is just a clarity improvement.

}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Future improvement: Whitespace-only secret test coverage

Consider adding a test case for whitespace-only secret strings (e.g., " "). This would verify behavior when getQueryParameter("secret") returns only whitespace, which gets trimmed to an empty string.

Suggested test case
@Test
fun `getTotpDataOrNull with whitespace-only secret returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "   "
    }

    assertNull(uri.getTotpDataOrNull())
}

This would test the isNotEmpty() check at line 109 in TotpUriUtils.kt after trimming.

Impact: Low - Additional edge case coverage for robustness.

Comment on lines +54 to +64
@Test
fun `getTotpDataOrNull with invalid digits returns null`() {
val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "secret"
every { getQueryParameter("digits") } returns "11"
}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Future improvement: Lower boundary test for digits

Good coverage for the upper boundary (11 > 10). Consider also testing the lower boundary to ensure comprehensive validation:

Suggested test case
@Test
fun `getTotpDataOrNull with zero digits returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "JBSWY3DPEHPK3PXP"  // Valid Base32
        every { getQueryParameter("digits") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

This would test the digits < 1 validation at line 75 in TotpUriUtils.kt.

Impact: Low - Comprehensive boundary testing for robustness.

Comment on lines +66 to +77
@Test
fun `getTotpDataOrNull with invalid period returns null`() {
val uri = mockk<Uri> {
every { scheme } returns "otpauth"
every { host } returns "totp"
every { getQueryParameter("secret") } returns "secret"
every { getQueryParameter("digits") } returns "5"
every { getQueryParameter("period") } returns "0"
}

assertNull(uri.getTotpDataOrNull())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🌱 Future improvement: Zero period boundary test

Since the validation is period < 1 (line 93 in TotpUriUtils.kt), consider explicitly testing period = 0:

Suggested test case
@Test
fun `getTotpDataOrNull with zero period returns null`() {
    val uri = mockk<Uri> {
        every { scheme } returns "otpauth"
        every { host } returns "totp"
        every { getQueryParameter("secret") } returns "JBSWY3DPEHPK3PXP"  // Valid Base32
        every { getQueryParameter("digits") } returns "6"
        every { getQueryParameter("period") } returns "0"
    }

    assertNull(uri.getTotpDataOrNull())
}

The current test uses an empty string for period which is also good, but testing "0" explicitly would provide complete boundary coverage.

Impact: Low - Comprehensive boundary testing for robustness.

@david-livefront
Copy link
Collaborator Author

Thanks @SaintPatrck

@david-livefront david-livefront added this pull request to the merge queue Nov 4, 2025
Merged via the queue into main with commit ed47ff4 Nov 4, 2025
27 of 49 checks passed
@david-livefront david-livefront deleted the PM-27771-improve-totp-parsing branch November 4, 2025 20:48
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