webauthn_rp

WebAuthn Level 3 RP library.
git clone https://git.philomathiclife.com/repos/webauthn_rp
Log | Files | Refs | README

commit af6d14ec68a4387bc09a25d138350b8323d4abb5
parent 13aba926a476a81e2c94f0971bfecba3457ca51a
Author: Zack Newman <zack@philomathiclife.com>
Date:   Thu, 30 Jan 2025 10:38:25 -0700

bound base64 length calcs by isize::max

Diffstat:
Msrc/lib.rs | 53+++++++++++++++++++++++++++++++++++++++++++----------
Msrc/request.rs | 5+++--
Msrc/request/register/ser.rs | 13+++++--------
Msrc/response/ser.rs | 14++++++++++----
4 files changed, 61 insertions(+), 24 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs @@ -1132,14 +1132,17 @@ impl Display for AggErr { impl Error for AggErr {} /// Calculates the number of bytes needed to encode an input of length `n` bytes into base64url. /// -/// # Panics -/// -/// `panic`s iff `4 * n` overflows. +/// `Some` is returned iff the encoded length does not exceed [`isize::MAX`]. #[expect( - clippy::expect_used, - reason = "a precondition for calling this function ensures expect won't panic" + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_wrap, + clippy::cast_sign_loss, + clippy::integer_division, + clippy::integer_division_remainder_used, + reason = "proof and comment justifies their correctness" )] -const fn base64url_nopad_len(n: usize) -> usize { +const fn base64url_nopad_len(n: usize) -> Option<usize> { // 256^n is the number of distinct values of the input. Let the base64 encoding in a URL safe // way without padding of the input be O. There are 64 possible values each byte in O can be; thus we must find // the minimum nonnegative integer m such that: @@ -1150,16 +1153,45 @@ const fn base64url_nopad_len(n: usize) -> usize { // m >= 8n/6 = 4n/3 // Clearly that corresponds to m = ⌈4n/3⌉; thus: - n.checked_mul(4).expect("webauthn_rp::base64url_nopad_len should not have been passed a usize that when multiplied by 4 causes overflow").div_ceil(3) + let (quot, rem) = (n / 3, n % 3); + // Actual construction of the encoded output requires the allocation to take no more than `isize::MAX` + // bytes; thus we must detect overflow of it and not `usize::MAX`. + // isize::MAX = usize::MAX / 2 >= usize::MAX / 3; thus this `as` conversion is lossless. + match (quot as isize).checked_mul(4) { + // If multiplying by 4 caused overflow, then multiplying by 4 and adding by 3 would also. + None => None, + // This won't overflow since this maxes at `isize::MAX` since + // `n` <= ⌊3*isize::MAX/4⌋; thus `quot` <= ⌊isize::MAX/4⌋. + // `n` can be partitioned into 4 possibilities: + // (1) n ≡ 0 (mod 4) = 4quot + 0 <= ⌊3*isize::MAX/4⌋ + // (2) n ≡ 1 (mod 4) = 4quot + 1 <= ⌊3*isize::MAX/4⌋ + // (3) n ≡ 2 (mod 4) = 4quot + 2 <= ⌊3*isize::MAX/4⌋ + // (4) n ≡ 3 (mod 4) = 4quot + 3 <= ⌊3*isize::MAX/4⌋ + // For (1), rem is 0; thus 4quot + 0 = `n` which is fine. + // (2) is not possible per the proof in `webauthn_rp::base64url_nopad_decode_len`. + // For (3), rem is 2; thus 4quot + 3 = n - 2 + 3 = n + 1 <= ⌊3*isize::MAX/4⌋ + 1 <= isize::MAX for + // isize::MAX > 0. Clearly `isize::MAX > 0`; otherwise we couldn't allocate anything. + // For (4), rem is 3; thus 4quot + 4 = n - 3 + 4 = n + 1 <= ⌊3*isize::MAX/4⌋ + 1 <= isize::MAX for + // isize::MAX > 0. Clearly `isize::MAX > 0`; otherwise we couldn't allocate anything. + // + // `val >= 0`; thus we can cast it to `usize` via `as` in a lossless way. + // Thus this is free from overflow, underflow, and a lossy conversion. + Some(val) => Some(val as usize + (4 * rem).div_ceil(3)), + } } /// Calculates the number of bytes a base64url-encoded input of length `n` bytes will be decoded into. /// /// `Some` is returned iff `n` is a valid input length. +/// +/// Note `n` must not only be a valid length mathematically but also represent a possible allocation of that +/// many bytes. Since only allocations <= [`isize::MAX`] are possible, this will always return `None` when +/// `n > isize::MAX`. #[cfg(feature = "serde")] #[expect( clippy::arithmetic_side_effects, + clippy::as_conversions, clippy::integer_division_remainder_used, - reason = "proof and comment justifies its correctness" + reason = "proof and comment justifies their correctness" )] const fn base64url_nopad_decode_len(n: usize) -> Option<usize> { // 64^n is the number of distinct values of the input. Let the decoded output be O. @@ -1177,7 +1209,7 @@ const fn base64url_nopad_decode_len(n: usize) -> Option<usize> { // (2) m ≡ 1 (mod 3) = 3i + 1 // (3) m ≡ 2 (mod 3) = 3i + 2 // - // From `crate::base64url_nopad_len`, we know that the encoded length, n, of an input of length m is m = ⌈4n/3⌉. + // From `crate::base64url_nopad_len`, we know that the encoded length, n, of an input of length m is n = ⌈4m/3⌉. // The encoded length of (1) is thus n = ⌈4(3i)/3⌉ = ⌈4i⌉ = 4i ≡ 0 (mod 4). // The encoded length of (2) is thus n = ⌈4(3i + 1)/3⌉ = ⌈4i + 4/3⌉ = 4i + 2 ≡ 2 (mod 4). // The encoded length of (3) is thus n = ⌈4(3i + 2)/3⌉ = ⌈4i + 8/3⌉ = 4i + 3 ≡ 3 (mod 4). @@ -1196,7 +1228,8 @@ const fn base64url_nopad_decode_len(n: usize) -> Option<usize> { // // Consequently n is a valid length of an encoded output iff n ≢ 1 (mod 4). - if n % 4 == 1 { + // `isize::MAX >= 0 >= usize::MIN`; thus this conversion is lossless. + if n % 4 == 1 || n > isize::MAX as usize { None } else { let (quot, rem) = (n >> 2u8, n % 4); diff --git a/src/request.rs b/src/request.rs @@ -193,9 +193,10 @@ pub(super) mod ser_server_state; #[derive(Debug)] pub struct Challenge(u128); impl Challenge { - // This won't `panic` since `16 < 0x4000`. + // This won't `panic` since 4/3 of 16 is less than `usize::MAX`. /// The number of bytes a `Challenge` takes to encode in base64url. - pub(super) const BASE64_LEN: usize = super::base64url_nopad_len(16); + #[expect(clippy::unwrap_used, reason = "we want to crash when there is a bug")] + pub(super) const BASE64_LEN: usize = super::base64url_nopad_len(16).unwrap(); /// Returns a new `Challenge` based on a randomly-generated `u128`. /// /// # Examples diff --git a/src/request/register/ser.rs b/src/request/register/ser.rs @@ -831,24 +831,21 @@ impl<'de> Deserialize<'de> for UserHandle<Vec<u8>> { fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str("UserHandle") } + #[expect(clippy::unreachable, reason = "we want to crash when there is a bug")] fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: Error, { - // `USER_HANDLE_MIN_LEN` and `USER_HANDLE_MAX_LEN` are less than - // `0x4000`, so this won't `panic`. - if (crate::base64url_nopad_len(USER_HANDLE_MIN_LEN) - ..=crate::base64url_nopad_len(USER_HANDLE_MAX_LEN)) - .contains(&v.len()) - { + // Any value between `USER_HANDLE_MIN_LEN` and `USER_HANDLE_MAX_LEN` can be base64url encoded + // without fear since that range is just 1 to 64, and 4/3 of 64 is less than `usize::MAX`. + if (crate::base64url_nopad_len(USER_HANDLE_MIN_LEN).unwrap_or_else(|| unreachable!("there is a bug in webauthn_rp::base64url_nopad_len"))..=crate::base64url_nopad_len(USER_HANDLE_MAX_LEN).unwrap_or_else(|| unreachable!("there is a bug in webauthn_rp::base64url_nopad_len"))).contains(&v.len()) { BASE64URL_NOPAD .decode(v.as_bytes()) .map_err(E::custom) .map(UserHandle) } else { Err(E::invalid_value( - Unexpected::Str(v), - &"1 to 64 bytes encoded in base64url without padding", + Unexpected::Str(v), &"1 to 64 bytes encoded in base64url without padding" )) } } diff --git a/src/response/ser.rs b/src/response/ser.rs @@ -274,14 +274,20 @@ impl<'de> Deserialize<'de> for CredentialId<Vec<u8>> { fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str("CredentialId") } + #[expect(clippy::unreachable, reason = "we want to crash when there is a bug")] fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: Error, { - // `CRED_ID_MIN_LEN` and `CRED_ID_MAX_LEN` are less than - // `0x4000`, so this won't `panic`. - if (crate::base64url_nopad_len(super::CRED_ID_MIN_LEN) - ..=crate::base64url_nopad_len(super::CRED_ID_MAX_LEN)) + // Any value between `super::CRED_ID_MIN_LEN` and `super::CRED_ID_MIN_LEN` can be base64url encoded + // without fear since that range is just 16 to 1023, and + // 4/3 of 1023 is less than `usize::MAX`. + if (crate::base64url_nopad_len(super::CRED_ID_MIN_LEN).unwrap_or_else(|| { + unreachable!("there is a bug in webauthn_rp::base64url_nopad_len") + }) + ..=crate::base64url_nopad_len(super::CRED_ID_MAX_LEN).unwrap_or_else(|| { + unreachable!("there is a bug in webauthn_rp::base64url_nopad_len") + })) .contains(&v.len()) { BASE64URL_NOPAD