Skip to main content

av_identity/
workload.rs

1//! Platform workload identity: RFC 7523 JWT-bearer assertions signed with a
2//! Cloud Foundry instance identity certificate.
3//!
4//! Cloud Foundry gives every app instance an X.509 certificate and RSA key
5//! (paths in `CF_INSTANCE_CERT` and `CF_INSTANCE_KEY`), rotated before they
6//! expire. The certificate's subject names the instance (`CN`) and, in `OU`
7//! values, its `app:<guid>`, `space:<guid>`, and `organization:<guid>`.
8//!
9//! An app (or the AgentVisor sidecar beside it) proves that identity by
10//! sending `/v1/token` an RFC 7523 assertion
11//! (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`): a short-lived
12//! JWT signed `RS256` with the instance key, whose `x5c` header carries the
13//! instance certificate and its intermediates. [`WorkloadTrust::verify`]
14//! checks, in order:
15//!
16//! 1. size, JOSE header (`alg = RS256`, 1 to 4 `x5c` certificates);
17//! 2. the certificate chain up to a configured instance-identity root, valid
18//!    now, with the clientAuth extended key usage;
19//! 3. the assertion signature with the certificate's key;
20//! 4. the claims: `aud` names the gateway, `sub` is the instance GUID, the
21//!    lifetime is at most [`MAX_ASSERTION_LIFETIME_S`], and `jti` is present;
22//! 5. `jti` was not seen before (a bounded replay cache).
23//!
24//! The caller then maps the verified identity to a configured workload,
25//! which names the human principal the agent acts for, and issues a
26//! gateway-signed agent token.
27
28use base64::Engine as _;
29use rustls_pki_types::{CertificateDer, TrustAnchor, UnixTime};
30use serde::Deserialize;
31use std::collections::HashMap;
32
33/// RFC 7523 ยง2.1 grant type.
34pub const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
35
36/// Longest accepted assertion lifetime (`exp - iat`), in seconds.
37pub const MAX_ASSERTION_LIFETIME_S: u64 = 300;
38
39/// Tolerated clock difference, in seconds.
40const CLOCK_SKEW_S: u64 = 60;
41
42/// Largest accepted assertion, before any decoding.
43const MAX_ASSERTION_BYTES: usize = 48 * 1024;
44
45/// Most certificates accepted in `x5c` (leaf plus intermediates).
46const MAX_CHAIN_CERTS: usize = 4;
47
48/// Largest single certificate accepted.
49const MAX_CERT_BYTES: usize = 16 * 1024;
50
51/// Most unexpired assertion ids remembered for replay detection.
52const MAX_REPLAY_ENTRIES: usize = 100_000;
53
54/// Signature algorithms accepted on the certificate chain.
55static 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/// Why an assertion was refused.
65#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
66#[non_exhaustive]
67pub enum WorkloadError {
68    /// Not a well-formed RS256 JWT with an `x5c` chain.
69    #[error("malformed assertion: {0}")]
70    Malformed(String),
71    /// The certificate chain does not lead to a trusted root.
72    #[error("certificate chain rejected: {0}")]
73    Chain(String),
74    /// The assertion signature does not verify with the certificate key.
75    #[error("assertion signature does not verify")]
76    Signature,
77    /// The claims are invalid (audience, lifetime, subject, id).
78    #[error("invalid assertion claims: {0}")]
79    Claims(String),
80    /// The certificate subject lacks the instance-identity fields.
81    #[error("certificate subject is not a Cloud Foundry instance identity: {0}")]
82    Subject(String),
83    /// This assertion id was already used.
84    #[error("assertion was already used")]
85    Replay,
86    /// The trust configuration is invalid.
87    #[error("invalid workload trust configuration: {0}")]
88    Config(String),
89}
90
91/// The platform identity proven by a verified assertion.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CfInstanceIdentity {
94    /// Instance GUID (certificate `CN`).
95    pub instance_guid: String,
96    /// App GUID (`OU=app:<guid>`).
97    pub app_guid: String,
98    /// Space GUID (`OU=space:<guid>`).
99    pub space_guid: String,
100    /// Organization GUID (`OU=organization:<guid>`).
101    pub org_guid: String,
102}
103
104/// A verified assertion.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct VerifiedAssertion {
107    /// The proven platform identity.
108    pub identity: CfInstanceIdentity,
109    /// Assertion id.
110    pub jti: String,
111    /// Assertion expiry, epoch seconds.
112    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
141/// Trusted instance-identity roots plus the replay cache.
142pub struct WorkloadTrust {
143    anchors: Vec<TrustAnchor<'static>>,
144    seen: parking_lot::Mutex<HashMap<(String, String), u64>>,
145}
146
147impl WorkloadTrust {
148    /// Trust the `CERTIFICATE` blocks of a PEM bundle as roots.
149    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    /// Number of trusted roots.
188    pub fn anchor_count(&self) -> usize {
189        self.anchors.len()
190    }
191
192    /// Verify an assertion addressed to `audience` at `now_s`.
193    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    /// Record an assertion id, refusing one already seen. Entries expire
322    /// with their assertion; a full cache of live entries fails closed.
323    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
342/// Split one DER TLV off the front of `input`: `(tag, value, rest)`.
343///
344/// Exposed so a fuzz target can throw arbitrary bytes at the hand-rolled
345/// DER reader. A panic here would be a denial of service on every
346/// assertion that carries an `x5c` header.
347pub 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
371/// `(attribute OID, text)` pairs of an X.509 Name, given the contents of its
372/// outer SEQUENCE. String types other than UTF8String, PrintableString, and
373/// IA5String are skipped.
374///
375/// Exposed for fuzzing alongside [`tlv`].
376pub 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
403/// Read a Cloud Foundry instance identity out of an X.509 subject name.
404///
405/// Exposed for fuzzing alongside [`tlv`] and [`name_attributes`].
406pub 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        // SET { SEQ { OID 2.5.4.11, UTF8 "app:abc" } } SET { SEQ { OID 2.5.4.3, PRINTABLE "inst" } }
482        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    /// Build one DER TLV with a short (one-byte) length form.
500    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    /// Build one DER TLV with the long length form, using `len_bytes` bytes
507    /// of length field. Used to probe the boundary of the length decoder.
508    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    /// One X.509 attribute: SET { SEQ { OID, UTF8String value } }.
518    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    /// A full Cloud Foundry identity subject name with the four required
524    /// attributes.
525    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    /// A well-formed subject name round-trips through the parser and
536    /// produces exactly the identity it encoded.
537    #[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    /// The GUID shape check rejects fields with anything but alphanumerics
553    /// and hyphens, empty fields, and fields over 64 characters. A hostile
554    /// certificate must not smuggle an arbitrary string into the identity.
555    #[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    /// Duplicate CN or a missing OU must be refused, not silently merged.
578    #[test]
579    fn duplicate_and_missing_attributes_are_refused() {
580        let good = cf_subject("inst-1", "app-1", "s-1", "o-1");
581        // Two CNs.
582        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        // No space OU.
585        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        // Empty OU value after the prefix.
593        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        /// `tlv` is total: arbitrary bytes either parse or return `None`,
605        /// and never panic. The value and the rest always sit inside the
606        /// input, and the header (tag plus length field) is never shorter
607        /// than two bytes.
608        #[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                // At least one tag byte and one length byte precede the value.
615                let header = data.len() - value.len() - rest.len();
616                prop_assert!(header >= 2, "header too short: {header}");
617            }
618        }
619
620        /// `name_attributes` is total on arbitrary bytes.
621        #[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        /// `cf_identity` is total on arbitrary bytes and never accepts
629        /// something that is not a well-formed DER name.
630        #[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        /// A long-form length with four length bytes must not overflow the
638        /// length decoder. Values past the remaining input are refused.
639        #[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            // Clamp so the test stays within a readable range while still
647            // probing the top of the decoder.
648            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        /// The long-length builder and the parser agree on the length of
662        /// the value they encode.
663        #[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        /// Round-tripping a well-formed identity through the builder and
676        /// the parser always recovers the same four GUID fields.
677        #[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}