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.

724 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 plain(&doc)?;
120 // Signed first: nothing else is read until it verifies.
121 let assertion = only_child(response, ASSERTION, "Assertion")?;
122 let Some(assertion) = assertion else {
123 if child(response, ASSERTION, "EncryptedAssertion").is_some() {
124 return Err("The identity provider encrypted the assertion. Turn off assertion encryption for g1t: the response reaches g1t over HTTPS.".to_owned());
125 }
126 return Err(status_message(response).unwrap_or_else(|| "The identity provider's response has no assertion.".to_owned()));
127 };
128 let mut assertion_signed = false;
129 let mut response_signed = false;
130 if let Some(signature) = only_child(assertion, DSIG, "Signature")? {
131 verify_signature(&xml, signature, expect.certificates)?;
132 assertion_signed = true;
133 }
134 if let Some(signature) = only_child(response, DSIG, "Signature")? {
135 verify_signature(&xml, signature, expect.certificates)?;
136 response_signed = true;
137 }
138 if !assertion_signed && !response_signed {
139 return Err(NOT_SIGNED.to_owned());
140 }
141 if let Some(message) = status_message(response) {
142 return Err(message);
143 }
144 check_response(response, assertion, expect, response_signed)
145}
146
147/// Why the provider said no, when its status is not success.
148fn status_message(response: Node) -> Option<String> {
149 let status = child(response, PROTOCOL, "Status")?;
150 let code = child(status, PROTOCOL, "StatusCode")?.attribute("Value")?;
151 if code == SUCCESS {
152 return None;
153 }
154 let said = child(status, PROTOCOL, "StatusMessage").and_then(|m| m.text()).map(str::trim).filter(|m| !m.is_empty());
155 Some(match said {
156 Some(said) => format!("The identity provider refused the sign-in: {said}"),
157 None => format!("The identity provider refused the sign-in ({}).", code.rsplit(':').next().unwrap_or(code)),
158 })
159}
160
161/// `response_signed`: whether the response itself was signed. When only the
162/// assertion is, the response's own `InResponseTo` is anyone's to write,
163/// so the assertion's signed subject confirmation must name the request.
164fn check_response(response: Node, assertion: Node, expect: &Expect, response_signed: bool) -> Result<Asserted, String> {
165 match response.attribute("InResponseTo") {
166 Some(id) if id == expect.request_id => {}
167 Some(_) => return Err("This response answers another sign-in. Start again.".to_owned()),
168 None => return Err("Start single sign-on from g1t: sign-ins started at the identity provider are not accepted.".to_owned()),
169 }
170 if let Some(destination) = response.attribute("Destination")
171 && destination != expect.acs_url
172 {
173 return Err(format!("The response was sent to {destination}, not this workspace's assertion consumer service ({}).", expect.acs_url));
174 }
175 if let Some(issuer) = child(response, ASSERTION, "Issuer")
176 && text(issuer) != expect.idp_entity_id
177 {
178 return Err(issuer_mismatch(&text(issuer), expect.idp_entity_id));
179 }
180 let issuer = child(assertion, ASSERTION, "Issuer").map(text).unwrap_or_default();
181 if issuer != expect.idp_entity_id {
182 return Err(issuer_mismatch(&issuer, expect.idp_entity_id));
183 }
184 let now = expect.now_ms;
185 let conditions = child(assertion, ASSERTION, "Conditions");
186 if let Some(conditions) = conditions {
187 within(conditions.attribute("NotBefore"), conditions.attribute("NotOnOrAfter"), now)?;
188 }
189 let audiences: Vec<String> = conditions
190 .into_iter()
191 .flat_map(|c| children(c, ASSERTION, "AudienceRestriction"))
192 .flat_map(|r| children(r, ASSERTION, "Audience"))
193 .map(text)
194 .collect();
195 if !audiences.iter().any(|audience| audience == expect.audience) {
196 return Err(format!("The assertion is not for g1t: set the provider's audience (SP entity id) to {}.", expect.audience));
197 }
198 let subject = child(assertion, ASSERTION, "Subject").ok_or("The assertion names no one.")?;
199 let confirmed = children(subject, ASSERTION, "SubjectConfirmation")
200 .filter(|c| c.attribute("Method") == Some(BEARER))
201 .filter_map(|c| child(c, ASSERTION, "SubjectConfirmationData"))
202 .any(|data| {
203 data.attribute("Recipient") == Some(expect.acs_url)
204 && match data.attribute("InResponseTo") {
205 Some(id) => id == expect.request_id,
206 None => response_signed,
207 }
208 && data.attribute("NotBefore").is_none()
209 && data.attribute("NotOnOrAfter").and_then(parse_time).is_some_and(|at| now < at + SKEW_MS)
210 });
211 if !confirmed {
212 return Err(format!(
213 "The assertion's subject confirmation does not match: the recipient must be {} and it must not have expired.",
214 expect.acs_url
215 ));
216 }
217 let name_id = child(subject, ASSERTION, "NameID").ok_or("The assertion has no NameID.")?;
218 let subject_id = text(name_id);
219 if subject_id.is_empty() || subject_id.len() > 512 {
220 return Err("The assertion's NameID is empty.".to_owned());
221 }
222 let email = attribute_email(assertion).or_else(|| {
223 let format = name_id.attribute("Format").unwrap_or(UNSPECIFIED_FORMAT);
224 (format == EMAIL_FORMAT || subject_id.contains('@')).then(|| subject_id.clone())
225 });
226 Ok(Asserted { subject: subject_id, email: email.map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@')) })
227}
228
229fn issuer_mismatch(said: &str, expected: &str) -> String {
230 format!("The response was issued by {said:?}, not the identity provider saved for this workspace ({expected:?}). Check the provider's entity id.")
231}
232
233fn attribute_email(assertion: Node) -> Option<String> {
234 children(assertion, ASSERTION, "AttributeStatement")
235 .flat_map(|s| children(s, ASSERTION, "Attribute"))
236 .find(|a| a.attribute("Name").is_some_and(|name| EMAIL_ATTRIBUTES.iter().any(|known| known.eq_ignore_ascii_case(name))))
237 .and_then(|a| child(a, ASSERTION, "AttributeValue"))
238 .map(text)
239 .filter(|value| !value.is_empty())
240}
241
242fn parse_time(at: &str) -> Option<u64> {
243 parse_rfc3339(at.trim())
244}
245
246fn within(not_before: Option<&str>, not_on_or_after: Option<&str>, now: u64) -> Result<(), String> {
247 if let Some(at) = not_before {
248 let at = parse_time(at).ok_or("The assertion's times could not be read.")?;
249 if now + SKEW_MS < at {
250 return Err("The assertion is not valid yet: check the identity provider's clock.".to_owned());
251 }
252 }
253 if let Some(at) = not_on_or_after {
254 let at = parse_time(at).ok_or("The assertion's times could not be read.")?;
255 if now >= at + SKEW_MS {
256 return Err("The assertion has expired. Start again.".to_owned());
257 }
258 }
259 Ok(())
260}
261
262// --- The document -----------------------------------------------------------
263
264/// All of an element's text, as canonicalization reads it: every text node
265/// inside it, not only the first, so what is read is what was signed.
266fn text(node: Node) -> String {
267 let all: String = node.descendants().filter(Node::is_text).filter_map(|t| t.text()).collect();
268 all.trim().to_owned()
269}
270
271/// The deepest a response may nest. Real ones are under ten.
272const MAX_DEPTH: usize = 64;
273
274/// Refuses comments and processing instructions anywhere in the response,
275/// and nesting deeper than any provider sends. Canonicalization leaves
276/// comments out, so `ada@acme.test<!---->.evil.io` signs as one address and
277/// would read as another; no provider puts them in a response. Depth is
278/// checked before canonicalization, which recurses.
279fn plain(doc: &Document) -> Result<(), String> {
280 // Document order visits a parent before its children, so each depth is
281 // its parent's plus one, found without recursion.
282 let mut depths: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
283 for node in doc.descendants() {
284 if node.is_comment() || node.is_pi() {
285 return Err("The identity provider's response holds comments or processing instructions, which g1t does not accept.".to_owned());
286 }
287 let depth = node.parent().and_then(|parent| depths.get(&parent.id()).copied()).unwrap_or(0) + 1;
288 if depth > MAX_DEPTH {
289 return Err("The identity provider's response nests too deeply.".to_owned());
290 }
291 depths.insert(node.id(), depth);
292 }
293 Ok(())
294}
295
296fn children<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator<Item = Node<'a, 'i>> + 'a {
297 node.children().filter(move |c| c.is_element() && c.has_tag_name((ns, name)))
298}
299
300fn child<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> Option<Node<'a, 'i>> {
301 children(node, ns, name).next()
302}
303
304/// The child named so, refusing more than one.
305fn only_child<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> Result<Option<Node<'a, 'i>>, String> {
306 let mut found = children(node, ns, name);
307 let first = found.next();
308 if found.next().is_some() {
309 return Err(format!("The identity provider's response has more than one {name}."));
310 }
311 Ok(first)
312}
313
314/// Refuses a document where two elements share an `ID`: a signature names
315/// what it signs by id, so the id must name one thing.
316fn unique_ids(doc: &Document) -> Result<(), String> {
317 let mut seen = std::collections::HashSet::new();
318 for node in doc.descendants().filter(Node::is_element) {
319 for attribute in node.attributes() {
320 if attribute.namespace().is_none() && matches!(attribute.name(), "ID" | "Id" | "id") && !seen.insert(attribute.value()) {
321 return Err("The identity provider's response uses an id twice.".to_owned());
322 }
323 }
324 }
325 Ok(())
326}
327
328// --- XML Signature ----------------------------------------------------------
329
330#[derive(Clone, Copy, Debug, PartialEq, Eq)]
331enum Hash {
332 Sha256,
333 Sha384,
334 Sha512,
335}
336
337impl Hash {
338 fn digest_method(uri: &str) -> Result<Hash, String> {
339 match uri {
340 "http://www.w3.org/2001/04/xmlenc#sha256" => Ok(Hash::Sha256),
341 "http://www.w3.org/2001/04/xmldsig-more#sha384" => Ok(Hash::Sha384),
342 "http://www.w3.org/2001/04/xmlenc#sha512" => Ok(Hash::Sha512),
343 "http://www.w3.org/2000/09/xmldsig#sha1" => Err(SHA1.to_owned()),
344 other => Err(format!("The identity provider used a digest g1t does not accept ({other}).")),
345 }
346 }
347
348 fn signature_method(uri: &str) -> Result<Hash, String> {
349 match uri {
350 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" => Ok(Hash::Sha256),
351 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" => Ok(Hash::Sha384),
352 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" => Ok(Hash::Sha512),
353 "http://www.w3.org/2000/09/xmldsig#rsa-sha1" => Err(SHA1.to_owned()),
354 other => Err(format!("The identity provider signed with an algorithm g1t does not accept ({other}). Use RSA with SHA-256.")),
355 }
356 }
357
358 fn digest(self, data: &[u8]) -> Vec<u8> {
359 match self {
360 Hash::Sha256 => Sha256::digest(data).to_vec(),
361 Hash::Sha384 => Sha384::digest(data).to_vec(),
362 Hash::Sha512 => Sha512::digest(data).to_vec(),
363 }
364 }
365
366 fn scheme(self) -> Pkcs1v15Sign {
367 match self {
368 Hash::Sha256 => Pkcs1v15Sign::new::<Sha256>(),
369 Hash::Sha384 => Pkcs1v15Sign::new::<Sha384>(),
370 Hash::Sha512 => Pkcs1v15Sign::new::<Sha512>(),
371 }
372 }
373}
374
375const SHA1: &str = "The identity provider signs with SHA-1, which g1t does not accept. Set it to sign with SHA-256.";
376
377/// The canonicalization an element names: exclusive, with or without
378/// comments, and its inclusive prefixes.
379fn exclusive(method: Node) -> Result<(bool, Vec<String>), String> {
380 let comments = match method.attribute("Algorithm") {
381 Some(EXC_C14N) => false,
382 Some(EXC_C14N_COMMENTS) => true,
383 other => {
384 return Err(format!(
385 "The identity provider canonicalizes with {} where g1t needs exclusive canonicalization.",
386 other.unwrap_or("nothing")
387 ));
388 }
389 };
390 let prefixes = method
391 .children()
392 .find(|c| c.is_element() && c.tag_name().name() == "InclusiveNamespaces" && c.tag_name().namespace() == Some(EXC_C14N))
393 .and_then(|c| c.attribute("PrefixList"))
394 .map(|list| list.split_whitespace().map(|p| if p == "#default" { String::new() } else { p.to_owned() }).collect())
395 .unwrap_or_default();
396 Ok((comments, prefixes))
397}
398
399/// Verifies `signature`, an enveloped signature over its parent element.
400fn verify_signature(input: &str, signature: Node, certificates: &[Vec<u8>]) -> Result<(), String> {
401 let signed = signature.parent_element().ok_or(NOT_SIGNED)?;
402 let signed_info = only_child(signature, DSIG, "SignedInfo")?.ok_or(NOT_SIGNED)?;
403 let (info_comments, info_prefixes) = exclusive(only_child(signed_info, DSIG, "CanonicalizationMethod")?.ok_or(NOT_SIGNED)?)?;
404 let method = only_child(signed_info, DSIG, "SignatureMethod")?.ok_or(NOT_SIGNED)?;
405 let hash = Hash::signature_method(method.attribute("Algorithm").unwrap_or_default())?;
406 let reference = only_child(signed_info, DSIG, "Reference")?.ok_or(NOT_SIGNED)?;
407 let id = signed.attribute("ID").ok_or("The signed element has no ID.")?;
408 if reference.attribute("URI") != Some(&format!("#{id}")) {
409 return Err("The identity provider's signature covers something other than what it signs.".to_owned());
410 }
411 let mut enveloped = false;
412 let mut canonical: Option<(bool, Vec<String>)> = None;
413 if let Some(transforms) = only_child(reference, DSIG, "Transforms")? {
414 for transform in children(transforms, DSIG, "Transform") {
415 match transform.attribute("Algorithm") {
416 Some(ENVELOPED) if canonical.is_none() => enveloped = true,
417 Some(EXC_C14N | EXC_C14N_COMMENTS) if canonical.is_none() => canonical = Some(exclusive(transform)?),
418 other => {
419 return Err(format!("The identity provider's signature uses a transform g1t does not accept ({}).", other.unwrap_or("none")));
420 }
421 }
422 }
423 }
424 let Some((comments, prefixes)) = canonical.filter(|_| enveloped) else {
425 return Err("The identity provider's signature must be enveloped and use exclusive canonicalization.".to_owned());
426 };
427 let digest_method = only_child(reference, DSIG, "DigestMethod")?.ok_or(NOT_SIGNED)?;
428 let digest_hash = Hash::digest_method(digest_method.attribute("Algorithm").unwrap_or_default())?;
429 let expected = only_child(reference, DSIG, "DigestValue")?.map(base64_of).ok_or(NOT_SIGNED)??;
430 let content = canonicalize(input, signed, Some(signature.id()), &prefixes, comments);
431 if digest_hash.digest(content.as_bytes()) != expected {
432 return Err("The signed part of the identity provider's response was changed after it was signed.".to_owned());
433 }
434 let value = only_child(signature, DSIG, "SignatureValue")?.map(base64_of).ok_or(NOT_SIGNED)??;
435 let info = canonicalize(input, signed_info, None, &info_prefixes, info_comments);
436 let hashed = hash.digest(info.as_bytes());
437 let verified = certificates.iter().any(|der| {
438 spki_of(der)
439 .and_then(|spki| RsaPublicKey::from_public_key_der(spki).ok())
440 .is_some_and(|key| key.verify(hash.scheme(), &hashed, &value).is_ok())
441 });
442 if verified { Ok(()) } else { Err(BAD_SIGNATURE.to_owned()) }
443}
444
445fn base64_of(node: Node) -> Result<Vec<u8>, String> {
446 let compact: String = node.text().unwrap_or_default().chars().filter(|c| !c.is_whitespace()).collect();
447 STANDARD.decode(compact).map_err(|_| "The identity provider's signature could not be read.".to_owned())
448}
449
450// --- Exclusive XML canonicalization (xml-exc-c14n) --------------------------
451
452/// The prefix of a qualified name as written: `""` for none.
453fn prefix_of(qname: &str) -> &str {
454 qname.split_once(':').map_or("", |(prefix, _)| prefix)
455}
456
457/// An element's name as written in the source.
458fn element_qname<'i>(input: &'i str, node: Node) -> &'i str {
459 let start = node.range().start + 1;
460 let rest = &input[start..];
461 let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(rest.len());
462 &rest[..end]
463}
464
465/// The subtree at `apex` in exclusive canonical form, leaving out
466/// `exclude` (the enveloped signature).
467pub fn canonicalize(input: &str, apex: Node, exclude: Option<NodeId>, inclusive: &[String], comments: bool) -> String {
468 let mut out = String::new();
469 render(input, apex, exclude, inclusive, comments, &[], &mut out);
470 out
471}
472
473fn render(
474 input: &str,
475 node: Node,
476 exclude: Option<NodeId>,
477 inclusive: &[String],
478 comments: bool,
479 rendered: &[(String, String)],
480 out: &mut String,
481) {
482 let qname = element_qname(input, node);
483 let mut needed: Vec<&str> = vec![prefix_of(qname)];
484 let mut attributes: Vec<(&str, &str, &str, &str)> = Vec::new();
485 for attribute in node.attributes() {
486 let written = &input[attribute.range_qname()];
487 let prefix = prefix_of(written);
488 if !prefix.is_empty() && prefix != "xml" {
489 needed.push(prefix);
490 }
491 attributes.push((attribute.namespace().unwrap_or(""), attribute.name(), written, attribute.value()));
492 }
493 needed.extend(inclusive.iter().map(String::as_str));
494 let mut scope: Vec<(String, String)> = rendered.to_vec();
495 let mut declared: Vec<(String, String)> = Vec::new();
496 for prefix in needed {
497 if prefix == "xml" || declared.iter().any(|(p, _)| p == prefix) {
498 continue;
499 }
500 let uri = node.lookup_namespace_uri((!prefix.is_empty()).then_some(prefix)).unwrap_or("");
501 let current = scope.iter().find(|(p, _)| p == prefix).map(|(_, u)| u.as_str());
502 let render = if uri.is_empty() {
503 // Undeclaring the default only matters when an ancestor set one;
504 // a prefix not in scope is not rendered.
505 prefix.is_empty() && current.is_some_and(|u| !u.is_empty())
506 } else {
507 current != Some(uri)
508 };
509 if render {
510 declared.push((prefix.to_owned(), uri.to_owned()));
511 scope.retain(|(p, _)| p != prefix);
512 scope.push((prefix.to_owned(), uri.to_owned()));
513 }
514 }
515 declared.sort();
516 attributes.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
517 out.push('<');
518 out.push_str(qname);
519 for (prefix, uri) in &declared {
520 if prefix.is_empty() {
521 out.push_str(" xmlns=\"");
522 } else {
523 out.push_str(" xmlns:");
524 out.push_str(prefix);
525 out.push_str("=\"");
526 }
527 escape_attribute(uri, out);
528 out.push('"');
529 }
530 for (_, _, written, value) in &attributes {
531 out.push(' ');
532 out.push_str(written);
533 out.push_str("=\"");
534 escape_attribute(value, out);
535 out.push('"');
536 }
537 out.push('>');
538 for child in node.children() {
539 match child.node_type() {
540 NodeType::Element if Some(child.id()) != exclude => render(input, child, exclude, inclusive, comments, &scope, out),
541 NodeType::Text => escape_text(child.text().unwrap_or_default(), out),
542 NodeType::Comment if comments => {
543 out.push_str("<!--");
544 out.push_str(child.text().unwrap_or_default());
545 out.push_str("-->");
546 }
547 NodeType::PI => {
548 if let Some(pi) = child.pi() {
549 out.push_str("<?");
550 out.push_str(pi.target);
551 if let Some(value) = pi.value.filter(|v| !v.is_empty()) {
552 out.push(' ');
553 out.push_str(value);
554 }
555 out.push_str("?>");
556 }
557 }
558 _ => {}
559 }
560 }
561 out.push_str("</");
562 out.push_str(qname);
563 out.push('>');
564}
565
566fn escape_text(text: &str, out: &mut String) {
567 for c in text.chars() {
568 match c {
569 '&' => out.push_str("&amp;"),
570 '<' => out.push_str("&lt;"),
571 '>' => out.push_str("&gt;"),
572 '\r' => out.push_str("&#xD;"),
573 c => out.push(c),
574 }
575 }
576}
577
578fn escape_attribute(text: &str, out: &mut String) {
579 for c in text.chars() {
580 match c {
581 '&' => out.push_str("&amp;"),
582 '<' => out.push_str("&lt;"),
583 '"' => out.push_str("&quot;"),
584 '\t' => out.push_str("&#x9;"),
585 '\n' => out.push_str("&#xA;"),
586 '\r' => out.push_str("&#xD;"),
587 c => out.push(c),
588 }
589 }
590}
591
592fn escape(text: &str) -> String {
593 let mut out = String::new();
594 escape_attribute(text, &mut out);
595 out.replace('>', "&gt;")
596}
597
598// --- Certificates -----------------------------------------------------------
599
600/// One DER item: its tag, its contents, the whole item, and what follows.
601type Der<'a> = (u8, &'a [u8], &'a [u8], &'a [u8]);
602
603fn der_item(bytes: &[u8]) -> Option<Der<'_>> {
604 let tag = *bytes.first()?;
605 let first = *bytes.get(1)?;
606 let (length, header) = if first < 0x80 {
607 (first as usize, 2)
608 } else {
609 let count = (first & 0x7f) as usize;
610 if count == 0 || count > 4 {
611 return None;
612 }
613 let mut length = 0usize;
614 for byte in bytes.get(2..2 + count)? {
615 length = (length << 8) | *byte as usize;
616 }
617 (length, 2 + count)
618 };
619 let end = header.checked_add(length)?;
620 Some((tag, bytes.get(header..end)?, bytes.get(..end)?, bytes.get(end..)?))
621}
622
623/// The SubjectPublicKeyInfo of an X.509 certificate, DER.
624fn spki_of(certificate: &[u8]) -> Option<&[u8]> {
625 let (0x30, certificate, _, _) = der_item(certificate)? else { return None };
626 let (0x30, mut tbs, _, _) = der_item(certificate)? else { return None };
627 // [0] version, when present; then serial, signature, issuer, validity
628 // and subject; then the key.
629 if tbs.first() == Some(&0xa0) {
630 tbs = der_item(tbs)?.3;
631 }
632 for _ in 0..5 {
633 tbs = der_item(tbs)?.3;
634 }
635 let (0x30, _, whole, _) = der_item(tbs)? else { return None };
636 Some(whole)
637}
638
639/// The certificates in what an owner pasted: PEM blocks, or one bare
640/// base64 certificate (as some providers download it). Each must hold an
641/// RSA key g1t can use.
642pub fn certificates_from(pasted: &str) -> Result<Vec<Vec<u8>>, String> {
643 let mut blocks: Vec<String> = Vec::new();
644 if pasted.contains("-----BEGIN") {
645 let mut current: Option<String> = None;
646 for line in pasted.lines().map(str::trim) {
647 if line.starts_with("-----BEGIN CERTIFICATE") {
648 current = Some(String::new());
649 } else if line.starts_with("-----END CERTIFICATE") {
650 blocks.extend(current.take());
651 } else if let Some(block) = current.as_mut() {
652 block.push_str(line);
653 }
654 }
655 } else if !pasted.trim().is_empty() {
656 blocks.push(pasted.chars().filter(|c| !c.is_whitespace()).collect());
657 }
658 if blocks.is_empty() {
659 return Err("Paste the identity provider's signing certificate (PEM, starting -----BEGIN CERTIFICATE-----).".to_owned());
660 }
661 let mut out = Vec::new();
662 for block in blocks.into_iter().take(4) {
663 let der = STANDARD.decode(block).map_err(|_| "That certificate could not be read: paste it in PEM form.".to_owned())?;
664 let usable = spki_of(&der).and_then(|spki| RsaPublicKey::from_public_key_der(spki).ok()).is_some();
665 if !usable {
666 return Err("That certificate holds no RSA key g1t can check signatures with.".to_owned());
667 }
668 out.push(der);
669 }
670 Ok(out)
671}
672
673/// SHA-256 fingerprint of a certificate, as providers show it: `AB:CD:…`.
674pub fn fingerprint(der: &[u8]) -> String {
675 Sha256::digest(der).iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(":")
676}
677
678// --- What g1t sends ---------------------------------------------------------
679
680/// A request id: starts with a letter, as XML ids must.
681pub fn request_id(random_hex: &str) -> String {
682 format!("_{random_hex}")
683}
684
685/// Where to send the browser: the provider's sign-in URL with the
686/// deflated AuthnRequest (HTTP-Redirect binding).
687pub fn authn_request_url(sso_url: &str, id: &str, entity_id: &str, acs_url: &str, relay_state: &str, now_ms: u64) -> Option<String> {
688 let request = format!(
689 "<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>",
690 escape(id),
691 issue_instant(now_ms),
692 escape(sso_url),
693 escape(acs_url),
694 escape(entity_id),
695 );
696 let deflated = miniz_oxide::deflate::compress_to_vec(request.as_bytes(), 6);
697 let mut url = worker::Url::parse(sso_url).ok()?;
698 if url.scheme() != "https" {
699 return None;
700 }
701 url.query_pairs_mut()
702 .append_pair("SAMLRequest", &STANDARD.encode(deflated))
703 .append_pair("RelayState", relay_state);
704 Some(url.to_string())
705}
706
707/// SAML's times: RFC 3339 in UTC, to the second.
708fn issue_instant(now_ms: u64) -> String {
709 let full = rfc3339(now_ms);
710 format!("{}Z", full.split('.').next().unwrap_or(&full))
711}
712
713/// g1t's metadata for a workspace, for its provider to read.
714pub fn metadata(entity_id: &str, acs_url: &str) -> String {
715 format!(
716 "<?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",
717 escape(entity_id),
718 escape(acs_url),
719 )
720}
721
722#[cfg(test)]
723#[path = "saml_tests.rs"]
724mod tests;