Skip to main content

xml_sec/policy/
profiles.rs

1//! Trusted profile selection and compilation; no configuration discovery or I/O.
2
3use super::*;
4use crate::provider::{CryptoProvider, ProviderCapability};
5
6mod composition;
7mod constraints;
8
9/// XML Security presets, selected only by trusted application code.
10/// These names do not certify full SAML, SOAP or XAdES conformance.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum PolicyProfile {
13    /// Implemented libxmlsec1 mechanisms, with explicit compatibility permission.
14    XmlSecCompatible,
15    /// Modern signatures, SHA-2/SHA-3 digests and authenticated encryption.
16    Modern,
17    /// Restricted SAML signatures and explicit authenticated target coverage.
18    SecureSaml,
19    /// SOAP Security-header signatures and explicit authenticated target coverage.
20    WsSecurity,
21    /// XML signatures with authenticated, typed SignedProperties references.
22    Xades,
23    /// Compatibility verification only, requiring expiring authorization.
24    LegacyVerify,
25}
26
27/// Auditable, caller-authorized exception with an exclusive expiration boundary.
28/// The core does not decide who is entitled to grant exceptions; the trusted
29/// application authenticates that authority before constructing this value.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PolicyValidity {
32    /// Application's audit record identifier, unrelated to document content.
33    pub authorization_id: String,
34    /// Authenticated principal granting the exception.
35    pub authority: String,
36    /// Non-secret justification retained for audit.
37    pub reason: String,
38    /// Inclusive beginning of validity.
39    pub not_before: SystemTime,
40    /// Exclusive expiration; cached policies cannot outlive this boundary.
41    pub expires_at: SystemTime,
42}
43
44impl PolicyValidity {
45    /// Check metadata and the half-open interval against an explicit time.
46    pub fn validate_at(&self, now: SystemTime) -> Result<(), PolicyViolation> {
47        if self.authorization_id.trim().is_empty()
48            || self.authority.trim().is_empty()
49            || self.reason.trim().is_empty()
50            || self.not_before >= self.expires_at
51        {
52            return Err(configuration(
53                "authorization needs identity, authority, reason and a nonempty validity interval",
54            ));
55        }
56        if now < self.not_before || now >= self.expires_at {
57            return Err(PolicyViolation::AuthorizationExpired);
58        }
59        Ok(())
60    }
61}
62
63/// Signature-level XML Security constraints, not application protocol semantics.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65pub enum SignatureConstraints {
66    /// Generic XMLDSig processing with caller-selected request requirements.
67    #[default]
68    General,
69    /// SAML Core §5.4 signature format and coverage requirements.
70    Saml,
71    /// WS-Security §8.2 Security-header signature placement and target coverage.
72    WsSecurity,
73    /// ETSI EN 319 132-1 §4.4.2 SignedProperties protection.
74    Xades,
75}
76
77/// Editable compilation input. `None` disables an operation; a restrictive
78/// layer can disable but never re-enable an operation inherited as disabled.
79#[derive(Debug, Clone, Default)]
80pub struct PolicySet {
81    /// XMLDSig verification policy.
82    pub verification: Option<VerificationPolicy>,
83    /// XMLDSig signing policy.
84    pub signing: Option<SigningPolicy>,
85    /// XMLEnc encryption policy.
86    #[cfg(feature = "xmlenc")]
87    pub encryption: Option<EncryptionPolicy>,
88    /// XMLEnc decryption policy.
89    #[cfg(feature = "xmlenc")]
90    pub decryption: Option<DecryptionPolicy>,
91}
92
93/// Caller-owned capabilities promised to operations using the compiled policy.
94/// This is a contract, not permission to discover resources. Actual resource
95/// maps and target identities must still be supplied to each operation.
96pub struct PolicyCompileContext {
97    /// Caller guarantees an explicit external-resource map on applicable calls.
98    pub external_resources: bool,
99    /// Caller uses `verify_request` with nonempty expected target identities.
100    pub target_evidence: bool,
101}
102
103/// Immutable compiled output. Operation snapshots retain all constraints and
104/// authorization metadata when cloned into existing processing contexts.
105#[derive(Debug, Clone)]
106pub struct CompiledPolicies {
107    profile: PolicyProfile,
108    policies: PolicySet,
109}
110
111impl CompiledPolicies {
112    /// Explicit trusted profile identity, not inferred from XML.
113    pub const fn profile(&self) -> PolicyProfile {
114        self.profile
115    }
116    /// Borrow the compiled verification snapshot, if enabled.
117    pub fn verification(&self) -> Option<&VerificationPolicy> {
118        self.policies.verification.as_ref()
119    }
120    /// Borrow the compiled signing snapshot, if enabled.
121    pub fn signing(&self) -> Option<&SigningPolicy> {
122        self.policies.signing.as_ref()
123    }
124    /// Borrow the compiled encryption snapshot, if enabled.
125    #[cfg(feature = "xmlenc")]
126    pub fn encryption(&self) -> Option<&EncryptionPolicy> {
127        self.policies.encryption.as_ref()
128    }
129    /// Borrow the compiled decryption snapshot, if enabled.
130    #[cfg(feature = "xmlenc")]
131    pub fn decryption(&self) -> Option<&DecryptionPolicy> {
132        self.policies.decryption.as_ref()
133    }
134}
135
136/// Compose layers in system → application → tenant order. Every ordinary layer
137/// intersects the previous snapshot; only `authorize_replacement` can relax it.
138pub struct PolicyCompiler<'a> {
139    provider: &'a dyn CryptoProvider,
140    profile: PolicyProfile,
141    policies: PolicySet,
142}
143
144impl<'a> PolicyCompiler<'a> {
145    /// Construct a named profile using this engine's advertised capabilities.
146    /// LegacyVerify requires a valid authorization even before compilation.
147    pub fn new(
148        profile: PolicyProfile,
149        provider: &'a dyn CryptoProvider,
150        authorization: Option<PolicyValidity>,
151    ) -> Result<Self, PolicyViolation> {
152        if profile == PolicyProfile::LegacyVerify && authorization.is_none() {
153            return Err(configuration(
154                "LegacyVerify requires expiring authorization",
155            ));
156        }
157        if let Some(validity) = &authorization {
158            validity.validate_at(SystemTime::now())?;
159        }
160        let policies = profile_policies(profile, provider, authorization);
161        Ok(Self {
162            provider,
163            profile,
164            policies,
165        })
166    }
167
168    /// Intersect another complete layer without granting any new permission.
169    /// `None` operation fields mean disabled, not unspecified inheritance.
170    pub fn restrict(mut self, layer: &PolicySet) -> Result<Self, PolicyViolation> {
171        self.policies = self.policies.intersect(layer)?;
172        Ok(self)
173    }
174
175    /// Explicit relaxation path: replace the effective policy and retain the
176    /// authenticated grant on every enabled operation. Earlier authorization
177    /// lifetimes are intersected, so replacement cannot extend an existing grant.
178    pub fn authorize_replacement(
179        mut self,
180        mut replacement: PolicySet,
181        authorization: PolicyValidity,
182    ) -> Result<Self, PolicyViolation> {
183        authorization.validate_at(SystemTime::now())?;
184        replacement.bind_authorization(authorization, &self.policies)?;
185        self.policies = replacement;
186        Ok(self)
187    }
188
189    /// Compile contradictions before parsing, resolver calls or cryptography.
190    pub fn compile(
191        self,
192        context: &PolicyCompileContext,
193    ) -> Result<CompiledPolicies, PolicyViolation> {
194        self.policies.validate(context, self.provider)?;
195        Ok(CompiledPolicies {
196            profile: self.profile,
197            policies: self.policies,
198        })
199    }
200
201    /// Inspect a layer input for customization before restrictive composition.
202    pub fn policies(&self) -> &PolicySet {
203        &self.policies
204    }
205}
206
207fn configuration(reason: &'static str) -> PolicyViolation {
208    PolicyViolation::Configuration { reason }
209}
210
211fn profile_policies(
212    profile: PolicyProfile,
213    provider: &dyn CryptoProvider,
214    validity: Option<PolicyValidity>,
215) -> PolicySet {
216    let compatibility = matches!(
217        profile,
218        PolicyProfile::XmlSecCompatible | PolicyProfile::LegacyVerify
219    );
220    let constraints = match profile {
221        PolicyProfile::SecureSaml => SignatureConstraints::Saml,
222        PolicyProfile::WsSecurity => SignatureConstraints::WsSecurity,
223        PolicyProfile::Xades => SignatureConstraints::Xades,
224        _ => SignatureConstraints::General,
225    };
226    let signatures: HashSet<_> = SignatureAlgorithm::ALL
227        .into_iter()
228        .filter(|a| provider.supports(ProviderCapability::Verify(*a)))
229        .filter(|a| {
230            compatibility
231                || (a.signing_allowed() && a.hmac_output_bits().is_none_or(|bits| bits >= 256))
232        })
233        .filter(|a| !matches!(a, SignatureAlgorithm::PostQuantum(_)))
234        .collect();
235    let digests: HashSet<_> = DigestAlgorithm::ALL
236        .into_iter()
237        .filter(|a| provider.supports(ProviderCapability::Digest(*a)))
238        .filter(|a| compatibility || (a.signing_allowed() && a.output_len() >= 32))
239        .collect();
240    let transforms = if compatibility {
241        TransformPolicy::default()
242    } else {
243        TransformPolicy {
244            allowed_algorithms: Some(
245                [
246                    "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
247                    "http://www.w3.org/2001/10/xml-exc-c14n#",
248                    "http://www.w3.org/2001/10/xml-exc-c14n#WithComments",
249                    "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
250                    "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments",
251                    "http://www.w3.org/2006/12/xml-c14n11",
252                    "http://www.w3.org/2006/12/xml-c14n11#WithComments",
253                ]
254                .into_iter()
255                .map(str::to_owned)
256                .collect(),
257            ),
258            ..TransformPolicy::default()
259        }
260    };
261    let mut verification = VerificationPolicy {
262        validity: validity.clone(),
263        signature_constraints: constraints,
264        signature_algorithms: Some(signatures),
265        parameterized_rsa_pss: if compatibility {
266            RsaPssPermission::AllSupported
267        } else {
268            RsaPssPermission::SecureSupported
269        },
270        digest_algorithms: Some(digests),
271        transforms: transforms.clone(),
272        ..VerificationPolicy::default()
273    };
274    verification.key_trust.parameterized_rsa_pss = verification.parameterized_rsa_pss;
275    if compatibility {
276        verification.key_trust.allowed_legacy_signature_algorithms = verification
277            .signature_algorithms
278            .clone()
279            .unwrap_or_default();
280        verification.key_trust.rsa_keys.minimum_modulus_bits = 1024;
281        verification.key_trust.dsa_keys.minimum_modulus_bits = 1024;
282        verification.hmac.minimum_output_bits = 80;
283        verification.transforms.same_document_id_semantics =
284            SameDocumentIdSemantics::XmlSecBarename;
285    }
286    if constraints != SignatureConstraints::General {
287        verification.key_sources = KeySourcePolicy {
288            preset_key: true,
289            key_name: true,
290            x509_data: true,
291            key_value: false,
292            der_encoded_key_value: false,
293            key_info_reference: false,
294        };
295    }
296    if constraints == SignatureConstraints::Saml {
297        verification.uris.references = UriTypeSet::new(false, true, false);
298        verification.transforms.allowed_algorithms = Some(
299            [
300                "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
301                "http://www.w3.org/2001/10/xml-exc-c14n#",
302                "http://www.w3.org/2001/10/xml-exc-c14n#WithComments",
303            ]
304            .into_iter()
305            .map(str::to_owned)
306            .collect(),
307        );
308    }
309    let signing = SigningPolicy {
310        validity: validity.clone(),
311        signature_constraints: constraints,
312        parameterized_rsa_pss: RsaPssPermission::SecureSupported,
313        signature_algorithms: Some(
314            SignatureAlgorithm::ALL
315                .into_iter()
316                .filter(|a| a.signing_allowed() && !matches!(a, SignatureAlgorithm::PostQuantum(_)))
317                .filter(|a| a.hmac_output_bits().is_none_or(|bits| bits >= 256))
318                .filter(|a| provider.supports(ProviderCapability::Sign(*a)))
319                .collect(),
320        ),
321        digest_algorithms: Some(
322            DigestAlgorithm::ALL
323                .into_iter()
324                .filter(|a| a.signing_allowed() && a.output_len() >= 32)
325                .filter(|a| provider.supports(ProviderCapability::Digest(*a)))
326                .collect(),
327        ),
328        transforms: if constraints == SignatureConstraints::Saml {
329            verification.transforms.clone()
330        } else {
331            transforms
332        },
333        uris: if constraints == SignatureConstraints::Saml {
334            verification.uris
335        } else {
336            UriPolicy::default()
337        },
338        ..SigningPolicy::default()
339    };
340    let legacy = profile == PolicyProfile::LegacyVerify;
341    PolicySet {
342        verification: Some(verification),
343        signing: (!legacy).then_some(signing),
344        #[cfg(feature = "xmlenc")]
345        encryption: (!legacy).then(|| encryption_profile(provider, validity.clone())),
346        #[cfg(feature = "xmlenc")]
347        decryption: (!legacy).then(|| decryption_profile(provider, compatibility, validity)),
348    }
349}
350
351#[cfg(feature = "xmlenc")]
352const MODERN_CONTENT_METHODS: [DataEncryptionAlgorithm; 3] = [
353    DataEncryptionAlgorithm::Aes128Gcm,
354    DataEncryptionAlgorithm::Aes256Gcm,
355    DataEncryptionAlgorithm::ChaCha20Poly1305,
356];
357#[cfg(feature = "xmlenc")]
358const MODERN_WRAP_METHODS: [KeyWrapAlgorithm; 2] =
359    [KeyWrapAlgorithm::AesKw128, KeyWrapAlgorithm::AesKw256];
360#[cfg(feature = "xmlenc")]
361const MODERN_OAEP_DIGESTS: [OaepDigestAlgorithm; 3] = [
362    OaepDigestAlgorithm::Sha256,
363    OaepDigestAlgorithm::Sha384,
364    OaepDigestAlgorithm::Sha512,
365];
366
367#[cfg(feature = "xmlenc")]
368const COMPATIBLE_CONTENT_METHODS: &[DataEncryptionAlgorithm] = &[
369    DataEncryptionAlgorithm::Aes128Cbc,
370    DataEncryptionAlgorithm::Aes256Cbc,
371    DataEncryptionAlgorithm::Aes128Gcm,
372    DataEncryptionAlgorithm::Aes256Gcm,
373    DataEncryptionAlgorithm::Camellia128Cbc,
374    DataEncryptionAlgorithm::Camellia192Cbc,
375    DataEncryptionAlgorithm::Camellia256Cbc,
376    DataEncryptionAlgorithm::ChaCha20,
377    DataEncryptionAlgorithm::ChaCha20Poly1305,
378    #[cfg(feature = "legacy-algorithms")]
379    DataEncryptionAlgorithm::TripleDesCbc,
380    #[cfg(feature = "legacy-algorithms")]
381    DataEncryptionAlgorithm::Aes192Cbc,
382    #[cfg(feature = "legacy-algorithms")]
383    DataEncryptionAlgorithm::Aes192Gcm,
384];
385#[cfg(feature = "xmlenc")]
386const COMPATIBLE_WRAP_METHODS: &[KeyWrapAlgorithm] = &[
387    KeyWrapAlgorithm::AesKw128,
388    KeyWrapAlgorithm::AesKw256,
389    KeyWrapAlgorithm::CamelliaKw128,
390    KeyWrapAlgorithm::CamelliaKw192,
391    KeyWrapAlgorithm::CamelliaKw256,
392    #[cfg(feature = "legacy-algorithms")]
393    KeyWrapAlgorithm::TripleDes,
394    #[cfg(feature = "legacy-algorithms")]
395    KeyWrapAlgorithm::AesKw192,
396];
397#[cfg(feature = "xmlenc")]
398const COMPATIBLE_TRANSPORT_METHODS: &[KeyTransportAlgorithm] = &[
399    KeyTransportAlgorithm::RsaOaepMgf1p,
400    KeyTransportAlgorithm::RsaOaep11,
401    #[cfg(feature = "legacy-algorithms")]
402    KeyTransportAlgorithm::RsaPkcs1v15,
403];
404#[cfg(feature = "xmlenc")]
405const OAEP_MGF_DIGESTS: [OaepDigestAlgorithm; 5] = [
406    OaepDigestAlgorithm::Sha1,
407    OaepDigestAlgorithm::Sha224,
408    OaepDigestAlgorithm::Sha256,
409    OaepDigestAlgorithm::Sha384,
410    OaepDigestAlgorithm::Sha512,
411];
412
413#[cfg(feature = "xmlenc")]
414fn encryption_profile(
415    provider: &dyn CryptoProvider,
416    validity: Option<PolicyValidity>,
417) -> EncryptionPolicy {
418    EncryptionPolicy {
419        validity,
420        data_algorithms: Some(
421            MODERN_CONTENT_METHODS
422                .into_iter()
423                .filter(|a| provider.supports(ProviderCapability::Encrypt(*a)))
424                .collect(),
425        ),
426        key_transport_algorithms: Some(HashSet::from([KeyTransportAlgorithm::RsaOaep11])),
427        key_wrap_algorithms: Some(
428            MODERN_WRAP_METHODS
429                .into_iter()
430                .filter(|a| provider.supports(ProviderCapability::KeyWrap(*a)))
431                .collect(),
432        ),
433        oaep_digests: Some(HashSet::from(MODERN_OAEP_DIGESTS)),
434        ..EncryptionPolicy::default()
435    }
436}
437
438#[cfg(feature = "xmlenc")]
439fn decryption_profile(
440    provider: &dyn CryptoProvider,
441    compatibility: bool,
442    validity: Option<PolicyValidity>,
443) -> DecryptionPolicy {
444    let content = if compatibility {
445        COMPATIBLE_CONTENT_METHODS
446    } else {
447        &MODERN_CONTENT_METHODS
448    };
449    let wraps = if compatibility {
450        COMPATIBLE_WRAP_METHODS
451    } else {
452        &MODERN_WRAP_METHODS
453    };
454    let cbc_methods = if compatibility {
455        COMPATIBLE_CONTENT_METHODS
456    } else {
457        &[]
458    };
459    let oaep_digests = if compatibility {
460        DigestAlgorithm::ALL
461            .into_iter()
462            .filter(|digest| provider.supports(ProviderCapability::Digest(*digest)))
463            .filter_map(|digest| OaepDigestAlgorithm::from_uri(digest.uri()))
464            .collect()
465    } else {
466        HashSet::from(MODERN_OAEP_DIGESTS)
467    };
468    let transports = if compatibility {
469        COMPATIBLE_TRANSPORT_METHODS
470            .iter()
471            .copied()
472            .filter(|method| recovery_method_available(provider, *method, &oaep_digests))
473            .collect()
474    } else {
475        HashSet::from([KeyTransportAlgorithm::RsaOaep11])
476    };
477    DecryptionPolicy {
478        validity,
479        data_algorithms: Some(
480            content
481                .iter()
482                .copied()
483                .filter(|algorithm| provider.supports(ProviderCapability::Decrypt(*algorithm)))
484                .collect(),
485        ),
486        key_transport_algorithms: Some(transports),
487        key_wrap_algorithms: Some(
488            wraps
489                .iter()
490                .copied()
491                .chain(
492                    cbc_methods
493                        .iter()
494                        .copied()
495                        .filter(|algorithm| algorithm.cbc_block_len().is_some())
496                        .map(KeyWrapAlgorithm::Cbc),
497                )
498                .filter(|algorithm| provider.supports(ProviderCapability::KeyUnwrap(*algorithm)))
499                .collect(),
500        ),
501        oaep_digests: Some(oaep_digests),
502        ..DecryptionPolicy::default()
503    }
504}
505
506#[cfg(feature = "xmlenc")]
507fn recovery_method_available(
508    provider: &dyn CryptoProvider,
509    method: KeyTransportAlgorithm,
510    digests: &HashSet<OaepDigestAlgorithm>,
511) -> bool {
512    #[cfg(feature = "legacy-algorithms")]
513    if method == KeyTransportAlgorithm::RsaPkcs1v15 {
514        return provider.supports(ProviderCapability::Pkcs1v15Recovery);
515    }
516    // Admission here identifies an available mechanism; compilation still
517    // checks every permitted digest/MGF tuple, never silently dropping a tuple.
518    digests.iter().any(|&digest| {
519        OAEP_MGF_DIGESTS.into_iter().any(|mgf| {
520            digests.contains(&mgf)
521                && provider.supports(ProviderCapability::KeyRecovery(
522                    &crate::xmlenc::RsaOaepParameters {
523                        algorithm: method,
524                        digest,
525                        mgf_digest: mgf,
526                        label: Vec::new(),
527                    },
528                ))
529        })
530    })
531}