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.

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