Skip to content

Commit adf181c

Browse files
committed
Enforce normalization rules during signing
For the authority, path, and scheme components, RFC 9421 requires that > Characters other than those in the "reserved" set are equivalent to > their percent-encoded octets: the normal form is to not encode them. This implies that we need to ensure we perform percent decoding to arrive at the normal form, ensuring that percent-encoded URLs and their non-encoded forms are treated as equivalent.
1 parent 2cd6a2c commit adf181c

5 files changed

Lines changed: 212 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ sha2 = "0.10.9"
3030
base64 = "0.22.1"
3131
serde_json = "1.0.140"
3232
data-url = "0.3.1"
33+
percent-encoding = "2.3.2"
3334

3435
# workspace dependencies
3536
web-bot-auth = { version = "0.5.0", path = "./crates/web-bot-auth" }

crates/web-bot-auth/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ serde = { workspace = true }
2020
serde_json = { workspace = true }
2121
sha2 = { workspace = true }
2222
base64 = { workspace = true }
23-
data-url = { workspace = true }
23+
data-url = { workspace = true }
24+
percent-encoding = { workspace = true }

crates/web-bot-auth/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ pub enum ImplementationError {
7171
/// during both signing and verification, as both steps require constructing the signature
7272
/// base.
7373
NonAsciiContentFound,
74+
/// When normalizing path, query, or authority to percent-decoded forms as mandated by RFC 9421,
75+
/// an invalid UTF-8 sequence was found. This will be thrown during signing, and indicates malformed
76+
/// UTF-8 input.
77+
InvalidUTF8Found,
7478
/// Signature bases are terminated with a line beginning with `@signature-params`. This error
7579
/// is thrown if the value of that line could not be converted into a structured field value.
7680
/// This is considered "impossible" as invalid values should not be present in the structure

crates/web-bot-auth/src/message_signatures.rs

Lines changed: 202 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::components::CoveredComponent;
1+
use crate::components::{CoveredComponent, DerivedComponent, HTTPField};
22
use crate::keyring::{Algorithm, KeyRing};
33
use indexmap::IndexMap;
44
use sfv::SerializeValue;
@@ -182,7 +182,11 @@ impl SignatureBase {
182182

183183
for (component, serialized_value) in self.components {
184184
let sfv_item = match component {
185-
CoveredComponent::HTTP(http) => sfv::Item::try_from(http)?,
185+
CoveredComponent::HTTP(http) => {
186+
let HTTPField { name, parameters } = http;
187+
let name = name.to_lowercase();
188+
sfv::Item::try_from(HTTPField { name, parameters })?
189+
}
186190
CoveredComponent::Derived(derived) => sfv::Item::try_from(derived)?,
187191
};
188192

@@ -245,10 +249,12 @@ pub trait SignedMessage {
245249
/// Trait that messages seeking signing should implement to generate `Signature-Input`
246250
/// and `Signature` header contents.
247251
pub trait UnsignedMessage {
248-
/// Obtain a list of covered components to be included. HTTP fields must be lowercased before
249-
/// emitting. It is NOT RECOMMENDED to include `signature` and `signature-input` fields here.
250-
/// If signing a Web Bot Auth message, and `Signature-Agent` header is intended present, you MUST
251-
/// include it as a component here for successful verification.
252+
/// Obtain a list of covered components to be included. The `MessageSigner` struct will
253+
/// apply URL normalization to the authority, scheme, path and query derived components,
254+
/// as well as lowercase HTTP field names. It is NOT RECOMMENDED to include `Signature` and
255+
/// `Signature-Input` fields here. If signing a Web Bot Auth message, and `Signature-Agent`
256+
/// header is intended present, you MUST include it as a component here for successful
257+
/// verification.
252258
fn fetch_components_to_cover(&self) -> IndexMap<CoveredComponent, String>;
253259
/// Store the contents of a generated `Signature-Input` and `Signature` header value.
254260
/// It is the responsibility of the application to generate a consistent label for both.
@@ -283,7 +289,7 @@ impl MessageSigner {
283289
algorithm: Algorithm,
284290
signing_key: &Vec<u8>,
285291
) -> Result<(), ImplementationError> {
286-
let components_to_cover = message.fetch_components_to_cover();
292+
let mut components_to_cover = message.fetch_components_to_cover();
287293
let mut sfv_parameters = sfv::Parameters::new();
288294

289295
sfv_parameters.insert(
@@ -352,6 +358,44 @@ impl MessageSigner {
352358
sfv::BareItem::Integer(sfv::Integer::constant(expires_as_i64)),
353359
);
354360

361+
// Handle RFC 9421 rules regarding normal form of different components
362+
for (component, value) in components_to_cover.iter_mut() {
363+
match component {
364+
CoveredComponent::Derived(DerivedComponent::Authority { req: _ }) => {
365+
// In normal form, default ports are removed. This has edge case when someone is using
366+
// HTTP with port 443 or HTTPS with port 80, but this is very unlikely.
367+
// We need to know the scheme to remove the default ports accurately, but
368+
// this info is not guaranteed to be present in the components to cover.
369+
for suffix in [":80", ":443", ":"] {
370+
if let Some(stripped) = value.strip_suffix(suffix) {
371+
*value = stripped.to_string();
372+
break;
373+
}
374+
}
375+
value.make_ascii_lowercase();
376+
}
377+
CoveredComponent::Derived(DerivedComponent::Scheme { req: _ }) => {
378+
value.make_ascii_lowercase();
379+
}
380+
CoveredComponent::Derived(DerivedComponent::Path { req: _ }) => {
381+
*value = percent_encoding::percent_decode(value.as_bytes())
382+
.decode_utf8()
383+
.map_err(|_| ImplementationError::InvalidUTF8Found)?
384+
.to_string();
385+
if value.is_empty() {
386+
*value = "/".to_string();
387+
}
388+
}
389+
CoveredComponent::Derived(DerivedComponent::Query { req: _ }) => {
390+
*value = percent_encoding::percent_decode(value.as_bytes())
391+
.decode_utf8()
392+
.map_err(|_| ImplementationError::InvalidUTF8Found)?
393+
.to_string();
394+
}
395+
_ => {}
396+
}
397+
}
398+
355399
let (signature_base, signature_params_content) = SignatureBase {
356400
components: components_to_cover,
357401
parameters: sfv_parameters.into(),
@@ -547,6 +591,18 @@ mod tests {
547591

548592
use super::*;
549593

594+
const PRIVATE_KEY: [u8; ed25519_dalek::SECRET_KEY_LENGTH] = [
595+
0x9f, 0x83, 0x62, 0xf8, 0x7a, 0x48, 0x4a, 0x95, 0x4e, 0x6e, 0x74, 0x0c, 0x5b, 0x4c, 0x0e,
596+
0x84, 0x22, 0x91, 0x39, 0xa2, 0x0a, 0xa8, 0xab, 0x56, 0xff, 0x66, 0x58, 0x6f, 0x6a, 0x7d,
597+
0x29, 0xc5,
598+
];
599+
600+
const PUBLIC_KEY: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
601+
0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58, 0x2b,
602+
0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd, 0xce, 0x43,
603+
0xd1, 0xbb,
604+
];
605+
550606
struct StandardTestVector {}
551607

552608
impl SignedMessage for StandardTestVector {
@@ -582,16 +638,11 @@ mod tests {
582638
#[test]
583639
fn test_verifying_as_http_signature() {
584640
let test = StandardTestVector {};
585-
let public_key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = [
586-
0x26, 0xb4, 0x0b, 0x8f, 0x93, 0xff, 0xf3, 0xd8, 0x97, 0x11, 0x2f, 0x7e, 0xbc, 0x58,
587-
0x2b, 0x23, 0x2d, 0xbd, 0x72, 0x51, 0x7d, 0x08, 0x2f, 0xe8, 0x3c, 0xfb, 0x30, 0xdd,
588-
0xce, 0x43, 0xd1, 0xbb,
589-
];
590641
let mut keyring = KeyRing::default();
591642
keyring.import_raw(
592643
"poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U".to_string(),
593644
Algorithm::Ed25519,
594-
public_key.to_vec(),
645+
PUBLIC_KEY.to_vec(),
595646
);
596647
let verifier = MessageVerifier::parse(&test, |(_, _)| true).unwrap();
597648
let timing = verifier.verify(&keyring, None).unwrap();
@@ -637,12 +688,6 @@ mod tests {
637688
tag: "web-bot-auth".into(),
638689
};
639690

640-
let private_key: [u8; ed25519_dalek::SECRET_KEY_LENGTH] = [
641-
0x9f, 0x83, 0x62, 0xf8, 0x7a, 0x48, 0x4a, 0x95, 0x4e, 0x6e, 0x74, 0x0c, 0x5b, 0x4c,
642-
0x0e, 0x84, 0x22, 0x91, 0x39, 0xa2, 0x0a, 0xa8, 0xab, 0x56, 0xff, 0x66, 0x58, 0x6f,
643-
0x6a, 0x7d, 0x29, 0xc5,
644-
];
645-
646691
let mut test = SigningTest {};
647692

648693
assert!(
@@ -651,7 +696,7 @@ mod tests {
651696
&mut test,
652697
Duration::from_secs(10),
653698
Algorithm::Ed25519,
654-
&private_key.to_vec()
699+
&PRIVATE_KEY.to_vec()
655700
)
656701
.is_ok()
657702
);
@@ -694,4 +739,141 @@ mod tests {
694739
let (base, _) = sigbase.into_ascii().unwrap();
695740
assert_eq!(base, expected_base);
696741
}
742+
743+
#[test]
744+
fn test_equivalent_forms_generate_same_signature() {
745+
struct EquivalentForm1 {
746+
signature_input: Option<String>,
747+
signature_header: Option<String>,
748+
}
749+
750+
struct EquivalentForm2 {
751+
signature_input: Option<String>,
752+
signature_header: Option<String>,
753+
}
754+
755+
impl UnsignedMessage for EquivalentForm1 {
756+
fn fetch_components_to_cover(&self) -> IndexMap<CoveredComponent, String> {
757+
IndexMap::from_iter([
758+
(
759+
CoveredComponent::HTTP(HTTPField {
760+
name: "content-length".to_string(),
761+
parameters: HTTPFieldParametersSet(vec![]),
762+
}),
763+
"18".to_string(),
764+
),
765+
(
766+
CoveredComponent::Derived(DerivedComponent::Authority { req: false }),
767+
"example.com".to_string(),
768+
),
769+
(
770+
CoveredComponent::Derived(DerivedComponent::Scheme { req: false }),
771+
"http".to_string(),
772+
),
773+
(
774+
CoveredComponent::Derived(DerivedComponent::Path { req: false }),
775+
"/~smith".to_string(),
776+
),
777+
(
778+
CoveredComponent::Derived(DerivedComponent::Query { req: false }),
779+
"?param=value&foo=bar&baz=bat~man".to_string(),
780+
),
781+
])
782+
}
783+
784+
fn register_header_contents(
785+
&mut self,
786+
signature_input: String,
787+
signature_header: String,
788+
) {
789+
self.signature_input = Some(signature_input);
790+
self.signature_header = Some(signature_header);
791+
}
792+
}
793+
794+
impl UnsignedMessage for EquivalentForm2 {
795+
fn fetch_components_to_cover(&self) -> IndexMap<CoveredComponent, String> {
796+
IndexMap::from_iter([
797+
(
798+
CoveredComponent::HTTP(HTTPField {
799+
name: "CONTENT-LENGTH".to_string(),
800+
parameters: HTTPFieldParametersSet(vec![]),
801+
}),
802+
"18".to_string(),
803+
),
804+
(
805+
CoveredComponent::Derived(DerivedComponent::Authority { req: false }),
806+
"example.com:".to_string(),
807+
),
808+
(
809+
CoveredComponent::Derived(DerivedComponent::Scheme { req: false }),
810+
"HTTP".to_string(),
811+
),
812+
(
813+
CoveredComponent::Derived(DerivedComponent::Path { req: false }),
814+
"/%7Esmith".to_string(),
815+
),
816+
(
817+
CoveredComponent::Derived(DerivedComponent::Query { req: false }),
818+
"?param=value&foo=bar&baz=bat%7Eman".to_string(),
819+
),
820+
])
821+
}
822+
823+
fn register_header_contents(
824+
&mut self,
825+
signature_input: String,
826+
signature_header: String,
827+
) {
828+
self.signature_input = Some(signature_input);
829+
self.signature_header = Some(signature_header);
830+
}
831+
}
832+
833+
let signer = MessageSigner {
834+
keyid: "test".into(),
835+
nonce: "another-test".into(),
836+
tag: "web-bot-auth".into(),
837+
};
838+
839+
let mut equivalent_form_1 = EquivalentForm1 {
840+
signature_input: None,
841+
signature_header: None,
842+
};
843+
let mut equivalent_form_2 = EquivalentForm2 {
844+
signature_input: None,
845+
signature_header: None,
846+
};
847+
848+
assert!(
849+
signer
850+
.generate_signature_headers_content(
851+
&mut equivalent_form_1,
852+
Duration::from_secs(10),
853+
Algorithm::Ed25519,
854+
&PRIVATE_KEY.to_vec()
855+
)
856+
.is_ok()
857+
);
858+
859+
assert!(
860+
signer
861+
.generate_signature_headers_content(
862+
&mut equivalent_form_2,
863+
Duration::from_secs(10),
864+
Algorithm::Ed25519,
865+
&PRIVATE_KEY.to_vec()
866+
)
867+
.is_ok()
868+
);
869+
870+
assert_eq!(
871+
equivalent_form_1.signature_input.unwrap(),
872+
equivalent_form_2.signature_input.unwrap()
873+
);
874+
assert_eq!(
875+
equivalent_form_1.signature_header.unwrap(),
876+
equivalent_form_2.signature_header.unwrap()
877+
);
878+
}
697879
}

0 commit comments

Comments
 (0)