1use base64::Engine as _;
29use rustls_pki_types::{CertificateDer, TrustAnchor, UnixTime};
30use serde::Deserialize;
31use std::collections::HashMap;
32
33pub const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
35
36pub const MAX_ASSERTION_LIFETIME_S: u64 = 300;
38
39const CLOCK_SKEW_S: u64 = 60;
41
42const MAX_ASSERTION_BYTES: usize = 48 * 1024;
44
45const MAX_CHAIN_CERTS: usize = 4;
47
48const MAX_CERT_BYTES: usize = 16 * 1024;
50
51const MAX_REPLAY_ENTRIES: usize = 100_000;
53
54static CHAIN_ALGORITHMS: &[&dyn rustls_pki_types::SignatureVerificationAlgorithm] = &[
56 webpki::ring::RSA_PKCS1_2048_8192_SHA256,
57 webpki::ring::RSA_PKCS1_2048_8192_SHA384,
58 webpki::ring::RSA_PKCS1_2048_8192_SHA512,
59 webpki::ring::RSA_PSS_2048_8192_SHA256_LEGACY_KEY,
60 webpki::ring::ECDSA_P256_SHA256,
61 webpki::ring::ECDSA_P384_SHA384,
62];
63
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
66#[non_exhaustive]
67pub enum WorkloadError {
68 #[error("malformed assertion: {0}")]
70 Malformed(String),
71 #[error("certificate chain rejected: {0}")]
73 Chain(String),
74 #[error("assertion signature does not verify")]
76 Signature,
77 #[error("invalid assertion claims: {0}")]
79 Claims(String),
80 #[error("certificate subject is not a Cloud Foundry instance identity: {0}")]
82 Subject(String),
83 #[error("assertion was already used")]
85 Replay,
86 #[error("invalid workload trust configuration: {0}")]
88 Config(String),
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CfInstanceIdentity {
94 pub instance_guid: String,
96 pub app_guid: String,
98 pub space_guid: String,
100 pub org_guid: String,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct VerifiedAssertion {
107 pub identity: CfInstanceIdentity,
109 pub jti: String,
111 pub exp: u64,
113}
114
115#[derive(Deserialize)]
116struct Header {
117 alg: String,
118 #[serde(default)]
119 x5c: Vec<String>,
120}
121
122#[derive(Deserialize)]
123#[serde(untagged)]
124enum AudienceClaim {
125 One(String),
126 Many(Vec<String>),
127}
128
129#[derive(Deserialize)]
130struct Claims {
131 iss: String,
132 sub: String,
133 aud: AudienceClaim,
134 iat: u64,
135 exp: u64,
136 #[serde(default)]
137 nbf: Option<u64>,
138 jti: String,
139}
140
141pub struct WorkloadTrust {
143 anchors: Vec<TrustAnchor<'static>>,
144 seen: parking_lot::Mutex<HashMap<(String, String), u64>>,
145}
146
147impl WorkloadTrust {
148 pub fn from_pem(pem: &[u8]) -> Result<Self, WorkloadError> {
150 let text =
151 std::str::from_utf8(pem).map_err(|_| WorkloadError::Config("CA bundle is not UTF-8".into()))?;
152 let mut anchors = Vec::new();
153 let mut rest = text;
154 while let Some(start) = rest.find("-----BEGIN CERTIFICATE-----") {
155 let body_start = start.saturating_add("-----BEGIN CERTIFICATE-----".len());
156 let body_and_rest = rest.get(body_start..).unwrap_or_default();
157 let end = body_and_rest
158 .find("-----END CERTIFICATE-----")
159 .ok_or_else(|| WorkloadError::Config("unterminated CERTIFICATE block".into()))?;
160 let body: String = body_and_rest
161 .get(..end)
162 .unwrap_or_default()
163 .chars()
164 .filter(|c| !c.is_whitespace())
165 .collect();
166 let der = base64::engine::general_purpose::STANDARD
167 .decode(body)
168 .map_err(|_| WorkloadError::Config("CERTIFICATE block is not base64".into()))?;
169 let cert = CertificateDer::from(der);
170 let anchor = webpki::anchor_from_trusted_cert(&cert)
171 .map_err(|error| WorkloadError::Config(format!("unusable CA certificate: {error:?}")))?
172 .to_owned();
173 anchors.push(anchor);
174 rest = body_and_rest
175 .get(end.saturating_add("-----END CERTIFICATE-----".len())..)
176 .unwrap_or_default();
177 }
178 if anchors.is_empty() {
179 return Err(WorkloadError::Config("CA bundle contains no certificate".into()));
180 }
181 Ok(Self {
182 anchors,
183 seen: parking_lot::Mutex::new(HashMap::new()),
184 })
185 }
186
187 pub fn anchor_count(&self) -> usize {
189 self.anchors.len()
190 }
191
192 pub fn verify(
194 &self,
195 assertion: &str,
196 audience: &str,
197 now_s: u64,
198 ) -> Result<VerifiedAssertion, WorkloadError> {
199 if assertion.len() > MAX_ASSERTION_BYTES {
200 return Err(WorkloadError::Malformed("assertion is too large".into()));
201 }
202 let mut parts = assertion.split('.');
203 let (Some(header_b64), Some(claims_b64), Some(signature_b64), None) =
204 (parts.next(), parts.next(), parts.next(), parts.next())
205 else {
206 return Err(WorkloadError::Malformed("not a compact JWS".into()));
207 };
208 let url = base64::engine::general_purpose::URL_SAFE_NO_PAD;
209 let header: Header = serde_json::from_slice(
210 &url.decode(header_b64)
211 .map_err(|_| WorkloadError::Malformed("header is not base64url".into()))?,
212 )
213 .map_err(|_| WorkloadError::Malformed("header is not a JOSE header".into()))?;
214 if header.alg != "RS256" {
215 return Err(WorkloadError::Malformed(format!(
216 "alg {:?} is not RS256",
217 header.alg
218 )));
219 }
220 if header.x5c.is_empty() || header.x5c.len() > MAX_CHAIN_CERTS {
221 return Err(WorkloadError::Malformed(format!(
222 "x5c must carry 1 to {MAX_CHAIN_CERTS} certificates"
223 )));
224 }
225 let mut chain = Vec::with_capacity(header.x5c.len());
226 for encoded in &header.x5c {
227 let der = base64::engine::general_purpose::STANDARD
228 .decode(encoded)
229 .map_err(|_| WorkloadError::Malformed("x5c entry is not base64".into()))?;
230 if der.len() > MAX_CERT_BYTES {
231 return Err(WorkloadError::Malformed("x5c certificate is too large".into()));
232 }
233 chain.push(CertificateDer::from(der));
234 }
235 let (leaf, intermediates) = chain
236 .split_first()
237 .ok_or_else(|| WorkloadError::Malformed("x5c is empty".into()))?;
238 let end_entity = webpki::EndEntityCert::try_from(leaf)
239 .map_err(|error| WorkloadError::Chain(format!("leaf certificate: {error:?}")))?;
240 let time = UnixTime::since_unix_epoch(std::time::Duration::from_secs(now_s));
241 end_entity
242 .verify_for_usage(
243 CHAIN_ALGORITHMS,
244 &self.anchors,
245 intermediates,
246 time,
247 webpki::KeyUsage::client_auth(),
248 None,
249 None,
250 )
251 .map_err(|error| WorkloadError::Chain(format!("{error:?}")))?;
252 let signature = url
253 .decode(signature_b64)
254 .map_err(|_| WorkloadError::Malformed("signature is not base64url".into()))?;
255 let signing_input = assertion
256 .get(
257 ..header_b64
258 .len()
259 .saturating_add(1)
260 .saturating_add(claims_b64.len()),
261 )
262 .ok_or_else(|| WorkloadError::Malformed("signing input".into()))?;
263 end_entity
264 .verify_signature(
265 webpki::ring::RSA_PKCS1_2048_8192_SHA256,
266 signing_input.as_bytes(),
267 &signature,
268 )
269 .map_err(|_| WorkloadError::Signature)?;
270
271 let identity = cf_identity(end_entity.subject())?;
272 let claims: Claims = serde_json::from_slice(
273 &url.decode(claims_b64)
274 .map_err(|_| WorkloadError::Malformed("claims are not base64url".into()))?,
275 )
276 .map_err(|_| WorkloadError::Claims("claims must include iss, sub, aud, iat, exp, jti".into()))?;
277 let audience_ok = match &claims.aud {
278 AudienceClaim::One(aud) => aud == audience,
279 AudienceClaim::Many(auds) => auds.iter().any(|aud| aud == audience),
280 };
281 if !audience_ok {
282 return Err(WorkloadError::Claims("aud does not name this gateway".into()));
283 }
284 if claims.iss.trim().is_empty() {
285 return Err(WorkloadError::Claims("iss is empty".into()));
286 }
287 if claims.sub != identity.instance_guid {
288 return Err(WorkloadError::Claims(
289 "sub must be the instance GUID of the certificate".into(),
290 ));
291 }
292 if claims.jti.trim().is_empty() || claims.jti.len() > 256 || claims.jti.chars().any(char::is_control)
293 {
294 return Err(WorkloadError::Claims(
295 "jti must be 1-256 printable characters".into(),
296 ));
297 }
298 if claims.exp <= now_s {
299 return Err(WorkloadError::Claims("assertion has expired".into()));
300 }
301 if claims.iat > now_s.saturating_add(CLOCK_SKEW_S)
302 || claims
303 .nbf
304 .is_some_and(|nbf| nbf > now_s.saturating_add(CLOCK_SKEW_S))
305 {
306 return Err(WorkloadError::Claims("assertion is not yet valid".into()));
307 }
308 if claims.exp < claims.iat || claims.exp.saturating_sub(claims.iat) > MAX_ASSERTION_LIFETIME_S {
309 return Err(WorkloadError::Claims(format!(
310 "assertion lifetime must be at most {MAX_ASSERTION_LIFETIME_S} seconds"
311 )));
312 }
313 self.remember(&identity.instance_guid, &claims.jti, claims.exp, now_s)?;
314 Ok(VerifiedAssertion {
315 identity,
316 jti: claims.jti,
317 exp: claims.exp,
318 })
319 }
320
321 fn remember(&self, instance: &str, jti: &str, exp: u64, now_s: u64) -> Result<(), WorkloadError> {
324 let mut seen = self.seen.lock();
325 let key = (instance.to_owned(), jti.to_owned());
326 if seen.get(&key).is_some_and(|expires| *expires > now_s) {
327 return Err(WorkloadError::Replay);
328 }
329 if seen.len() >= MAX_REPLAY_ENTRIES {
330 seen.retain(|_, expires| *expires > now_s);
331 if seen.len() >= MAX_REPLAY_ENTRIES {
332 return Err(WorkloadError::Claims(
333 "too many live assertions; retry shortly".into(),
334 ));
335 }
336 }
337 seen.insert(key, exp.saturating_add(CLOCK_SKEW_S));
338 Ok(())
339 }
340}
341
342pub fn tlv(input: &[u8]) -> Option<(u8, &[u8], &[u8])> {
348 let (&tag, rest) = input.split_first()?;
349 let (&first, rest) = rest.split_first()?;
350 let (length, rest) = if first < 0x80 {
351 (usize::from(first), rest)
352 } else {
353 let count = usize::from(first & 0x7f);
354 if count == 0 || count > 4 {
355 return None;
356 }
357 let (bytes, rest) = rest.split_at_checked(count)?;
358 let mut length = 0_usize;
359 for byte in bytes {
360 length = length.checked_mul(256)?.checked_add(usize::from(*byte))?;
361 }
362 (length, rest)
363 };
364 let (value, rest) = rest.split_at_checked(length)?;
365 Some((tag, value, rest))
366}
367
368const OID_COMMON_NAME: &[u8] = &[0x55, 0x04, 0x03];
369const OID_ORGANIZATIONAL_UNIT: &[u8] = &[0x55, 0x04, 0x0b];
370
371pub fn name_attributes(mut name: &[u8]) -> Option<Vec<(Vec<u8>, String)>> {
377 let mut attributes = Vec::new();
378 while !name.is_empty() {
379 let (set_tag, mut set, rest) = tlv(name)?;
380 if set_tag != 0x31 {
381 return None;
382 }
383 while !set.is_empty() {
384 let (sequence_tag, attribute, after) = tlv(set)?;
385 if sequence_tag != 0x30 {
386 return None;
387 }
388 let (oid_tag, oid, value_part) = tlv(attribute)?;
389 if oid_tag != 0x06 {
390 return None;
391 }
392 let (value_tag, value, _) = tlv(value_part)?;
393 if matches!(value_tag, 0x0c | 0x13 | 0x16) {
394 attributes.push((oid.to_vec(), String::from_utf8(value.to_vec()).ok()?));
395 }
396 set = after;
397 }
398 name = rest;
399 }
400 Some(attributes)
401}
402
403pub fn cf_identity(subject: &[u8]) -> Result<CfInstanceIdentity, WorkloadError> {
407 let attributes =
408 name_attributes(subject).ok_or_else(|| WorkloadError::Subject("subject is not valid DER".into()))?;
409 let mut common_names = attributes
410 .iter()
411 .filter(|(oid, _)| oid == OID_COMMON_NAME)
412 .map(|(_, value)| value.as_str());
413 let instance_guid = match (common_names.next(), common_names.next()) {
414 (Some(cn), None) => cn.to_owned(),
415 _ => return Err(WorkloadError::Subject("expected exactly one CN".into())),
416 };
417 let unit = |prefix: &str| -> Result<String, WorkloadError> {
418 let mut values = attributes
419 .iter()
420 .filter(|(oid, _)| oid == OID_ORGANIZATIONAL_UNIT)
421 .filter_map(|(_, value)| value.strip_prefix(prefix));
422 match (values.next(), values.next()) {
423 (Some(value), None) if !value.is_empty() => Ok(value.to_owned()),
424 _ => Err(WorkloadError::Subject(format!(
425 "expected exactly one OU {prefix}<guid>"
426 ))),
427 }
428 };
429 let identity = CfInstanceIdentity {
430 instance_guid,
431 app_guid: unit("app:")?,
432 space_guid: unit("space:")?,
433 org_guid: unit("organization:")?,
434 };
435 let guid_like = |value: &str| {
436 !value.is_empty()
437 && value.len() <= 64
438 && value.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
439 };
440 if ![
441 &identity.instance_guid,
442 &identity.app_guid,
443 &identity.space_guid,
444 &identity.org_guid,
445 ]
446 .iter()
447 .all(|value| guid_like(value))
448 {
449 return Err(WorkloadError::Subject("identity fields must be GUIDs".into()));
450 }
451 Ok(identity)
452}
453
454#[cfg(test)]
455mod tests {
456 #![allow(
457 clippy::unwrap_used,
458 clippy::expect_used,
459 clippy::panic,
460 clippy::indexing_slicing
461 )]
462
463 use super::*;
464
465 #[test]
466 fn tlv_rejects_truncated_and_oversized_lengths() {
467 assert_eq!(
468 tlv(&[0x30, 0x02, 0x01, 0x02]),
469 Some((0x30, &[0x01, 0x02][..], &[][..]))
470 );
471 assert!(tlv(&[0x30, 0x05, 0x01]).is_none(), "truncated value");
472 assert!(
473 tlv(&[0x30, 0x85, 1, 2, 3, 4, 5]).is_none(),
474 "length of length > 4"
475 );
476 assert!(tlv(&[0x30]).is_none());
477 }
478
479 #[test]
480 fn name_parsing_extracts_cn_and_ou() {
481 let name = [
483 0x31, 0x10, 0x30, 0x0e, 0x06, 0x03, 0x55, 0x04, 0x0b, 0x0c, 0x07, b'a', b'p', b'p', b':', b'a',
484 b'b', b'c', 0x31, 0x0d, 0x30, 0x0b, 0x06, 0x03, 0x55, 0x04, 0x03, 0x13, 0x04, b'i', b'n', b's',
485 b't',
486 ];
487 let attributes = name_attributes(&name).unwrap();
488 assert_eq!(attributes.len(), 2);
489 assert_eq!(
490 attributes[0],
491 (OID_ORGANIZATIONAL_UNIT.to_vec(), "app:abc".to_owned())
492 );
493 assert_eq!(attributes[1], (OID_COMMON_NAME.to_vec(), "inst".to_owned()));
494 assert!(cf_identity(&name).is_err(), "space and organization are missing");
495 }
496
497 use proptest::prelude::*;
498
499 fn der_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
501 let mut out = vec![tag, u8::try_from(value.len()).unwrap()];
502 out.extend_from_slice(value);
503 out
504 }
505
506 fn der_tlv_long(tag: u8, value: &[u8], len_bytes: usize) -> Vec<u8> {
509 let mut out = vec![tag, 0x80 | u8::try_from(len_bytes).unwrap()];
510 for index in (0..len_bytes).rev() {
511 out.push(u8::try_from((value.len() >> (8 * index)) & 0xff).unwrap());
512 }
513 out.extend_from_slice(value);
514 out
515 }
516
517 fn der_attribute(oid: &[u8], value: &str) -> Vec<u8> {
519 let sequence = [der_tlv(0x06, oid), der_tlv(0x0c, value.as_bytes())].concat();
520 der_tlv(0x31, &der_tlv(0x30, &sequence))
521 }
522
523 fn cf_subject(instance: &str, app: &str, space: &str, org: &str) -> Vec<u8> {
526 [
527 der_attribute(OID_COMMON_NAME, instance),
528 der_attribute(OID_ORGANIZATIONAL_UNIT, &format!("app:{app}")),
529 der_attribute(OID_ORGANIZATIONAL_UNIT, &format!("space:{space}")),
530 der_attribute(OID_ORGANIZATIONAL_UNIT, &format!("organization:{org}")),
531 ]
532 .concat()
533 }
534
535 #[test]
538 fn well_formed_subject_round_trips() {
539 let subject = cf_subject(
540 "11111111-2222-3333-4444-555555555555",
541 "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
542 "ffffffff-0000-1111-2222-333333333333",
543 "44444444-5555-6666-7777-888888888888",
544 );
545 let identity = cf_identity(&subject).unwrap();
546 assert_eq!(identity.instance_guid, "11111111-2222-3333-4444-555555555555");
547 assert_eq!(identity.app_guid, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
548 assert_eq!(identity.space_guid, "ffffffff-0000-1111-2222-333333333333");
549 assert_eq!(identity.org_guid, "44444444-5555-6666-7777-888888888888");
550 }
551
552 #[test]
556 fn identity_fields_must_look_like_guids() {
557 for (instance, app, space, org) in [
558 (
559 "has spaces here",
560 "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
561 "s-1",
562 "o-1",
563 ),
564 ("inst-1", "has/slash", "s-1", "o-1"),
565 ("inst-1", "app-1", "has:colon", "o-1"),
566 ("inst-1", "app-1", "s-1", "has.dot"),
567 ("", "app-1", "s-1", "o-1"),
568 (&"x".repeat(65), "app-1", "s-1", "o-1"),
569 ] {
570 assert!(
571 cf_identity(&cf_subject(instance, app, space, org)).is_err(),
572 "accepted non-GUID fields: {instance} {app} {space} {org}"
573 );
574 }
575 }
576
577 #[test]
579 fn duplicate_and_missing_attributes_are_refused() {
580 let good = cf_subject("inst-1", "app-1", "s-1", "o-1");
581 let two_cns = [good.clone(), der_attribute(OID_COMMON_NAME, "inst-2")].concat();
583 assert!(cf_identity(&two_cns).is_err(), "two CNs accepted");
584 let no_space = [
586 der_attribute(OID_COMMON_NAME, "inst-1"),
587 der_attribute(OID_ORGANIZATIONAL_UNIT, "app:app-1"),
588 der_attribute(OID_ORGANIZATIONAL_UNIT, "organization:o-1"),
589 ]
590 .concat();
591 assert!(cf_identity(&no_space).is_err(), "missing space accepted");
592 let empty_ou = [
594 der_attribute(OID_COMMON_NAME, "inst-1"),
595 der_attribute(OID_ORGANIZATIONAL_UNIT, "app:"),
596 der_attribute(OID_ORGANIZATIONAL_UNIT, "space:s-1"),
597 der_attribute(OID_ORGANIZATIONAL_UNIT, "organization:o-1"),
598 ]
599 .concat();
600 assert!(cf_identity(&empty_ou).is_err(), "empty OU value accepted");
601 }
602
603 proptest! {
604 #[test]
609 fn tlv_is_total_and_never_slices_past_the_input(
610 data in prop::collection::vec(any::<u8>(), 0..256),
611 ) {
612 if let Some((_tag, value, rest)) = tlv(&data) {
613 prop_assert!(value.len() + rest.len() <= data.len());
614 let header = data.len() - value.len() - rest.len();
616 prop_assert!(header >= 2, "header too short: {header}");
617 }
618 }
619
620 #[test]
622 fn name_attributes_is_total(
623 data in prop::collection::vec(any::<u8>(), 0..256),
624 ) {
625 let _ = name_attributes(&data);
626 }
627
628 #[test]
631 fn cf_identity_is_total(
632 data in prop::collection::vec(any::<u8>(), 0..256),
633 ) {
634 let _ = cf_identity(&data);
635 }
636
637 #[test]
640 fn long_form_lengths_never_overflow(
641 len_bytes in 1_usize..=4,
642 declared in any::<u32>(),
643 payload in prop::collection::vec(any::<u8>(), 0..64),
644 ) {
645 let mut encoded = declared as usize;
646 if len_bytes < 4 {
649 encoded &= (1_usize << (8 * len_bytes)) - 1;
650 }
651 let mut input = vec![0x30, 0x80 | u8::try_from(len_bytes).unwrap()];
652 for index in (0..len_bytes).rev() {
653 input.push(u8::try_from((encoded >> (8 * index)) & 0xff).unwrap());
654 }
655 input.extend_from_slice(&payload);
656 if let Some((_, value, rest)) = tlv(&input) {
657 prop_assert_eq!(value.len() + rest.len(), payload.len());
658 }
659 }
660
661 #[test]
664 fn der_tlv_long_round_trips(
665 payload in prop::collection::vec(any::<u8>(), 0..128),
666 len_bytes in 1_usize..=4,
667 ) {
668 let input = der_tlv_long(0x30, &payload, len_bytes);
669 let (tag, value, rest) = tlv(&input).expect("well-formed TLV must parse");
670 prop_assert_eq!(tag, 0x30);
671 prop_assert_eq!(value, &payload[..]);
672 prop_assert!(rest.is_empty());
673 }
674
675 #[test]
678 fn built_subject_round_trips(
679 instance in "[a-z0-9-]{1,32}",
680 app in "[a-z0-9-]{1,32}",
681 space in "[a-z0-9-]{1,32}",
682 org in "[a-z0-9-]{1,32}",
683 ) {
684 let identity = cf_identity(&cf_subject(&instance, &app, &space, &org))
685 .expect("built subject must parse");
686 prop_assert_eq!(identity.instance_guid, instance);
687 prop_assert_eq!(identity.app_guid, app);
688 prop_assert_eq!(identity.space_guid, space);
689 prop_assert_eq!(identity.org_guid, org);
690 }
691 }
692}