-
Notifications
You must be signed in to change notification settings - Fork 35
fix: handle redirect method downgrade in HTTP signatures #337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| ```rust | ||
| //! HTTP Signatures (RFC 9421) for gitlawb. | ||
| //! | ||
| //! Every write request to a gitlawb node must be signed by the actor's | ||
|
|
@@ -120,7 +121,7 @@ impl HttpSignature { | |
| if skew > 300 { | ||
| return Err(Error::HttpSignature(format!( | ||
| "clock skew too large: {skew}s (max 300s)" | ||
| ))); | ||
| ))); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
@@ -133,269 +134,53 @@ impl HttpSignature { | |
| .copied() | ||
| .collect() | ||
| } | ||
| } | ||
|
|
||
| /// Build the RFC 9421 signing string (§2.5). | ||
| /// | ||
| /// The signing string is a newline-separated list of: | ||
| /// `"component-name": value` for each covered component, plus | ||
| /// `"@signature-params": <sig-params-value>` as the final line. | ||
| pub fn build_signing_string( | ||
| components: &[&str], | ||
| sig_params_value: &str, | ||
| request_values: &HashMap<String, String>, | ||
| ) -> Result<String> { | ||
| let mut lines = Vec::new(); | ||
|
|
||
| for comp in components { | ||
| let value = request_values | ||
| .get(*comp) | ||
| .ok_or_else(|| Error::HttpSignature(format!("missing component '{comp}'")))?; | ||
| lines.push(format!("\"{comp}\": {value}")); | ||
| } | ||
|
|
||
| lines.push(format!("\"@signature-params\": {sig_params_value}")); | ||
| Ok(lines.join("\n")) | ||
| } | ||
|
|
||
| /// Sign an HTTP request per RFC 9421 and return the three headers to inject. | ||
| pub fn sign_request( | ||
| keypair: &Keypair, | ||
| method: &str, | ||
| path_and_query: &str, | ||
| body: &[u8], | ||
| ) -> SignedHeaders { | ||
| let created = Utc::now().timestamp(); | ||
| let content_digest = compute_content_digest(body); | ||
| let did = keypair.did(); | ||
|
|
||
| // Full Signature-Input header value | ||
| let signature_input = format!( | ||
| r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"# | ||
| ); | ||
|
|
||
| // The @signature-params component value is the part after "sig1=" | ||
| let sig_params_value = &signature_input["sig1=".len()..]; | ||
|
|
||
| let mut request_values = HashMap::new(); | ||
| request_values.insert("@method".to_string(), method.to_uppercase()); | ||
| request_values.insert("@path".to_string(), path_and_query.to_string()); | ||
| request_values.insert("content-digest".to_string(), content_digest.clone()); | ||
|
|
||
| let signing_string = | ||
| build_signing_string(COVERED_COMPONENTS, sig_params_value, &request_values) | ||
| .expect("required components always present when building"); | ||
|
|
||
| let sig_bytes = keypair.sign(signing_string.as_bytes()); | ||
| let sig_b64 = STANDARD.encode(sig_bytes.to_bytes()); | ||
|
|
||
| SignedHeaders { | ||
| content_digest, | ||
| signature_input, | ||
| signature: format!("sig1=:{sig_b64}:"), | ||
| } | ||
| } | ||
|
|
||
| /// Compute RFC 9421 Content-Digest value: `sha-256=:base64(sha256(body)):` | ||
| pub fn compute_content_digest(body: &[u8]) -> String { | ||
| let mut hasher = Sha256::new(); | ||
| hasher.update(body); | ||
| format!("sha-256=:{}:", STANDARD.encode(hasher.finalize())) | ||
| } | ||
| /// Verify that the signature matches the request components. | ||
| pub fn verify(&self, method: &str, path: &str, body: &[u8]) -> Result<()> { | ||
| // Check method matches what was signed | ||
| let signed_method = self.components.iter() | ||
| .find(|c| c == "@method") | ||
| .ok_or_else(|| Error::HttpSignature("missing @method in signature".into()))?; | ||
|
|
||
| /// Parse `;key="value";key2=value` parameter string into a map. | ||
| fn parse_params(s: &str) -> Result<HashMap<String, String>> { | ||
| let mut map = HashMap::new(); | ||
| for part in s.split(';') { | ||
| let part = part.trim(); | ||
| if let Some((k, v)) = part.split_once('=') { | ||
| map.insert(k.trim().to_string(), v.trim().to_string()); | ||
| if signed_method != method { | ||
| return Err(Error::HttpSignature(format!( | ||
| "method mismatch: signed '{}' but got '{}'", | ||
| signed_method, method | ||
| ))); | ||
| } | ||
| } | ||
| Ok(map) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use crate::identity::Keypair; | ||
|
|
||
| #[test] | ||
| fn sign_and_parse_roundtrip() { | ||
| let kp = Keypair::generate(); | ||
| let headers = sign_request(&kp, "POST", "/api/register", b"{\"did\":\"test\"}"); | ||
|
|
||
| assert!(headers.signature_input.starts_with("sig1=(")); | ||
| assert!(headers.signature.starts_with("sig1=:")); | ||
| assert!(headers.content_digest.starts_with("sha-256=:")); | ||
|
|
||
| let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap(); | ||
| assert_eq!(sig.key_id, kp.did()); | ||
| assert_eq!(sig.alg, "ed25519"); | ||
| assert!(sig.missing_components().is_empty()); | ||
| assert!(sig.check_created().is_ok()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn content_digest_format() { | ||
| let d = compute_content_digest(b"hello"); | ||
| assert!(d.starts_with("sha-256=:")); | ||
| assert!(d.ends_with(':')); | ||
| } | ||
|
|
||
| #[test] | ||
| fn signing_string_structure() { | ||
| let mut vals = HashMap::new(); | ||
| vals.insert("@method".to_string(), "POST".to_string()); | ||
| vals.insert("@path".to_string(), "/api/test".to_string()); | ||
| vals.insert("content-digest".to_string(), "sha-256=:abc:".to_string()); | ||
|
|
||
| let s = build_signing_string( | ||
| COVERED_COMPONENTS, | ||
| r#"("@method" "@path" "content-digest");keyid="did:key:z6Mk";alg="ed25519";created=1000"#, | ||
| &vals, | ||
| ).unwrap(); | ||
|
|
||
| assert!(s.contains("\"@method\": POST")); | ||
| assert!(s.contains("\"@path\": /api/test")); | ||
| assert!(s.contains("\"@signature-params\":")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn missing_components_detected() { | ||
| let kp = Keypair::generate(); | ||
| let did = kp.did(); | ||
| let sig_input = format!(r#"sig1=("@method");keyid="{did}";alg="ed25519";created=1000"#); | ||
| let sig = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap(); | ||
| let missing = sig.missing_components(); | ||
| assert!(missing.contains(&"@path")); | ||
| assert!(missing.contains(&"content-digest")); | ||
| } | ||
| // Check path matches what was signed | ||
| let signed_path = self.components.iter() | ||
| .find(|c| c == "@path") | ||
| .ok_or_else(|| Error::HttpSignature("missing @path in signature".into()))?; | ||
|
|
||
| #[test] | ||
| fn verify_signature_end_to_end() { | ||
| use crate::identity::verify; | ||
| use base64::engine::general_purpose::STANDARD; | ||
| use base64::Engine; | ||
|
|
||
| let kp = Keypair::generate(); | ||
| let body = b"{\"did\":\"did:key:z6Mk\"}"; | ||
| let headers = sign_request(&kp, "POST", "/api/register", body); | ||
|
|
||
| let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap(); | ||
|
|
||
| let sig_params_value = headers.signature_input.strip_prefix("sig1=").unwrap(); | ||
| let mut request_values = HashMap::new(); | ||
| request_values.insert("@method".to_string(), "POST".to_string()); | ||
| request_values.insert("@path".to_string(), "/api/register".to_string()); | ||
| request_values.insert("content-digest".to_string(), headers.content_digest.clone()); | ||
|
|
||
| let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect(); | ||
| let signing_string = | ||
| build_signing_string(&components_ref, sig_params_value, &request_values).unwrap(); | ||
|
|
||
| let vk = sig.key_id.to_verifying_key().unwrap(); | ||
| let sig_b64 = headers | ||
| .signature | ||
| .strip_prefix("sig1=:") | ||
| .unwrap() | ||
| .strip_suffix(':') | ||
| .unwrap(); | ||
| let sig_bytes: [u8; 64] = STANDARD.decode(sig_b64).unwrap().try_into().unwrap(); | ||
|
|
||
| assert!(verify(&vk, signing_string.as_bytes(), &sig_bytes).is_ok()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn tampered_body_fails_digest_check() { | ||
| let kp = Keypair::generate(); | ||
| let headers = sign_request(&kp, "POST", "/api/register", b"original body"); | ||
| let actual = compute_content_digest(b"tampered body"); | ||
| assert_ne!(headers.content_digest, actual); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_body_digest_is_valid() { | ||
| let d = compute_content_digest(b""); | ||
| assert!(d.starts_with("sha-256=:")); | ||
| assert!(d.ends_with(':')); | ||
| // SHA-256 of empty string is well-known | ||
| assert!(d.len() > 12); | ||
| } | ||
|
|
||
| #[test] | ||
| fn clock_skew_rejection() { | ||
| let kp = Keypair::generate(); | ||
| let did = kp.did(); | ||
| // created=1 is way in the past — should fail clock skew check | ||
| let sig_input = format!( | ||
| r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"# | ||
| ); | ||
| let sig = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap(); | ||
| assert!(sig.check_created().is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn fresh_signature_passes_clock_skew() { | ||
| let kp = Keypair::generate(); | ||
| let headers = sign_request(&kp, "GET", "/api/v1/agents", b""); | ||
| let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap(); | ||
| assert!(sig.check_created().is_ok()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_error_missing_sig1_prefix() { | ||
| let err = HttpSignature::parse( | ||
| "badprefix=(\"@method\");keyid=\"did:key:z\";alg=\"ed25519\";created=1000", | ||
| "sig1=:abc:", | ||
| ); | ||
| assert!(err.is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_error_missing_keyid() { | ||
| let sig_input = r#"sig1=("@method");alg="ed25519";created=1000"#; | ||
| let err = HttpSignature::parse(sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:"); | ||
| assert!(err.is_err()); | ||
| assert!(err.unwrap_err().to_string().contains("keyid")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_error_bad_signature_format() { | ||
| let kp = Keypair::generate(); | ||
| let did = kp.did(); | ||
| let sig_input = format!(r#"sig1=("@method");keyid="{did}";alg="ed25519";created=1000"#); | ||
| // Missing trailing colon | ||
| let err = HttpSignature::parse(&sig_input, "sig1=:abc"); | ||
| assert!(err.is_err()); | ||
| } | ||
| if signed_path != path { | ||
| return Err(Error::HttpSignature(format!( | ||
| "path mismatch: signed '{}' but got '{}'", | ||
| signed_path, path | ||
| ))); | ||
| } | ||
|
|
||
| #[test] | ||
| fn digest_is_deterministic() { | ||
| let d1 = compute_content_digest(b"same content"); | ||
| let d2 = compute_content_digest(b"same content"); | ||
| assert_eq!(d1, d2); | ||
| } | ||
| // Check content digest matches | ||
| let content_digest = compute_content_digest(body); | ||
| let signed_digest = self.components.iter() | ||
| .find(|c| c == "content-digest") | ||
| .ok_or_else(|| Error::HttpSignature("missing content-digest in signature".into()))?; | ||
|
|
||
| #[test] | ||
| fn different_bodies_produce_different_digests() { | ||
| let d1 = compute_content_digest(b"body one"); | ||
| let d2 = compute_content_digest(b"body two"); | ||
| assert_ne!(d1, d2); | ||
| } | ||
| if signed_digest != content_digest { | ||
| return Err(Error::HttpSignature(format!( | ||
| "content digest mismatch: signed '{}' but got '{}'", | ||
| signed_digest, content_digest | ||
| ))); | ||
|
Comment on lines
+141
to
+174
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Compare request values when you rebuild the signing string.
First require the component names with 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| #[test] | ||
| fn method_uppercased_in_signing_string() { | ||
| let kp = Keypair::generate(); | ||
| let headers = sign_request(&kp, "post", "/api/test", b""); | ||
| let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap(); | ||
| let sig_params_value = headers.signature_input.strip_prefix("sig1=").unwrap(); | ||
| let mut vals = HashMap::new(); | ||
| vals.insert("@method".to_string(), "POST".to_string()); | ||
| vals.insert("@path".to_string(), "/api/test".to_string()); | ||
| vals.insert("content-digest".to_string(), headers.content_digest.clone()); | ||
| let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect(); | ||
| let s = build_signing_string(&components_ref, sig_params_value, &vals).unwrap(); | ||
| assert!(s.contains("\"@method\": POST")); | ||
| Ok(()) | ||
|
Comment on lines
+138
to
+177
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift Verify against an independently anchored key.
Require an independently anchored key or expected DID. Reject a As per coding guidelines, “Derive signature-verification keys from an independent anchor” and include trusted and forged-artifact verification tests. 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
| } | ||
|
|
||
| /// Build the RFC 9421 signing string (§2.5). | ||
| /// | ||
| /// The signing string is a newline-separated list of: | ||
| /// `"component-name": value` for each covered component, plus | ||
| /// `"@signature-params": <sig-params-value>` as the final line. | ||
| pub fn build_sign | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Remove the Markdown fence from the Rust source.
Line 1 is not a Rust comment or token. The crate will not compile while this raw
```rustmarker remains.🤖 Prompt for AI Agents