c2pa 0.91.11

Rust SDK for C2PA (Coalition for Content Provenance and Authenticity) implementors
Documentation
// Copyright 2025 Adobe. All rights reserved.
// This file is licensed to you under the Apache License,
// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
// or the MIT license (http://opensource.org/licenses/MIT),
// at your option.

// Unless required by applicable law or agreed to in writing,
// this software is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or
// implied. See the LICENSE-MIT and LICENSE-APACHE files for the
// specific language governing permissions and limitations under
// each license.

//! This module contains the APIs you will use to validate a
//! C2PA Manifest that contains one or more CAWG identity assertions.

use async_trait::async_trait;
use serde_json::Value;

use crate::{
    context::Context,
    dynamic_assertion::{AsyncPostValidator, PartialClaim},
    identity::IdentityAssertion,
    status_tracker::StatusTracker,
    ManifestAssertion,
};

/// Validates a CAWG identity assertion.
///
/// A `CawgValidator` carries the [`Context`] that governs CAWG validation,
/// including the `trust.trusted_ica_issuers` allow-list. Construct one with
/// [`CawgValidator::new`] to validate under a specific [`Context`].
pub struct CawgValidator<'a> {
    context: &'a Context,
}

impl<'a> CawgValidator<'a> {
    /// Create a [`CawgValidator`] from the provided context.
    pub fn new(context: &'a Context) -> Self {
        Self { context }
    }
}

#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl AsyncPostValidator for CawgValidator<'_> {
    async fn validate(
        &self,
        label: &str,
        assertion: &ManifestAssertion,
        uri: &str,
        partial_claim: &PartialClaim,
        tracker: &mut StatusTracker,
    ) -> crate::Result<Option<Value>> {
        if label == "cawg.identity" || label.starts_with("cawg.identity__") {
            let identity_assertion: IdentityAssertion = assertion.to_assertion()?;
            tracker.push_current_uri(uri.to_string());
            let result = identity_assertion
                .validate_partial_claim_async(partial_claim, tracker, self.context)
                .await
                .ok();
            tracker.pop_current_uri();
            return Ok(result);
        };
        Ok(None)
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::panic)]
    #![allow(clippy::unwrap_used)]
    use std::io::{Cursor, Read};

    use async_trait::async_trait;
    use c2pa_macros::c2pa_test_async;
    #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
    use wasm_bindgen_test::wasm_bindgen_test;

    use crate::{
        http::{AsyncHttpResolver, HttpResolverError},
        Context, Reader, ValidationState,
    };

    const CONNECTED_IDENTITIES_VALID: &[u8] =
        include_bytes!("tests/fixtures/claim_aggregation/adobe_connected_identities.jpg");

    const NO_HARD_BINDING: &[u8] =
        include_bytes!("tests/fixtures/validation_method/no_hard_binding.jpg");

    const MULTIPLE_IDENTITIES_VALID: &[u8] =
        include_bytes!("tests/fixtures/claim_aggregation/ims_multiple_manifests.jpg");

    // DID document for the `did:web` issuer that both Adobe-signed fixtures above
    // were signed against. Served by a local mock so validation does not depend on
    // reaching the Adobe stage server over the network.
    const CONNECTED_IDENTITIES_DID: &str =
        include_str!("tests/fixtures/claim_aggregation/connected_identities_did.json");

    /// Serves [`CONNECTED_IDENTITIES_DID`] for any request, without opening a
    /// real socket. `httpmock` (used by [`mock_connected_identities_did`]) needs
    /// a real TCP listener, which isn't available under WASI, so this resolver
    /// is what keeps `did:web` resolution hermetic there.
    struct MockDidResolver;

    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
    impl AsyncHttpResolver for MockDidResolver {
        async fn http_resolve_async(
            &self,
            _request: http::Request<Vec<u8>>,
        ) -> Result<http::Response<Box<dyn Read>>, HttpResolverError> {
            Ok(http::Response::builder()
                .status(200)
                .header("content-type", "application/did+json")
                .body(
                    Box::new(Cursor::new(CONNECTED_IDENTITIES_DID.as_bytes().to_vec()))
                        as Box<dyn Read>,
                )
                .unwrap())
        }
    }

    /// Start a local mock server that serves the issuer DID document and redirect
    /// `did:web` resolution for the Adobe stage domain to it. The returned
    /// `MockServer` must be kept alive for the duration of the test.
    #[cfg(not(target_arch = "wasm32"))]
    fn mock_connected_identities_did() -> httpmock::MockServer {
        use httpmock::prelude::*;

        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/.well-known/did.json");
            then.status(200)
                .header("content-type", "application/did+json")
                .body(CONNECTED_IDENTITIES_DID);
        });

        crate::identity::claim_aggregation::w3c_vc::did_web::set_proxy(
            "connected-identities.identity-stage.adobe.com",
            &server.url("/"),
        );

        server
    }

    #[c2pa_test_async]
    async fn test_connected_identities_valid() {
        crate::settings::set_settings_value("verify.verify_trust", false).unwrap();
        crate::settings::set_settings_value(
            "soft_binding.soft_binding_algorithms",
            [
                "com.adobe.trustmark.P".to_string(),
                "com.adobe.icn.dense".to_string(),
            ],
        )
        .unwrap();
        let mut settings = crate::settings::get_thread_local_settings();
        // add a CAWG specific trust anchor set
        let anchors = &mut settings.trust.anchors.unwrap();
        let mut cawg_anchor: crate::settings::TrustAnchor = anchors[0].clone();
        cawg_anchor.trust_kind = crate::settings::TrustListKind::CAWG;
        cawg_anchor.trust_uri = Some("cawg_trust".to_string());
        cawg_anchor.trusted_ica_issuers = Some(vec![
                "did:jwk:eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiTXA1LTBlODNuTmdRaGRoQlc4UnNoa2p5OTBzYTFBOUpJemtJdGNEcUN1SSJ9".to_string(),
                "did:web:connected-identities.identity-stage.adobe.com".to_string(),
            ]);
        anchors.push(cawg_anchor);
        settings.trust.anchors = Some(anchors.clone());

        // Serve did:web resolution from an in-process resolver rather than a real
        // network connection, so this test is hermetic on every target (including
        // WASI, which has no compatible TCP-based mock server).
        let context = Context::default()
            .with_settings(settings)
            .unwrap()
            .with_resolver_async(MockDidResolver);

        let mut stream = Cursor::new(CONNECTED_IDENTITIES_VALID);

        let reader = Reader::from_context(context)
            .with_stream_async("image/jpeg", &mut stream)
            .await
            .unwrap();

        println!("validation results: {}", reader);

        assert!(reader
            .validation_results()
            .unwrap()
            .active_manifest()
            .unwrap()
            .success()
            .iter()
            .any(|s| s.code() == "cawg.ica.credential_valid"));
    }

    #[c2pa_test_async]
    async fn test_multiple_identities_valid() {
        crate::settings::set_settings_value("verify.verify_trust", false).unwrap();
        crate::settings::set_settings_value(
            "soft_binding.soft_binding_algorithms",
            [
                "com.adobe.trustmark.P".to_string(),
                "com.adobe.icn.dense".to_string(),
            ],
        )
        .unwrap();
        let settings = crate::settings::get_thread_local_settings();
        let context = Context::new()
            .with_settings(settings)
            .unwrap()
            .with_resolver_async(MockDidResolver);

        let mut stream = Cursor::new(MULTIPLE_IDENTITIES_VALID);

        let reader = Reader::from_context(context)
            .with_stream_async("image/jpeg", &mut stream)
            .await
            .unwrap();

        println!("validation results: {reader}");

        assert_eq!(
            reader
                .validation_results()
                .unwrap()
                .ingredient_deltas()
                .unwrap()
                .len(),
            1
        );
        assert_eq!(reader.validation_state(), ValidationState::Valid);
    }

    #[cfg(not(target_arch = "wasm32"))]
    #[c2pa_test_async]
    async fn ica_issuer_untrusted_does_not_affect_manifest_state() {
        use crate::Settings;

        let _did_server = mock_connected_identities_did();

        // Build a Context that trusts NO ICA issuers (empty allow-list) and skips
        // C2PA certificate trust checking. The empty list must reach the verifier
        // through the Context carried by the CawgValidator.
        let settings = Settings::new()
            .with_value("verify.verify_trust", false)
            .unwrap()
            .with_value("core.decode_identity_assertions", false)
            .unwrap()
            .with_value("cawg_trust.trusted_ica_issuers", Vec::<String>::new())
            .unwrap()
            .with_value(
                "soft_binding.soft_binding_algorithms",
                [
                    "com.adobe.trustmark.P".to_string(),
                    "com.adobe.icn.dense".to_string(),
                ],
            )
            .unwrap();
        let context = Context::new()
            .with_settings(settings)
            .unwrap()
            .into_shared();

        // Both the reader and the validator share this context, so the empty
        // allow-list reaches the verifier through the CawgValidator.
        let validator = super::CawgValidator::new(&context);

        let mut stream = Cursor::new(CONNECTED_IDENTITIES_VALID);
        let mut reader = Reader::from_shared_context(&context)
            .with_stream_async("image/jpeg", &mut stream)
            .await
            .unwrap();

        reader.post_validate_async(&validator).await.unwrap();

        let results = reader.validation_results().unwrap();
        let active = results.active_manifest().unwrap();

        // The credential's issuer is not on the (empty) allow-list, so an
        // untrusted-issuer notice is recorded informationally for this identity
        // assertion (not as a failure, so it does not affect manifest state)...
        assert!(active
            .informational()
            .iter()
            .any(|s| s.code() == "cawg.ica.untrusted_issuer"));
        assert!(!active
            .failure()
            .iter()
            .any(|s| s.code() == "cawg.ica.untrusted_issuer"));

        // ...and, because the issuer is untrusted, the credential is not reported
        // as valid.
        assert!(!active
            .success()
            .iter()
            .any(|s| s.code() == "cawg.ica.credential_valid"));

        // But the untrusted ICA issuer is scoped to the identity assertion: it
        // does NOT invalidate the enclosing C2PA manifest.
        assert_eq!(reader.validation_state(), ValidationState::Valid);
    }

    /// CAWG Identity 1.3 ยง7.2: status codes from X.509 signature validation are
    /// reported against the identity assertion, not a placeholder such as
    /// `Cose_Sign1`.
    #[c2pa_test_async]
    async fn x509_algorithm_unsupported_reported_against_identity_assertion() {
        // Identity assertion signature whose protected `alg` header is RS256.
        const ALGORITHM_UNSUPPORTED: &[u8] =
            include_bytes!("tests/fixtures/validation_method/x509_algorithm_unsupported.jpg");

        let reader = Reader::default()
            .with_stream_async("image/jpeg", Cursor::new(ALGORITHM_UNSUPPORTED))
            .await
            .unwrap();

        let results = reader.validation_results().unwrap();
        let failure = results
            .active_manifest()
            .unwrap()
            .failure()
            .iter()
            .find(|s| s.code() == "cawg.x509.algorithm.unsupported")
            .unwrap();

        assert!(
            failure
                .url()
                .is_some_and(|u| u.ends_with("/c2pa.assertions/cawg.identity")),
            "{:?}",
            failure.url()
        );
    }

    #[c2pa_test_async]
    async fn test_cawg_validate_with_hard_binding_missing() {
        let mut stream = Cursor::new(NO_HARD_BINDING);

        let reader = Reader::default()
            .with_stream_async("image/jpeg", &mut stream)
            .await
            .unwrap();

        assert_eq!(
            reader
                .validation_results()
                .unwrap()
                .active_manifest()
                .unwrap()
                .failure()[0]
                .code(),
            "cawg.identity.hard_binding_missing"
        );
    }
}