| // 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/absl_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/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 IsLegacyClaimBox(absl::string_view label, const Uuid& uuid) { | |
| return label == kClaimV1Label && uuid == kClaimUuid; | |
| } | |
| bool IsClaimBox(absl::string_view label, const Uuid& uuid) { | |
| return (label == kClaimV2Label && uuid == kClaimUuid) || | |
| IsLegacyClaimBox(label, uuid); | |
| } | |
| // 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>()) { | |
| continue; | |
| } | |
| const SuperBox& super_box = box.Get<SuperBox>(); | |
| const auto& description = super_box.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}); | |
| return std::nullopt; | |
| } | |
| claim_box = &super_box; | |
| claim_box_label = label; | |
| } else if (label == kClaimSignatureLabel && uid == kClaimSignatureUuid) { | |
| claim_signature_box = &super_box; | |
| } | |
| } | |
| // 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}); | |
| return std::nullopt; | |
| } 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()) { | |
| ABSL_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 = | |
| ValidateAssertions(assertion_uris, validation_tracker); | |
| if (validation_tracker.HasFailures()) { | |
| return std::nullopt; | |
| } | |
| auto options = CreateClaimOptions{ | |
| .label = claim_box_label, | |
| .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 { | |
| ClaimValidatorImpl::ValidateAssertionResult result; | |
| if (assertion_uris.created_assertions.has_value()) { | |
| for (const auto& a : ParseHashedUriArray(*assertion_uris.created_assertions, | |
| validation_tracker)) { | |
| result.created_assertions.push_back( | |
| a.ToProto(assertion_uris.default_algorithm)); | |
| } | |
| } | |
| if (assertion_uris.gathered_assertions.has_value()) { | |
| for (const auto& a : ParseHashedUriArray( | |
| *assertion_uris.gathered_assertions, validation_tracker)) { | |
| result.gathered_assertions.push_back( | |
| a.ToProto(assertion_uris.default_algorithm)); | |
| } | |
| } | |
| 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 { | |
| result.redacted_assertions.emplace_back(*uri); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| } // namespace credentio |