| // Copyright 2026 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // https://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| // |
| |
| #include "claim/validator.h" |
| |
| #include <optional> |
| #include <string> |
| #include <vector> |
| |
| #include "absl/log/log.h" |
| #include "absl/status/status.h" |
| #include "absl/status/statusor.h" |
| #include "absl/strings/str_cat.h" |
| #include "absl/strings/string_view.h" |
| #include "absl/types/span.h" |
| #include "cbor/cbor.h" |
| #include "cbor/parse.h" |
| #include "constants/labels.h" |
| #include "constants/status_codes.h" |
| #include "jumbf/box.h" |
| #include "proto/assertion.pb.h" |
| #include "proto/cose_verification_result.pb.h" |
| #include "proto/generator_info.cbor.h" |
| #include "proto/generator_info.pb.h" |
| #include "proto/hashed_uri.pb.h" |
| #include "proto/manifest.pb.h" |
| #include "proto/signature_info.pb.h" |
| #include "uuid/uuid.h" |
| #include "validator/tracker.h" |
| |
| namespace credentio { |
| namespace { |
| |
| using ::credentio::Uuid; |
| using ::jumbf::CborBox; |
| using ::jumbf::SuperBox; |
| |
| constexpr absl::string_view kSignatureKey = "signature"; |
| constexpr absl::string_view kInstanceIdKey = "instanceID"; |
| constexpr absl::string_view kClaimSignatureUri = "self#jumbf=c2pa.signature"; |
| constexpr absl::string_view kClaimSignatureUriPrefix = "self#jumbf="; |
| constexpr absl::string_view kClaimSignatureUriSuffix = "c2pa.signature"; |
| constexpr absl::string_view kClaimGeneratorInfoKey = "claim_generator_info"; |
| constexpr absl::string_view kClaimGeneratorV1Key = "claim_generator"; |
| constexpr absl::string_view kCreatedAssertionsKey = "created_assertions"; |
| constexpr absl::string_view kGatheredAssertionsKey = "gathered_assertions"; |
| constexpr absl::string_view kRedactedAssertionsKey = "redacted_assertions"; |
| constexpr absl::string_view kUrlKey = "url"; |
| constexpr absl::string_view kHashKey = "hash"; |
| constexpr absl::string_view kAlgKey = "alg"; |
| |
| absl::StatusOr<absl::string_view> ExtractCbor(const SuperBox* box) { |
| // Check if the box only contains a single CBOR content type. |
| if (box->contents.size() != 1) { |
| return absl::InvalidArgumentError( |
| "box contains zero or more than one contents"); |
| } |
| const auto& content = box->contents[0]; |
| if (!content.Holds<CborBox>()) { |
| return absl::InvalidArgumentError("box does not contain a CBOR content"); |
| } |
| return content.Get<CborBox>().payload; |
| } |
| |
| // See hashed-uri-map in |
| // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_uri_references. |
| struct HashedUriInternal { |
| absl::string_view url; |
| absl::string_view hash; |
| std::optional<absl::string_view> algorithm; |
| |
| HashedUri ToProto(std::optional<absl::string_view> default_algorithm) const { |
| HashedUri hashed_uri; |
| hashed_uri.set_url(url); |
| hashed_uri.set_hash(hash); |
| hashed_uri.set_algorithm( |
| algorithm.value_or(default_algorithm.value_or(""))); |
| return hashed_uri; |
| } |
| }; |
| |
| std::vector<HashedUriInternal> ParseHashedUriArray( |
| const cbor::ArrayView& uri_array, ValidationTracker& validation_tracker) { |
| std::vector<HashedUriInternal> assertions; |
| for (int i = 0; i < uri_array.size(); ++i) { |
| auto uri_map = uri_array.GetMap(i); |
| if (!uri_map.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing, |
| {}); |
| continue; |
| } |
| auto url = uri_map->GetString(kUrlKey); |
| if (!url.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing, |
| {}); |
| continue; |
| } |
| auto hash = uri_map->GetByteString(kHashKey); |
| if (!hash.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing, |
| {.url = *url}); |
| continue; |
| } |
| auto algorithm = uri_map->GetOptionalString(kAlgKey); |
| assertions.push_back( |
| HashedUriInternal{.url = *url, .hash = *hash, .algorithm = algorithm}); |
| } |
| return assertions; |
| } |
| |
| GeneratorInfo CreateGeneratorInfo(const cbor::MapView& cbor_map, |
| absl::string_view claim_box_label, |
| ValidationTracker& validation_tracker) { |
| GeneratorInfo generator_info; |
| auto holder = cbor_map.GetOptionalMap(kClaimGeneratorInfoKey); |
| if (holder.has_value()) { |
| auto info_map = *holder; |
| auto status = cbor::ToProto(info_map, &generator_info); |
| if (!status.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid, |
| {.url = claim_box_label}); |
| } |
| } else if (auto v1_generator = |
| cbor_map.GetOptionalString(kClaimGeneratorV1Key); |
| v1_generator.has_value()) { |
| // The v1 `claim_generator` is formatted as a User-Agent string, but |
| // that's at least arguably a "human readable string naming the claim |
| // generator" as the `name` field is described. |
| generator_info.set_name(*v1_generator); |
| } else { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kClaimMalformed, |
| {.url = claim_box_label, |
| .explanation = "Claim is missing generator info"}); |
| } |
| return generator_info; |
| } |
| |
| struct CreateClaimOptions { |
| const std::string label; |
| const cbor::MapView& claim_map; |
| const absl::Span<HashedUri> created_assertions; |
| const absl::Span<HashedUri> gathered_assertions; |
| const absl::Span<std::string> redacted_assertions; |
| const std::optional<SignatureInfo> signature_info; |
| std::optional<std::string> default_algorithm; |
| }; |
| |
| Claim CreateClaim(const CreateClaimOptions& options, |
| ValidationTracker& validation_tracker) { |
| // Construct the claim proto. |
| Claim claim; |
| claim.set_label(options.label); |
| // Add instance ID. |
| std::optional<absl::string_view> instance_id = |
| options.claim_map.GetOptionalString(kInstanceIdKey); |
| if (instance_id.has_value()) { |
| claim.set_instance_id(*instance_id); |
| } else { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kClaimMalformed, |
| {.url = options.label, .explanation = "Claim is missing instance ID"}); |
| } |
| // Add signature info. |
| if (options.signature_info.has_value()) { |
| *claim.mutable_signature_info() = *options.signature_info; |
| } |
| // Add generator info. |
| *claim.mutable_claim_generator_info() = |
| CreateGeneratorInfo(options.claim_map, options.label, validation_tracker); |
| // Add assertion URIs |
| claim.mutable_created_assertions()->Add(options.created_assertions.begin(), |
| options.created_assertions.end()); |
| claim.mutable_gathered_assertions()->Add(options.gathered_assertions.begin(), |
| options.gathered_assertions.end()); |
| claim.mutable_redacted_assertions()->Add(options.redacted_assertions.begin(), |
| options.redacted_assertions.end()); |
| if (options.default_algorithm.has_value()) { |
| claim.set_default_algorithm(*options.default_algorithm); |
| } |
| return claim; |
| } |
| |
| bool IsClaimBox(absl::string_view label, const Uuid& uuid) { |
| if (label == kClaimV2Label && uuid == kClaimUuid) { |
| return true; |
| } |
| if (label == kClaimV1Label && uuid == kClaimUuid) { |
| return true; |
| } |
| return false; |
| } |
| |
| bool IsLegacyClaimBox(absl::string_view label, const Uuid& uuid) { |
| return label == kClaimV1Label && uuid == kClaimUuid; |
| } |
| |
| // Checks if the signature URI is a self#jumbf location in the current manifest |
| // that is being processed (provided in the `manifest` parameter). |
| bool IsValidSignatureUri(absl::string_view uri, |
| const jumbf::SuperBox& manifest) { |
| // Check if the signature URI is a relative URI first for efficiency. |
| if (uri == kClaimSignatureUri) { |
| return true; |
| } |
| return manifest.description.label.has_value() && |
| uri == absl::StrCat(kClaimSignatureUriPrefix, "/c2pa/", |
| *manifest.description.label, "/", |
| kClaimSignatureUriSuffix); |
| } |
| |
| } // namespace |
| |
| std::optional<Claim> ClaimValidatorImpl::Validate( |
| const jumbf::SuperBox& manifest, |
| ValidationTracker& validation_tracker) const { |
| std::optional<const jumbf::SuperBox*> claim_box; |
| std::string claim_box_label = ""; |
| std::optional<const jumbf::SuperBox*> claim_signature_box; |
| |
| // Parse manifest boxes to find the claim and claim signature boxes. |
| for (const auto& box : manifest.contents) { |
| if (box.Holds<SuperBox>()) { |
| const auto& description = box.Get<SuperBox>().description; |
| absl::string_view label = description.label.value_or(""); |
| Uuid uid = description.type_uuid; |
| if (IsLegacyClaimBox(label, uid)) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kGoogleUnsupportedSpecVersion, |
| {.url = label, .explanation = "Unsupported claim version"}); |
| return std::nullopt; |
| } |
| if (IsClaimBox(label, uid)) { |
| if (claim_box.has_value()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimMultiple, |
| {.url = label}); |
| } |
| if (box.Holds<SuperBox>()) { |
| claim_box = &box.Get<SuperBox>(); |
| claim_box_label = label; |
| } |
| } else if (label == kClaimSignatureLabel && uid == kClaimSignatureUuid) { |
| if (box.Holds<SuperBox>()) { |
| claim_signature_box = &box.Get<SuperBox>(); |
| } |
| } |
| } |
| } |
| |
| // Claim box should be present and it shall consist of a single CBOR content |
| // type. Extract the CBOR content type from the claim box if these |
| // conditions are met, otherwise update the failure status. |
| |
| absl::string_view claim_cbor = ""; |
| if (!claim_box.has_value()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimMissing, |
| {.url = claim_box_label}); |
| } else { |
| auto result = ExtractCbor(*claim_box); |
| if (!result.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid, |
| {.url = claim_box_label}); |
| } else { |
| claim_cbor = *result; |
| } |
| } |
| |
| // Parse the claim CBOR as a map and validate the signature field. |
| // The signature URI must refer to a location within the same C2PA Manifest |
| // box (a self#jumbf location). Moreover, since the claim signature label |
| // must be `c2pa.signature`, this implies that the signature URI should be |
| // equal to `self#jumbf:c2pa.signature`. |
| |
| AssertionUris assertion_uris; |
| std::optional<cbor::MapView> claim_map; |
| auto claim_result = cbor::Parse(claim_cbor); |
| if (!claim_result.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid, |
| {.url = claim_box_label}); |
| } else { |
| auto claim_map_result = (*claim_result)->AsMap(); |
| if (!claim_map_result.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid, |
| {.url = claim_box_label}); |
| } else { |
| // Validate the signature field. |
| claim_map = *claim_map_result; |
| auto signature_uri = claim_map->GetString(kSignatureKey); |
| if (!signature_uri.ok()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kClaimMalformed, |
| {.url = claim_box_label, |
| .explanation = "Claim is missing signature URI"}); |
| } else if (!IsValidSignatureUri(*signature_uri, manifest)) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kClaimSignatureMissing, |
| {.url = kClaimSignatureLabel}); |
| } |
| // Extract the optional `alg` field. |
| assertion_uris.default_algorithm = claim_map->GetOptionalString(kAlgKey); |
| // Extract the required `created_assertions` field. |
| assertion_uris.created_assertions = |
| claim_map->GetOptionalArray(kCreatedAssertionsKey); |
| // Extract the optional `gathered_assertions` field. |
| assertion_uris.gathered_assertions = |
| claim_map->GetOptionalArray(kGatheredAssertionsKey); |
| // Extract the optional `redacted_assertions` field |
| assertion_uris.redacted_assertions = |
| claim_map->GetOptionalArray(kRedactedAssertionsKey); |
| // Confirm that the `created_assertions` field is set. This field is |
| // required in c2pa.claim.v2. |
| if (!assertion_uris.created_assertions.has_value()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kClaimMalformed, |
| {.url = claim_box_label, |
| .explanation = "Claim is missing created assertions"}); |
| } |
| } |
| } |
| |
| // Claim signature box should be present and it shall consist of a single |
| // CBOR content type. Extract the CBOR content type from the claim signature |
| // box if these conditions are met, otherwise update the failure status. |
| |
| std::optional<absl::string_view> claim_signature_cbor; |
| if (!claim_signature_box.has_value()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing, |
| {.url = kClaimSignatureLabel}); |
| } else { |
| auto result = ExtractCbor(*claim_signature_box); |
| if (!result.ok()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kClaimSignatureMissing, |
| {.url = kClaimSignatureLabel}); |
| } else { |
| claim_signature_cbor = *result; |
| } |
| } |
| |
| std::optional<SignatureInfo> signature_info; |
| if (claim_signature_cbor.has_value() && !claim_cbor.empty()) { |
| CoseVerificationResult verification_result = |
| cose_verifier_.Verify(*claim_signature_cbor, claim_cbor); |
| if (verification_result.has_signature_info()) { |
| signature_info = verification_result.signature_info(); |
| } |
| validation_tracker.MergeStatuses(verification_result.validation_status()); |
| if (!verification_result.has_signature_info() && |
| !validation_tracker.HasFailures()) { |
| LOG_EVERY_N_SEC(ERROR, 60) << "Signature verification " |
| "failed but no failure was recorded"; |
| validation_tracker.RecordFailure(FailureStatusCode::kGoogleInternalError, |
| {.url = kClaimSignatureLabel}); |
| } |
| } else { |
| validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing, |
| {.url = kClaimSignatureLabel}); |
| } |
| |
| ClaimValidatorImpl::ValidateAssertionResult validate_assertion_result; |
| if (claim_box.has_value()) { |
| validate_assertion_result = |
| ValidateAssertions(assertion_uris, validation_tracker); |
| } |
| |
| if (validation_tracker.HasFailures()) { |
| return std::nullopt; |
| } |
| |
| auto options = CreateClaimOptions{ |
| .label = |
| claim_box.has_value() |
| ? std::string(claim_box.value()->description.label.value_or("")) |
| : "", |
| .claim_map = *claim_map, |
| .created_assertions = |
| absl::MakeSpan(validate_assertion_result.created_assertions), |
| .gathered_assertions = |
| absl::MakeSpan(validate_assertion_result.gathered_assertions), |
| .redacted_assertions = |
| absl::MakeSpan(validate_assertion_result.redacted_assertions), |
| .signature_info = signature_info, |
| .default_algorithm = assertion_uris.default_algorithm, |
| }; |
| return CreateClaim(options, validation_tracker); |
| } |
| |
| ClaimValidatorImpl::ValidateAssertionResult |
| ClaimValidatorImpl::ValidateAssertions( |
| const AssertionUris& assertion_uris, |
| ValidationTracker& validation_tracker) const { |
| // Parse CBOR maps in hashed URI arrays |
| std::vector<HashedUriInternal> created_assertions, gathered_assertions; |
| ClaimValidatorImpl::ValidateAssertionResult result; |
| if (assertion_uris.created_assertions.has_value()) { |
| created_assertions = ParseHashedUriArray(*assertion_uris.created_assertions, |
| validation_tracker); |
| } |
| if (assertion_uris.gathered_assertions.has_value()) { |
| gathered_assertions = ParseHashedUriArray( |
| *assertion_uris.gathered_assertions, validation_tracker); |
| } |
| for (const auto& a : created_assertions) { |
| result.created_assertions.push_back( |
| a.ToProto(assertion_uris.default_algorithm)); |
| } |
| for (const auto& a : gathered_assertions) { |
| result.gathered_assertions.push_back( |
| a.ToProto(assertion_uris.default_algorithm)); |
| } |
| std::vector<std::string> redacted_assertions; |
| if (assertion_uris.redacted_assertions.has_value()) { |
| for (int i = 0; i < assertion_uris.redacted_assertions->size(); ++i) { |
| auto uri = assertion_uris.redacted_assertions->GetString(i); |
| if (!uri.ok()) { |
| validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing, |
| {}); |
| } else { |
| redacted_assertions.push_back(std::string(*uri)); |
| } |
| } |
| } |
| result.redacted_assertions = redacted_assertions; |
| return result; |
| } |
| |
| } // namespace credentio |