Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

688 linesCodeBlameRaw
1//! SAML 2.0 for single sign-on: the request g1t sends (HTTP-Redirect
2//! binding), the metadata it publishes, and checking the response the
3//! provider posts back (HTTP-POST binding).
4//!
5//! A response is believed only when its signature verifies under one of
6//! the certificates the workspace's owner pasted in, and only the signed
7//! element is read: the one assertion, signed itself or inside a signed
8//! response. The checks follow the SAML profile and XML Signature, kept
9//! deliberately narrow so nothing unusual gets through:
10//!
11//! - **Signed by the provider.** One `Reference`, to its own parent by
12//! `ID`; transforms only enveloped-signature and exclusive
13//! canonicalization; RSA with SHA-256, -384 or -512 (never SHA-1). IDs
14//! must be unique in the document, so no second element can stand in for
15//! the signed one, and no DTD is read at all.
16//! - **For this sign-in.** `InResponseTo` names the request g1t sent (its
17//! state row is spent on use, so a response replays nowhere), the
18//! destination and recipient are g1t's assertion consumer service, the
19//! audience is g1t's entity id, the issuer is the provider's, and the
20//! times hold, give or take three minutes.
21//! - **Not encrypted.** An encrypted assertion is refused with how to turn
22//! it off: the response arrives over TLS straight from the browser.
23//!
24//! Provider-started sign-ins (no `InResponseTo`) are refused: they could
25//! sign a person in to a session that never asked.
26
27use base64::Engine;
28use base64::engine::general_purpose::STANDARD;
29use g1t_contracts::time::{parse_rfc3339, rfc3339};
30use roxmltree::{Document, Node, NodeId, NodeType, ParsingOptions};
31use rsa::pkcs8::DecodePublicKey;
32use rsa::{Pkcs1v15Sign, RsaPublicKey};
33use sha2::{Digest, Sha256, Sha384, Sha512};
34
35pub const PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
36pub const ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
37const METADATA: &str = "urn:oasis:names:tc:SAML:2.0:metadata";
38const DSIG: &str = "http://www.w3.org/2000/09/xmldsig#";
39const EXC_C14N: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
40const EXC_C14N_COMMENTS: &str = "http://www.w3.org/2001/10/xml-exc-c14n#WithComments";
41const ENVELOPED: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
42const SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
43const BEARER: &str = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
44const POST_BINDING: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
45const EMAIL_FORMAT: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
46const UNSPECIFIED_FORMAT: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified";
47/// How far apart g1t's clock and the provider's may be.
48const SKEW_MS: u64 = 3 * 60 * 1000;
49/// Bigger than any real response.
50const MAX_RESPONSE_BYTES: usize = 512 * 1024;
51/// Attribute names providers use for the person's address.
52const EMAIL_ATTRIBUTES: &[&str] = &[
53 "email",
54 "mail",
55 "emailaddress",
56 "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
57 "urn:oid:0.9.2342.19200300.100.1.3",
58];
59
60const NOT_SIGNED: &str = "The identity provider's response is not signed. Set it to sign the assertion or the response.";
61const BAD_SIGNATURE: &str = "The identity provider's signature did not verify with the certificate saved for this workspace. Check that the certificate in the single sign-on settings is the provider's current signing certificate.";
62
63/// What a response must match.
64pub struct Expect<'a> {
65 /// The provider's entity id: the issuer.
66 pub idp_entity_id: &'a str,
67 /// Its signing certificates, DER.
68 pub certificates: &'a [Vec<u8>],
69 /// g1t's assertion consumer service, exactly as sent.
70 pub acs_url: &'a str,
71 /// g1t's entity id: the audience.
72 pub audience: &'a str,
73 /// The id of the request g1t sent.
74 pub request_id: &'a str,
75 pub now_ms: u64,
76}
77
78/// Who the provider says signed in.
79#[derive(Debug, PartialEq, Eq)]
80pub struct Asserted {
81 /// The NameID: the person's stable id at the provider.
82 pub subject: String,
83 pub email: Option<String>,
84}
85
86/// The `InResponseTo` of a posted response, to find its request by, before
87/// anything is believed: nothing read here is trusted.
88pub fn in_response_to(saml_response: &str) -> Option<String> {
89 let xml = decode(saml_response).ok()?;
90 let doc = parse(&xml).ok()?;
91 doc.root_element().attribute("InResponseTo").map(str::to_owned)
92}
93
94fn decode(saml_response: &str) -> Result<String, String> {
95 let compact: String = saml_response.chars().filter(|c| !c.is_whitespace()).collect();
96 if compact.len() > MAX_RESPONSE_BYTES * 4 / 3 + 4 {
97 return Err("The identity provider's response is too large.".to_owned());
98 }
99 let bytes = STANDARD.decode(compact).map_err(|_| "The identity provider's response could not be read.".to_owned())?;
100 String::from_utf8(bytes).map_err(|_| "The identity provider's response could not be read.".to_owned())
101}
102
103fn parse(xml: &str) -> Result<Document<'_>, String> {
104 // No DTD, ever: entities are how XML is made to lie.
105 Document::parse_with_options(xml, ParsingOptions { allow_dtd: false, ..ParsingOptions::default() })
106 .map_err(|_| "The identity provider's response is not valid XML.".to_owned())
107}
108
109/// Checks a posted `SAMLResponse` (base64) against `expect` and says who
110/// signed in.
111pub fn verify_response(saml_response: &str, expect: &Expect) -> Result<Asserted, String> {
112 let xml = decode(saml_response)?;
113 let doc = parse(&xml)?;
114 let response = doc.root_element();
115 if !response.has_tag_name((PROTOCOL, "Response")) {
116 return Err("The identity provider did not send a SAML response.".to_owned());
117 }
118 unique_ids(&doc)?;
119 // Signed first: nothing else is read until it verifies.
120 let assertion = only_child(response, ASSERTION, "Assertion")?;
121 let Some(assertion) = assertion else {
122 if child(response, ASSERTION, "EncryptedAssertion").is_some() {
123 return Err("The identity provider encrypted the assertion. Turn off assertion encryption for g1t: the response reaches g1t over HTTPS.".to_owned());
124 }
125 return Err(status_message(response).unwrap_or_else(|| "The identity provider's response has no assertion.".to_owned()));
126 };
127 let mut signed = false;
128 if let Some(signature) = only_child(assertion, DSIG, "Signature")? {
129 verify_signature(&xml, signature, expect.certificates)?;
130 signed = true;
131 }
132 if let Some(signature) = only_child(response, DSIG, "Signature")? {
133 verify_signature(&xml, signature, expect.certificates)?;
134 signed = true;
135 }
136 if !signed {
137 return Err(NOT_SIGNED.to_owned());
138 }
139 if let Some(message) = status_message(response) {
140 return Err(message);
141 }
142 check_response(response, assertion, expect)
143}
144
145/// Why the provider said no, when its status is not success.
146fn status_message(response: Node) -> Option<String> {
147 let status = child(response, PROTOCOL, "Status")?;
148 let code = child(status, PROTOCOL, "StatusCode")?.attribute("Value")?;
149 if code == SUCCESS {
150 return None;
151 }
152 let said = child(status, PROTOCOL, "StatusMessage").and_then(|m| m.text()).map(str::trim).filter(|m| !m.is_empty());
153 Some(match said {
154 Some(said) => format!("The identity provider refused the sign-in: {said}"),
155 None => format!("The identity provider refused the sign-in ({}).", code.rsplit(':').next().unwrap_or(code)),
156 })
157}
158
159fn check_response(response: Node, assertion: Node, expect: &Expect) -> Result<Asserted, String> {
160 match response.attribute("InResponseTo") {
161 Some(id) if id == expect.request_id => {}
162 Some(_) => return Err("This response answers another sign-in. Start again.".to_owned()),
163 None => return Err("Start single sign-on from g1t: sign-ins started at the identity provider are not accepted.".to_owned()),
164 }
165 if let Some(destination) = response.attribute("Destination")
166 && destination != expect.acs_url
167 {
168 return Err(format!("The response was sent to {destination}, not this workspace's assertion consumer service ({}).", expect.acs_url));
169 }
170 if let Some(issuer) = child(response, ASSERTION, "Issuer")
171 && text(issuer) != expect.idp_entity_id
172 {
173 return Err(issuer_mismatch(&text(issuer), expect.idp_entity_id));
174 }
175 let issuer = child(assertion, ASSERTION, "Issuer").map(text).unwrap_or_default();
176 if issuer != expect.idp_entity_id {
177 return Err(issuer_mismatch(&issuer, expect.idp_entity_id));
178 }
179 let now = expect.now_ms;
180 let conditions = child(assertion, ASSERTION, "Conditions");
181 if let Some(conditions) = conditions {
182 within(conditions.attribute("NotBefore"), conditions.attribute("NotOnOrAfter"), now)?;
183 }
184 let audiences: Vec<String> = conditions
185 .into_iter()
186 .flat_map(|c| children(c, ASSERTION, "AudienceRestriction"))
187 .flat_map(|r| children(r, ASSERTION, "Audience"))
188 .map(text)
189 .collect();
190 if !audiences.iter().any(|audience| audience == expect.audience) {
191 return Err(format!("The assertion is not for g1t: set the provider's audience (SP entity id) to {}.", expect.audience));
192 }
193 let subject = child(assertion, ASSERTION, "Subject").ok_or("The assertion names no one.")?;
194 let confirmed = children(subject, ASSERTION, "SubjectConfirmation")
195 .filter(|c| c.attribute("Method") == Some(BEARER))
196 .filter_map(|c| child(c, ASSERTION, "SubjectConfirmationData"))
197 .any(|data| {
198 data.attribute("Recipient") == Some(expect.acs_url)
199 && data.attribute("InResponseTo").is_none_or(|id| id == expect.request_id)
200 && data.attribute("NotBefore").is_none()
201 && data.attribute("NotOnOrAfter").and_then(parse_time).is_some_and(|at| now < at + SKEW_MS)
202 });
203 if !confirmed {
204 return Err(format!(
205 "The assertion's subject confirmation does not match: the recipient must be {} and it must not have expired.",
206 expect.acs_url
207 ));
208 }
209 let name_id = child(subject, ASSERTION, "NameID").ok_or("The assertion has no NameID.")?;
210 let subject_id = text(name_id);
211 if subject_id.is_empty() || subject_id.len() > 512 {
212 return Err("The assertion's NameID is empty.".to_owned());
213 }
214 let email = attribute_email(assertion).or_else(|| {
215 let format = name_id.attribute("Format").unwrap_or(UNSPECIFIED_FORMAT);
216 (format == EMAIL_FORMAT || subject_id.contains('@')).then(|| subject_id.clone())
217 });
218 Ok(Asserted { subject: subject_id, email: email.map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@')) })
219}
220
221fn issuer_mismatch(said: &str, expected: &str) -> String {
222 format!("The response was issued by {said:?}, not the identity provider saved for this workspace ({expected:?}). Check the provider's entity id.")
223}
224
225fn attribute_email(assertion: Node) -> Option<String> {
226 children(assertion, ASSERTION, "AttributeStatement")
227 .flat_map(|s| children(s, ASSERTION, "Attribute"))
228 .find(|a| a.attribute("Name").is_some_and(|name| EMAIL_ATTRIBUTES.iter().any(|known| known.eq_ignore_ascii_case(name))))
229 .and_then(|a| child(a, ASSERTION, "AttributeValue"))
230 .map(text)
231 .filter(|value| !value.is_empty())
232}
233
234fn parse_time(at: &str) -> Option<u64> {
235 parse_rfc3339(at.trim())
236}
237
238fn within(not_before: Option<&str>, not_on_or_after: Option<&str>, now: u64) -> Result<(), String> {
239 if let Some(at) = not_before {
240 let at = parse_time(at).ok_or("The assertion's times could not be read.")?;
241 if now + SKEW_MS < at {
242 return Err("The assertion is not valid yet: check the identity provider's clock.".to_owned());
243 }
244 }
245 if let Some(at) = not_on_or_after {
246 let at = parse_time(at).ok_or("The assertion's times could not be read.")?;
247 if now >= at + SKEW_MS {
248 return Err("The assertion has expired. Start again.".to_owned());
249 }
250 }
251 Ok(())
252}
253
254// --- The document -----------------------------------------------------------
255
256fn text(node: Node) -> String {
257 node.text().unwrap_or_default().trim().to_owned()
258}
259
260fn children<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator<Item = Node<'a, 'i>> + 'a {
261 node.children().filter(move |c| c.is_element() && c.has_tag_name((ns, name)))
262}
263
264fn child<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> Option<Node<'a, 'i>> {
265 children(node, ns, name).next()
266}
267
268/// The child named so, refusing more than one.
269fn only_child<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> Result<Option<Node<'a, 'i>>, String> {
270 let mut found = children(node, ns, name);
271 let first = found.next();
272 if found.next().is_some() {
273 return Err(format!("The identity provider's response has more than one {name}."));
274 }
275 Ok(first)
276}
277
278/// Refuses a document where two elements share an `ID`: a signature names
279/// what it signs by id, so the id must name one thing.
280fn unique_ids(doc: &Document) -> Result<(), String> {
281 let mut seen = std::collections::HashSet::new();
282 for node in doc.descendants().filter(Node::is_element) {
283 for attribute in node.attributes() {
284 if attribute.namespace().is_none() && matches!(attribute.name(), "ID" | "Id" | "id") && !seen.insert(attribute.value()) {
285 return Err("The identity provider's response uses an id twice.".to_owned());
286 }
287 }
288 }
289 Ok(())
290}
291
292// --- XML Signature ----------------------------------------------------------
293
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295enum Hash {
296 Sha256,
297 Sha384,
298 Sha512,
299}
300
301impl Hash {
302 fn digest_method(uri: &str) -> Result<Hash, String> {
303 match uri {
304 "http://www.w3.org/2001/04/xmlenc#sha256" => Ok(Hash::Sha256),
305 "http://www.w3.org/2001/04/xmldsig-more#sha384" => Ok(Hash::Sha384),
306 "http://www.w3.org/2001/04/xmlenc#sha512" => Ok(Hash::Sha512),
307 "http://www.w3.org/2000/09/xmldsig#sha1" => Err(SHA1.to_owned()),
308 other => Err(format!("The identity provider used a digest g1t does not accept ({other}).")),
309 }
310 }
311
312 fn signature_method(uri: &str) -> Result<Hash, String> {
313 match uri {
314 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" => Ok(Hash::Sha256),
315 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" => Ok(Hash::Sha384),
316 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" => Ok(Hash::Sha512),
317 "http://www.w3.org/2000/09/xmldsig#rsa-sha1" => Err(SHA1.to_owned()),
318 other => Err(format!("The identity provider signed with an algorithm g1t does not accept ({other}). Use RSA with SHA-256.")),
319 }
320 }
321
322 fn digest(self, data: &[u8]) -> Vec<u8> {
323 match self {
324 Hash::Sha256 => Sha256::digest(data).to_vec(),
325 Hash::Sha384 => Sha384::digest(data).to_vec(),
326 Hash::Sha512 => Sha512::digest(data).to_vec(),
327 }
328 }
329
330 fn scheme(self) -> Pkcs1v15Sign {
331 match self {
332 Hash::Sha256 => Pkcs1v15Sign::new::<Sha256>(),
333 Hash::Sha384 => Pkcs1v15Sign::new::<Sha384>(),
334 Hash::Sha512 => Pkcs1v15Sign::new::<Sha512>(),
335 }
336 }
337}
338
339const SHA1: &str = "The identity provider signs with SHA-1, which g1t does not accept. Set it to sign with SHA-256.";
340
341/// The canonicalization an element names: exclusive, with or without
342/// comments, and its inclusive prefixes.
343fn exclusive(method: Node) -> Result<(bool, Vec<String>), String> {
344 let comments = match method.attribute("Algorithm") {
345 Some(EXC_C14N) => false,
346 Some(EXC_C14N_COMMENTS) => true,
347 other => {
348 return Err(format!(
349 "The identity provider canonicalizes with {} where g1t needs exclusive canonicalization.",
350 other.unwrap_or("nothing")
351 ));
352 }
353 };
354 let prefixes = method
355 .children()
356 .find(|c| c.is_element() && c.tag_name().name() == "InclusiveNamespaces" && c.tag_name().namespace() == Some(EXC_C14N))
357 .and_then(|c| c.attribute("PrefixList"))
358 .map(|list| list.split_whitespace().map(|p| if p == "#default" { String::new() } else { p.to_owned() }).collect())
359 .unwrap_or_default();
360 Ok((comments, prefixes))
361}
362
363/// Verifies `signature`, an enveloped signature over its parent element.
364fn verify_signature(input: &str, signature: Node, certificates: &[Vec<u8>]) -> Result<(), String> {
365 let signed = signature.parent_element().ok_or(NOT_SIGNED)?;
366 let signed_info = only_child(signature, DSIG, "SignedInfo")?.ok_or(NOT_SIGNED)?;
367 let (info_comments, info_prefixes) = exclusive(only_child(signed_info, DSIG, "CanonicalizationMethod")?.ok_or(NOT_SIGNED)?)?;
368 let method = only_child(signed_info, DSIG, "SignatureMethod")?.ok_or(NOT_SIGNED)?;
369 let hash = Hash::signature_method(method.attribute("Algorithm").unwrap_or_default())?;
370 let reference = only_child(signed_info, DSIG, "Reference")?.ok_or(NOT_SIGNED)?;
371 let id = signed.attribute("ID").ok_or("The signed element has no ID.")?;
372 if reference.attribute("URI") != Some(&format!("#{id}")) {
373 return Err("The identity provider's signature covers something other than what it signs.".to_owned());
374 }
375 let mut enveloped = false;
376 let mut canonical: Option<(bool, Vec<String>)> = None;
377 if let Some(transforms) = only_child(reference, DSIG, "Transforms")? {
378 for transform in children(transforms, DSIG, "Transform") {
379 match transform.attribute("Algorithm") {
380 Some(ENVELOPED) if canonical.is_none() => enveloped = true,
381 Some(EXC_C14N | EXC_C14N_COMMENTS) if canonical.is_none() => canonical = Some(exclusive(transform)?),
382 other => {
383 return Err(format!("The identity provider's signature uses a transform g1t does not accept ({}).", other.unwrap_or("none")));
384 }
385 }
386 }
387 }
388 let Some((comments, prefixes)) = canonical.filter(|_| enveloped) else {
389 return Err("The identity provider's signature must be enveloped and use exclusive canonicalization.".to_owned());
390 };
391 let digest_method = only_child(reference, DSIG, "DigestMethod")?.ok_or(NOT_SIGNED)?;
392 let digest_hash = Hash::digest_method(digest_method.attribute("Algorithm").unwrap_or_default())?;
393 let expected = only_child(reference, DSIG, "DigestValue")?.map(base64_of).ok_or(NOT_SIGNED)??;
394 let content = canonicalize(input, signed, Some(signature.id()), &prefixes, comments);
395 if digest_hash.digest(content.as_bytes()) != expected {
396 return Err("The signed part of the identity provider's response was changed after it was signed.".to_owned());
397 }
398 let value = only_child(signature, DSIG, "SignatureValue")?.map(base64_of).ok_or(NOT_SIGNED)??;
399 let info = canonicalize(input, signed_info, None, &info_prefixes, info_comments);
400 let hashed = hash.digest(info.as_bytes());
401 let verified = certificates.iter().any(|der| {
402 spki_of(der)
403 .and_then(|spki| RsaPublicKey::from_public_key_der(spki).ok())
404 .is_some_and(|key| key.verify(hash.scheme(), &hashed, &value).is_ok())
405 });
406 if verified { Ok(()) } else { Err(BAD_SIGNATURE.to_owned()) }
407}
408
409fn base64_of(node: Node) -> Result<Vec<u8>, String> {
410 let compact: String = node.text().unwrap_or_default().chars().filter(|c| !c.is_whitespace()).collect();
411 STANDARD.decode(compact).map_err(|_| "The identity provider's signature could not be read.".to_owned())
412}
413
414// --- Exclusive XML canonicalization (xml-exc-c14n) --------------------------
415
416/// The prefix of a qualified name as written: `""` for none.
417fn prefix_of(qname: &str) -> &str {
418 qname.split_once(':').map_or("", |(prefix, _)| prefix)
419}
420
421/// An element's name as written in the source.
422fn element_qname<'i>(input: &'i str, node: Node) -> &'i str {
423 let start = node.range().start + 1;
424 let rest = &input[start..];
425 let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(rest.len());
426 &rest[..end]
427}
428
429/// The subtree at `apex` in exclusive canonical form, leaving out
430/// `exclude` (the enveloped signature).
431pub fn canonicalize(input: &str, apex: Node, exclude: Option<NodeId>, inclusive: &[String], comments: bool) -> String {
432 let mut out = String::new();
433 render(input, apex, exclude, inclusive, comments, &[], &mut out);
434 out
435}
436
437fn render(
438 input: &str,
439 node: Node,
440 exclude: Option<NodeId>,
441 inclusive: &[String],
442 comments: bool,
443 rendered: &[(String, String)],
444 out: &mut String,
445) {
446 let qname = element_qname(input, node);
447 let mut needed: Vec<&str> = vec![prefix_of(qname)];
448 let mut attributes: Vec<(&str, &str, &str, &str)> = Vec::new();
449 for attribute in node.attributes() {
450 let written = &input[attribute.range_qname()];
451 let prefix = prefix_of(written);
452 if !prefix.is_empty() && prefix != "xml" {
453 needed.push(prefix);
454 }
455 attributes.push((attribute.namespace().unwrap_or(""), attribute.name(), written, attribute.value()));
456 }
457 needed.extend(inclusive.iter().map(String::as_str));
458 let mut scope: Vec<(String, String)> = rendered.to_vec();
459 let mut declared: Vec<(String, String)> = Vec::new();
460 for prefix in needed {
461 if prefix == "xml" || declared.iter().any(|(p, _)| p == prefix) {
462 continue;
463 }
464 let uri = node.lookup_namespace_uri((!prefix.is_empty()).then_some(prefix)).unwrap_or("");
465 let current = scope.iter().find(|(p, _)| p == prefix).map(|(_, u)| u.as_str());
466 let render = if uri.is_empty() {
467 // Undeclaring the default only matters when an ancestor set one;
468 // a prefix not in scope is not rendered.
469 prefix.is_empty() && current.is_some_and(|u| !u.is_empty())
470 } else {
471 current != Some(uri)
472 };
473 if render {
474 declared.push((prefix.to_owned(), uri.to_owned()));
475 scope.retain(|(p, _)| p != prefix);
476 scope.push((prefix.to_owned(), uri.to_owned()));
477 }
478 }
479 declared.sort();
480 attributes.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
481 out.push('<');
482 out.push_str(qname);
483 for (prefix, uri) in &declared {
484 if prefix.is_empty() {
485 out.push_str(" xmlns=\"");
486 } else {
487 out.push_str(" xmlns:");
488 out.push_str(prefix);
489 out.push_str("=\"");
490 }
491 escape_attribute(uri, out);
492 out.push('"');
493 }
494 for (_, _, written, value) in &attributes {
495 out.push(' ');
496 out.push_str(written);
497 out.push_str("=\"");
498 escape_attribute(value, out);
499 out.push('"');
500 }
501 out.push('>');
502 for child in node.children() {
503 match child.node_type() {
504 NodeType::Element if Some(child.id()) != exclude => render(input, child, exclude, inclusive, comments, &scope, out),
505 NodeType::Text => escape_text(child.text().unwrap_or_default(), out),
506 NodeType::Comment if comments => {
507 out.push_str("<!--");
508 out.push_str(child.text().unwrap_or_default());
509 out.push_str("-->");
510 }
511 NodeType::PI => {
512 if let Some(pi) = child.pi() {
513 out.push_str("<?");
514 out.push_str(pi.target);
515 if let Some(value) = pi.value.filter(|v| !v.is_empty()) {
516 out.push(' ');
517 out.push_str(value);
518 }
519 out.push_str("?>");
520 }
521 }
522 _ => {}
523 }
524 }
525 out.push_str("</");
526 out.push_str(qname);
527 out.push('>');
528}
529
530fn escape_text(text: &str, out: &mut String) {
531 for c in text.chars() {
532 match c {
533 '&' => out.push_str("&amp;"),
534 '<' => out.push_str("&lt;"),
535 '>' => out.push_str("&gt;"),
536 '\r' => out.push_str("&#xD;"),
537 c => out.push(c),
538 }
539 }
540}
541
542fn escape_attribute(text: &str, out: &mut String) {
543 for c in text.chars() {
544 match c {
545 '&' => out.push_str("&amp;"),
546 '<' => out.push_str("&lt;"),
547 '"' => out.push_str("&quot;"),
548 '\t' => out.push_str("&#x9;"),
549 '\n' => out.push_str("&#xA;"),
550 '\r' => out.push_str("&#xD;"),
551 c => out.push(c),
552 }
553 }
554}
555
556fn escape(text: &str) -> String {
557 let mut out = String::new();
558 escape_attribute(text, &mut out);
559 out.replace('>', "&gt;")
560}
561
562// --- Certificates -----------------------------------------------------------
563
564/// One DER item: its tag, its contents, the whole item, and what follows.
565type Der<'a> = (u8, &'a [u8], &'a [u8], &'a [u8]);
566
567fn der_item(bytes: &[u8]) -> Option<Der<'_>> {
568 let tag = *bytes.first()?;
569 let first = *bytes.get(1)?;
570 let (length, header) = if first < 0x80 {
571 (first as usize, 2)
572 } else {
573 let count = (first & 0x7f) as usize;
574 if count == 0 || count > 4 {
575 return None;
576 }
577 let mut length = 0usize;
578 for byte in bytes.get(2..2 + count)? {
579 length = (length << 8) | *byte as usize;
580 }
581 (length, 2 + count)
582 };
583 let end = header.checked_add(length)?;
584 Some((tag, bytes.get(header..end)?, bytes.get(..end)?, bytes.get(end..)?))
585}
586
587/// The SubjectPublicKeyInfo of an X.509 certificate, DER.
588fn spki_of(certificate: &[u8]) -> Option<&[u8]> {
589 let (0x30, certificate, _, _) = der_item(certificate)? else { return None };
590 let (0x30, mut tbs, _, _) = der_item(certificate)? else { return None };
591 // [0] version, when present; then serial, signature, issuer, validity
592 // and subject; then the key.
593 if tbs.first() == Some(&0xa0) {
594 tbs = der_item(tbs)?.3;
595 }
596 for _ in 0..5 {
597 tbs = der_item(tbs)?.3;
598 }
599 let (0x30, _, whole, _) = der_item(tbs)? else { return None };
600 Some(whole)
601}
602
603/// The certificates in what an owner pasted: PEM blocks, or one bare
604/// base64 certificate (as some providers download it). Each must hold an
605/// RSA key g1t can use.
606pub fn certificates_from(pasted: &str) -> Result<Vec<Vec<u8>>, String> {
607 let mut blocks: Vec<String> = Vec::new();
608 if pasted.contains("-----BEGIN") {
609 let mut current: Option<String> = None;
610 for line in pasted.lines().map(str::trim) {
611 if line.starts_with("-----BEGIN CERTIFICATE") {
612 current = Some(String::new());
613 } else if line.starts_with("-----END CERTIFICATE") {
614 blocks.extend(current.take());
615 } else if let Some(block) = current.as_mut() {
616 block.push_str(line);
617 }
618 }
619 } else if !pasted.trim().is_empty() {
620 blocks.push(pasted.chars().filter(|c| !c.is_whitespace()).collect());
621 }
622 if blocks.is_empty() {
623 return Err("Paste the identity provider's signing certificate (PEM, starting -----BEGIN CERTIFICATE-----).".to_owned());
624 }
625 let mut out = Vec::new();
626 for block in blocks.into_iter().take(4) {
627 let der = STANDARD.decode(block).map_err(|_| "That certificate could not be read: paste it in PEM form.".to_owned())?;
628 let usable = spki_of(&der).and_then(|spki| RsaPublicKey::from_public_key_der(spki).ok()).is_some();
629 if !usable {
630 return Err("That certificate holds no RSA key g1t can check signatures with.".to_owned());
631 }
632 out.push(der);
633 }
634 Ok(out)
635}
636
637/// SHA-256 fingerprint of a certificate, as providers show it: `AB:CD:…`.
638pub fn fingerprint(der: &[u8]) -> String {
639 Sha256::digest(der).iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(":")
640}
641
642// --- What g1t sends ---------------------------------------------------------
643
644/// A request id: starts with a letter, as XML ids must.
645pub fn request_id(random_hex: &str) -> String {
646 format!("_{random_hex}")
647}
648
649/// Where to send the browser: the provider's sign-in URL with the
650/// deflated AuthnRequest (HTTP-Redirect binding).
651pub fn authn_request_url(sso_url: &str, id: &str, entity_id: &str, acs_url: &str, relay_state: &str, now_ms: u64) -> Option<String> {
652 let request = format!(
653 "<samlp:AuthnRequest xmlns:samlp=\"{PROTOCOL}\" xmlns:saml=\"{ASSERTION}\" ID=\"{}\" Version=\"2.0\" IssueInstant=\"{}\" Destination=\"{}\" AssertionConsumerServiceURL=\"{}\" ProtocolBinding=\"{POST_BINDING}\"><saml:Issuer>{}</saml:Issuer><samlp:NameIDPolicy Format=\"{UNSPECIFIED_FORMAT}\" AllowCreate=\"true\"/></samlp:AuthnRequest>",
654 escape(id),
655 issue_instant(now_ms),
656 escape(sso_url),
657 escape(acs_url),
658 escape(entity_id),
659 );
660 let deflated = miniz_oxide::deflate::compress_to_vec(request.as_bytes(), 6);
661 let mut url = worker::Url::parse(sso_url).ok()?;
662 if url.scheme() != "https" {
663 return None;
664 }
665 url.query_pairs_mut()
666 .append_pair("SAMLRequest", &STANDARD.encode(deflated))
667 .append_pair("RelayState", relay_state);
668 Some(url.to_string())
669}
670
671/// SAML's times: RFC 3339 in UTC, to the second.
672fn issue_instant(now_ms: u64) -> String {
673 let full = rfc3339(now_ms);
674 format!("{}Z", full.split('.').next().unwrap_or(&full))
675}
676
677/// g1t's metadata for a workspace, for its provider to read.
678pub fn metadata(entity_id: &str, acs_url: &str) -> String {
679 format!(
680 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<md:EntityDescriptor xmlns:md=\"{METADATA}\" entityID=\"{}\"><md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"true\" protocolSupportEnumeration=\"{PROTOCOL}\"><md:NameIDFormat>{EMAIL_FORMAT}</md:NameIDFormat><md:NameIDFormat>{UNSPECIFIED_FORMAT}</md:NameIDFormat><md:AssertionConsumerService Binding=\"{POST_BINDING}\" Location=\"{}\" index=\"0\" isDefault=\"true\"/></md:SPSSODescriptor></md:EntityDescriptor>\n",
681 escape(entity_id),
682 escape(acs_url),
683 )
684}
685
686#[cfg(test)]
687#[path = "saml_tests.rs"]
688mod tests;