proptest_pkce.rs 2.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. //! `test(proptest_pkce)` — PKCE round-trip property suite (task 42).
  2. //!
  3. //! Properties over `crypto::pkce` (RFC 7636 S256, mandatory under RFC 9700):
  4. //! a derived challenge always verifies, a tampered challenge never does,
  5. //! presence is symmetric (downgrade protection), and malformed verifiers are
  6. //! rejected at derivation. No `unwrap`/`expect`/`panic` anywhere.
  7. use d_oauth::crypto::pkce::{PkceError, challenge_from_verifier, verify, verify_s256};
  8. use proptest::prelude::*;
  9. /// RFC 7636 §4.1 verifier alphabet, 43–128 characters.
  10. fn verifier() -> impl Strategy<Value = String> {
  11. "[A-Za-z0-9_.~-]{43,128}"
  12. }
  13. proptest! {
  14. #[test]
  15. fn pkce_challenge_round_trips(v in verifier()) {
  16. match challenge_from_verifier(&v) {
  17. Ok(challenge) => {
  18. prop_assert_eq!(challenge.len(), 43);
  19. prop_assert!(verify_s256(&v, &challenge).is_ok());
  20. prop_assert!(verify(Some(&v), Some(&challenge)).is_ok());
  21. }
  22. Err(_) => prop_assert!(false, "alphabet verifier must derive: {:?}", v),
  23. }
  24. }
  25. #[test]
  26. fn pkce_tampered_challenge_never_verifies(
  27. v in verifier(),
  28. tamper_at in 0..43_usize,
  29. replacement in "[A-Za-z0-9_-]",
  30. ) {
  31. let challenge = match challenge_from_verifier(&v) {
  32. Ok(challenge) => challenge,
  33. Err(_) => return Ok(()),
  34. };
  35. let mut tampered = challenge.clone();
  36. tampered.replace_range(tamper_at..tamper_at + 1, &replacement);
  37. if tampered == challenge {
  38. return Ok(());
  39. }
  40. // A changed challenge is either malformed or a digest mismatch — never
  41. // accepted.
  42. prop_assert!(verify_s256(&v, &tampered).is_err());
  43. prop_assert!(verify(Some(&v), Some(&tampered)).is_err());
  44. }
  45. #[test]
  46. fn pkce_presence_is_symmetric(v in verifier()) {
  47. let challenge = match challenge_from_verifier(&v) {
  48. Ok(challenge) => challenge,
  49. Err(_) => return Ok(()),
  50. };
  51. // Asymmetric presence is a downgrade attempt and always fails.
  52. prop_assert_eq!(verify(Some(&v), None), Err(PkceError::MissingChallenge));
  53. prop_assert_eq!(
  54. verify(None, Some(&challenge)),
  55. Err(PkceError::MissingVerifier)
  56. );
  57. // PKCE is mandatory: absent on both sides fails closed too.
  58. prop_assert!(verify(None, None).is_err());
  59. }
  60. #[test]
  61. fn pkce_malformed_verifiers_are_rejected(raw in "\\PC{0,200}") {
  62. if challenge_from_verifier(&raw).is_ok() {
  63. // Accepted values satisfy the §4.1 grammar by construction.
  64. prop_assert!((43..=128).contains(&raw.len()));
  65. prop_assert!(
  66. raw
  67. .bytes()
  68. .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'))
  69. );
  70. }
  71. }
  72. }