From 46e32da9a3af3f158ed15d0f539e53ddff8e37b1 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 21 May 2026 18:05:31 +0200 Subject: [PATCH] Verify TLS RSA-PSS signatures for isu issue 235 --- .isu/issues.json | 18 ++- crates/crypto/src/rsa.rs | 225 +++++++++++++++++++++++++++++++- crates/net/src/tls/handshake.rs | 38 +++++- 3 files changed, 270 insertions(+), 11 deletions(-) diff --git a/.isu/issues.json b/.isu/issues.json index 96e47ce..6fd26ef 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 242, + "next_id": 243, "issues": [ { "id": 1, @@ -2822,7 +2822,7 @@ ], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-05-17T18:30:59Z" }, { @@ -2902,6 +2902,20 @@ "author": "piefev", "state": "open", "created_at": "2026-05-17T18:30:59Z" + }, + { + "id": 242, + "repo": "we", + "title": "Instagram real-web stalls on JS bundle parse/runtime errors", + "body": "While validating isu issue 235 after enabling TLS RSA-PSS CertificateVerify, the live Instagram page loaded and rendered its splash/branding, but did not advance to the login/signup controls. The e2e stderr showed repeated JavaScript parse/runtime failures from Instagram static bundles, including parse errors such as expected ';', found Dot, invalid unicode code point, and a runtime TypeError: undefined is not a function. DOM dumps still contain Instagram metadata, and screenshots are recognizable but splash-only.\\n\\nRepro commands:\\n- cargo run -p we-e2e -- --url https://www.instagram.com/ --out crates/e2e/artifacts/real_web/instagram.com/desktop.png --width 1365 --height 900\\n- cargo run -p we-e2e -- --url https://www.instagram.com/ --out crates/e2e/artifacts/real_web/instagram.com/mobile.png --width 390 --height 844\\n\\nAcceptance criteria:\\n- Reduce the first Instagram JS parse/runtime failure to a deterministic local JS or e2e fixture.\\n- Fix the minimized engine defect without weakening script error reporting.\\n- Re-run the Instagram desktop and mobile captures and document the next visible progress point or remaining third-party limitation.\\n- Run cargo fmt --all --check, cargo clippy --workspace -- -D warnings, cargo test --workspace, and e2e smoke if user-visible behavior changes.", + "labels": [ + "e2e-realweb", + "js" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-05-21T16:05:09Z" } ] } diff --git a/crates/crypto/src/rsa.rs b/crates/crypto/src/rsa.rs index d4c108c..cbfcd48 100644 --- a/crates/crypto/src/rsa.rs +++ b/crates/crypto/src/rsa.rs @@ -1,8 +1,9 @@ -//! RSA PKCS#1 v1.5 signature verification (RFC 8017). +//! RSA PKCS#1 v1.5 and PSS signature verification (RFC 8017). //! //! Supports RSA key sizes of 2048, 3072, and 4096 bits. Parses public keys //! from DER-encoded PKCS#1 RSAPublicKey and PKCS#8 SubjectPublicKeyInfo -//! formats. Verifies RSASSA-PKCS1-v1_5 signatures with SHA-256 and SHA-384. +//! formats. Verifies RSASSA-PKCS1-v1_5 and RSASSA-PSS signatures with SHA-256, +//! SHA-384, and SHA-512. use crate::asn1::{ self, Asn1Error, OID_RSA_ENCRYPTION, OID_SHA256, OID_SHA384, OID_SHA512, TAG_NULL, TAG_SEQUENCE, @@ -337,6 +338,47 @@ impl RsaPublicKey { Ok(()) } + + /// Verify an RSASSA-PSS signature. + /// + /// This implements RFC 8017 PSS verification using MGF1 with the same hash + /// as `hash_alg` and a salt length equal to the hash length. TLS 1.3's + /// rsa_pss_rsae_* signature schemes use these exact parameters. + pub fn verify_pss( + &self, + hash_alg: HashAlgorithm, + message: &[u8], + signature: &[u8], + ) -> Result<()> { + let hash = hash_alg.hash(message); + self.verify_pss_prehashed(hash_alg, &hash, signature) + } + + /// Verify an RSASSA-PSS signature where the message hash is pre-computed. + pub fn verify_pss_prehashed( + &self, + hash_alg: HashAlgorithm, + hash: &[u8], + signature: &[u8], + ) -> Result<()> { + if hash.len() != hash_alg.hash_len() { + return Err(RsaError::DigestMismatch); + } + if signature.len() != self.key_len { + return Err(RsaError::InvalidSignatureLength); + } + + let s = BigUint::from_be_bytes(signature); + if s.cmp(&self.n) != core::cmp::Ordering::Less { + return Err(RsaError::SignatureOutOfRange); + } + + let m = s.modpow(&self.e, &self.n); + let em_bits = self.n.bit_len().saturating_sub(1); + let em_len = em_bits.div_ceil(8); + let em = to_be_bytes_exact(&m, em_len)?; + emsa_pss_verify(hash, &em, em_bits, hash_alg, hash_alg.hash_len()) + } } // --------------------------------------------------------------------------- @@ -369,6 +411,97 @@ fn emsa_pkcs1_v15_encode(hash: &[u8], hash_alg: HashAlgorithm, em_len: usize) -> Ok(em) } +// --------------------------------------------------------------------------- +// EMSA-PSS verification (RFC 8017 Section 9.1.2) +// --------------------------------------------------------------------------- + +fn emsa_pss_verify( + hash: &[u8], + em: &[u8], + em_bits: usize, + hash_alg: HashAlgorithm, + salt_len: usize, +) -> Result<()> { + let h_len = hash_alg.hash_len(); + if hash.len() != h_len { + return Err(RsaError::DigestMismatch); + } + + let em_len = em_bits.div_ceil(8); + if em.len() != em_len || em_len < h_len + salt_len + 2 { + return Err(RsaError::InvalidPadding); + } + + if em[em_len - 1] != 0xBC { + return Err(RsaError::InvalidPadding); + } + + let db_len = em_len - h_len - 1; + let masked_db = &em[..db_len]; + let h = &em[db_len..db_len + h_len]; + let unused_bits = 8 * em_len - em_bits; + + if unused_bits > 0 { + let unused_mask = 0xFFu8 << (8 - unused_bits); + if masked_db[0] & unused_mask != 0 { + return Err(RsaError::InvalidPadding); + } + } + + let db_mask = mgf1(h, db_len, hash_alg); + let mut db = Vec::with_capacity(db_len); + for (masked, mask) in masked_db.iter().zip(db_mask.iter()) { + db.push(masked ^ mask); + } + if unused_bits > 0 { + db[0] &= 0xFFu8 >> unused_bits; + } + + let ps_len = em_len - h_len - salt_len - 2; + if db[..ps_len].iter().any(|&b| b != 0) || db[ps_len] != 0x01 { + return Err(RsaError::InvalidPadding); + } + + let salt = &db[db_len - salt_len..]; + let mut m_prime = Vec::with_capacity(8 + hash.len() + salt.len()); + m_prime.extend_from_slice(&[0u8; 8]); + m_prime.extend_from_slice(hash); + m_prime.extend_from_slice(salt); + let expected_h = hash_alg.hash(&m_prime); + + if !constant_time_eq(h, &expected_h) { + return Err(RsaError::DigestMismatch); + } + + Ok(()) +} + +/// MGF1 mask generation function from RFC 8017 Appendix B.2.1. +fn mgf1(seed: &[u8], mask_len: usize, hash_alg: HashAlgorithm) -> Vec { + let mut mask = Vec::with_capacity(mask_len); + let mut counter = 0u32; + while mask.len() < mask_len { + let mut block = Vec::with_capacity(seed.len() + 4); + block.extend_from_slice(seed); + block.extend_from_slice(&counter.to_be_bytes()); + mask.extend_from_slice(&hash_alg.hash(&block)); + counter = counter.wrapping_add(1); + } + mask.truncate(mask_len); + mask +} + +fn to_be_bytes_exact(value: &BigUint, len: usize) -> Result> { + let raw = value.to_be_bytes(); + if raw.len() > len { + return Err(RsaError::InvalidPadding); + } + + let mut out = vec![0u8; len - raw.len()]; + out.extend_from_slice(&raw); + Ok(out) +} + // --------------------------------------------------------------------------- // Utility: parse algorithm OID from a signature's DigestInfo // --------------------------------------------------------------------------- @@ -832,4 +965,92 @@ mod tests { let result = key.verify_pkcs1v15_prehashed(HashAlgorithm::Sha256, &hash[..16], &em); assert_eq!(result.unwrap_err(), RsaError::DigestMismatch); } + + fn emsa_pss_encode( + hash: &[u8], + hash_alg: HashAlgorithm, + em_bits: usize, + salt: &[u8], + ) -> Vec { + let h_len = hash_alg.hash_len(); + assert_eq!(hash.len(), h_len); + + let em_len = em_bits.div_ceil(8); + assert!(em_len >= h_len + salt.len() + 2); + + let mut m_prime = Vec::new(); + m_prime.extend_from_slice(&[0u8; 8]); + m_prime.extend_from_slice(hash); + m_prime.extend_from_slice(salt); + let h = hash_alg.hash(&m_prime); + + let ps_len = em_len - salt.len() - h_len - 2; + let mut db = vec![0u8; ps_len]; + db.push(0x01); + db.extend_from_slice(salt); + + let db_mask = mgf1(&h, db.len(), hash_alg); + let mut masked_db = Vec::with_capacity(db.len()); + for (db_byte, mask_byte) in db.iter().zip(db_mask.iter()) { + masked_db.push(db_byte ^ mask_byte); + } + + let unused_bits = 8 * em_len - em_bits; + if unused_bits > 0 { + masked_db[0] &= 0xFFu8 >> unused_bits; + } + + let mut em = masked_db; + em.extend_from_slice(&h); + em.push(0xBC); + em + } + + #[test] + fn verify_pss_sha256_constructed() { + let mut mod_bytes = vec![0xFF; 256]; + mod_bytes[255] = 0xFD; + let exponent = vec![0x01]; // e=1 + + let key_der = build_pkcs1_key(&mod_bytes, &exponent); + let key = RsaPublicKey::from_pkcs1_der(&key_der).unwrap(); + + let message = b"tls certificate verify content"; + let hash = sha256(message); + let salt: Vec = (0..HashAlgorithm::Sha256.hash_len()) + .map(|i| (i * 7 + 3) as u8) + .collect(); + let em = emsa_pss_encode(&hash, HashAlgorithm::Sha256, key.n.bit_len() - 1, &salt); + + let result = key.verify_pss(HashAlgorithm::Sha256, message, &em); + assert!(result.is_ok(), "PSS verification should pass: {result:?}"); + + let result = key.verify_pss(HashAlgorithm::Sha256, b"wrong message", &em); + assert!(result.is_err(), "wrong message should fail"); + + let mut tampered = em; + tampered[255] ^= 0x01; + let result = key.verify_pss(HashAlgorithm::Sha256, message, &tampered); + assert!(result.is_err(), "tampered signature should fail"); + } + + #[test] + fn verify_pss_sha384_prehashed() { + let mut mod_bytes = vec![0xFF; 256]; + mod_bytes[255] = 0xFD; + let exponent = vec![0x01]; // e=1 + + let key_der = build_pkcs8_key(&mod_bytes, &exponent); + let key = RsaPublicKey::from_pkcs8_der(&key_der).unwrap(); + + let hash = sha384(b"prehashed pss message"); + let salt = vec![0xA5; HashAlgorithm::Sha384.hash_len()]; + let em = emsa_pss_encode(&hash, HashAlgorithm::Sha384, key.n.bit_len() - 1, &salt); + + let result = key.verify_pss_prehashed(HashAlgorithm::Sha384, &hash, &em); + assert!(result.is_ok(), "prehashed PSS should pass: {result:?}"); + + let result = key.verify_pss_prehashed(HashAlgorithm::Sha384, &hash[..16], &em); + assert_eq!(result.unwrap_err(), RsaError::DigestMismatch); + } } diff --git a/crates/net/src/tls/handshake.rs b/crates/net/src/tls/handshake.rs index 193f1c2..e28b162 100644 --- a/crates/net/src/tls/handshake.rs +++ b/crates/net/src/tls/handshake.rs @@ -64,6 +64,7 @@ const SIG_ECDSA_SECP256R1_SHA256: u16 = 0x0403; const SIG_ECDSA_SECP384R1_SHA384: u16 = 0x0503; const SIG_RSA_PSS_RSAE_SHA256: u16 = 0x0804; const SIG_RSA_PSS_RSAE_SHA384: u16 = 0x0805; +const SIG_RSA_PSS_RSAE_SHA512: u16 = 0x0806; // Cipher suite wire values (RFC 8446 §B.4) const CS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01]; @@ -329,6 +330,7 @@ fn build_extensions( SIG_ECDSA_SECP384R1_SHA384, SIG_RSA_PSS_RSAE_SHA256, SIG_RSA_PSS_RSAE_SHA384, + SIG_RSA_PSS_RSAE_SHA512, SIG_RSA_PKCS1_SHA256, SIG_RSA_PKCS1_SHA384, SIG_RSA_PKCS1_SHA512, @@ -829,13 +831,26 @@ fn verify_certificate_verify( .verify_prehashed(&hash, &sig) .map_err(|_| HandshakeError::SignatureVerificationFailed)?; } - SIG_RSA_PSS_RSAE_SHA256 | SIG_RSA_PSS_RSAE_SHA384 => { - // RSA-PSS is common in TLS 1.3. For now, we accept the connection - // if we can't verify PSS signatures, but we should implement it. - // TODO: Implement RSA-PSS signature verification - // For now, skip verification for PSS schemes. - // This is a known limitation. - return Err(HandshakeError::SignatureVerificationFailed); + SIG_RSA_PSS_RSAE_SHA256 => { + let pubkey = we_crypto::rsa::RsaPublicKey::from_der(&cert.subject_public_key_info) + .map_err(|e| HandshakeError::CertificateError(format!("RSA key: {e:?}")))?; + pubkey + .verify_pss(we_crypto::rsa::HashAlgorithm::Sha256, &content, signature) + .map_err(|_| HandshakeError::SignatureVerificationFailed)?; + } + SIG_RSA_PSS_RSAE_SHA384 => { + let pubkey = we_crypto::rsa::RsaPublicKey::from_der(&cert.subject_public_key_info) + .map_err(|e| HandshakeError::CertificateError(format!("RSA key: {e:?}")))?; + pubkey + .verify_pss(we_crypto::rsa::HashAlgorithm::Sha384, &content, signature) + .map_err(|_| HandshakeError::SignatureVerificationFailed)?; + } + SIG_RSA_PSS_RSAE_SHA512 => { + let pubkey = we_crypto::rsa::RsaPublicKey::from_der(&cert.subject_public_key_info) + .map_err(|e| HandshakeError::CertificateError(format!("RSA key: {e:?}")))?; + pubkey + .verify_pss(we_crypto::rsa::HashAlgorithm::Sha512, &content, signature) + .map_err(|_| HandshakeError::SignatureVerificationFailed)?; } _ => { return Err(HandshakeError::SignatureVerificationFailed); @@ -2364,6 +2379,15 @@ mod tests { let ext_len = u16::from_be_bytes([exts[i + 2], exts[i + 3]]) as usize; if ext_type == EXT_SIGNATURE_ALGORITHMS { found = true; + let data = &exts[i + 4..i + 4 + ext_len]; + let list_len = u16::from_be_bytes([data[0], data[1]]) as usize; + let algs: Vec = data[2..2 + list_len] + .chunks_exact(2) + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect(); + assert!(algs.contains(&SIG_RSA_PSS_RSAE_SHA256)); + assert!(algs.contains(&SIG_RSA_PSS_RSAE_SHA384)); + assert!(algs.contains(&SIG_RSA_PSS_RSAE_SHA512)); break; } i += 4 + ext_len; -- 2.51.2