Public release GitOrigin-RevId: bc0df9de9abe54f0c2d7dc3522d8b1f4f6675021 Change-Id: I617787e963f4bb77d849fcae87aab5b94db39eb2
diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000..a9dda58 --- /dev/null +++ b/.bazelrc
@@ -0,0 +1,13 @@ +# Default C++ version +build --cxxopt=-std=c++20 + +# Force Clang to use LLVM's libc++ instead of the old system libstdc++ +build --cxxopt=-stdlib=libc++ +build --linkopt=-stdlib=libc++ + +# Mac-specific setting +build --macos_minimum_os=10.13 + +# Use clang for C++ compilation +build --repo_env=CC=clang +build --repo_env=CXX=clang++ \ No newline at end of file
diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 0000000..12f0996 --- /dev/null +++ b/BUILD.bazel
@@ -0,0 +1,15 @@ +# 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. +# +
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e8f2e04 --- /dev/null +++ b/CONTRIBUTING.md
@@ -0,0 +1,30 @@ +# How to contribute + +We'd love to accept your patches and contributions to this project. + +## Before you begin + +### Sign our Contributor License Agreement + +Contributions to this project must be accompanied by a +[Contributor License Agreement](https://cla.developers.google.com/about) (CLA). +You (or your employer) retain the copyright to your contribution; this simply +gives us permission to use and redistribute your contributions as part of the +project. + +If you or your current employer have already signed the Google CLA (even if it +was for a different project), you probably don't need to do it again. + +Visit <https://cla.developers.google.com/> to see your current agreements or to +sign a new one. + +### Review our community guidelines + +This project follows +[Google's Open Source Community Guidelines](https://opensource.google/conduct/). + +## Contribution process + +### Code reviews + +All submissions, including submissions by project members, require review.
diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE
@@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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.
diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 0000000..0c82647 --- /dev/null +++ b/MODULE.bazel
@@ -0,0 +1,68 @@ +# 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. +# + +module( + name = "c2pa", + version = "0.1.0", +) + +bazel_dep(name = "abseil-cpp", version = "20260526.0") +bazel_dep(name = "googletest", version = "1.17.0.bcr.2") +bazel_dep(name = "protobuf", version = "34.1") +bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "rules_foreign_cc", version = "0.15.1") +bazel_dep(name = "riegeli", version = "0.0.0-20250822-9f2744d") +bazel_dep(name = "nlohmann_json", version = "3.12.0.bcr.1") +bazel_dep(name = "boringssl", version = "0.20260413.0") +bazel_dep(name = "tink_cc", version = "2.7.0") +bazel_dep(name = "re2", version = "2025-11-05.bcr.1") + +# Add rules_android and its dependencies +bazel_dep(name = "rules_java", version = "9.6.1") # Often needed by rules_android +bazel_dep(name = "bazel_skylib", version = "1.9.0") # Often needed by rules_android +bazel_dep(name = "rules_android", version = "0.7.1") + +single_version_override( + module_name = "rules_go", + version = "0.60.0", +) + +# Configure Android SDK +remote_android_extensions = use_extension( + "@rules_android//bzlmod_extensions:android_extensions.bzl", + "remote_android_tools_extensions", +) +use_repo(remote_android_extensions, "android_tools") + +android_sdk_repository_extension = use_extension("@rules_android//rules/android_sdk_repository:rule.bzl", "android_sdk_repository_extension") +use_repo(android_sdk_repository_extension, "androidsdk") + +register_toolchains("@androidsdk//:sdk-toolchain", "@androidsdk//:all") + +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "libcppbor", + build_file = "@//:external/libcppbor.BUILD", + urls = ["https://android.googlesource.com/platform/system/libcppbor/+archive/refs/heads/main.tar.gz"], +) + +archive_override( + module_name = "boringssl", + patch_strip = 1, + patches = ["@//:external/boringssl_pki_visibility.patch"], + strip_prefix = "boringssl-0.20260413.0", + urls = ["https://github.com/google/boringssl/archive/0.20260413.0.tar.gz"], +)
diff --git a/README.md b/README.md new file mode 100644 index 0000000..7612f3c --- /dev/null +++ b/README.md
@@ -0,0 +1,99 @@ +# Project Credentio + +C++ libraries to support validation and generation of C2PA Content Credentials +(https://c2pa.org/). + +NOTE: We are actively developing this project and may introduce breaking changes +without notice. + +## Supported Media Formats + +The library supports C2PA provenance extraction and validation for files with +the following extensions: + +| Category | Extensions | +| :-------------- | :-------------------------------------------------- | +| **Image** | `.avif`, `.dng`, `.gif`, `.heic`, `.heif`, `.jpeg`, | +: : `.jpg`, `.png`, `.tif`, `.tiff`, `.webp` : +| **Video/Audio** | `.m4a`, `.mov`, `.mp3`, `.mp4`, `.wav`, `.flac` | +| **Document** | `.pdf`, `.docx`, `.pptx`, `.xlsx` | + +## Prerequisites + +* Clang +* Bazel +* Git + +## Dependencies + +* [Abseil (`abseil-cpp`)](https://abseil.io/) +* [Riegeli](https://github.com/google/riegeli) +* [BoringSSL](https://github.com/google/boringssl) +* [GoogleTest (`googletest`)](https://github.com/google/googletest) +* [Google Tink (`tink_cc`)](https://github.com/tink-crypto/tink-cc) +* [LibCppBor](https://android.googlesource.com/platform/system/libcppbor/) +* [nlohmann_json](https://github.com/nlohmann/json) +* [Protocol Buffers (`protobuf`)](https://github.com/protocolbuffers/protobuf) + +## Quickstart + +Project Credentio includes a fast standalone command-line tool for inspecting +and verifying C2PA metadata embedded in media assets. + +#### Building the CLI + +```bash +bazel build tools:c2pa_validate +``` + +#### Validating with Trust Anchors + +You can validate assets against specific PEM-encoded trust anchors for claim +signers and Time Stamping Authorities (TSA): + +```bash +bazel run tools:c2pa_validate -- \ + --asset=/path/to/asset.jpg \ + --claim_signer_trust=/path/to/claim_signer_trust_anchors.pem \ + --tsa_trust=/path/to/tsa_trust_anchors.pem +``` + +> **Note on Trust Lists:** Project Credentio does not distribute or provide +> trust anchor lists. You can obtain the latest official C2PA trust lists from +> the C2PA organization on GitHub +> ([https://github.com/c2pa-org/conformance-public/tree/main/trust-list](https://github.com/c2pa-org/conformance-public/tree/main/trust-list)). + +## Building & Testing + +To build the entire project: + +```bash +bazel build ... +``` + +To execute all unit tests: + +```bash +bazel test ... +``` + +## Support & Releases + +Project Credentio recommends that users **live-at-head** (updating to the latest +commit on the main branch as often as possible). We are actively developing this +project and may introduce breaking changes without notice. + +## License & Disclaimer + +Project Credentio is licensed under the terms of the Apache 2.0 License. See +[LICENSE](LICENSE) for more information. + +### Disclaimer + +This is not an officially supported Google product. This project is not eligible +for the +[Google Open Source Software Vulnerability Rewards Program](https://bughunters.google.com/open-source-security). + +## Contact + +For questions, feedback, or inquiries, please contact: `c2pa-core@google.com`.
diff --git a/assertion/BUILD b/assertion/BUILD new file mode 100644 index 0000000..a7250bb --- /dev/null +++ b/assertion/BUILD
@@ -0,0 +1,405 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "parse_assertion", + srcs = ["parse_assertion.cc"], + hdrs = ["parse_assertion.h"], + deps = [ + "//cbor:parse", + "//proto:actions_assertion_cbor_proto", + "//proto:actions_assertion_cc_proto", + "//proto:ai_disclosure_assertion_cbor_proto", + "//proto:ai_disclosure_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:asset_reference_assertion_cbor_proto", + "//proto:asset_reference_assertion_cc_proto", + "//proto:asset_types_assertion_cbor_proto", + "//proto:asset_types_assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cbor_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cbor_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cbor_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cbor_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:environmental_sustainability_assertion_cbor_proto", + "//proto:environmental_sustainability_assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cbor_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:metadata_assertion_cc_proto", + "//proto:multi_asset_hash_assertion_cbor_proto", + "//proto:multi_asset_hash_assertion_cc_proto", + "//proto:repository_receipt_assertion_cc_proto", + "//proto:soft_binding_assertion_cbor_proto", + "//proto:soft_binding_assertion_cc_proto", + "//proto:thumbnail_assertion_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings:string_view", + "@nlohmann_json//:json", + "@protobuf//:struct_cc_proto", + ], +) + +cc_test( + name = "parse_assertion_test", + srcs = ["parse_assertion_test.cc"], + deps = [ + ":parse_assertion", + "//proto:actions_assertion_cc_proto", + "//proto:ai_disclosure_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:asset_reference_assertion_cc_proto", + "//proto:asset_types_assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:environmental_sustainability_assertion_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:repository_receipt_assertion_cc_proto", + "//testing:cbor_utils", + "//testing:proto_test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@nlohmann_json//:json", + "@protobuf//:struct_cc_proto", + ], +) + +cc_library( + name = "assertion_encoder", + srcs = ["assertion_encoder.cc"], + hdrs = ["assertion_encoder.h"], + deps = [ + "//cbor", + "//jumbf:box_builder", + "//jumbf:constants", + "//proto:actions_assertion_cbor_proto", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cbor_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cbor_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cbor_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cbor_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:ingredient_assertion_cbor_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:multi_asset_hash_assertion_cbor_proto", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "assertion_encoder_test", + srcs = ["assertion_encoder_test.cc"], + deps = [ + ":assertion_encoder", + ":assertion_parser", + "//cbor", + "//cbor:parse", + "//jumbf:box", + "//jumbf:parse", + "//proto:actions_assertion_cbor_proto", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cbor_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cbor_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cbor_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:ingredient_assertion_cbor_proto", + "//proto:ingredient_assertion_cc_proto", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "hashed_uri_validator", + srcs = ["hashed_uri_validator.cc"], + hdrs = ["hashed_uri_validator.h"], + deps = [ + "//constants:status_codes", + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "//jumbf:uri", + "//jumbf:utils", + "//proto:hashed_uri_cc_proto", + "//validator:tracker", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "hashed_uri_validator_test", + srcs = ["hashed_uri_validator_test.cc"], + deps = [ + ":hashed_uri_validator", + "//constants:status_codes", + "//crypto:algorithms", + "//crypto:hash", + "//jumbf:box", + "//jumbf:uri", + "//proto:hashed_uri_cc_proto", + "//testing:jumbf_utils", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "references_validator", + srcs = ["references_validator.cc"], + hdrs = ["references_validator.h"], + deps = [ + ":assertion_parser", + ":hashed_uri_validator", + "//constants:labels", + "//constants:status_codes", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//validator:tracker", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "references_validator_test", + srcs = ["references_validator_test.cc"], + deps = [ + ":hashed_uri_validator", + ":references_validator", + "//crypto:hash", + "//crypto/default:hasher", + "//jumbf:box", + "//jumbf:uri", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//testing:jumbf_utils", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "assertion_parser", + srcs = ["assertion_parser.cc"], + hdrs = ["assertion_parser.h"], + deps = [ + ":parse_assertion", + "//constants:labels", + "//constants:status_codes", + "//proto:assertion_cc_proto", + "//validator:tracker", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@protobuf//:struct_cc_proto", + ], +) + +cc_test( + name = "assertion_parser_test", + srcs = ["assertion_parser_test.cc"], + deps = [ + ":assertion_parser", + "//constants:status_codes", + "//proto:assertion_cc_proto", + "//testing:cbor_utils", + "//testing:jumbf_utils", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "//validator:tracker", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "actions_assertion_validator", + srcs = ["actions_assertion_validator.cc"], + hdrs = ["actions_assertion_validator.h"], + deps = [ + "//constants:ingredient_relationships", + "//constants:labels", + "//constants:status_codes", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//validator:tracker", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "actions_assertion_validator_test", + srcs = ["actions_assertion_validator_test.cc"], + deps = [ + ":actions_assertion_validator", + "//constants:status_codes", + "//proto:manifest_cc_proto", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "assertion_referencer", + srcs = ["assertion_referencer.cc"], + hdrs = ["assertion_referencer.h"], + deps = [ + "//crypto:hash", + "//jumbf:box_builder", + "//jumbf:utils", + "//proto:assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + ], +) + +cc_test( + name = "assertion_referencer_test", + srcs = ["assertion_referencer_test.cc"], + deps = [ + ":assertion_referencer", + "//crypto:mock_hash", + "//jumbf:box_builder", + "//jumbf:constants", + "//jumbf:test_utils", + "//proto:assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "validator", + srcs = ["validator.cc"], + hdrs = ["validator.h"], + deps = [ + ":actions_assertion_validator", + ":assertion_parser", + ":hashed_uri_validator", + ":references_validator", + "//constants:ingredient_relationships", + "//constants:labels", + "//constants:status_codes", + "//jumbf:box", + "//jumbf:uri", + "//proto:actions_assertion_cc_proto", + "//proto:assertion_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//validator:tracker", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@protobuf//:protobuf_lite", + ], +) + +cc_test( + name = "validator_test", + srcs = ["validator_test.cc"], + deps = [ + ":validator", + "//constants:status_codes", + "//jumbf:box", + "//jumbf:uri", + "//proto:assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:manifest_cc_proto", + "//testing:cbor_utils", + "//testing:jumbf_utils", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + ], +)
diff --git a/assertion/actions_assertion_validator.cc b/assertion/actions_assertion_validator.cc new file mode 100644 index 0000000..97d627d --- /dev/null +++ b/assertion/actions_assertion_validator.cc
@@ -0,0 +1,269 @@ +// 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 "assertion/actions_assertion_validator.h" + +#include <string> + +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "constants/ingredient_relationships.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +namespace { + +absl::string_view Basename(absl::string_view path) { + return path.substr(path.find_last_of('/') + 1); +} + +bool RequiresParentOfRelationship(absl::string_view action) { + return action == "c2pa.opened" || action == "c2pa.repackaged" || + action == "c2pa.transcoded"; +} + +bool RequiresComponentOfRelationship(absl::string_view action) { + return action == "c2pa.placed" || action == "c2pa.removed"; +} + +bool RequiresIngredientValidation(absl::string_view action) { + return RequiresParentOfRelationship(action) || + RequiresComponentOfRelationship(action); +} + +template <class IngredientAssertionType> +bool ValidateIngredientRelationship(absl::string_view action, + const IngredientAssertionType& ingredient, + absl::string_view assertion_url, + ValidationTracker& validation_tracker) { + // 3.c.ii.A. Check that the ingredient relationship is parentOf. + if (RequiresParentOfRelationship(action)) { + if (ingredient.relationship() != kIngredientRelationshipParentOf) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionIngredientMismatch, + {.url = assertion_url, + .explanation = absl::StrFormat( + "action %s has wrong ingredient relationship: %s; want %s", + action, ingredient.relationship(), + kIngredientRelationshipParentOf)}); + return false; + } + } + // 3.c.ii.B. Check that the ingredient relationship is componentOf. + if (RequiresComponentOfRelationship(action)) { + if (ingredient.relationship() != kIngredientRelationshipComponentOf) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionIngredientMismatch, + {.url = assertion_url, + .explanation = absl::StrFormat( + "action %s has wrong ingredient relationship: %s; " + "want %s", + action, ingredient.relationship(), + kIngredientRelationshipComponentOf)}); + return false; + } + } + return true; +} + +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_actions_validation. +template <class ActionsAssertionType> +bool ValidateActions( + const ActionsAssertionType& assertion, + absl::flat_hash_map<std::string, const Assertion*> assertion_store, + ValidationTracker& validation_tracker) { + // 1. Ensure that it has an actions field. + if (assertion.actions().empty()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, {}); + return false; + } + for (auto i = 0; i < assertion.actions().size(); ++i) { + const auto& action = assertion.actions(i); + // 3.a. Ensure that it has an action field. + if (action.action().empty()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.explanation = "empty action"}); + return false; + } + // 3.b. If the action field is c2pa.created or c2pa.opened, it must be the + // first action. + if (action.action() == "c2pa.created" || action.action() == "c2pa.opened") { + if (i != 0) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.explanation = "created/opened not first action"}); + return false; + } + } + // 3.c. Check ingredient for certain actions. + if (RequiresIngredientValidation(action.action())) { + // 3.c.i. Check ingredients are present. + if (!action.has_parameters()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionIngredientMismatch, + {.explanation = absl::StrFormat("action %s missing parameters", + action.action())}); + return false; + } + if (action.parameters().ingredients().size() == 0) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionIngredientMismatch, + {.explanation = absl::StrFormat("action %s missing ingredients", + action.action())}); + return false; + } + // 3.c.ii. For each ingredient, check that it has a url and it is valid. + for (const auto& ingredient : action.parameters().ingredients()) { + if (!ingredient.has_url() || ingredient.url().empty()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionIngredientMismatch, + {.explanation = absl::StrFormat( + "action %s missing ingredient url", action.action())}); + return false; + } + auto label = Basename(ingredient.url()); + if (!assertion_store.contains(label)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionIngredientMismatch, + {.explanation = absl::StrFormat( + "action %s missing ingredient assertion", action.action())}); + return false; + } + auto* assertion = assertion_store[label]; + std::string assertion_url = absl::StrCat( + kAssertionStoreLabel, kManifestLabelDelimiter, assertion->label()); + if (assertion->has_ingredient_v3()) { + if (!ValidateIngredientRelationship( + action.action(), assertion->ingredient_v3(), assertion_url, + validation_tracker)) { + return false; + } + } else { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionIngredientMismatch, + {.url = assertion_url, + .explanation = absl::StrFormat( + "action %s missing ingredient assertion", action.action())}); + return false; + } + } + } + // 3.d. If the action is c2pa.redacted, check that the redacted field that + // is a member of the parameters object for the presence of a JUMBF URI and + // that the URI resolves to an assertion. + if (action.action() == "c2pa.redacted") { + if (action.parameters().redacted_uri().empty()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionRedactionMismatch, + {.explanation = absl::StrFormat( + "action %s missing redacted in parameters", action.action())}); + return false; + } + auto redacted_label = Basename(action.parameters().redacted_uri()); + if (!assertion_store.contains(redacted_label)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionRedactionMismatch, + {.explanation = absl::StrFormat( + "action %s missing redacted assertion", action.action())}); + return false; + } + } + } + return true; +} + +// 2. Check the first action in each actions assertion. +template <class ActionsAssertionType> +bool ValidateFirstAction(absl::string_view label, + const ActionsAssertionType& assertion, + bool is_first_action_assertion, + ValidationTracker& validation_tracker) { + bool is_created_or_opened = + assertion.actions_size() > 0 && + (assertion.actions(0).action() == "c2pa.created" || + assertion.actions(0).action() == "c2pa.opened"); + if (is_first_action_assertion && !is_created_or_opened) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.explanation = "the first action is not c2pa.created or c2pa.opened"}); + return false; + } + if (is_created_or_opened && !is_first_action_assertion) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.explanation = "c2pa.created or c2pa.opened is not the first action"}); + return false; + } + return true; +} + +} // namespace + +bool ActionsAssertionValidator::Validate( + const Manifest& manifest, ValidationTracker& validation_tracker) const { + absl::flat_hash_map<std::string, const Assertion*> assertion_store; + for (const auto& assertion : manifest.assertions()) { + assertion_store[assertion.label()] = &assertion; + } + absl::flat_hash_set<std::string> created_assertions; + created_assertions.reserve(manifest.claim().created_assertions_size()); + for (const auto& assertion : manifest.claim().created_assertions()) { + created_assertions.insert(std::string(Basename(assertion.url()))); + } + bool is_first_action_assertion = true; + auto validate_actions_assertion = [&](absl::string_view label, + const auto& actions_assertion) -> bool { + if (!ValidateActions(actions_assertion, assertion_store, + validation_tracker)) { + return false; + } + if (created_assertions.contains(label) && + !ValidateFirstAction(label, actions_assertion, + is_first_action_assertion, validation_tracker)) { + return false; + } + is_first_action_assertion = false; + return true; + }; + + for (const auto& assertion : manifest.assertions()) { + if (assertion.has_actions_v1() && + !validate_actions_assertion(assertion.label(), + assertion.actions_v1())) { + return false; + } + if (assertion.has_actions() && + !validate_actions_assertion(assertion.label(), assertion.actions())) { + return false; + } + } + return true; +} + +} // namespace credentio
diff --git a/assertion/actions_assertion_validator.h b/assertion/actions_assertion_validator.h new file mode 100644 index 0000000..5e57523 --- /dev/null +++ b/assertion/actions_assertion_validator.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_ACTIONS_ASSERTION_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_ACTIONS_ASSERTION_VALIDATOR_H_ + +#include "proto/actions_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +// Validator of actions assertions according to +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_actions_validation. +class ActionsAssertionValidator { + public: + bool Validate(const Manifest& manifest, + ValidationTracker& validation_tracker) const; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_ACTIONS_ASSERTION_VALIDATOR_H_
diff --git a/assertion/actions_assertion_validator_test.cc b/assertion/actions_assertion_validator_test.cc new file mode 100644 index 0000000..3bd1bc2 --- /dev/null +++ b/assertion/actions_assertion_validator_test.cc
@@ -0,0 +1,902 @@ +// 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 "assertion/actions_assertion_validator.h" + +#include <string> + +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/manifest.pb.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" + +namespace credentio { +namespace { + +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::IsEmpty; +using ::testing::TestWithParam; +using ::testing::ValuesIn; + +// Returns the appropriate ingredient relationship for the given action. +std::string IngredientRelation(absl::string_view action) { + return (action == "c2pa.placed" || action == "c2pa.removed") ? "componentOf" + : "parentOf"; +} + +TEST(ActionsAssertionValidatorTest, Okay) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" } + } + assertions { + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + } + )pb"); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ActionsAssertionValidatorTest, OkayV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" } + } + assertions { + label: "c2pa.actions" + actions_v1 { actions { action: "c2pa.created" } } + } + )pb"); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ActionsAssertionValidatorTest, NoActions) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { actions {} } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, NoActionsV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { actions_v1 {} } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, CreatedNotFirst) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { + actions { + actions { action: "c2pa.drawing" } + actions { action: "c2pa.created" } + } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, CreatedNotFirstV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { + actions_v1 { + actions { action: "c2pa.drawing" } + actions { action: "c2pa.created" } + } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, OpenedNotFirst) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { + actions { + actions { action: "c2pa.drawing" } + actions { action: "c2pa.opened" } + } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, OpenedNotFirstV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { + actions_v1 { + actions { action: "c2pa.drawing" } + actions { action: "c2pa.opened" } + } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, EmptyAction) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { actions { actions { action: "" } } } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, EmptyActionV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { actions_v1 { actions { action: "" } } } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +struct ActionParam { + std::string action_name; +}; + +using ValidateIngredientTest = TestWithParam<ActionParam>; +TEST_P(ValidateIngredientTest, MissingParameters) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>( + absl::StrFormat(R"pb( + assertions { actions { actions { action: "%s" } } } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, MissingParametersV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>( + absl::StrFormat(R"pb( + assertions { actions_v1 { actions { action: "%s" } } } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, MissingIngredients) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = + ParseTextProtoOrDie<Manifest>(absl::StrFormat(R"pb( + assertions { + actions { + actions { + action: "%s" + parameters {} + } + } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, MissingIngredientsV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = + ParseTextProtoOrDie<Manifest>(absl::StrFormat(R"pb( + assertions { + actions_v1 { + actions { + action: "%s" + parameters {} + } + } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, EmptyIngredientUrl) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>( + absl::StrFormat(R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { ingredients {} } + } + } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, EmptyIngredientUrlV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>( + absl::StrFormat(R"pb( + assertions { + actions_v1 { + actions { + action: "%s" + parameters { ingredients {} } + } + } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, MissingIngredientAssertion) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, WrongAssertionType) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + actions {} + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientTest, NoC2paManifestInIngredientV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "%s" } + } + )pb", + GetParam().action_name, IngredientRelation(GetParam().action_name))); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty()); +} + +TEST_P(ValidateIngredientTest, NoC2paManifestInIngredientV2) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "%s" } + } + )pb", + GetParam().action_name, IngredientRelation(GetParam().action_name))); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty()); +} + +TEST_P(ValidateIngredientTest, NoC2paManifestInIngredientV3) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "%s" } + } + )pb", + GetParam().action_name, IngredientRelation(GetParam().action_name))); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty()); +} + +constexpr ActionParam kActionParams[] = { + {.action_name = "c2pa.opened"}, {.action_name = "c2pa.placed"}, + {.action_name = "c2pa.removed"}, {.action_name = "c2pa.repackaged"}, + {.action_name = "c2pa.transcoded"}, +}; +INSTANTIATE_TEST_SUITE_P(ActionsAssertionValidatorTest, ValidateIngredientTest, + ValuesIn(kActionParams)); + +using ValidateIngredientParentOfTest = TestWithParam<ActionParam>; +TEST_P(ValidateIngredientParentOfTest, Valid) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf", + active_manifest {} + } + } + )pb", + GetParam().action_name)); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty()); +} + +TEST_P(ValidateIngredientParentOfTest, WrongRelationshipIngredientV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "childOf" } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientParentOfTest, WrongRelationshipIngredientV2) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "childOf" } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientParentOfTest, WrongRelationshipIngredientV3) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "childOf" } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} +constexpr ActionParam kParentOfActionParams[] = { + {.action_name = "c2pa.opened"}, + {.action_name = "c2pa.repackaged"}, + {.action_name = "c2pa.transcoded"}, +}; +INSTANTIATE_TEST_SUITE_P(ActionsAssertionValidatorTest, + ValidateIngredientParentOfTest, + ValuesIn(kParentOfActionParams)); + +using ValidateIngredientComponentOfTest = TestWithParam<ActionParam>; +TEST_P(ValidateIngredientComponentOfTest, Valid) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "componentOf", + active_manifest {} + } + } + )pb", + GetParam().action_name)); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty()); +} + +TEST_P(ValidateIngredientComponentOfTest, WrongRelationshipIngredientV1) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "parentOf" } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientComponentOfTest, WrongRelationshipIngredientV2) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "parentOf" } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} + +TEST_P(ValidateIngredientComponentOfTest, WrongRelationshipIngredientV3) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat( + R"pb( + assertions { + actions { + actions { + action: "%s" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { relationship: "parentOf" } + } + )pb", + GetParam().action_name)); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch)); +} +constexpr ActionParam kComponentOfActionParams[] = { + {.action_name = "c2pa.placed"}, + {.action_name = "c2pa.removed"}, +}; +INSTANTIATE_TEST_SUITE_P(ActionsAssertionValidatorTest, + ValidateIngredientComponentOfTest, + ValuesIn(kComponentOfActionParams)); + +TEST(ActionsAssertionValidatorTest, ValidateRedactedActionMissingRedactedUri) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { + actions { + actions { + action: "c2pa.redacted" + parameters {} + } + } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionRedactionMismatch)); +} + +TEST(ActionsAssertionValidatorTest, + ValidateRedactedActionMissingRedactedAssertion) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { + actions { + actions { + action: "c2pa.redacted" + parameters { + redacted_uri: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT( + tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionRedactionMismatch)); +} + +TEST(ActionsAssertionValidatorTest, ValidateRedactedActionOkay) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + assertions { + actions { + actions { + action: "c2pa.redacted" + parameters { + redacted_uri: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + } + } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 {} + } + )pb"); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty()); +} + +TEST(ActionsAssertionValidatorTest, ValidCreatedAssertions) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2__1" + } + } + assertions { + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + } + assertions { + label: "c2pa.actions.v2__1" + actions { actions { action: "c2pa.resized" } } + } + )pb"); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ActionsAssertionValidatorTest, ValidOpenedAssertions) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2__1" + } + } + assertions { + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.opened" + parameters { + ingredients { url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" } + } + } + } + } + assertions { + label: "c2pa.actions.v2__1" + actions { actions { action: "c2pa.resized" } } + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf", + active_manifest {} + } + } + )pb"); + EXPECT_TRUE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ActionsAssertionValidatorTest, MalformedFirstAssertion) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" } + } + assertions { + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.resized" } } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, MalformedCreatedAssertions) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" } + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" } + } + assertions { + label: "c2pa.actions" + actions_v1 { actions { action: "c2pa.resized" } } + } + assertions { + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, MalformedOpenedAssertions) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" } + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" } + } + assertions { + label: "c2pa.actions.v2" + actions_v1 { actions { action: "c2pa.resized" } } + } + assertions { + label: "c2pa.actions" + actions { actions { action: "c2pa.opened" } } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +TEST(ActionsAssertionValidatorTest, MalformedDuplicatedCreatedAssertion) { + TestValidationTracker tracker; + ActionsAssertionValidator validator; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + claim { + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" } + created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" } + } + assertions { + label: "c2pa.actions.v2" + actions_v1 { actions { action: "c2pa.created" } } + } + assertions { + label: "c2pa.actions" + actions { actions { action: "c2pa.created" } } + } + )pb"); + EXPECT_FALSE(validator.Validate(manifest, tracker.tracker())); + EXPECT_THAT(tracker.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionMalformed)); +} + +} // namespace +} // namespace credentio
diff --git a/assertion/assertion_encoder.cc b/assertion/assertion_encoder.cc new file mode 100644 index 0000000..70b0524 --- /dev/null +++ b/assertion/assertion_encoder.cc
@@ -0,0 +1,102 @@ +// 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 "assertion/assertion_encoder.h" + +#include <utility> + +#include "absl/base/no_destructor.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "cbor/options.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "proto/actions_assertion.cbor.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.cbor.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.cbor.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.cbor.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.cbor.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/ingredient_assertion.cbor.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/multi_asset_hash_assertion.cbor.h" + +namespace credentio { + +namespace { + +template <typename ProtoType> +absl::StatusOr<absl::Cord> EncodeProtoToCbor( + const ProtoType& proto, const cbor::FromProtoOptions& options) { + ABSL_ASSIGN_OR_RETURN(auto cbor, cbor::FromProto(proto, options)); + return absl::Cord(cbor->toString()); +} + +} // namespace + +absl::StatusOr<absl::Cord> StandardAssertionEncoder::ToCbor( + const Assertion& assertion) const { + if (assertion.has_actions()) { + return EncodeProtoToCbor(assertion.actions(), options_); + } + if (assertion.has_bmff_based_hash()) { + return EncodeProtoToCbor(assertion.bmff_based_hash(), options_); + } + if (assertion.has_data_hash()) { + return EncodeProtoToCbor(assertion.data_hash(), options_); + } + if (assertion.has_collection_data_hash()) { + return EncodeProtoToCbor(assertion.collection_data_hash(), options_); + } + if (assertion.has_ingredient_v3()) { + return EncodeProtoToCbor(assertion.ingredient_v3(), options_); + } + if (assertion.has_boxes_hash()) { + return EncodeProtoToCbor(assertion.boxes_hash(), options_); + } + if (assertion.has_multi_asset_hash()) { + return EncodeProtoToCbor(assertion.multi_asset_hash(), options_); + } + return absl::InvalidArgumentError("Assertion not supported."); +} + +absl::StatusOr<jumbf::BuiltSuperBox> AssertionEncoder::ToJumbf( + const Assertion& assertion) const { + ABSL_ASSIGN_OR_RETURN(absl::Cord cbor_data, ToCbor(assertion)); + jumbf::SuperBoxBuilder assertion_builder( + jumbf::kCborBoxTypeUuid, {.label = absl::Cord(assertion.label())}); + ABSL_RETURN_IF_ERROR( + assertion_builder.AddContent(jumbf::kCborBoxType, std::move(cbor_data))); + return std::move(assertion_builder).Finalize(); // NOLINT: rvalue method call +} + +const AssertionEncoder& GetAssertionEncoder( + bool skip_validity_checks_for_test) { + static const absl::NoDestructor<StandardAssertionEncoder> encoder_for_test( + /*skip_validity_checks_for_test=*/true); + static const absl::NoDestructor<StandardAssertionEncoder> encoder( + /*skip_validity_checks_for_test=*/false); + return skip_validity_checks_for_test ? *encoder_for_test : *encoder; +} + +} // namespace credentio
diff --git a/assertion/assertion_encoder.h b/assertion/assertion_encoder.h new file mode 100644 index 0000000..1a964eb --- /dev/null +++ b/assertion/assertion_encoder.h
@@ -0,0 +1,56 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_ENCODER_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_ENCODER_H_ + +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "cbor/options.h" +#include "jumbf/box_builder.h" +#include "proto/assertion.pb.h" + +namespace credentio { + +// An encoder to serialize an Assertion's metadata to the CBOR or JUMBF format. +class AssertionEncoder { + public: + virtual ~AssertionEncoder() = default; + + virtual absl::StatusOr<absl::Cord> ToCbor( + const Assertion& assertion) const = 0; + + virtual absl::StatusOr<jumbf::BuiltSuperBox> ToJumbf( + const Assertion& assertion) const; +}; + +class StandardAssertionEncoder : public AssertionEncoder { + public: + explicit StandardAssertionEncoder(bool skip_validity_checks_for_test = false) + : options_({.skip_validation = skip_validity_checks_for_test}) {} + ~StandardAssertionEncoder() override = default; + + absl::StatusOr<absl::Cord> ToCbor(const Assertion& assertion) const override; + + private: + cbor::FromProtoOptions options_; +}; + +// Returns a singleton StandardAssertionEncoder. +const AssertionEncoder& GetAssertionEncoder(bool skip_validity_checks_for_test); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_ENCODER_H_
diff --git a/assertion/assertion_encoder_test.cc b/assertion/assertion_encoder_test.cc new file mode 100644 index 0000000..46759a4 --- /dev/null +++ b/assertion/assertion_encoder_test.cc
@@ -0,0 +1,391 @@ +// 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 "assertion/assertion_encoder.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "assertion/assertion_parser.h" +#include "cbor/cbor.h" +#include "cbor/parse.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/parse.h" +#include "proto/actions_assertion.cbor.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.cbor.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.cbor.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/data_hash_assertion.cbor.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/ingredient_assertion.cbor.h" +#include "proto/ingredient_assertion.pb.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" + +namespace credentio { + +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::cbor::Parse; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; + +TEST(AssertionEncoderTest, UnsupportedAssertionToEncode) { + auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( + label: "unsupported_assertion" + )pb"); + + StandardAssertionEncoder encoder; + EXPECT_THAT(encoder.ToCbor(assertion), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Assertion not supported."))); +} + +TEST(AssertionEncoderTest, EncodeAction) { + auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { + all_actions_included: true + actions { + action: "c2pa.created" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + software_agent { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + parameters { + instance_id: "ed610ae51f604002be3dbf0c589a2f1f" + ingredients { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "hash" + } + ingredients { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient__2" + algorithm: "sha256" + hash: "hash" + } + } + } + actions { + action: "c2pa.filtered" + software_agent { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + } + actions { action: "c2pa.cropped", software_agent_index: 1 } + templates { + action: "com.joesphoto.filter" + description: "Magic Filter" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic" + software_agent { name: "Joe\'s Photo Editor" version: "2.0" } + icon { url: "http://cdn.examplephotoagency.com/logo.svg" hash: "hash" } + } + software_agents { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + software_agents { + name: "Bob\'s Photo Editor" + version: "1.0" + operating_system: "Windows 10" + } + } + )pb"); + + StandardAssertionEncoder encoder; + auto cbor = encoder.ToCbor(assertion); + ABSL_ASSERT_OK(cbor); + std::string cbor_string(*cbor); + auto parse_result = Parse(cbor_string); + ABSL_ASSERT_OK(parse_result); + auto map = (*parse_result)->AsMap(); + ABSL_ASSERT_OK(map); + credentio::ActionsAssertion result; + auto status = cbor::ToProto(*map, &result); + ABSL_ASSERT_OK(status); + EXPECT_THAT(result, EqualsProto(assertion.actions())); +} + +TEST(AssertionEncoderTest, EncodeBmffBasedHash) { + auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.bmff.v3" + bmff_based_hash { + hash: "Zm9v", + name: "Example `c2pa.hash.bmff.v2` assertion", + exclusions: + [ { + data: + [ { value: "Zm9v", offset: 8 }], + xpath: "/uuid" + } + , { xpath: "/ftyp" } + , { xpath: "/mfra" } + , { xpath: "/moov[1]/pssh" } + , { + data: + [ { value: "Zm9v", offset: 20 }], + xpath: "/emsg" + }] + } + )pb"); + + StandardAssertionEncoder encoder; + auto cbor = encoder.ToCbor(assertion); + ABSL_ASSERT_OK(cbor); + std::string cbor_string(*cbor); + auto parse_result = Parse(cbor_string); + ABSL_ASSERT_OK(parse_result); + auto map = (*parse_result)->AsMap(); + ABSL_ASSERT_OK(map); + credentio::BmffBasedHashAssertion result; + auto status = cbor::ToProto(*map, &result); + ABSL_ASSERT_OK(status); + EXPECT_THAT(result, EqualsProto(assertion.bmff_based_hash())); +} + +TEST(AssertionEncoderTest, EncodeDataHash) { + auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + alg: "sha256", + pad: "b64'Zm9v'", + hash: "b64'Zm9v'", + name: "JUMBF manifest", + exclusions: + [ { start: 9960, length: 4213 } + , { start: 14173, length: 1000 }] + } + )pb"); + + StandardAssertionEncoder encoder; + auto cbor = encoder.ToCbor(assertion); + ABSL_ASSERT_OK(cbor); + std::string cbor_string(*cbor); + auto parse_result = Parse(cbor_string); + ABSL_ASSERT_OK(parse_result); + auto map = (*parse_result)->AsMap(); + ABSL_ASSERT_OK(map); + EXPECT_THAT(map->GetByteString("pad"), IsOk()); + credentio::DataHashAssertion result; + auto status = cbor::ToProto(*map, &result); + ABSL_ASSERT_OK(status); + + auto expected = assertion.data_hash(); + expected.clear_pad(); + auto actual = result; + actual.clear_pad(); + EXPECT_THAT(actual, EqualsProto(expected)); +} + +TEST(AssertionEncoderTest, EncodeIngredientAssertion) { + auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "image 1.jpg", + media_type: "image/jpeg", + thumbnail: { + url: "self#jumbf=c2pa/urn:c2pa:F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4/c2pa.thumbnail.ingredient_1.jpg", + hash: "b64'UjRAYWiAq4lfCRDmksWAlDJN/XtHHFFwMWymsZsm3j8='" + }, + relationship: "parentOf", + active_manifest: { + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322", + hash: "b64'1kjJTO108b71cL95UxgfHD3eDgk9VrCedW8n3fYTRMk='" + }, + claim_signature: { + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature", + hash: "b64'85KAvU3+3YgtIjj6IV0fzKwj8si/85+gevVSK2Iw+S0='" + }, + validation_results: { + active_manifest: { + successes: + [ { + code: "claimSignature.validated", + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + , { + code: "signingCredential.trusted", + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + , { + code: "timeStamp.validated", + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + , { + code: "timeStamp.trusted", + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + , { + code: "assertion.hashedURI.match", + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3" + }], + informationals: + [ { + code: "signingCredential.ocsp.skipped", + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + }], + failures: [] + }, + ingredient_deltas: + [ { + ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3", + validation_deltas: { + successes: [], + informationals: [], + failures: + [ { + code: "assertion.hashedURI.mismatch", + url: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.metadata" + }] + } + } + , { + ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.ingredient.v3", + validation_deltas: { + successes: [], + informationals: [], + failures: + [ { + code: "signingCredential.untrusted", + url: "self#jumbf=c2pa/urn:c2pa:72C28A7C-7F5B-4301-B373-3183C10AF7C5/c2pa.signature" + }] + } + }] + } + } + )pb"); + + StandardAssertionEncoder encoder; + auto cbor = encoder.ToCbor(assertion); + ABSL_ASSERT_OK(cbor); + std::string cbor_string(*cbor); + auto parse_result = Parse(cbor_string); + ABSL_ASSERT_OK(parse_result); + auto map = (*parse_result)->AsMap(); + ABSL_ASSERT_OK(map); + credentio::IngredientAssertionV3 result; + auto status = cbor::ToProto(*map, &result); + ABSL_ASSERT_OK(status); + EXPECT_THAT(result, EqualsProto(assertion.ingredient_v3())); +} + +TEST(AssertionEncoderTest, EncodeBoxesHash) { + auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.boxes" + boxes_hash { + alg: "sha256", + boxes: + [ { names: [ "SOI", "APP0", "APP2" ], hash: "b64'Zm9v'" } + , { names: [ "C2PA" ], hash: "b64'YmFy'" }] + } + )pb"); + + StandardAssertionEncoder encoder; + auto cbor = encoder.ToCbor(assertion); + ABSL_ASSERT_OK(cbor); + std::string cbor_string(*cbor); + auto parse_result = Parse(cbor_string); + ABSL_ASSERT_OK(parse_result); + auto map = (*parse_result)->AsMap(); + ABSL_ASSERT_OK(map); + credentio::BoxesHashAssertion result; + auto status = cbor::ToProto(*map, &result); + ABSL_ASSERT_OK(status); + EXPECT_THAT(result, EqualsProto(assertion.boxes_hash())); +} + +TEST(GetAssertionEncoderTest, ReturnsSameInstanceForSameParam) { + const auto& encoder1 = GetAssertionEncoder(true); + const auto& encoder2 = GetAssertionEncoder(true); + EXPECT_EQ(&encoder1, &encoder2); + + const auto& encoder3 = GetAssertionEncoder(false); + const auto& encoder4 = GetAssertionEncoder(false); + EXPECT_EQ(&encoder3, &encoder4); +} + +TEST(GetAssertionEncoderTest, ReturnsDifferentInstancesForDifferentParams) { + const auto& encoder1 = GetAssertionEncoder(true); + const auto& encoder2 = GetAssertionEncoder(false); + EXPECT_NE(&encoder1, &encoder2); +} + +absl::StatusOr<Assertion> ParseAssertionBox(absl::string_view assertion_data) { + ABSL_ASSIGN_OR_RETURN(auto assertion_box, + jumbf::ConsumeSuperBox(&assertion_data, 9)); + if (assertion_box.contents.size() != 1) { + return absl::InvalidArgumentError(absl::StrCat( + "the content size is not 1, but ", assertion_box.contents.size())); + } + AssertionParser parser; + TestValidationTracker validation_tracker; + std::optional<Assertion> assertion = + parser.ParseCbor(assertion_box.description.label.value_or(""), + assertion_box.contents[0].Get<jumbf::CborBox>().payload, + validation_tracker.tracker()); + if (!assertion.has_value()) { + return absl::InvalidArgumentError( + absl::StrJoin(validation_tracker.GetFailures(), ";")); + } + return assertion.value(); +} + +TEST(AssertionEncoderTest, ToJumbf) { + StandardAssertionEncoder encoder( + /*skip_validity_checks_for_test=*/false); + auto assertion = ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + software_agent { name: "test generator" } + description: "created for testing" + digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture" + } + } + )pb"); + auto assertion_box = encoder.ToJumbf(assertion); + ABSL_ASSERT_OK(assertion_box); + auto serialized = std::move(*assertion_box).AsCord(); + + auto parsed_assertion = ParseAssertionBox(serialized.Flatten()); + ABSL_ASSERT_OK(parsed_assertion); + EXPECT_THAT(*parsed_assertion, EqualsProto(assertion)); +} + +} // namespace +} // namespace credentio
diff --git a/assertion/assertion_parser.cc b/assertion/assertion_parser.cc new file mode 100644 index 0000000..a42c4ad --- /dev/null +++ b/assertion/assertion_parser.cc
@@ -0,0 +1,204 @@ +// 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 "assertion/assertion_parser.h" + +#include <optional> + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "assertion/parse_assertion.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "google/protobuf/struct.pb.h" +#include "proto/assertion.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +namespace { +void RecordAssertionParsingFailure(absl::string_view label, absl::Status error, + ValidationTracker& validation_tracker) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionCborInvalid, + {.url = + absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label), + .explanation = absl::StrCat("Failed to parse the assertion with label: ", + label, " and error: ", error.message())}); +} + +bool IsBmffBasedHashLabel(absl::string_view label) { + return label == kBmffBasedHashAssertionV2Label || + label == kBmffBasedHashAssertionV3Label || + label == kBmffBasedHashAssertionV2PartLabel || + label == kBmffBasedHashAssertionV3PartLabel; +} + +bool IsDataHashLabel(absl::string_view label) { + return label == kDataHashAssertionLabel || + label == kDataHashAssertionPartLabel; +} + +bool IsUnsupportedIngredientLabel(absl::string_view label) { + return label == kIngredientAssertionV1Label || + label == kIngredientAssertionV2Label; +} + +bool IsBoxesHashLabel(absl::string_view label) { + return label == kBoxesHashAssertionLabel || + label == kBoxesHashAssertionPartLabel; +} + +bool IsCollectionDataHashLabel(absl::string_view label) { + return label == kCollectionDataHashAssertionLabel || + label == kCollectionDataHashAssertionPartLabel; +} + +} // namespace + +absl::string_view StripMultipleInstanceSuffix(absl::string_view label) { + return label.substr(0, label.find("__")); +} + +bool AssertionTypeMatcher::Matches(absl::string_view label) const { + return StripMultipleInstanceSuffix(label) == label_; +} + +std::optional<Assertion> AssertionParser::ParseCbor( + absl::string_view label, absl::string_view content, + ValidationTracker& validation_tracker) const { + absl::string_view stripped_label = StripMultipleInstanceSuffix(label); + if (IsUnsupportedIngredientLabel(stripped_label)) { + // Unlike other assertion types, it's not safe to ignore an unsupported + // ingredient assertion. + validation_tracker.RecordFailure( + FailureStatusCode::kGoogleUnsupportedSpecVersion, + {.url = + absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label), + .explanation = "c2pa.ingredient (v1) and c2pa.ingredient.v2 " + "assertions are not supported"}); + return std::nullopt; + } + + Assertion assertion; + assertion.set_label(label); + absl::Status status; + + if (stripped_label == kActionsAssertionV2Label) { + status = ParseAssertion(content, assertion.mutable_actions()); + } else if (stripped_label == kActionsAssertionV1Label) { + status = ParseAssertion(content, assertion.mutable_actions_v1()); + } else if (IsBmffBasedHashLabel(stripped_label)) { + status = ParseAssertion(content, assertion.mutable_bmff_based_hash()); + } else if (IsDataHashLabel(stripped_label)) { + status = ParseAssertion(content, assertion.mutable_data_hash()); + } else if (stripped_label == kIngredientAssertionV3Label) { + status = ParseAssertion(content, assertion.mutable_ingredient_v3()); + } else if (IsBoxesHashLabel(stripped_label)) { + status = ParseAssertion(content, assertion.mutable_boxes_hash()); + } else if (stripped_label == kMultiAssetHashAssertionLabel) { + status = ParseAssertion(content, assertion.mutable_multi_asset_hash()); + } else if (IsCollectionDataHashLabel(stripped_label)) { + status = ParseAssertion(content, assertion.mutable_collection_data_hash()); + } else if (stripped_label == kAiDisclosureAssertionLabel) { + status = ParseAssertion(content, assertion.mutable_ai_disclosure()); + } else if (stripped_label == kEnvironmentalSustainabilityAssertionLabel) { + status = ParseAssertion(content, + assertion.mutable_environmental_sustainability()); + } else if (stripped_label == kSoftBindingAssertionLabel) { + status = ParseAssertion(content, assertion.mutable_soft_binding()); + } else { + return std::nullopt; + } + + if (!status.ok()) { + RecordAssertionParsingFailure(label, status, validation_tracker); + return std::nullopt; + } + return assertion; +} + +std::optional<Assertion> AssertionParser::ParseThumbnail( + absl::string_view label, absl::string_view media_type, + absl::string_view data, std::optional<absl::string_view> file_name, + ValidationTracker& validation_tracker) const { + Assertion assertion; + assertion.set_label(label); + if (absl::StartsWith(label, kThumbnailClaimAssertionLabelPrefix)) { + *assertion.mutable_claim_thumbnail() = + ParseThumbnailAssertion(media_type, data, file_name); + return assertion; + } + if (absl::StartsWith(label, kThumbnailIngredientAssertionLabelPrefix)) { + *assertion.mutable_ingredient_thumbnail() = + ParseThumbnailAssertion(media_type, data, file_name); + return assertion; + } + return std::nullopt; +} + +std::optional<Assertion> AssertionParser::ParseMetadata( + absl::string_view label, absl::string_view content, + ValidationTracker& validation_tracker) const { + Assertion assertion; + assertion.set_label(label); + absl::string_view stripped_label = StripMultipleInstanceSuffix(label); + + if (stripped_label == kRepositoryReceiptAssertionLabel) { + if (auto status = + ParseAssertion(content, assertion.mutable_repository_receipt()); + !status.ok()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionJsonInvalid, + {.url = absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, + label), + .explanation = + absl::StrCat("Failed to parse the assertion with label: ", label, + " and error: ", status.message())}); + return std::nullopt; + } + return assertion; + } + + if (!absl::EndsWith(stripped_label, kMetadataAssertionLabelSuffix)) { + return std::nullopt; + } + if (auto status = + ParseStruct(content, assertion.mutable_metadata()->mutable_value()); + !status.ok()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionJsonInvalid, + {.url = + absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label), + .explanation = + absl::StrCat("Failed to parse the assertion with label: ", label, + " and error: ", status.message())}); + return std::nullopt; + } + if (!assertion.metadata().value().fields().contains("@context")) { + validation_tracker.RecordFailure( + FailureStatusCode::kGeneralError, + {.url = + absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label), + .explanation = "Metadata doesn't have @context"}); + return std::nullopt; + } + return assertion; +} + +} // namespace credentio
diff --git a/assertion/assertion_parser.h b/assertion/assertion_parser.h new file mode 100644 index 0000000..8ffedc4 --- /dev/null +++ b/assertion/assertion_parser.h
@@ -0,0 +1,62 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_PARSER_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_PARSER_H_ + +#include <optional> +#include <string> + +#include "absl/strings/string_view.h" +#include "proto/assertion.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +// Returns the label with any multiple instance suffix (e.g., `__1`) removed. +absl::string_view StripMultipleInstanceSuffix(absl::string_view label); + +// A helper class to match an assertion type (e.g., c2pa.actions.v2). +class AssertionTypeMatcher { + public: + explicit AssertionTypeMatcher(absl::string_view label) : label_(label) {} + bool Matches(absl::string_view label) const; + + private: + const std::string label_; +}; + +class AssertionParser { + public: + AssertionParser() = default; + // Parses the given assertion content into an Assertion proto. + std::optional<Assertion> ParseCbor( + absl::string_view label, absl::string_view content, + ValidationTracker& validation_tracker) const; + + // Parses the given thumbnail assertion into an Assertion proto. + std::optional<Assertion> ParseThumbnail( + absl::string_view label, absl::string_view media_type, + absl::string_view data, std::optional<absl::string_view> file_name, + ValidationTracker& validation_tracker) const; + + std::optional<Assertion> ParseMetadata( + absl::string_view label, absl::string_view content, + ValidationTracker& validation_tracker) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_PARSER_H_
diff --git a/assertion/assertion_parser_test.cc b/assertion/assertion_parser_test.cc new file mode 100644 index 0000000..9ded34e --- /dev/null +++ b/assertion/assertion_parser_test.cc
@@ -0,0 +1,1071 @@ +// 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 "assertion/assertion_parser.h" + +#include <optional> +#include <string> + +#include "absl/strings/match.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/assertion.pb.h" +#include "testing/cbor_utils.h" +#include "testing/jumbf_utils.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +using ::cbor::FromJson; +using ::credentio::CreateActionsAssertionBoxFromCbor; +using ::credentio::CreateAssertionBoxFromCbor; +using ::credentio::CreateBmffBasedHashAssertionBoxFromCbor; +using ::credentio::CreateDataHashAssertionBoxFromCbor; +using ::credentio::StripMultipleInstanceSuffix; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::Eq; +using ::testing::IsEmpty; +using ::testing::Optional; + +MATCHER_P2(FailureStatusIs, code, explanation, "") { + return arg.code() == absl::StrCat(code) && + absl::StrContains(arg.explanation(), explanation); +} + +class AssertionParserTest : public ::testing::Test { + protected: + AssertionParserTest() = default; + credentio::AssertionParser parser_; +}; + +TEST_F(AssertionParserTest, ParseActionsAssertion) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created", + "softwareAgent": { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + "parameters": { + "instanceID": "ed610ae51f604002be3dbf0c589a2f1f", + "ingredients" : [ + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient__1", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + }, + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient__2", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + } + ] + } + }, + { + "action": "c2pa.filtered", + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + } + }, + { + "action": "c2pa.cropped", + "softwareAgentIndex": 1 + } + ], + "templates": [{ + "action": "com.joesphoto.filter", + "description": "Magic Filter", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic", + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "schema.org.SoftwareApplication.operatingSystem": "Windows 10" + }, + "icon": { + "url": "http://cdn.examplephotoagency.com/logo.svg", + "hash": "b64'aGFzaA=='" + } + }], + "metadata": { + "reviewRatings": [ + { + "value": 1, + "explanation": "Content bindings did not validate" + } + ], + "reference": { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.actions__1", + "alg": "sha256", + "hash": "b64'aGFzaA=='" + } + }, + "softwareAgents": [ + { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + }, + { + "name": "Bob's Photo Editor", + "version": "1.0", + "operating_system": "Windows 10" + } + ] + })json"); + auto expected = ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + software_agent { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + parameters { + instance_id: "ed610ae51f604002be3dbf0c589a2f1f" + ingredients { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "hash" + } + ingredients { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient__2" + algorithm: "sha256" + hash: "hash" + } + } + } + actions { + action: "c2pa.filtered" + software_agent { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + } + actions { action: "c2pa.cropped", software_agent_index: 1 } + templates { + action: "com.joesphoto.filter" + description: "Magic Filter" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic" + software_agent { name: "Joe\'s Photo Editor" version: "2.0" } + icon { url: "http://cdn.examplephotoagency.com/logo.svg" hash: "hash" } + } + software_agents { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + software_agents { + name: "Bob\'s Photo Editor" + version: "1.0" + operating_system: "Windows 10" + } + } + )pb"); + credentio::TestValidationTracker t; + EXPECT_THAT(parser_.ParseCbor("c2pa.actions.v2", cbor, t.tracker()), + Optional(EqualsProto(expected))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseBmffBasedHashAssertion) { + credentio::TestValidationTracker t; + std::string bmff_based_hash_assertion_cbor = FromJson(R"json( + { + "hash": "b64'Zm9v'", + "name": "Example `c2pa.hash.bmff.v2` assertion", + "exclusions": [ + { + "data": [{ "value": "b64'Zm9v'", "offset": 8 }], + "xpath": "/uuid" + }, + { "xpath": "/ftyp" }, + { "xpath": "/mfra" }, + { "xpath": "/moov[1]/pssh" }, + { + "data": [{ "value": "b64'Zm9v'", "offset": 20 }], + "xpath": "/emsg" + } + ] + })json"); + EXPECT_THAT( + parser_.ParseCbor("c2pa.hash.bmff.v2", bmff_based_hash_assertion_cbor, + t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.hash.bmff.v2" + bmff_based_hash { + hash: "foo" + name: "Example `c2pa.hash.bmff.v2` assertion" + exclusions { + data { value: "foo" offset: 8 } + xpath: "/uuid" + } + exclusions { xpath: "/ftyp" } + exclusions { xpath: "/mfra" } + exclusions { xpath: "/moov[1]/pssh" } + exclusions { + data { value: "foo" offset: 20 } + xpath: "/emsg" + } + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseDataHashAssertion) { + credentio::TestValidationTracker t; + std::string data_hash_assertion_cbor = FromJson(R"json( + { + "alg": "sha256", + "pad": "b64'Zm9v'", + "hash": "b64'Zm9v'", + "name": "JUMBF manifest", + "exclusions": [ + { + "start": 9960, + "length": 4213 + }, + { + "start": 14173, + "length": 1000 + } + ] + })json"); + EXPECT_THAT( + parser_.ParseCbor("c2pa.hash.data", data_hash_assertion_cbor, + t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseAiDisclosureAssertion) { + credentio::TestValidationTracker t; + std::string ai_disclosure_cbor = FromJson(R"json( + { + "modelType": "c2pa.types.model.onnx", + "modelName": "My Awesome AI Model", + "modelIdentifier": "https://example.com/models/awesome-ai-v1", + "contentProfile": { + "humanOversightLevel": "prompt_guided", + "scientificDomain": ["cs.AI"] + } + })json"); + EXPECT_THAT( + parser_.ParseCbor("c2pa.ai-disclosure", ai_disclosure_cbor, t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.ai-disclosure" + ai_disclosure { + model_type: "c2pa.types.model.onnx" + model_name: "My Awesome AI Model" + model_identifier: "https://example.com/models/awesome-ai-v1" + content_profile { + human_oversight_level: "prompt_guided" + scientific_domain: "cs.AI" + } + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseEnvironmentalSustainabilityAssertion) { + credentio::TestValidationTracker t; + std::string environmental_sustainability_cbor = FromJson(R"json( + { + "energy_kwh": { + "value": 1.23, + "measurementMethod": "com.example.direct-measurement" + }, + "carbon_kgco2e": { + "value": 0.456 + }, + "water_litres": { + "value": 7.89, + "measurementMethod": "com.example.estimation" + } + })json"); + EXPECT_THAT( + parser_.ParseCbor("c2pa.environmental-sustainability", + environmental_sustainability_cbor, t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.environmental-sustainability" + environmental_sustainability { + energy_kwh { + value: 1.23 + measurement_method: "com.example.direct-measurement" + } + carbon_kgco2e { value: 0.456 } + water_litres { + value: 7.89 + measurement_method: "com.example.estimation" + } + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseSoftBindingAssertion) { + credentio::TestValidationTracker t; + std::string soft_binding_assertion_cbor = FromJson(R"json( + { + "alg": "soft-algorithm", + "name": "soft-binding-name", + "alg-params": "b64'cGFyYW1z'", + "blocks": [ + { + "scope": { + "extent": "b64'ZXh0ZW50'", + "timespan": { + "start": 100, + "end": 200 + } + }, + "value": "b64'dmFsdWU='" + } + ], + "bindingMetadata": { + "description": "metadata-desc", + "contact": "metadata-contact", + "informationalUrl": "metadata-url" + } + })json"); + EXPECT_THAT( + parser_.ParseCbor("c2pa.soft-binding", soft_binding_assertion_cbor, + t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.soft-binding" + soft_binding { + algorithm: "soft-algorithm" + name: "soft-binding-name" + alg_params: "params" + blocks { + scope { + extent: "extent" + timespan { start: 100 end: 200 } + } + value: "value" + } + binding_metadata { + description: "metadata-desc" + contact: "metadata-contact" + informational_url: "metadata-url" + } + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseInvalidSoftBindingAssertion) { + // Soft binding assertion is missing the `alg` field. + std::string soft_binding_assertion_cbor = FromJson(R"json( + { + "name": "soft-binding-name" + })json"); + credentio::TestValidationTracker t; + EXPECT_THAT(parser_.ParseCbor("c2pa.soft-binding", + soft_binding_assertion_cbor, t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kAssertionCborInvalid, + "c2pa.soft-binding"))); +} + +TEST_F(AssertionParserTest, ParseIngredientAssertionV2Unsupported) { + credentio::TestValidationTracker t; + std::string ingredient_0_assertion_cbor = FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "relationship": "ingredient-relationship", + "documentID": "ingredient-document-id", + "instanceID": "ingredient-instance-id", + "data": { + "url": "ingredient-data-url", + "alg": "ingredient-data-alg", + "hash": "b64'aGFzaA=='", + "size": 1234567890 + }, + "c2pa_manifest": { + "url": "ingredient-manifest-url", + "alg": "ingredient-manifest-alg", + "hash": "b64'aGFzaA=='" + }, + "thumbnail": { + "url": "ingredient-thumbnail-url", + "alg": "ingredient-thumbnail-alg", + "hash": "b64'aGFzaA=='" + }, + "validationStatus": [ + { + "code": "status.1", + "url": "url.1", + "explanation": "explanation.1" + }, + { + "code": "status.2", + "url": "url.2", + "explanation": "explanation.2" + } + ], + "description": "ingredient-description", + "informational_URI": "ingredient-informational-uri", + "metadata": { + "reviewRatings": [ + { + "value": 1, + "explanation": "can't dance to it" + } + ] + } + })json"); + EXPECT_THAT(parser_.ParseCbor("c2pa.ingredient.v2", + ingredient_0_assertion_cbor, t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.tracker().GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kGoogleUnsupportedSpecVersion, + "c2pa.ingredient.v2"))); +} + +TEST_F(AssertionParserTest, ParseIngredientAssertionV3) { + credentio::TestValidationTracker t; + std::string ingredient_0_assertion_cbor = FromJson(R"json( + { + "dc:title": "image 1.jpg", + "metadata": { + "reviewRatings": [ + { + "value": 5, + "explanation": "Content bindings validated" + } + ] + }, + "dc:format": "image/jpeg", + "thumbnail": { + "url": "self#jumbf=c2pa/urn:c2pa:F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4/c2pa.thumbnail.ingredient_1.jpg", + "hash": "b64'UjRAYWiAq4lfCRDmksWAlDJN/XtHHFFwMWymsZsm3j8='" + }, + "relationship": "parentOf", + "activeManifest": { + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322", + "hash": "b64'1kjJTO108b71cL95UxgfHD3eDgk9VrCedW8n3fYTRMk='" + }, + "claimSignature": { + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature", + "hash": "b64'85KAvU3+3YgtIjj6IV0fzKwj8si/85+gevVSK2Iw+S0='" + }, + "validationResults": { + "activeManifest": { + "success": [ + { + "code": "claimSignature.validated", + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + }, + { + "code": "signingCredential.trusted", + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + }, + { + "code": "timeStamp.validated", + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + }, + { + "code": "timeStamp.trusted", + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3" + } + ], + "informational": [{ + "code": "signingCredential.ocsp.skipped", + "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + }], + "failure": [] + }, + "ingredientDeltas": [ + { + "ingredientAssertionURI": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3", + "validationDeltas": { + "success": [], + "informational": [], + "failure": [{ + "code": "assertion.hashedURI.mismatch", + "url": "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.metadata" + }] + } + }, + { + "ingredientAssertionURI": "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.ingredient.v3", + "validationDeltas": { + "success": [], + "informational": [], + "failure": [{ + "code": "signingCredential.untrusted", + "url": "self#jumbf=c2pa/urn:c2pa:72C28A7C-7F5B-4301-B373-3183C10AF7C5/c2pa.signature" + }] + } + } + ] + } + })json"); + auto result = parser_.ParseCbor("c2pa.ingredient.v3", + ingredient_0_assertion_cbor, t.tracker()); + ASSERT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT( + result, + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "image 1.jpg" + media_type: "image/jpeg" + relationship: "parentOf" + active_manifest { + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322" + hash: "\326H\311L\355t\361\276\365p\277yS\030\037\034=\336\016\t=V\260\236uo\'\335\366\023D\311" + } + claim_signature { + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + hash: "\363\222\200\275M\376\335\210-\"8\372!]\037\314\254#\362\310\277\363\237\240z\365R+b0\371-" + } + thumbnail { + url: "self#jumbf=c2pa/urn:c2pa:F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4/c2pa.thumbnail.ingredient_1.jpg" + hash: "R4@ah\200\253\211_\t\020\346\222\305\200\2242M\375{G\034Qp1l\246\261\233&\336?" + } + validation_results { + active_manifest { + successes { + code: "claimSignature.validated" + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + successes { + code: "signingCredential.trusted" + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + successes { + code: "timeStamp.validated" + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + successes { + code: "timeStamp.trusted" + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + successes { + code: "assertion.hashedURI.match" + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3" + } + informationals { + code: "signingCredential.ocsp.skipped" + url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature" + } + } + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3" + validation_deltas { + failures { + code: "assertion.hashedURI.mismatch" + url: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.metadata" + } + } + } + ingredient_deltas { + ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.ingredient.v3" + validation_deltas { + failures { + code: "signingCredential.untrusted" + url: "self#jumbf=c2pa/urn:c2pa:72C28A7C-7F5B-4301-B373-3183C10AF7C5/c2pa.signature" + } + } + } + } + } + )pb")))); +} + +TEST_F(AssertionParserTest, ParseBoxesHashAssertion) { + credentio::TestValidationTracker t; + std::string boxes_hash_assertion_cbor = FromJson(R"json( + { + "alg" : "sha256", + "boxes": [ + { + "names" : ["SOI", "APP0", "APP2"], + "hash" : "b64'Zm9v'" + }, + { + "names" : ["C2PA"], + "hash" : "b64'YmFy'" + } + ] + })json"); + EXPECT_THAT( + parser_.ParseCbor("c2pa.hash.boxes", boxes_hash_assertion_cbor, + t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.hash.boxes" + boxes_hash { + alg: "sha256" + boxes { names: "SOI" names: "APP0" names: "APP2" hash: "foo" } + boxes { names: "C2PA" hash: "bar" } + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseThumbnailClaimAssertion) { + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseThumbnail("c2pa.thumbnail.claim.png", "image/png", "foo", + "foo.png", t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.thumbnail.claim.png" + claim_thumbnail { + media_type: "image/png" + data: "foo" + file_name: "foo.png" + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseThumbnailIngredientAssertion) { + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseThumbnail("c2pa.thumbnail.ingredient.png", "image/png", + "foo", "foo.png", t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.thumbnail.ingredient.png" + ingredient_thumbnail { + media_type: "image/png" + data: "foo" + file_name: "foo.png" + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseThumbnailAssertionUnrecognizedLabelIgnored) { + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseThumbnail("c2pa.thumbnail.something.png", "image/png", "foo", + std::nullopt, t.tracker()), + Eq(std::nullopt)); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseMetadataAssertion) { + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseMetadata("c2pa.metadata", + R"json({ + "@context": "bar", + "foo": "baz" + })json", + t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.metadata" + metadata { + value { + fields { + key: "@context" + value { string_value: "bar" } + } + fields { + key: "foo" + value { string_value: "baz" } + } + } + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseMetadataAssertionCustomLabel) { + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseMetadata("com.litware.metadata", + R"json({ + "@context": "bar", + "foo": "baz" + })json", + t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "com.litware.metadata" + metadata { + value { + fields { + key: "@context" + value { string_value: "bar" } + } + fields { + key: "foo" + value { string_value: "baz" } + } + } + } + )pb")))); + EXPECT_THAT(t.tracker().GetFailureStatuses(), IsEmpty()); +} + +TEST_F(AssertionParserTest, ParseMetadataAssertionMissingContext) { + credentio::TestValidationTracker t; + EXPECT_THAT(parser_.ParseMetadata("c2pa.metadata", + R"json({ + "@something": "bar" + })json", + t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.tracker().GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kGeneralError, "@context"))); +} + +TEST_F(AssertionParserTest, ParseMetadataAssertionInvalidJson) { + credentio::TestValidationTracker t; + EXPECT_THAT(parser_.ParseMetadata("c2pa.metadata", + R"json({ + "foo": + })json", + t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.tracker().GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kAssertionJsonInvalid, + "c2pa.metadata"))); +} + +TEST_F(AssertionParserTest, ParseRepositoryReceiptAssertion) { + credentio::TestValidationTracker t; + std::string repository_receipt_json = R"json( + { + "repository": { + "uri": "https://repository.example.com/manifests/xyz123", + "manifestId": "urn:uuid:12345678-1234-5678-1234-567812345678" + }, + "anchor": { + "uri": "https://anchor.example.com/receipts/9876", + "parameters": { + "version": "1.0" + }, + "proof": { + "signature": "c2lnbmF0dXJl" + } + } + })json"; + EXPECT_THAT( + parser_.ParseMetadata("c2pa.repository-receipt", repository_receipt_json, + t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.repository-receipt" + repository_receipt { + repository { + uri: "https://repository.example.com/manifests/xyz123" + manifest_id: "urn:uuid:12345678-1234-5678-1234-567812345678" + } + anchor { + uri: "https://anchor.example.com/receipts/9876" + parameters { + fields { + key: "version" + value { string_value: "1.0" } + } + } + proof { + fields { + key: "signature" + value { string_value: "c2lnbmF0dXJl" } + } + } + } + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseInvalidActionsAssertion) { + // Actions assertion is missing the `action` field. + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [ + { + } + ] + })json"); + auto actions_assertion_box = + CreateActionsAssertionBoxFromCbor(actions_assertion_cbor); + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kAssertionCborInvalid, + "c2pa.actions.v2"))); +} + +TEST_F(AssertionParserTest, ParseInvalidBmffBasedHashAssertion) { + // `xpath` field is missing in the exclusion. + std::string bmff_based_hash_assertion_cbor = FromJson(R"json( + { "exclusions": [{}] } + )json"); + auto bmff_based_hash_assertion_box = + CreateBmffBasedHashAssertionBoxFromCbor(bmff_based_hash_assertion_cbor); + credentio::TestValidationTracker t; + EXPECT_THAT(parser_.ParseCbor("c2pa.hash.bmff.v2", + bmff_based_hash_assertion_cbor, t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kAssertionCborInvalid, + "c2pa.hash.bmff.v2"))); +} + +TEST_F(AssertionParserTest, ParseInvalidDataHashAssertion) { + // Data hash assertion is missing the `hash` field. + std::string data_hash_assertion_cbor = FromJson(R"json({})json"); + auto data_hash_assertion_box = + CreateDataHashAssertionBoxFromCbor(data_hash_assertion_cbor); + credentio::TestValidationTracker t; + EXPECT_THAT(parser_.ParseCbor("c2pa.hash.data", data_hash_assertion_cbor, + t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kAssertionCborInvalid, + "c2pa.hash.data"))); +} + +TEST_F(AssertionParserTest, ParseAssertionWithUnsupportedLabel) { + std::string actions_assertion_cbor = FromJson(R"json({})json"); + auto actions_assertion_box = + CreateAssertionBoxFromCbor("random_label", actions_assertion_cbor); + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseCbor("random_label", actions_assertion_cbor, t.tracker()), + Eq(std::nullopt)); + // Skip recording failures for now. + EXPECT_THAT(t.GetFailures(), IsEmpty()); +} + +TEST_F(AssertionParserTest, ParseIngredientAssertionV1Unsupported) { + credentio::TestValidationTracker t; + std::string ingredient_0_assertion_cbor = FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "documentID": "ingredient-document-id", + "instanceID": "ingredient-instance-id", + "relationship": "ingredient-relationship", + "c2pa_manifest": { + "url": "ingredient-manifest-url", + "alg": "ingredient-manifest-alg", + "hash": "b64'aGFzaA=='" + }, + "thumbnail": { + "url": "ingredient-thumbnail-url", + "alg": "ingredient-thumbnail-alg", + "hash": "b64'aGFzaA=='" + }, + "validationStatus": [ + { + "code": "status.1", + "url": "url.1", + "explanation": "explanation.1" + }, + { + "code": "status.2", + "url": "url.2", + "explanation": "explanation.2" + } + ], + "metadata": { + "reviewRatings": [ + { + "value": 1, + "explanation": "can't dance to it" + } + ] + } + })json"); + EXPECT_THAT(parser_.ParseCbor("c2pa.ingredient", ingredient_0_assertion_cbor, + t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.tracker().GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kGoogleUnsupportedSpecVersion, + "c2pa.ingredient"))); +} + +TEST_F(AssertionParserTest, ParseMultiAssetHashAssertion) { + credentio::TestValidationTracker t; + std::string multi_asset_hash_assertion_cbor = FromJson(R"json( + { + "parts": [ + { + "location": { + "byteOffset": 0, + "length": 100 + }, + "hashAssertion": { + "url": "hash-url-1", + "alg": "sha256", + "hash": "b64'aGFzaA=='" + } + }, + { + "location": { + "bmffBox": "test-bmff-box" + }, + "hashAssertion": { + "url": "hash-url-2", + "alg": "sha256", + "hash": "b64'aGFzaA=='" + }, + "optional": true + } + ], + "pad": "b64'Zm9v'" + })json"); + EXPECT_THAT( + parser_.ParseCbor("c2pa.hash.multi-asset", + multi_asset_hash_assertion_cbor, t.tracker()), + Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb( + label: "c2pa.hash.multi-asset" + multi_asset_hash { + parts { + location { byte_offset: 0 length: 100 } + hash_assertion { + url: "hash-url-1" + algorithm: "sha256" + hash: "hash" + } + } + parts { + location { bmff_box: "test-bmff-box" } + hash_assertion { + url: "hash-url-2" + algorithm: "sha256" + hash: "hash" + } + is_optional: true + } + pad: "foo" + } + )pb")))); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST(StripMultipleInstanceSuffixTest, Works) { + EXPECT_EQ(StripMultipleInstanceSuffix("c2pa.hash.data"), "c2pa.hash.data"); + EXPECT_EQ(StripMultipleInstanceSuffix("c2pa.hash.data__1"), "c2pa.hash.data"); + EXPECT_EQ(StripMultipleInstanceSuffix("c2pa.hash.data__13"), + "c2pa.hash.data"); + EXPECT_EQ(StripMultipleInstanceSuffix(""), ""); +} + +TEST_F(AssertionParserTest, ParseActionsAssertionEmptyRelatedAssertions) { + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "relatedAssertions": [] + } + } + ] + })json"); + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t.GetFailureStatuses(), + testing::Contains(FailureStatusIs( + credentio::FailureStatusCode::kAssertionCborInvalid, + "c2pa.actions.v2"))); +} + +TEST_F(AssertionParserTest, ParseActionsAssertionMissingRelatedAssertions) { + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "instanceID": "ed610ae51f604002be3dbf0c589a2f1f" + } + } + ] + })json"); + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()), + testing::Ne(std::nullopt)); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +TEST_F(AssertionParserTest, ParseActionsAssertionNonEmptyRelatedAssertions) { + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "relatedAssertions": [ + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.other", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + } + ] + } + } + ] + })json"); + credentio::TestValidationTracker t; + EXPECT_THAT( + parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()), + testing::Ne(std::nullopt)); + EXPECT_FALSE(t.tracker().HasFailures()); +} + +} // namespace +} // namespace credentio
diff --git a/assertion/assertion_referencer.cc b/assertion/assertion_referencer.cc new file mode 100644 index 0000000..e70c5ca --- /dev/null +++ b/assertion/assertion_referencer.cc
@@ -0,0 +1,54 @@ +// 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 "assertion/assertion_referencer.h" + +#include <memory> +#include <optional> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/hash.h" +#include "jumbf/box_builder.h" +#include "jumbf/utils.h" +#include "proto/assertion.pb.h" +#include "proto/hashed_uri.pb.h" + +namespace credentio { + +absl::StatusOr<HashedUri> AssertionReferencer::Generate( + const jumbf::BuiltSuperBox& assertion_box) const { + if (!assertion_box.description_label().has_value()) { + return absl::InvalidArgumentError( + "the assertion box must have a description label"); + } + HashedUri uri; + uri.set_url(absl::StrCat("self#jumbf=c2pa.assertions/", + *assertion_box.description_label())); + absl::Cord serialized_assertion = assertion_box.Peek(); + absl::string_view serialized_assertion_view = serialized_assertion.Flatten(); + ABSL_ASSIGN_OR_RETURN(auto hasher, hasher_factory_.Create()); + ABSL_ASSIGN_OR_RETURN(auto content, + jumbf::StripBoxHeaders(serialized_assertion_view)); + hasher->Update(content); + uri.set_hash(hasher->Digest()); + return uri; +} + +} // namespace credentio
diff --git a/assertion/assertion_referencer.h b/assertion/assertion_referencer.h new file mode 100644 index 0000000..8968034 --- /dev/null +++ b/assertion/assertion_referencer.h
@@ -0,0 +1,50 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_REFERENCER_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_REFERENCER_H_ + +#include "absl/log/die_if_null.h" +#include "absl/status/statusor.h" +#include "crypto/hash.h" +#include "jumbf/box_builder.h" +#include "proto/assertion.pb.h" +#include "proto/hashed_uri.pb.h" + +namespace credentio { +// A utility class to create a hashed, JUMBF-type URI to an assertion. +// For more details, see +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_uri_references +// and +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hashed_uris. +class AssertionReferencer { + public: + explicit AssertionReferencer(const HasherFactory* hasher) + : hasher_factory_(*ABSL_DIE_IF_NULL(hasher)) {} + + // Generates a hashed URI for the given assertion. The URI is relative to the + // including manifest (e.g., "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"). + // The `algorithm` field field is omitted under the assumption that the + // HasherFactory's algorithm will be specified as the default hash algorithm + // in the Claim. + absl::StatusOr<HashedUri> Generate( + const jumbf::BuiltSuperBox& assertion) const; + + private: + const HasherFactory& hasher_factory_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_REFERENCER_H_
diff --git a/assertion/assertion_referencer_test.cc b/assertion/assertion_referencer_test.cc new file mode 100644 index 0000000..37bf5dc --- /dev/null +++ b/assertion/assertion_referencer_test.cc
@@ -0,0 +1,97 @@ +// 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 "assertion/assertion_referencer.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/mock_hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "jumbf/test_utils.h" +#include "proto/assertion.pb.h" +#include "proto/hashed_uri.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; +using ::testing::Return; + +TEST(AssertionReferencerTest, Generate) { + jumbf::SuperBoxBuilder builder( + jumbf::kCborBoxTypeUuid, + {.label = jumbf::CordFromConstexpr("c2pa.actions.v2")}); + ASSERT_TRUE(builder + .AddContent(jumbf::kCborBoxType, + jumbf::CordFromConstexpr("fake CBOR")) + .ok()); + auto box = std::move(builder).Finalize(); + ABSL_ASSERT_OK(box); + std::string expected_hash_input = absl::StrCat( + jumbf::EncodeDescriptionBox(jumbf::kCborBoxTypeUuid, "c2pa.actions.v2", + /*requestable=*/true), + jumbf::WrapBox("fake CBOR", jumbf::kCborBoxType)); + + testing::StrictMock<MockHasherFactory> hasher_factory; + auto hasher = std::make_unique<MockHasher>(); + EXPECT_CALL(*hasher, Update(expected_hash_input)).Times(1); + EXPECT_CALL(*hasher, Digest()).WillOnce(Return("FAKE-HASH")); + EXPECT_CALL(hasher_factory, Create()).WillOnce(Return(std::move(hasher))); + + AssertionReferencer assertion_referencer(&hasher_factory); + auto expected = ParseTextProtoOrDie<HashedUri>(R"pb( + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + hash: "FAKE-HASH" + )pb"); + auto result = assertion_referencer.Generate(*box); + EXPECT_THAT(result, IsOkAndHolds(EqualsProto(expected))); +} + +TEST(AssertionReferencerTest, FailsToGenerateWithoutLabel) { + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + ASSERT_TRUE(builder + .AddContent(jumbf::kCborBoxType, + jumbf::CordFromConstexpr("fake CBOR")) + .ok()); + auto box = std::move(builder).Finalize(); + ABSL_ASSERT_OK(box); + + testing::StrictMock<MockHasherFactory> hasher_factory; + + AssertionReferencer assertion_referencer(&hasher_factory); + EXPECT_THAT( + assertion_referencer.Generate(*box), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("the assertion box must have a description label"))); +} + +} // namespace +} // namespace credentio
diff --git a/assertion/hashed_uri_validator.cc b/assertion/hashed_uri_validator.cc new file mode 100644 index 0000000..4741a79 --- /dev/null +++ b/assertion/hashed_uri_validator.cc
@@ -0,0 +1,81 @@ +// 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 "assertion/hashed_uri_validator.h" + +#include <memory> +#include <optional> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "jumbf/uri.h" +#include "jumbf/utils.h" +#include "proto/hashed_uri.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +std::optional<std::string> HashedUriValidator::Validate( + const HashedUri& hashed_uri, absl::string_view assertion_url, + ValidationTracker& validation_tracker) const { + auto record_failure = [&](FailureStatusCode code, + absl::string_view explanation = + "") -> std::optional<std::string> { + validation_tracker.RecordFailure( + code, {.url = assertion_url, .explanation = explanation}); + return std::nullopt; + }; + + // Check the uri. + auto path = jumbf::UriResolver::GetAbsolutePathFromUri(hashed_uri.url(), + manifest_path_); + if (!path.ok()) { + return record_failure(codes_.missing, path.status().message()); + } + auto box = uri_resolver_.ResolvePath(*path); + if (!box.ok()) { + return record_failure(codes_.missing, box.status().message()); + } + + // Check the algorithm. + absl::string_view alg_name = + hashed_uri.algorithm().empty() ? alg_ : hashed_uri.algorithm(); + auto algorithm = ParseHashAlgorithm(alg_name); + if (!algorithm.ok()) { + return record_failure(FailureStatusCode::kAlgorithmUnsupported); + } + auto checker = hash_checker_factory_.Create(*algorithm); + if (!checker.ok()) { + return record_failure(FailureStatusCode::kAlgorithmUnsupported); + } + + // Check the hash. + auto box_bytes = jumbf::StripBoxHeaders((*box)->raw_bytes); + if (!box_bytes.ok()) { + return record_failure(codes_.mismatch); + } + (*checker)->Update(*box_bytes); + if (!(*checker)->Check(hashed_uri.hash())) { + return record_failure(codes_.mismatch); + } + + return *path; +} + +} // namespace credentio
diff --git a/assertion/hashed_uri_validator.h b/assertion/hashed_uri_validator.h new file mode 100644 index 0000000..54cdf0a --- /dev/null +++ b/assertion/hashed_uri_validator.h
@@ -0,0 +1,66 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_HASHED_URI_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_HASHED_URI_VALIDATOR_H_ + +#include <optional> +#include <string> +#include <utility> + +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "jumbf/uri.h" +#include "proto/hashed_uri.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +// A validator that validates `hashed_uri` according to +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validation_of_references +class HashedUriValidator { + public: + struct Codes { + FailureStatusCode missing = FailureStatusCode::kHashedUriMissing; + FailureStatusCode mismatch = FailureStatusCode::kHashedUriMismatch; + }; + HashedUriValidator(std::string manifest_path, + const jumbf::UriResolver& uri_resolver, + const HashCheckerFactory* hash_checker_factory = nullptr) + : manifest_path_(std::move(manifest_path)), + uri_resolver_(uri_resolver), + hash_checker_factory_(hash_checker_factory == nullptr + ? DefaultHashCheckerFactory() + : *hash_checker_factory) {} + void SetErrorCodes(Codes codes) { codes_ = codes; } + void SetDefaultAlgorithm(std::string alg) { alg_ = std::move(alg); } + // Returns the validated path if validation is successful (i.e., hash is + // matched); otherwise, std::nullopt. + std::optional<std::string> Validate( + const HashedUri& hashed_uri, absl::string_view assertion_url, + ValidationTracker& validation_tracker) const; + + private: + std::string manifest_path_; + const jumbf::UriResolver& uri_resolver_; + const HashCheckerFactory& hash_checker_factory_; + Codes codes_; + std::string alg_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_HASHED_URI_VALIDATOR_H_
diff --git a/assertion/hashed_uri_validator_test.cc b/assertion/hashed_uri_validator_test.cc new file mode 100644 index 0000000..667872b --- /dev/null +++ b/assertion/hashed_uri_validator_test.cc
@@ -0,0 +1,248 @@ +// 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 "assertion/hashed_uri_validator.h" + +#include <memory> +#include <optional> + +#include "absl/log/check.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/uri.h" +#include "proto/hashed_uri.pb.h" +#include "testing/jumbf_utils.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" + +namespace credentio { +namespace { + +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::Eq; +using ::testing::IsEmpty; + +class FakeHashChecker : public HashChecker { + public: + explicit FakeHashChecker(absl::string_view hash_value) + : hash_value_(hash_value) {} + void Update(absl::string_view data) override {} + bool Check(absl::string_view hash) override { return hash == hash_value_; } + + private: + absl::string_view hash_value_; +}; + +class FakeHashCheckerFactory : public HashCheckerFactory { + public: + explicit FakeHashCheckerFactory(absl::string_view hash_value) + : hash_value_(hash_value) {} + + absl::StatusOr<std::unique_ptr<HashChecker>> Create( + HashAlgorithm algorithm) const override { + return std::make_unique<FakeHashChecker>(hash_value_); + } + + private: + absl::string_view hash_value_; +}; + +class HashedUriValidatorTest : public ::testing::Test { + protected: + HashedUriValidatorTest() + : box_a_({ + .description = {.label = "box_a"}, + .raw_bytes = "box a contents", + }), + box_a_hash_("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE="), + manifest_store_(CreateManifestStore( + {CreateStandardManifest("urn:c2pa:manifest0", {}), + CreateStandardManifest("urn:c2pa:manifest1", {box_a_})})), + uri_resolver_( + jumbf::UriResolver::WithSingleRootChild(&manifest_store_)) {} + + jumbf::SuperBox box_a_; + absl::string_view box_a_hash_; + jumbf::SuperBox manifest_store_; + jumbf::UriResolver uri_resolver_; + TestValidationTracker t_; +}; + +TEST_F(HashedUriValidatorTest, UriDoesNotResolve) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_); + EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb( + url: "no_such_box" + )pb"), + "", t_.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t_.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMissing)); +} + +TEST_F(HashedUriValidatorTest, UriDoesNotResolveCustomCode) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_); + validator.SetErrorCodes( + {.missing = FailureStatusCode::kIngredientManifestMissing, + .mismatch = FailureStatusCode::kIngredientManifestMismatch}); + EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb( + url: "no_such_box" + )pb"), + "", t_.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t_.GetFailures(), + ContainsFailure(FailureStatusCode::kIngredientManifestMissing)); +} + +TEST_F(HashedUriValidatorTest, InvalidBoxHeader) { + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest("urn:c2pa:manifest0", {}), + CreateStandardManifest("urn:c2pa:manifest1", + {{ + .description = {.label = "box_a"}, + .raw_bytes = "x", // Invalid header + }})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver); + EXPECT_THAT( + validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb( + url: "self#jumbf=/c2pa/urn:c2pa:manifest1/box_a" + algorithm: "sha256" + hash: "abc" + )pb"), + "", t_.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t_.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMismatch)); +} + +TEST_F(HashedUriValidatorTest, HashMismatch) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_); + EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb( + url: "self#jumbf=/c2pa/urn:c2pa:manifest1" + algorithm: "sha256" + hash: "abc" + )pb"), + "", t_.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t_.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMismatch)); +} + +TEST_F(HashedUriValidatorTest, HashMismatchCustomCode) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_); + validator.SetErrorCodes( + {.missing = FailureStatusCode::kIngredientManifestMissing, + .mismatch = FailureStatusCode::kIngredientManifestMismatch}); + EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb( + url: "self#jumbf=/c2pa/urn:c2pa:manifest1" + algorithm: "sha256" + hash: "abc" + )pb"), + "", t_.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t_.GetFailures(), + ContainsFailure(FailureStatusCode::kIngredientManifestMismatch)); +} + +TEST_F(HashedUriValidatorTest, UnsupportedAlgorithm) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_); + EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb( + url: "self#jumbf=/c2pa/urn:c2pa:manifest1" + algorithm: "rutabaga256" + hash: "abc" + )pb"), + "", t_.tracker()), + Eq(std::nullopt)); + EXPECT_THAT(t_.GetFailures(), + ContainsFailure(FailureStatusCode::kAlgorithmUnsupported)); +} + +TEST_F(HashedUriValidatorTest, Success) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_); + HashedUri hashed_uri; + hashed_uri.set_url("self#jumbf=/c2pa/urn:c2pa:manifest1/box_a"); + hashed_uri.set_algorithm("sha256"); + ASSERT_TRUE( + absl::Base64Unescape("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE=", + hashed_uri.mutable_hash())); + EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()), + Eq("/c2pa/urn:c2pa:manifest1/box_a")); + EXPECT_THAT(t_.GetFailures(), IsEmpty()); +} + +TEST_F(HashedUriValidatorTest, RelativeUri) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_); + HashedUri hashed_uri; + hashed_uri.set_url("self#jumbf=box_a"); + hashed_uri.set_algorithm("sha256"); + ASSERT_TRUE( + absl::Base64Unescape("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE=", + hashed_uri.mutable_hash())); + EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()), + Eq("/c2pa/urn:c2pa:manifest1/box_a")); + EXPECT_THAT(t_.GetFailures(), IsEmpty()); +} + +TEST_F(HashedUriValidatorTest, DefaultAlgorithmIgnoredWithExplicitAlgorithm) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_); + validator.SetDefaultAlgorithm("sha512"); + HashedUri hashed_uri; + hashed_uri.set_url("self#jumbf=box_a"); + hashed_uri.set_algorithm("sha256"); + ASSERT_TRUE( + absl::Base64Unescape("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE=", + hashed_uri.mutable_hash())); + EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()), + Eq("/c2pa/urn:c2pa:manifest1/box_a")); + EXPECT_THAT(t_.GetFailures(), IsEmpty()); +} + +TEST_F(HashedUriValidatorTest, DefaultAlgorithmUsed) { + HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_); + validator.SetDefaultAlgorithm("sha512"); + HashedUri hashed_uri; + hashed_uri.set_url("self#jumbf=box_a"); + ASSERT_TRUE(absl::Base64Unescape( + R"(69KGliqftQfRtFL+ouQG63R8RZef70mzgHC1GmUahLbyrFOzXliLyZkzDNwX2WfUGLYP1b4KCc5JRfl4OvtNSQ==)", + hashed_uri.mutable_hash())); + EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()), + Eq("/c2pa/urn:c2pa:manifest1/box_a")); + EXPECT_THAT(t_.GetFailures(), IsEmpty()); +} + +TEST_F(HashedUriValidatorTest, InjectedHashCheckerFactory) { + FakeHashCheckerFactory hash_checker_factory("fake_hash_value"); + HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_, + &hash_checker_factory); + auto hashed_uri = ParseTextProtoOrDie<HashedUri>(R"pb( + url: "self#jumbf=box_a" + algorithm: "sha256" + hash: "fake_hash_value" + )pb"); + EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()), + Eq("/c2pa/urn:c2pa:manifest1/box_a")); + EXPECT_THAT(t_.GetFailures(), IsEmpty()); +} + +} // namespace +} // namespace credentio
diff --git a/assertion/parse_assertion.cc b/assertion/parse_assertion.cc new file mode 100644 index 0000000..4d9c2b4 --- /dev/null +++ b/assertion/parse_assertion.cc
@@ -0,0 +1,251 @@ +// 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 "assertion/parse_assertion.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/string_view.h" +#include "cbor/parse.h" +#include "google/protobuf/struct.pb.h" +#include "nlohmann/json.hpp" +#include "proto/actions_assertion.cbor.h" +#include "proto/actions_assertion.pb.h" +#include "proto/ai_disclosure_assertion.cbor.h" +#include "proto/ai_disclosure_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/asset_reference_assertion.cbor.h" +#include "proto/asset_reference_assertion.pb.h" +#include "proto/asset_types_assertion.cbor.h" +#include "proto/asset_types_assertion.pb.h" +#include "proto/bmff_based_hash_assertion.cbor.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.cbor.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.cbor.h" +#include "proto/data_hash_assertion.cbor.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/environmental_sustainability_assertion.cbor.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.cbor.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/metadata_assertion.pb.h" +#include "proto/multi_asset_hash_assertion.cbor.h" +#include "proto/repository_receipt_assertion.pb.h" +#include "proto/soft_binding_assertion.cbor.h" +#include "proto/soft_binding_assertion.pb.h" +#include "proto/thumbnail_assertion.pb.h" + +namespace credentio { +namespace { + +using Json = ::nlohmann::json; + +absl::Status ParseListValueHelper(const Json& json, + google::protobuf::ListValue* proto); +absl::Status ParseValueHelper(const Json& json, google::protobuf::Value* proto); +absl::Status ParseStructHelper(const Json& json, + google::protobuf::Struct* proto); + +absl::Status ParseListValueHelper(const Json& json, + google::protobuf::ListValue* proto) { + for (const auto& wrapped_value : json.items()) { + // The parsed JSON has a redundant object wrapper for each list item. + // We only need the beginning item as it contains the index and the + // actual value. + const auto& [_, value] = wrapped_value.value().items().begin(); + google::protobuf::Value* proto_value = proto->add_values(); + ABSL_RETURN_IF_ERROR(ParseValueHelper(value, proto_value)); + } + return absl::OkStatus(); +} + +absl::Status ParseValueHelper(const Json& json, + google::protobuf::Value* proto) { + if (json.is_null()) { + proto->set_null_value(google::protobuf::NULL_VALUE); + return absl::OkStatus(); + } + if (json.is_number()) { + proto->set_number_value(json.get<double>()); + return absl::OkStatus(); + } + if (json.is_string()) { + proto->set_string_value(json.get<std::string>()); + return absl::OkStatus(); + } + if (json.is_boolean()) { + proto->set_bool_value(json.get<bool>()); + return absl::OkStatus(); + } + if (json.is_object()) { + return ParseStructHelper(json, proto->mutable_struct_value()); + } + if (json.is_array()) { + return ParseListValueHelper(json, proto->mutable_list_value()); + } + return absl::InvalidArgumentError("Should not happen"); +} + +absl::Status ParseStructHelper(const Json& json, + google::protobuf::Struct* proto) { + for (const auto& [key, value] : json.items()) { + google::protobuf::Value proto_value; + ABSL_RETURN_IF_ERROR(ParseValueHelper(value, &proto_value)); + proto->mutable_fields()->insert({key, proto_value}); + } + return absl::OkStatus(); +} + +template <typename T> +absl::Status ParseAssertionHelper(absl::string_view assertion, T* proto) { + ABSL_ASSIGN_OR_RETURN(auto item, cbor::Parse(assertion)); + ABSL_ASSIGN_OR_RETURN(auto map, item->AsMap()); + return cbor::ToProto(map, proto); +} + +template <typename T> +void ClearPaddingFields(T* proto) { + proto->clear_pad(); + proto->clear_pad2(); +} + +std::optional<std::string> GetJsonString(const Json& obj, + absl::string_view key) { + if (auto it = obj.find(key); it != obj.end() && it->is_string()) { + return it->get<std::string>(); + } + return std::nullopt; +} + +const Json* GetJsonObject(const Json& obj, absl::string_view key) { + if (auto it = obj.find(key); it != obj.end() && it->is_object()) { + return &(*it); + } + return nullptr; +} + +} // namespace + +#define C2PA_DEFINE_CBOR_ASSERTION_PARSER(ProtoType) \ + absl::Status ParseAssertion(absl::string_view assertion, ProtoType* proto) { \ + return ParseAssertionHelper(assertion, proto); \ + } + +C2PA_DEFINE_CBOR_ASSERTION_PARSER(ActionsAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(BmffBasedHashAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(IngredientAssertionV3) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(AssetReferenceAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(AssetTypesAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(BoxesHashAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(MultiAssetHashAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(CollectionDataHashAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(AiDisclosureAssertion) +C2PA_DEFINE_CBOR_ASSERTION_PARSER(EnvironmentalSustainabilityAssertion) + +#undef C2PA_DEFINE_CBOR_ASSERTION_PARSER + +absl::Status ParseAssertion(absl::string_view assertion, + ActionsAssertionV1* proto) { + ABSL_RETURN_IF_ERROR(ParseAssertionHelper(assertion, proto)); + // Move v1 parameters field `ingredient` to v2 field `ingredients`. + for (auto& action : *proto->mutable_actions()) { + auto* params = action.mutable_parameters(); + if (params->has_ingredient()) { + params->add_ingredients()->Swap(params->mutable_ingredient()); + params->clear_ingredient(); + } + } + return absl::OkStatus(); +} + +absl::Status ParseAssertion(absl::string_view assertion, + DataHashAssertion* proto) { + ABSL_RETURN_IF_ERROR(ParseAssertionHelper(assertion, proto)); + // Clear irrelevant padding fields (for internal generator use only). + ClearPaddingFields(proto); + return absl::OkStatus(); +} + +absl::Status ParseAssertion(absl::string_view assertion, + SoftBindingAssertion* proto) { + ABSL_RETURN_IF_ERROR(ParseAssertionHelper(assertion, proto)); + // Clear irrelevant padding fields (for internal generator use only). + ClearPaddingFields(proto); + return absl::OkStatus(); +} + +absl::Status ParseAssertion(absl::string_view assertion, + RepositoryReceiptAssertion* proto) { + auto json = Json::parse(assertion, nullptr, false); + if (json.is_discarded()) { + return absl::InvalidArgumentError("JSON parsing failed"); + } + if (!json.is_object()) { + return absl::InvalidArgumentError("Assertion is not a JSON object"); + } + if (const Json* repo_json = GetJsonObject(json, "repository")) { + auto* repository = proto->mutable_repository(); + if (auto uri = GetJsonString(*repo_json, "uri")) { + repository->set_uri(*std::move(uri)); + } + if (auto manifest_id = GetJsonString(*repo_json, "manifestId")) { + repository->set_manifest_id(*std::move(manifest_id)); + } + } + if (const Json* anchor_json = GetJsonObject(json, "anchor")) { + auto* anchor = proto->mutable_anchor(); + if (auto uri = GetJsonString(*anchor_json, "uri")) { + anchor->set_uri(*std::move(uri)); + } + if (const Json* params = GetJsonObject(*anchor_json, "parameters")) { + ABSL_RETURN_IF_ERROR( + ParseStructHelper(*params, anchor->mutable_parameters())); + } + if (const Json* proof = GetJsonObject(*anchor_json, "proof")) { + ABSL_RETURN_IF_ERROR(ParseStructHelper(*proof, anchor->mutable_proof())); + } + } + return absl::OkStatus(); +} + +ThumbnailAssertion ParseThumbnailAssertion( + absl::string_view media_type, absl::string_view data, + std::optional<absl::string_view> file_name) { + ThumbnailAssertion proto; + proto.set_media_type(media_type); + proto.set_data(data); + if (file_name.has_value()) { + proto.set_file_name(*file_name); + } + return proto; +} + +absl::Status ParseStruct(absl::string_view content, + google::protobuf::Struct* proto) { + Json result = Json::parse(content.begin(), content.end(), /*cb=*/nullptr, + /*allow_exceptions=*/false); + if (result.is_discarded()) { + return absl::InvalidArgumentError("Invalid JSON: could not parse"); + } + return ParseStructHelper(result, proto); +} + +} // namespace credentio
diff --git a/assertion/parse_assertion.h b/assertion/parse_assertion.h new file mode 100644 index 0000000..acf35ca --- /dev/null +++ b/assertion/parse_assertion.h
@@ -0,0 +1,119 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_PARSE_ASSERTION_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_PARSE_ASSERTION_H_ + +#include <optional> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "google/protobuf/struct.pb.h" +#include "proto/actions_assertion.pb.h" +#include "proto/ai_disclosure_assertion.pb.h" +#include "proto/asset_reference_assertion.pb.h" +#include "proto/asset_types_assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/environmental_sustainability_assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/metadata_assertion.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "proto/repository_receipt_assertion.pb.h" +#include "proto/soft_binding_assertion.pb.h" +#include "proto/thumbnail_assertion.pb.h" + +namespace credentio { + +// Parses a CBOR-encoded `actions-map-v2`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_8. +absl::Status ParseAssertion(absl::string_view assertion, + ActionsAssertion* proto); + +// Parses a CBOR-encoded `actions-map-v1`. +absl::Status ParseAssertion(absl::string_view assertion, + ActionsAssertionV1* proto); + +// Parses a CBOR-encoded `bmff-based-hash-map`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_bmff_based_hash. +absl::Status ParseAssertion(absl::string_view assertion, + BmffBasedHashAssertion* proto); + +// Parses a CBOR-encoded `data-hash-map`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_data_hash. +absl::Status ParseAssertion(absl::string_view assertion, + DataHashAssertion* proto); + +// Parses a CBOR-encoded `ingredient-map-v3`. +absl::Status ParseAssertion(absl::string_view assertion, + IngredientAssertionV3* proto); + +// Parses a CBOR-encoded `asset-ref-map`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_reference. +absl::Status ParseAssertion(absl::string_view assertion, + AssetReferenceAssertion* proto); + +// Parses a CBOR-encoded `asset-type-map`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_type. +absl::Status ParseAssertion(absl::string_view assertion, + AssetTypesAssertion* proto); + +// Parses a CBOR-encoded `box-hash-map`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_box_hash. +absl::Status ParseAssertion(absl::string_view assertion, + BoxesHashAssertion* proto); + +// Parses a CBOR-encoded `multi-asset-hash-map`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_multi_asset_hash. +absl::Status ParseAssertion(absl::string_view assertion, + MultiAssetHashAssertion* proto); + +// Parses a CBOR-encoded `collection-data-hash-map`. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_collection_data_hash. +absl::Status ParseAssertion(absl::string_view assertion, + CollectionDataHashAssertion* proto); + +absl::Status ParseAssertion(absl::string_view assertion, + AiDisclosureAssertion* proto); + +absl::Status ParseAssertion(absl::string_view assertion, + EnvironmentalSustainabilityAssertion* proto); + +absl::Status ParseAssertion(absl::string_view assertion, + RepositoryReceiptAssertion* proto); + +absl::Status ParseAssertion(absl::string_view assertion, + SoftBindingAssertion* proto); + +ThumbnailAssertion ParseThumbnailAssertion( + absl::string_view media_type, absl::string_view data, + std::optional<absl::string_view> file_name); + +absl::Status ParseStruct(absl::string_view content, + google::protobuf::Struct* proto); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_PARSE_ASSERTION_H_
diff --git a/assertion/parse_assertion_test.cc b/assertion/parse_assertion_test.cc new file mode 100644 index 0000000..2d327ad --- /dev/null +++ b/assertion/parse_assertion_test.cc
@@ -0,0 +1,985 @@ +// 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 "assertion/parse_assertion.h" + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "google/protobuf/struct.pb.h" +#include "gtest/gtest.h" +#include "nlohmann/json.hpp" +#include "proto/actions_assertion.pb.h" +#include "proto/ai_disclosure_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/asset_reference_assertion.pb.h" +#include "proto/asset_types_assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/environmental_sustainability_assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/repository_receipt_assertion.pb.h" +#include "testing/cbor_utils.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using Json = ::nlohmann::json; + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::cbor::FromJson; +using ::credentio::ParseStruct; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::HasSubstr; + +TEST(ParseAssertionTest, ActionsAssertionOkay) { + auto cbor = FromJson(R"json( + { + "allActionsIncluded": true, + "actions": [ + { + "action": "c2pa.created", + "softwareAgent": { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + "parameters": { + "instanceID": "ed610ae51f604002be3dbf0c589a2f1f", + "ingredients" : [ + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3__1", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + }, + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient.v3__2", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + } + ], + "relatedAssertions": [ + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.other", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + } + ] + } + }, + { + "action": "c2pa.filtered", + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + } + }, + { + "action": "c2pa.cropped" + } + ], + "templates": [{ + "action": "com.joesphoto.filter", + "description": "Magic Filter", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic", + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "schema.org.SoftwareApplication.operatingSystem": "Windows 10" + }, + "icon": { + "url": "http://cdn.examplephotoagency.com/logo.svg", + "hash": "b64'aGFzaA=='" + } + }] + })json"); + auto expected = ParseTextProtoOrDie<credentio::ActionsAssertion>(R"pb( + all_actions_included: true + actions { + action: "c2pa.created" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + software_agent { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + parameters { + instance_id: "ed610ae51f604002be3dbf0c589a2f1f" + ingredients { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3__1" + algorithm: "sha256" + hash: "hash" + } + ingredients { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient.v3__2" + algorithm: "sha256" + hash: "hash" + } + related_assertions { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.other" + algorithm: "sha256" + hash: "hash" + } + } + } + actions { + action: "c2pa.filtered" + software_agent { + name: "Joe\'s Photo Editor" + version: "2.0" + operating_system: "Windows 10" + } + } + actions { action: "c2pa.cropped" } + templates { + action: "com.joesphoto.filter" + description: "Magic Filter" + digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic" + software_agent { name: "Joe\'s Photo Editor" version: "2.0" } + icon { url: "http://cdn.examplephotoagency.com/logo.svg" hash: "hash" } + } + )pb"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, EmptyRelatedAssertionsFails) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created", + "softwareAgent": { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + "parameters": { + "instanceID": "ed610ae51f604002be3dbf0c589a2f1f", + "ingredients" : [ + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3__1", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + }, + { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient.v3__2", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + } + ], + "relatedAssertions": [] + } + }, + { + "action": "c2pa.filtered", + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + } + }, + { + "action": "c2pa.cropped" + } + ], + "templates": [{ + "action": "com.joesphoto.filter", + "description": "Magic Filter", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic", + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "schema.org.SoftwareApplication.operatingSystem": "Windows 10" + }, + "icon": { + "url": "http://cdn.examplephotoagency.com/logo.svg", + "hash": "b64'aGFzaA=='" + } + }] + })json"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Field relatedAssertions cannot be empty"))); +} + +TEST(ParseAssertionTest, ActionV1IngredientField) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "ingredient" : { + "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3", + "alg": "sha256", + "hash" : "b64'aGFzaA=='" + } + } + } + ] + })json"); + // The `ingredient` field in v1 `parameters-map` is exposed in `ingredients`. + auto expected = ParseTextProtoOrDie<credentio::ActionsAssertionV1>(R"pb( + actions { + action: "c2pa.opened" + parameters { + ingredients { + url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "hash" + } + } + } + )pb"); + credentio::ActionsAssertionV1 proto; + EXPECT_THAT(ParseAssertion(cbor, &proto), IsOk()); + EXPECT_THAT(proto, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, ActionsAssertionInvalidNotAMap) { + auto cbor = FromJson(R"json([ + { + "action": "c2pa.cropped" + } + ])json"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR parsed result is not a map"))); +} + +TEST(ParseAssertionTest, ActionsAssertionInvalidNoAction) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "softwareAgent" : { + "name": "Joe's Photo Editor", + "version": "2.0", + "operating_system": "Windows 10" + } + } + ] + })json"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: action"))); +} + +TEST(ParseAssertionTest, ActionsAssertionInvalidActionNotAString) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "action": 123 + } + ] + })json"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: action"))); +} + +TEST(ParseAssertionTest, ActionsAssertionSkipsInvalidDigitalSourceType) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": 123 + } + ] + })json"); + auto expected = ParseTextProtoOrDie<credentio::ActionsAssertion>(R"pb( + actions { action: "c2pa.created" } + )pb"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, ActionsAssertionInvalidActionArrayContainsNonMap) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + }, + 123 + ] + })json"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR item is not a map"))); +} + +TEST(ParseAssertionTest, ActionsAssertionInvalidMalformedCbor) { + auto cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created" + } + ] + })json"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor.substr(0, 10), &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR parsing failed"))); +} + +TEST(ParseAssertionTest, ActionsAssertionSkipsInvalidNotArrayActions) { + // Invalid `actions`, as it should be an array, not a map. + auto cbor = FromJson(R"json( + { + "actions": + { + "action": "c2pa.created" + } + })json"); + credentio::ActionsAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: actions"))); +} + +TEST(ParseAssertionTest, IngredientAssertionOkay) { + auto cbor = FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "relationship": "ingredient-relationship", + "instanceID": "ingredient-instance-id", + "data": { + "url": "ingredient-data-url", + "alg": "ingredient-data-alg", + "hash": "b64'aGFzaA=='", + "size": 1234567890 + }, + "activeManifest": { + "url": "ingredient-manifest-url", + "alg": "ingredient-manifest-alg", + "hash": "b64'aGFzaA=='" + }, + "thumbnail": { + "url": "ingredient-thumbnail-url", + "alg": "ingredient-thumbnail-alg", + "hash": "b64'aGFzaA=='" + }, + "validationResults": { + "activeManifest": { + "success": [ + { + "code": "status.1", + "url": "url.1", + "explanation": "explanation.1" + }, + { + "code": "status.2", + "url": "url.2", + "explanation": "explanation.2" + } + ], + "failure": [], + "informational": [] + } + }, + "description": "ingredient-description", + "informationalURI": "ingredient-informational-uri" + })json"); + auto expected = ParseTextProtoOrDie<credentio::IngredientAssertionV3>(R"pb( + title: "ingredient-title" + media_type: "ingredient-format" + relationship: "ingredient-relationship" + instance_id: "ingredient-instance-id" + data { + url: "ingredient-data-url" + algorithm: "ingredient-data-alg" + hash: "hash" + size: 1234567890 + } + active_manifest { + url: "ingredient-manifest-url" + algorithm: "ingredient-manifest-alg" + hash: "hash" + } + thumbnail { + url: "ingredient-thumbnail-url" + algorithm: "ingredient-thumbnail-alg" + hash: "hash" + } + validation_results { + active_manifest { + successes: { + code: "status.1" + url: "url.1" + explanation: "explanation.1" + } + successes: { + code: "status.2" + url: "url.2" + explanation: "explanation.2" + } + } + } + description: "ingredient-description" + informational_uri: "ingredient-informational-uri" + )pb"); + credentio::IngredientAssertionV3 result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, AssetReferenceAssertionOkay) { + auto cbor = FromJson(R"json( + { + "references": [ + { + "description": "A copy of the asset on the web", + "reference": { + "uri": "https://some.storage.us/foo" + } + }, + { + "description": "A copy of the asset on IPFS", + "reference": { + "uri": "ipfs://cid" + } + } + ] + })json"); + auto expected = ParseTextProtoOrDie<credentio::AssetReferenceAssertion>(R"pb( + references { + description: "A copy of the asset on the web" + reference { uri: "https://some.storage.us/foo" } + } + references { + description: "A copy of the asset on IPFS" + reference { uri: "ipfs://cid" } + } + )pb"); + credentio::AssetReferenceAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, AssetReferenceAssertionInvalidMissingRequiredField) { + auto cbor = FromJson(R"json( + { + "references": [ + { + "reference": { + } + } + ] + })json"); + credentio::AssetReferenceAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: uri"))); +} + +TEST(ParseAssertionTest, AssetTypesAssertionOkay) { + auto cbor = FromJson(R"json( + { + "dc:format": "image/jpeg", + "types": + [ + { + "type": "c2pa.types.model.tensorflow", + "version": "2.11.0" + }, + { + "type": "c2pa.types.savedmodel", + "version": "2.11.0" + } + ] + })json"); + auto expected = ParseTextProtoOrDie<credentio::AssetTypesAssertion>(R"pb( + media_type: "image/jpeg" + types { type: "c2pa.types.model.tensorflow" version: "2.11.0" } + types { type: "c2pa.types.savedmodel" version: "2.11.0" } + )pb"); + credentio::AssetTypesAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, AssetTypesAssertionInvalidMissingRequiredField) { + auto cbor = FromJson(R"json( + { + "types": + [ + { + "version": "2.11.0" + } + ] + })json"); + credentio::AssetTypesAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: type"))); +} + +TEST(ParseAssertionTest, DataHashAssertionOkay) { + // The `pad` and `pad2` fields are omitted by the parser. + auto cbor = FromJson(R"json( + { + "alg": "sha256", + "pad": "b64'Zm9v'", + "pad2": "b64'Zm9v'", + "hash": "b64'Zm9v'", + "name": "JUMBF manifest", + "exclusions": [ + { + "start": 9960, + "length": 4213 + }, + { + "start": 14173, + "length": 1000 + } + ] + })json"); + auto expected = ParseTextProtoOrDie<credentio::DataHashAssertion>(R"pb( + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + )pb"); + credentio::DataHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, DataHashAssertionInvalidMissingRequiredField) { + auto cbor = FromJson(R"json( + { + "alg": "sha256" + })json"); + credentio::DataHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: hash"))); +} + +TEST(ParseAssertionTest, BmffBasedHashAssertionOkay) { + auto cbor = FromJson(R"json( + { + "hash": "b64'Zm9v'", + "name": "Example `c2pa.hash.bmff.v2` assertion", + "exclusions": [ + { + "data": [{ "value": "b64'Zm9v'", "offset": 8 }], + "xpath": "/uuid" + }, + { "xpath": "/ftyp" }, + { "xpath": "/mfra" }, + { "xpath": "/moov[1]/pssh" }, + { + "data": [{ "value": "b64'Zm9v'", "offset": 20 }], + "xpath": "/emsg" + } + ] + })json"); + auto expected = ParseTextProtoOrDie<credentio::BmffBasedHashAssertion>(R"pb( + hash: "foo" + name: "Example `c2pa.hash.bmff.v2` assertion" + exclusions { + data { value: "foo" offset: 8 } + xpath: "/uuid" + } + exclusions { xpath: "/ftyp" } + exclusions { xpath: "/mfra" } + exclusions { xpath: "/moov[1]/pssh" } + exclusions { + data { value: "foo" offset: 20 } + xpath: "/emsg" + } + )pb"); + credentio::BmffBasedHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, BmffBasedHashAssertionInvalidMissingRequiredField) { + auto cbor = FromJson(R"json( + { + "name": "Example `c2pa.hash.bmff.v2` assertion", + "exclusions": [ + { + "data": [ + { + "value": "2P7D1hsOSDySl1goh37EgQ==", + "offset": 8 + } + ] + } + ] + })json"); + credentio::BmffBasedHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: xpath"))); +} + +TEST(ParseAssertionTest, BoxesHashAssertionOkay) { + auto cbor = FromJson(R"json({ + "alg" : "sha256", + "boxes": [ + { + "names" : ["SOI", "APP0", "APP2"], + "hash" : "b64'Zm9v'" + }, + { + "names" : ["C2PA"], + "hash" : "b64'YmFy'" + } + ] + })json"); + auto expected = ParseTextProtoOrDie<credentio::BoxesHashAssertion>(R"pb( + alg: "sha256" + boxes { names: "SOI" names: "APP0" names: "APP2" hash: "foo" } + boxes { names: "C2PA" hash: "bar" } + )pb"); + credentio::BoxesHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, BoxesHashAssertionInvalidMissingRequiredField) { + auto cbor = FromJson(R"json({ + "boxes": [ + { + "names" : ["SOI", "APP0", "APP2"] + } + ] + })json"); + credentio::BoxesHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: hash"))); +} + +TEST(ParseAssertionTest, CollectionDataHashAssertionOkay) { + auto cbor = FromJson(R"json( + { + "alg": "sha256", + "uris": [ + { + "uri": "foo1.txt", + "hash": "b64'Zm9v'", + "size": 1234, + "dc:format": "text/plain", + "data_types": [ + { + "type": "c2pa.types.model.tensorflow", + "version": "2.11.0" + } + ] + }, + { + "uri": "foo2.jpg", + "hash": "b64'YmFy'", + "dc:format": "image/jpeg" + } + ], + "zip_central_directory_hash": "b64'emlwSGFzaA=='" + })json"); + auto expected = + ParseTextProtoOrDie<credentio::CollectionDataHashAssertion>(R"pb( + alg: "sha256" + uris { + uri: "foo1.txt" + hash: "foo" + size: 1234 + type: "text/plain" + data_types { type: "c2pa.types.model.tensorflow" version: "2.11.0" } + } + uris { uri: "foo2.jpg" hash: "bar" type: "image/jpeg" } + zip_central_directory_hash: "zipHash" + )pb"); + credentio::CollectionDataHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, + CollectionDataHashAssertionInvalidMissingRequiredField) { + auto cbor = FromJson(R"json( + { + "uris": [ + { + "uri": "foo1.txt", + "hash": "b64'Zm9v'" + } + ] + })json"); + credentio::CollectionDataHashAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: alg"))); +} + +TEST(ParseAssertionTest, SoftBindingAssertionOkay) { + auto cbor = FromJson(R"json( + { + "alg": "soft-algorithm", + "name": "soft-binding-name", + "pad": "b64'Zm9v'", + "pad2": "b64'Zm9v'", + "alg-params": "b64'cGFyYW1z'", + "blocks": [ + { + "scope": { + "extent": "b64'ZXh0ZW50'", + "timespan": { + "start": 100, + "end": 200 + } + }, + "value": "b64'dmFsdWU='" + } + ], + "bindingMetadata": { + "description": "metadata-desc", + "contact": "metadata-contact", + "informationalUrl": "metadata-url" + } + })json"); + auto expected = ParseTextProtoOrDie<credentio::SoftBindingAssertion>(R"pb( + algorithm: "soft-algorithm" + name: "soft-binding-name" + alg_params: "params" + blocks { + scope { + extent: "extent" + timespan { start: 100 end: 200 } + } + value: "value" + } + binding_metadata { + description: "metadata-desc" + contact: "metadata-contact" + informational_url: "metadata-url" + } + )pb"); + credentio::SoftBindingAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, SoftBindingAssertionMissingRequiredField) { + auto cbor = FromJson(R"json( + { + "name": "soft-binding-name" + })json"); + credentio::SoftBindingAssertion result; + EXPECT_THAT(ParseAssertion(cbor, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Missing required field: alg"))); +} + +TEST(ParseAssertionTest, ParseStructOkay) { + auto json = R"json({ + "string": "bar", + "int": 123, + "float": 123.456, + "bool": true, + "null": null, + "array": [1, 2, 3], + "object": { + "nested": "string" + } + })json"; + auto expected = ParseTextProtoOrDie<google::protobuf::Struct>(R"pb( + fields { + key: "string" + value { string_value: "bar" } + } + fields { + key: "int" + value { number_value: 123 } + } + fields { + key: "float" + value { number_value: 123.456 } + } + fields { + key: "bool" + value { bool_value: true } + } + fields { + key: "null" + value { null_value: NULL_VALUE } + } + fields { + key: "array" + value { + list_value { + values { number_value: 1 } + values { number_value: 2 } + values { number_value: 3 } + } + } + } + fields { + key: "object" + value { + struct_value { + fields { + key: "nested" + value { string_value: "string" } + } + } + } + } + )pb"); + google::protobuf::Struct result; + EXPECT_THAT(ParseStruct(json, &result), IsOk()); + EXPECT_THAT(result, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, ParseStructInvalidJson) { + auto json = R"json({ + "failed + })json"; + google::protobuf::Struct result; + EXPECT_THAT(ParseStruct(json, &result), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid JSON: could not parse"))); +} + +TEST(ParseAssertionTest, AiDisclosureAssertionOkay) { + auto cbor = FromJson(R"json( + { + "modelType": "c2pa.types.model.onnx", + "modelName": "My Awesome AI Model", + "modelIdentifier": "https://example.com/models/awesome-ai-v1", + "contentProfile": { + "humanOversightLevel": "prompt_guided", + "scientificDomain": ["cs.AI", "cs.CV"] + } + })json"); + auto expected = ParseTextProtoOrDie<credentio::AiDisclosureAssertion>(R"pb( + model_type: "c2pa.types.model.onnx" + model_name: "My Awesome AI Model" + model_identifier: "https://example.com/models/awesome-ai-v1" + content_profile { + human_oversight_level: "prompt_guided" + scientific_domain: "cs.AI" + scientific_domain: "cs.CV" + } + )pb"); + credentio::AiDisclosureAssertion actual; + ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk()); + EXPECT_THAT(actual, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, EnvironmentalSustainabilityAssertionOkay) { + auto cbor = FromJson(R"json( + { + "energy_kwh": { + "value": 1.23, + "measurementMethod": "com.example.direct-measurement" + }, + "carbon_kgco2e": { + "value": 0.456 + }, + "water_litres": { + "value": 7.89, + "measurementMethod": "com.example.estimation" + } + })json"); + auto expected = + ParseTextProtoOrDie<credentio::EnvironmentalSustainabilityAssertion>(R"pb( + energy_kwh { + value: 1.23 + measurement_method: "com.example.direct-measurement" + } + carbon_kgco2e { value: 0.456 } + water_litres { + value: 7.89 + measurement_method: "com.example.estimation" + } + )pb"); + credentio::EnvironmentalSustainabilityAssertion actual; + ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk()); + EXPECT_THAT(actual, EqualsProto(expected)); +} + +TEST(ParseAssertionTest, RepositoryReceiptAssertionOkay) { + auto json = R"json( + { + "repository": { + "uri": "https://repository.example.com/manifests/xyz123", + "manifestId": "urn:uuid:12345678-1234-5678-1234-567812345678" + }, + "anchor": { + "uri": "https://anchor.example.com/receipts/9876", + "parameters": { + "version": "1.0" + }, + "proof": { + "signature": "c2lnbmF0dXJl" + } + } + })json"; + auto expected = + ParseTextProtoOrDie<credentio::RepositoryReceiptAssertion>(R"pb( + repository { + uri: "https://repository.example.com/manifests/xyz123" + manifest_id: "urn:uuid:12345678-1234-5678-1234-567812345678" + } + anchor { + uri: "https://anchor.example.com/receipts/9876" + parameters { + fields { + key: "version" + value { string_value: "1.0" } + } + } + proof { + fields { + key: "signature" + value { string_value: "c2lnbmF0dXJl" } + } + } + } + )pb"); + credentio::RepositoryReceiptAssertion actual; + ASSERT_THAT(credentio::ParseAssertion(json, &actual), IsOk()); + EXPECT_THAT(actual, EqualsProto(expected)); +} + +} // namespace +} // namespace credentio
diff --git a/assertion/references_validator.cc b/assertion/references_validator.cc new file mode 100644 index 0000000..bb68c59 --- /dev/null +++ b/assertion/references_validator.cc
@@ -0,0 +1,172 @@ +// 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 "assertion/references_validator.h" + +#include <string> +#include <vector> + +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" +#include "absl/strings/string_view.h" +#include "assertion/assertion_parser.h" +#include "assertion/hashed_uri_validator.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +bool ValidateHashedUri(const HashedUri& hashed_uri, + const HashedUriValidator& hashed_uri_validator, + absl::string_view assertion_url, + ValidationTracker& validation_tracker) { + return hashed_uri_validator + .Validate(hashed_uri, assertion_url, validation_tracker) + .has_value(); +} + +bool IsUnsupportedRelatedAssertionLabel(absl::string_view label) { + return label == kIngredientAssertionV1Label || + label == kIngredientAssertionV2Label || + label == kIngredientAssertionV3Label || + label == kActionsAssertionV1Label || label == kActionsAssertionV2Label; +} + +bool ValidateTypeForRelatedAssertion(absl::string_view related_path, + absl::string_view manifest_label, + absl::string_view assertion_url, + ValidationTracker& validation_tracker) { + const std::vector<absl::string_view> related_tokens = + absl::StrSplit(related_path, '/'); + if (related_tokens.size() <= 3) { + return false; + } + + if (related_tokens[2] != manifest_label) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.url = assertion_url, + .explanation = "Related Assertion is not in the same manifest as the " + "action assertion."}); + return false; + } + + const absl::string_view label = + StripMultipleInstanceSuffix(related_tokens.back()); + if (IsUnsupportedRelatedAssertionLabel(label)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.url = assertion_url, + .explanation = + absl::StrCat("unsupported related assertion type: ", label)}); + return false; + } + return true; +} + +bool ValidateRelatedAssertions(const ActionParameters& parameters, + const HashedUriValidator& hashed_uri_validator, + absl::string_view manifest_label, + absl::string_view assertion_url, + ValidationTracker& validation_tracker) { + for (const auto& related_uri : parameters.related_assertions()) { + auto path = hashed_uri_validator.Validate(related_uri, assertion_url, + validation_tracker); + if (!path.has_value()) { + // Note: C2PA 2.4 Validation + // (https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_actions_validation) + // states that the HashedUri should be validated (which could add failure + // codes), and then states to additionally add the failure code + // assertion.action.malformed if the referenced assertion can not be + // resolved (i.e. is missing or hash mismatch) this results in two + // failures for the same issue. + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.url = assertion_url, + .explanation = absl::StrCat("could not resolve related assertion: ", + related_uri.url())}); + return false; + } + if (!ValidateTypeForRelatedAssertion(*path, manifest_label, assertion_url, + validation_tracker)) { + return false; + } + } + return true; +} + +template <typename ActionsT> +bool ValidateActions(const ActionsT& actions, + const HashedUriValidator& hashed_uri_validator, + absl::string_view manifest_label, + absl::string_view assertion_url, + ValidationTracker& validation_tracker) { + for (const auto& action : actions.actions()) { + if (!action.has_parameters()) { + continue; + } + for (const auto& ingredient : action.parameters().ingredients()) { + if (!ValidateHashedUri(ingredient, hashed_uri_validator, assertion_url, + validation_tracker)) { + return false; + } + } + if (!ValidateRelatedAssertions(action.parameters(), hashed_uri_validator, + manifest_label, assertion_url, + validation_tracker)) { + return false; + } + } + return true; +} + +bool ValidateIngredientV3(const IngredientAssertionV3& ingredient, + const HashedUriValidator& hashed_uri_validator, + absl::string_view assertion_url, + ValidationTracker& validation_tracker) { + // `active_manifest` and `claim_signature` are validated in `ManifestGraph` + // when appropriate -- the `active_manifest` hash won't validate when the + // ingredient has redactions. + return !ingredient.has_thumbnail() || + ValidateHashedUri(ingredient.thumbnail(), hashed_uri_validator, + assertion_url, validation_tracker); +} +} // namespace + +bool ReferencesValidator::Validate(const Assertion& assertion) const { + const std::string assertion_url = absl::StrCat( + kAssertionStoreLabel, kManifestLabelDelimiter, assertion.label()); + switch (assertion.assertion_case()) { + case Assertion::kActionsV1: + return ValidateActions(assertion.actions_v1(), hashed_uri_validator_, + manifest_label_, assertion_url, tracker_); + case Assertion::kActions: + return ValidateActions(assertion.actions(), hashed_uri_validator_, + manifest_label_, assertion_url, tracker_); + case Assertion::kIngredientV3: + return ValidateIngredientV3(assertion.ingredient_v3(), + hashed_uri_validator_, assertion_url, + tracker_); + default: + return true; + } +} +} // namespace credentio
diff --git a/assertion/references_validator.h b/assertion/references_validator.h new file mode 100644 index 0000000..416c652 --- /dev/null +++ b/assertion/references_validator.h
@@ -0,0 +1,45 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_REFERENCES_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_REFERENCES_VALIDATOR_H_ + +#include "absl/log/die_if_null.h" +#include "absl/strings/string_view.h" +#include "assertion/hashed_uri_validator.h" +#include "proto/assertion.pb.h" +#include "validator/tracker.h" + +namespace credentio { +// Validation of references according to +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validation_of_references. +class ReferencesValidator { + public: + ReferencesValidator(const HashedUriValidator* hashed_uri_validator, + absl::string_view manifest_label, + ValidationTracker* tracker) + : hashed_uri_validator_(*ABSL_DIE_IF_NULL(hashed_uri_validator)), + manifest_label_(manifest_label), + tracker_(*ABSL_DIE_IF_NULL(tracker)) {} + bool Validate(const Assertion& assertion) const; + + private: + const HashedUriValidator& hashed_uri_validator_; + const absl::string_view manifest_label_; + ValidationTracker& tracker_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_REFERENCES_VALIDATOR_H_
diff --git a/assertion/references_validator_test.cc b/assertion/references_validator_test.cc new file mode 100644 index 0000000..2adc17a --- /dev/null +++ b/assertion/references_validator_test.cc
@@ -0,0 +1,622 @@ +// 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 "assertion/references_validator.h" + +#include <memory> +#include <string> + +#include "absl/log/check.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "assertion/hashed_uri_validator.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/uri.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "testing/jumbf_utils.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" + +namespace credentio { +namespace { + +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::IsEmpty; +using ::testing::UnorderedElementsAre; + +constexpr absl::string_view kManifestLabel1 = + "urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"; +constexpr absl::string_view kManifestPath1 = + "/c2pa/urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"; +constexpr absl::string_view kManifestLabel2 = + "urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA"; + +std::string Digest(absl::string_view data) { + auto hasher = CreateHasher("sha256"); + CHECK_OK(hasher); + (*hasher)->Update(data); + return (*hasher)->Digest(); +} + +TEST(ReferencesValidatorTest, ValidateIngredientV3ThumbnailOkay) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.ingredient" + ingredient_v3 { + thumbnail { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg" + algorithm: "sha256" + hash: "%s" + } + } + )pb", + expected_hash)); + jumbf::SuperBox thumbnail_box = { + .description = + { + .label = "c2pa.thumbnail.ingredient_1.jpg", + }, + .raw_bytes = "00020000foo", + }; + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel2, {thumbnail_box})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_TRUE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty()); +} + +TEST(ReferencesValidatorTest, ValidateIngredientV3ThumbnailMissing) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.ingredient" + ingredient_v3 { + thumbnail { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg" + algorithm: "sha256" + hash: "%s" + } + } + )pb", + expected_hash)); + jumbf::SuperBox manifest_store = + CreateManifestStore({CreateStandardManifest(kManifestLabel2, {})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), UnorderedElementsAre("hashedUri.missing")); +} + +TEST(ReferencesValidatorTest, ValidateIngredientV3ThumbnailMismatch) { + TestValidationTracker tracker; + auto expected_hash = Digest("bar"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.ingredient" + ingredient_v3 { + thumbnail { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg" + algorithm: "sha256" + hash: "%s" + } + } + )pb", + expected_hash)); + jumbf::SuperBox thumbnail_box = { + .description = + { + .label = "c2pa.thumbnail.ingredient_1.jpg", + }, + .raw_bytes = "00020000foo", + }; + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel2, {thumbnail_box})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), + UnorderedElementsAre("hashedUri.mismatch")); +} + +TEST(ReferencesValidatorTest, ValidateIngredientV3ActiveManifestOkay) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA" + algorithm: "sha256" + hash: "%s" + } + } + )pb", + expected_hash)); + auto manifest_box = CreateStandardManifest(kManifestLabel2, {}); + manifest_box.raw_bytes = "00020000foo"; + jumbf::SuperBox manifest_store = CreateManifestStore({manifest_box}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_TRUE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ReferencesValidatorTest, + ValidateIngredientV3ActiveManifestMismatchIgnored) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.ingredient" + ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA" + algorithm: "sha256" + hash: "%s" + } + } + )pb", + expected_hash)); + jumbf::SuperBox manifest_store = + CreateManifestStore({CreateStandardManifest(kManifestLabel2, {})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + EXPECT_TRUE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ReferencesValidatorTest, + ValidateIngredientV3ClaimSignatureMismatchIgnored) { + TestValidationTracker tracker; + auto expected_hash = Digest("bar"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.ingredient" + ingredient_v3 { + claim_signature { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.signature" + algorithm: "sha256" + hash: "%s" + } + } + )pb", + expected_hash)); + jumbf::SuperBox claim_signature_box = { + .description = + { + .label = "c2pa.signature", + }, + .raw_bytes = "00020000foo", + }; + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel2, {claim_signature_box})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + EXPECT_TRUE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ReferencesValidatorTest, ValidateActionsV1IngredientOkay) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions_v1 { + actions { + action: "c2pa.opened", + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox ingredient_box = { + .description = + { + .label = "c2pa.ingredient__1", + }, + .raw_bytes = "00020000foo", + }; + auto assertion_store = CreateAssertionStoreBox({ingredient_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_TRUE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ReferencesValidatorTest, ValidateActionsV1IngredientMismatch) { + TestValidationTracker tracker; + auto expected_hash = Digest("bar"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions_v1 { + actions { + action: "c2pa.opened", + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox ingredient_box = { + .description = + { + .label = "c2pa.ingredient__1", + }, + .raw_bytes = "00020000foo", + }; + auto assertion_store = CreateAssertionStoreBox({ingredient_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), + UnorderedElementsAre("hashedUri.mismatch")); +} + +TEST(ReferencesValidatorTest, ValidateActionsV2IngredientOkay) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions { + actions { + action: "c2pa.opened", + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox ingredient_box = { + .description = + { + .label = "c2pa.ingredient__1", + }, + .raw_bytes = "00020000foo", + }; + auto assertion_store = CreateAssertionStoreBox({ingredient_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_TRUE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ReferencesValidatorTest, ValidateActionsV2IngredientMismatch) { + TestValidationTracker tracker; + auto expected_hash = Digest("bar"); + auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions { + actions { + action: "c2pa.opened", + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox ingredient_box = { + .description = + { + .label = "c2pa.ingredient__1", + }, + .raw_bytes = "00020000foo", + }; + auto assertion_store = CreateAssertionStoreBox({ingredient_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetFailures(), + UnorderedElementsAre("hashedUri.mismatch")); +} + +TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsOkay) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions { + actions { + action: "c2pa.opened", + parameters { + related_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.other" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox other_assertion_box = { + .description = + { + .label = "c2pa.other", + }, + .raw_bytes = "00020000foo", + }; + auto assertion_store = CreateAssertionStoreBox({other_assertion_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_TRUE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT(tracker.GetFailures(), IsEmpty()); +} + +TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsMissing) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions { + actions { + action: "c2pa.opened", + parameters { + related_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.other" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox manifest_store = + CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT( + tracker.GetFailures(), + UnorderedElementsAre("hashedUri.missing", "assertion.action.malformed")); +} + +TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsMismatch) { + TestValidationTracker tracker; + auto expected_hash = Digest("bar"); + Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions { + actions { + action: "c2pa.opened", + parameters { + related_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.other" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox other_assertion_box = { + .description = + { + .label = "c2pa.other", + }, + .raw_bytes = "00020000foo", + }; + auto assertion_store = CreateAssertionStoreBox({other_assertion_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT( + tracker.GetFailures(), + UnorderedElementsAre("hashedUri.mismatch", "assertion.action.malformed")); +} + +TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsIngredient) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions { + actions { + action: "c2pa.opened", + parameters { + related_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + jumbf::SuperBox ingredient_box = { + .description = + { + .label = "c2pa.ingredient__1", + }, + .raw_bytes = "00020000foo", + }; + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ingredient_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT(tracker.GetFailures(), + UnorderedElementsAre("assertion.action.malformed")); +} + +TEST(ReferencesValidatorTest, + ValidateActionsV2RelatedAssertionsInSeparateManifest) { + TestValidationTracker tracker; + auto expected_hash = Digest("foo"); + Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat( + R"pb( + label: "c2pa.action" + actions { + actions { + action: "c2pa.opened", + parameters { + related_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.other" + algorithm: "sha256" + hash: "%s" + } + } + } + } + )pb", + expected_hash)); + assertion.mutable_actions() + ->mutable_actions(0) + ->mutable_parameters() + ->mutable_related_assertions(0) + ->set_url(absl::StrCat("self#jumbf=/c2pa/", kManifestLabel2, + "/c2pa.assertions/c2pa.other")); + + jumbf::SuperBox ingredient_box = { + .description = + { + .label = "c2pa.other", + }, + .raw_bytes = "00020000foo", + }; + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ingredient_box}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel2, {assertion_store}), + CreateStandardManifest(kManifestLabel1, {})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + HashedUriValidator hashed_uri_validator(std::string(kManifestPath1), + uri_resolver); + ReferencesValidator references_validator(&hashed_uri_validator, + kManifestLabel1, &tracker.tracker()); + ASSERT_FALSE(references_validator.Validate(assertion)); + EXPECT_THAT(tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT(tracker.GetFailures(), + UnorderedElementsAre("assertion.action.malformed")); +} + +} // namespace +} // namespace credentio
diff --git a/assertion/validator.cc b/assertion/validator.cc new file mode 100644 index 0000000..cfd64d9 --- /dev/null +++ b/assertion/validator.cc
@@ -0,0 +1,703 @@ +// 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 "assertion/validator.h" + +#include <algorithm> +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/no_destructor.h" +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/log/log.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "assertion/actions_assertion_validator.h" +#include "assertion/assertion_parser.h" +#include "assertion/hashed_uri_validator.h" +#include "assertion/references_validator.h" +#include "constants/ingredient_relationships.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "google/protobuf/repeated_ptr_field.h" +#include "jumbf/box.h" +#include "jumbf/uri.h" +#include "proto/actions_assertion.pb.h" +#include "proto/assertion.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +using ::jumbf::BinaryDataBox; +using ::jumbf::CborBox; +using ::jumbf::EmbeddedFileDescriptionBox; +using ::jumbf::JsonBox; +using ::jumbf::SuperBox; + +constexpr absl::string_view kUnknown = "c2pa.unknown"; + +enum ManifestType { + kStandardManifest, + kUpdateManifest, +}; + +absl::string_view Basename(absl::string_view path) { + const size_t pos = path.find_last_of('/'); + if (pos == absl::string_view::npos) { + return path; + } + return path.substr(pos + 1); +} + +template <typename ActionsT> +bool ValidateActions(const ActionsT& actions, absl::string_view assertion_url, + ValidationTracker& validation_tracker) { + for (const auto& action : actions.actions()) { + if (action.action() == kUnknown) { + validation_tracker.RecordFailure( + FailureStatusCode::kGoogleAssertionActionUnknownAction, + {.url = assertion_url}); + return false; + } + } + return true; +} + +bool HasSuccesses(const ValidationStatusSet& validation) { + return !validation.successes().empty(); +} + +bool HasFailures(const ValidationStatusSet& validation) { + return !validation.failures().empty(); +} + +bool IsHardBindingSuccessCode(absl::string_view code) { + static const absl::NoDestructor<absl::flat_hash_set<std::string>> + kHardBindingSuccessCodes({ + absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch), + absl::StrCat(SuccessStatusCode::kAssertionBoxesHashMatch), + absl::StrCat(SuccessStatusCode::kAssertionCollectionHashMatch), + absl::StrCat(SuccessStatusCode::kAssertionDataHashMatch), + }); + return kHardBindingSuccessCodes->contains(code); +} + +bool HasHardBindingSuccess(const ValidationStatusSet& validation) { + return std::any_of(validation.successes().begin(), + validation.successes().end(), [](const auto& status) { + return IsHardBindingSuccessCode(status.code()); + }); +} + +bool ValidateIngredientAssertionValidationResults( + const IngredientAssertionV3& ingredient, absl::string_view assertion_url, + ManifestType manifest_type, ValidationTracker& validation_tracker) { + if (!ingredient.has_active_manifest()) { + // No validation results expected. + return true; + } + const ValidationStatusSet& ingredient_validation = + ingredient.validation_results().active_manifest(); + if (!HasFailures(ingredient_validation)) { + switch (manifest_type) { + case kStandardManifest: + if (!HasHardBindingSuccess(ingredient_validation)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionIngredientMalformed, + {.url = assertion_url, + .explanation = "no hard binding check recorded"}); + return false; + } + break; + case kUpdateManifest: + if (!HasSuccesses(ingredient_validation)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionIngredientMalformed, + {.url = assertion_url, + .explanation = "no validation successes or failures recorded"}); + return false; + } + break; + } + } + return true; +} + +bool IsValidIngredientRelationship(absl::string_view relationship) { + return relationship == kIngredientRelationshipParentOf || + relationship == kIngredientRelationshipComponentOf || + relationship == kIngredientRelationshipInputTo; +} + +bool ValidateIngredientAssertion(const IngredientAssertionV3& ingredient, + absl::string_view assertion_url, + ManifestType manifest_type, + ValidationTracker& validation_tracker) { + if (ingredient.has_active_manifest() && + !ingredient.digital_source_type().empty()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionIngredientMalformed, + {.url = assertion_url, + .explanation = "Ingredient assertion must not contain both " + "activeManifest and digitalSourceType"}); + return false; + } + // Note that while + // https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html#_performing_explicit_validation + // only calls for relationship validation on v3 ingredient assertions, this + // was subsequently corrected for C2PA 2.2 in + // https://github.com/c2pa-org/specs-core/pull/1513. + if (ingredient.relationship().empty()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionIngredientMalformed, + {.url = assertion_url, + .explanation = "Missing ingredient relationship"}); + return false; + } + if (!IsValidIngredientRelationship(ingredient.relationship())) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionIngredientMalformed, + {.url = assertion_url, + .explanation = "Unrecognized ingredient relationship"}); + return false; + } + return ValidateIngredientAssertionValidationResults( + ingredient, assertion_url, manifest_type, validation_tracker); +} + +bool ActionAllowedInUpdateManifest(absl::string_view action) { + return action == "c2pa.edited.metadata" || action == "c2pa.opened" || + action == "c2pa.published" || action == "c2pa.redacted"; +} + +struct AssertionCounter { + uint32_t hard_bindings = 0; + uint32_t ingredients = 0; + uint32_t thumbnails = 0; + std::string action_disallowed_in_update; + absl::flat_hash_map<std::string, uint32_t> ingredient_relationships; + bool has_soft_binding = false; + bool has_watermarked_bound_action = false; + + void RecordAction(absl::string_view action) { + if (action_disallowed_in_update.empty() && + !ActionAllowedInUpdateManifest(action)) { + action_disallowed_in_update = action; + } + if (action == "c2pa.watermarked.bound" || action == "c2pa.watermarked") { + has_watermarked_bound_action = true; + } + } + + void Add(const Assertion& assertion) { + if (assertion.has_soft_binding()) { + has_soft_binding = true; + } + if (assertion.has_data_hash() || assertion.has_bmff_based_hash() || + assertion.has_boxes_hash() || assertion.has_collection_data_hash()) { + if (!absl::StrContains(assertion.label(), ".part")) { + // Only count the full asset hard binding. + ++hard_bindings; + } + } + for (const auto& action : assertion.actions().actions()) { + RecordAction(action.action()); + } + for (const auto& action : assertion.actions_v1().actions()) { + RecordAction(action.action()); + } + if (assertion.has_ingredient_v3()) { + ++ingredients; + ++ingredient_relationships[assertion.ingredient_v3().relationship()]; + } + if (assertion.has_claim_thumbnail() || + assertion.has_ingredient_thumbnail()) { + ++thumbnails; + } + } + + // See: + // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_validate_the_assertions + void ValidateStandardManifestCounts(absl::string_view claim_box_url, + ValidationTracker& validation_tracker) { + if (hard_bindings == 0) { + validation_tracker.RecordFailure( + FailureStatusCode::kClaimHardBindingsMissing, + {.url = claim_box_url, + .explanation = "Standard manifest must contain one hard binding."}); + } else if (hard_bindings > 1) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionMultipleHardBindings, + {.url = kAssertionStoreLabel, + .explanation = "Standard manifest must not contain more than one " + "hard binding."}); + } + if (ingredient_relationships[kIngredientRelationshipParentOf] > 1) { + validation_tracker.RecordFailure( + FailureStatusCode::kManifestMultipleParents, + {.url = claim_box_url, + .explanation = "Standard manifest must contain at most one " + "ingredient with parentOf relationship."}); + } + } + + // See: + // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_validate_the_assertions + void ValidateUpdateManifestCounts(absl::string_view claim_box_url, + ValidationTracker& validation_tracker) { + if (hard_bindings != 0) { + validation_tracker.RecordFailure( + FailureStatusCode::kManifestUpdateInvalid, + {.url = claim_box_url, + .explanation = "Update manifest must not contain hard bindings."}); + } + if (!action_disallowed_in_update.empty()) { + validation_tracker.RecordFailure( + FailureStatusCode::kManifestUpdateInvalid, + {.url = claim_box_url, + .explanation = + absl::StrCat("Update manifest contains disallowed action: ", + action_disallowed_in_update)}); + } + if (ingredients != 1) { + validation_tracker.RecordFailure( + FailureStatusCode::kManifestUpdateWrongParents, + {.url = claim_box_url, + .explanation = absl::StrCat( + "Update manifest must contain exactly one ingredient; found ", + ingredients)}); + } else if (ingredient_relationships[kIngredientRelationshipParentOf] != 1) { + validation_tracker.RecordFailure( + FailureStatusCode::kManifestUpdateWrongParents, + {.url = claim_box_url, + .explanation = + "Update manifest ingredient relationship must be parentOf."}); + } + } + + void ValidateSoftBinding(absl::string_view claim_box_url, + ValidationTracker& validation_tracker) { + if (has_watermarked_bound_action && !has_soft_binding) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionSoftBindingMissing, + {.url = claim_box_url, + .explanation = "c2pa.watermarked.bound action requires a " + "c2pa.soft-binding assertion"}); + } + } +}; + +void ValidateCborAssertion( + absl::string_view label, absl::string_view payload, + ManifestType manifest_type, const AssertionParser& assertion_parser, + const ReferencesValidator& references_validator, + google::protobuf::RepeatedPtrField<Assertion>* assertions_out, + ValidationTracker& validation_tracker, + AssertionCounter& assertion_counter) { + std::optional<Assertion> assertion_proto = + assertion_parser.ParseCbor(label, payload, validation_tracker); + if (!assertion_proto.has_value()) { + return; + } + assertion_counter.Add(*assertion_proto); + if (!references_validator.Validate(*assertion_proto)) { + return; + } + const std::string assertion_url = absl::StrCat( + kAssertionStoreLabel, kManifestLabelDelimiter, assertion_proto->label()); + switch (assertion_proto->assertion_case()) { + case Assertion::kActionsV1: + if (!ValidateActions(assertion_proto->actions_v1(), assertion_url, + validation_tracker)) { + return; + } + break; + case Assertion::kActions: + if (!ValidateActions(assertion_proto->actions(), assertion_url, + validation_tracker)) { + return; + } + break; + case Assertion::kIngredientV3: + if (!ValidateIngredientAssertion(assertion_proto->ingredient_v3(), + assertion_url, manifest_type, + validation_tracker)) { + return; + } + break; + default: + break; + } + assertions_out->Add(*std::move(assertion_proto)); +} + +void ValidateAssertionContents( + const SuperBox* assertion_box, ManifestType manifest_type, + const AssertionParser& assertion_parser, + const ReferencesValidator& references_validator, + google::protobuf::RepeatedPtrField<Assertion>* assertions_out, + ValidationTracker& validation_tracker, + AssertionCounter& assertion_counter) { + // CBOR assertion. + if (assertion_box->contents.size() == 1 && + assertion_box->contents[0].Holds<CborBox>()) { + ValidateCborAssertion(assertion_box->description.label.value_or(""), + assertion_box->contents[0].Get<CborBox>().payload, + manifest_type, assertion_parser, references_validator, + assertions_out, validation_tracker, + assertion_counter); + return; + } + // Thumbnail assertion (EmbeddedFileDescriptionBox + BinaryDataBox). + if (assertion_box->contents.size() == 2 && + assertion_box->contents[0].Holds<EmbeddedFileDescriptionBox>() && + assertion_box->contents[1].Holds<BinaryDataBox>()) { + std::optional<Assertion> assertion_proto = assertion_parser.ParseThumbnail( + assertion_box->description.label.value_or(""), + assertion_box->contents[0].Get<EmbeddedFileDescriptionBox>().media_type, + assertion_box->contents[1].Get<BinaryDataBox>().payload, + assertion_box->contents[0].Get<EmbeddedFileDescriptionBox>().file_name, + validation_tracker); + if (assertion_proto.has_value()) { + assertions_out->Add(*std::move(assertion_proto)); + } + return; + } + // JSON-LD assertion (e.g., c2pa.metadata). + if (assertion_box->contents.size() == 1 && + assertion_box->contents[0].Holds<JsonBox>()) { + std::optional<Assertion> assertion_proto = assertion_parser.ParseMetadata( + assertion_box->description.label.value_or(""), + assertion_box->contents[0].Get<JsonBox>().payload, validation_tracker); + if (assertion_proto.has_value()) { + assertions_out->Add(*std::move(assertion_proto)); + } + return; + } + + // Ignore other types of assertions. +} + +bool ContainsAllZeroBytes(absl::string_view data) { + return std::all_of(data.begin(), data.end(), [](char c) { return c == 0; }); +} + +bool ContainsAllZeroBytes(const jumbf::ContentBox& content) { + if (content.Holds<CborBox>()) { + return ContainsAllZeroBytes(content.Get<CborBox>().payload); + } + if (content.Holds<JsonBox>()) { + return ContainsAllZeroBytes(content.Get<JsonBox>().payload); + } + if (content.Holds<BinaryDataBox>()) { + return ContainsAllZeroBytes(content.Get<BinaryDataBox>().payload); + } + if (content.Holds<EmbeddedFileDescriptionBox>()) { + // We don't (yet) require redaction of the file description. + return true; + } + return false; +} + +bool ContainsAllZeroBytes(const jumbf::SuperBox& box) { + return std::all_of( + box.contents.begin(), box.contents.end(), + [](const auto& content) { return ContainsAllZeroBytes(content); }); +} + +void ValidateRedactionsAllowed(const Claim& claim, + ValidationTracker& validation_tracker) { + // Note that the selfRedacted check is done in ManifestGraph. + const AssertionTypeMatcher actions_v1_matcher(kActionsAssertionV1Label); + const AssertionTypeMatcher actions_v2_matcher(kActionsAssertionV2Label); + for (const auto& redacted_assertion : claim.redacted_assertions()) { + absl::string_view label = Basename(redacted_assertion); + if (actions_v1_matcher.Matches(label) || + actions_v2_matcher.Matches(label)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionActionRedacted, + {.url = absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, + label)}); + } + } +} + +// Returns true if `path` is a child of `parent`. +bool IsChildPath(absl::string_view path, absl::string_view parent) { + if (!absl::StartsWith(path, parent)) return false; + if (path.size() <= parent.size()) return false; + if (path[parent.size()] != '/') return false; + return path.find('/', parent.size() + 1) == absl::string_view::npos; +} + +// Returns true if the validation succeeded. +bool ValidateRedactedAssertion(absl::string_view path, + const jumbf::UriResolver& uri_resolver, + ValidationTracker& validation_tracker) { + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_claim_signature_hash_validation_method + // "if the referenced assertion is present and its data consists of anything + // other than zero or more 0x00 bytes, the claim shall be rejected with a + // failure code of assertion.notRedacted." + + auto box = uri_resolver.ResolvePath(path); + if (!box.ok()) { + return true; + } + if (!ContainsAllZeroBytes(**box)) { + validation_tracker.RecordFailure(FailureStatusCode::kAssertionNotRedacted, + {.url = path}); + return false; + } + return true; +} + +std::optional<HashedUri> ConvertToHashedUri( + const InternalOrExternalHashedUri& uri, absl::string_view default_algorithm, + absl::string_view claim_uri, ValidationTracker& validation_tracker) { + if (!uri.has_url()) { + validation_tracker.RecordFailure( + FailureStatusCode::kHashedUriMissing, + {.url = claim_uri, .explanation = "Icon URI is missing"}); + return std::nullopt; + } + if (!absl::StartsWith(uri.url(), "self#jumbf=")) { + validation_tracker.RecordFailure( + FailureStatusCode::kGoogleHashedUriUnsupported, + {.url = claim_uri, + .explanation = "Icon URI is pointing to an external resource"}); + return std::nullopt; + } + HashedUri hashed_uri; + hashed_uri.set_url(uri.url()); + hashed_uri.set_hash(uri.hash()); + hashed_uri.set_algorithm(uri.has_algorithm() ? uri.algorithm() + : default_algorithm); + return hashed_uri; +} + +void ValidateAssertion( + const HashedUri& hashed_uri, absl::string_view manifest_path, + ManifestType manifest_type, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + const HashedUriValidator& assertion_uri_validator, + const ReferencesValidator& references_validator, + absl::string_view assertion_store_path, const Claim& claim, + const jumbf::UriResolver& uri_resolver, + const AssertionParser& assertion_parser, + google::protobuf::RepeatedPtrField<Assertion>* assertions_out, + absl::flat_hash_set<std::string>& assertion_labels, + ValidationTracker& validation_tracker, + AssertionCounter& assertion_counter) { + auto assertion_path = jumbf::UriResolver::GetAbsolutePathFromUri( + hashed_uri.url(), manifest_path); + if (!assertion_path.ok()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionMissing, + {.explanation = "malformed assertion URI"}); + return; + } + if (redacted_assertion_paths.contains(*assertion_path)) { + if (ValidateRedactedAssertion(*assertion_path, uri_resolver, + validation_tracker)) { + Assertion* redacted_assertion = assertions_out->Add(); + redacted_assertion->set_label(Basename(*assertion_path)); + redacted_assertion->mutable_redacted_by_generator(); + } + // Don't treat the redacted assertion as undeclared. + assertion_labels.insert(std::string(Basename(*assertion_path))); + return; + } + if (!assertion_uri_validator + .Validate(hashed_uri, absl::StrCat("self#jumbf=", *assertion_path), + validation_tracker) + .has_value()) { + return; // Failure has been recorded by Validate(). + } + validation_tracker.RecordSuccess( + SuccessStatusCode::kAssertionHashedUriMatch, + {.url = absl::StrCat("self#jumbf=", *assertion_path)}); + if (!absl::StartsWith(*assertion_path, manifest_path)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionOutsideManifest, + {.url = claim.label(), + .explanation = + absl::StrCat("Violated claim assertion: ", *assertion_path)}); + return; + } + if (!IsChildPath(*assertion_path, assertion_store_path)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionMissing, + {.url = claim.label(), + .explanation = + absl::StrCat("Violated claim assertion: ", *assertion_path)}); + return; + } + auto assertion_box = uri_resolver.ResolvePath(*assertion_path); + if (!assertion_box.ok()) { + // Can't happen -- assertion_uri_validator.Validate would have failed. + DLOG(FATAL) << "unexpected missing assertion: " << assertion_box.status(); + validation_tracker.RecordFailure( + FailureStatusCode::kGoogleInternalError, + {.url = claim.label(), .explanation = "unexpected missing assertion"}); + return; + } + ValidateAssertionContents(*assertion_box, manifest_type, assertion_parser, + references_validator, assertions_out, + validation_tracker, assertion_counter); + assertion_labels.insert(std::string(Basename(*assertion_path))); +} + +void ValidateClaimGeneratorIcon(const Claim& claim, + absl::string_view manifest_path, + const HashedUriValidator& hashed_uri_validator, + ValidationTracker& validation_tracker) { + if (!claim.claim_generator_info().has_icon()) { + return; + } + std::optional<HashedUri> icon_hashed_uri = ConvertToHashedUri( + claim.claim_generator_info().icon(), claim.default_algorithm(), + claim.label(), validation_tracker); + if (icon_hashed_uri.has_value()) { + auto icon_path = jumbf::UriResolver::GetAbsolutePathFromUri( + icon_hashed_uri->url(), manifest_path); + if (!icon_path.ok()) { + // The only way this can happen is if the icon URI doesn't start with + // "self#jumbf=". This would have already been caught by + // ConvertToHashedUri. + validation_tracker.RecordFailure( + FailureStatusCode::kHashedUriMissing, + {.url = claim.label(), .explanation = "Icon URI is malformed"}); + } else if (hashed_uri_validator + .Validate(*icon_hashed_uri, claim.label(), + validation_tracker) + .has_value()) { + // No success code for icon URI validation. See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validation_of_references + } + } +} + +void ValidateUndeclaredAssertions( + absl::string_view assertion_store_path, + const absl::flat_hash_set<std::string>& assertion_labels, + const jumbf::UriResolver& uri_resolver, + ValidationTracker& validation_tracker) { + auto assertion_store_box = uri_resolver.ResolvePath(assertion_store_path); + if (!assertion_store_box.ok()) { + return; + } + for (const auto& content : (*assertion_store_box)->contents) { + if (!content.Holds<SuperBox>()) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionUndeclared, + {.explanation = + "Assertion store contains a box that is not a superbox."}); + continue; + } + const SuperBox& assertion_box = content.Get<SuperBox>(); + auto assertion_label = assertion_box.description.label.value_or(""); + if (!assertion_labels.contains(assertion_label)) { + validation_tracker.RecordFailure( + FailureStatusCode::kAssertionUndeclared, + {.url = absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, + assertion_label)}); + continue; + } + } +} + +} // namespace + +void AssertionValidator::ValidateClaimAssertions( + const jumbf::UriResolver& uri_resolver, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, ValidationTracker& validation_tracker) const { + std::string manifest_path = absl::StrCat("/c2pa/", manifest.label()); + const Claim& claim = manifest.claim(); + AssertionCounter assertion_counter; + auto assertion_store_path = + jumbf::UriResolver::GetAbsolutePath(kAssertionStoreLabel, manifest_path); + // Validates claim assertion URIs. + HashedUriValidator assertion_uri_validator(std::string(manifest_path), + uri_resolver); + assertion_uri_validator.SetErrorCodes(HashedUriValidator::Codes{ + .missing = FailureStatusCode::kAssertionMissing, + .mismatch = FailureStatusCode::kAssertionHashedUriMismatch}); + assertion_uri_validator.SetDefaultAlgorithm(claim.default_algorithm()); + HashedUriValidator hashed_uri_validator(std::string(manifest_path), + uri_resolver); + hashed_uri_validator.SetDefaultAlgorithm(claim.default_algorithm()); + + ValidateClaimGeneratorIcon(claim, manifest_path, hashed_uri_validator, + validation_tracker); + + ManifestType manifest_type = manifest.is_update_manifest() + ? ManifestType::kUpdateManifest + : ManifestType::kStandardManifest; + ReferencesValidator references_validator( + &hashed_uri_validator, manifest.label(), &validation_tracker); + absl::flat_hash_set<std::string> assertion_labels; + assertion_labels.reserve(claim.created_assertions_size() + + claim.gathered_assertions_size()); + for (const auto& assertions : + {claim.created_assertions(), claim.gathered_assertions()}) { + for (const HashedUri& hashed_uri : assertions) { + ValidateAssertion( + hashed_uri, manifest_path, manifest_type, redacted_assertion_paths, + assertion_uri_validator, references_validator, assertion_store_path, + claim, uri_resolver, assertion_parser_, manifest.mutable_assertions(), + assertion_labels, validation_tracker, assertion_counter); + } + } + + // Validate that this claim's redactions are allowed. + ValidateRedactionsAllowed(claim, validation_tracker); + + switch (manifest_type) { + case ManifestType::kUpdateManifest: + assertion_counter.ValidateUpdateManifestCounts(claim.label(), + validation_tracker); + break; + case ManifestType::kStandardManifest: + assertion_counter.ValidateStandardManifestCounts(claim.label(), + validation_tracker); + break; + } + + assertion_counter.ValidateSoftBinding(claim.label(), validation_tracker); + + // Validates any undeclared assertions. + ValidateUndeclaredAssertions(assertion_store_path, assertion_labels, + uri_resolver, validation_tracker); + + if (!options_.skip_actions_assertion_validation_for_test) { + ActionsAssertionValidator actions_validator; + actions_validator.Validate(manifest, validation_tracker); + } +} + +} // namespace credentio
diff --git a/assertion/validator.h b/assertion/validator.h new file mode 100644 index 0000000..95fa24a --- /dev/null +++ b/assertion/validator.h
@@ -0,0 +1,59 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASSERTION_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_ASSERTION_VALIDATOR_H_ + +#include <string> + +#include "absl/container/flat_hash_set.h" +#include "assertion/assertion_parser.h" +#include "jumbf/uri.h" +#include "proto/assertion.pb.h" +#include "proto/manifest.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +// Validator of assertions referenced by a claim. +class AssertionValidator { + public: + struct Options { + bool skip_actions_assertion_validation_for_test = false; + }; + AssertionValidator() = default; + explicit AssertionValidator(const Options& options) : options_(options) {} + virtual ~AssertionValidator() = default; + + // Resolves and validates assertions in a claim, writing the assertions to + // the `assertions` field of the manifest. + // + // `redacted_assertion_paths` contains absolute paths of assertions that have + // been declared as redacted by some claim in the manifest store. The + // validator skips validating assertions with these paths, after verifying + // that they have been properly redacted. + virtual void ValidateClaimAssertions( + const jumbf::UriResolver& uri_resolver, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, ValidationTracker& validation_tracker) const; + + private: + const Options options_; + const AssertionParser assertion_parser_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASSERTION_VALIDATOR_H_
diff --git a/assertion/validator_test.cc b/assertion/validator_test.cc new file mode 100644 index 0000000..2e1b28e --- /dev/null +++ b/assertion/validator_test.cc
@@ -0,0 +1,3230 @@ +// 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 "assertion/validator.h" + +#include <string> +#include <vector> + +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "absl/types/span.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/uri.h" +#include "proto/assertion.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/manifest.pb.h" +#include "testing/cbor_utils.h" +#include "testing/jumbf_utils.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" + +namespace credentio { +namespace { + +using ::cbor::FromJson; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::credentio_testing::PartiallyEqualsProto; +using ::testing::Contains; +using ::testing::ElementsAre; +using ::testing::IsEmpty; +using ::testing::SizeIs; + +constexpr absl::string_view kManifestLabel1 = + "urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"; +constexpr absl::string_view kManifestLabel2 = + "urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA"; +constexpr absl::string_view kManifestLabel3 = + "urn:uuid:ABABABAB-0404-0505-0606-FFFFEEEEDDDD"; + +void AddCreatedAssertion(Claim* claim, jumbf::SuperBox assertion) { + absl::string_view algorithm = "sha256"; + auto* created_assertion = claim->add_created_assertions(); + created_assertion->set_url( + absl::StrCat("self#jumbf=c2pa.assertions/", + assertion.description.label.value_or("UNKNOWN_LABEL"))); + created_assertion->set_algorithm(algorithm); + created_assertion->set_hash(GetHash(algorithm, assertion)); +} + +class AssertionValidatorTest : public ::testing::Test { + protected: + std::string ingredient_assertion_cbor_ = cbor::FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "relationship": "componentOf", + "instanceID": "ingredient-instance-id", + "data": { + "url": "ingredient-data-url", + "alg": "ingredient-data-alg", + "hash": "b64'aGFzaA=='", + "size": 1234567890 + }, + "description": "ingredient-description", + "informationalURI": "ingredient-informational-uri", + "metadata": { + "reviewRatings": [ + { + "value": 1, + "explanation": "can't dance to it" + } + ] + } + } + )json"); + std::string asset_reference_assertion_cbor_ = cbor::FromJson(R"json( + { + "references": [ + { + "description": "A copy of the asset on the web", + "reference": { + "uri": "https://some.storage.us/foo" + } + } + ] + } + )json"); + std::string data_hash_assertion_cbor_ = cbor::FromJson(R"json( + { + "alg": "sha256", + "pad": "b64'Zm9v'", + "hash": "b64'Zm9v'", + "name": "JUMBF manifest", + "exclusions": [ + { + "start": 9960, + "length": 4213 + }, + { + "start": 14173, + "length": 1000 + } + ] + })json"); + std::string embedded_file_ = "fake embedded file contents"; + std::string metadata_json_ = R"json( + { + "@context" : { + "exif": "http://ns.adobe.com/exif/1.0/", + "exifEX": "http://cipa.jp/exif/2.32/", + "tiff": "http://ns.adobe.com/tiff/1.0/", + "Iptc4xmpCore": "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/", + "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", + "dc" : "http://purl.org/dc/elements/1.1/", + "photoshop" : "http://ns.adobe.com/photoshop/1.0/" + }, + "photoshop:DateCreated": "Aug 31, 2022", + "Iptc4xmpExt:DigitalSourceType": "https://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture", + "Iptc4xmpExt:LocationCreated": { + "Iptc4xmpExt:City": "San Francisco" + }, + "Iptc4xmpExt:PersonInImage": [ + "Erika Fictional" + ], + "Iptc4xmpCore:AltTextAccessibility": "Photo of Erika Fictional standing in front of the Golden Gate Bridge at sunset.", + "exif:GPSVersionID": "2.2.0.0", + "exif:GPSLatitude": "39,21.102N", + "exif:GPSLongitude": "74,26.5737W", + "exif:GPSAltitudeRef": 0, + "exif:GPSAltitude": "100963/29890", + "exif:GPSTimeStamp": "2019-09-22T18:22:57Z", + "exif:GPSSpeedRef": "K", + "exif:GPSSpeed": "4009/161323", + "exif:GPSImgDirectionRef": "T", + "exif:GPSImgDirection": "296140/911", + "exif:GPSDestBearingRef": "T", + "exif:GPSDestBearing": "296140/911", + "exif:GPSHPositioningError": "13244/2207", + "exif:ExposureTime": "1/100", + "exif:FNumber": 4.0, + "exif:ColorSpace": 1, + "exif:DigitalZoomRatio": 2.0, + "tiff:Make": "CameraCompany", + "tiff:Model": "Shooter S1", + "exifEX:LensMake": "CameraCompany", + "exifEX:LensModel": "17.0-35.0 mm", + "exifEX:LensSpecification": { "@list": [ 1.55, 4.2, 1.6, 2.4 ] } + } + )json"; +}; + +TEST_F(AssertionValidatorTest, ValidateOk) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.metadata" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3__1", + ingredient_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromJson("c2pa.metadata", metadata_json_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT( + manifest.assertions(), + ElementsAre( + EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + } + )pb")), + EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + title: "ingredient-title" + media_type: "ingredient-format" + relationship: "componentOf" + instance_id: "ingredient-instance-id" + data { + url: "ingredient-data-url" + algorithm: "ingredient-data-alg" + hash: "hash" + size: 1234567890 + } + description: "ingredient-description" + informational_uri: "ingredient-informational-uri" + } + )pb")), + EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.ingredient.v3__1" + ingredient_v3 { + title: "ingredient-title" + media_type: "ingredient-format" + relationship: "componentOf" + instance_id: "ingredient-instance-id" + data { + url: "ingredient-data-url" + algorithm: "ingredient-data-alg" + hash: "hash" + size: 1234567890 + } + description: "ingredient-description" + informational_uri: "ingredient-informational-uri" + } + )pb")), + EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.metadata" + metadata { + value { + fields { + key: "@context" + value { + struct_value { + fields { + key: "Iptc4xmpCore" + value { + string_value: "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/" + } + } + fields { + key: "Iptc4xmpExt" + value { + string_value: "http://iptc.org/std/Iptc4xmpExt/2008-02-29/" + } + } + fields { + key: "dc" + value { + string_value: "http://purl.org/dc/elements/1.1/" + } + } + fields { + key: "exif" + value { string_value: "http://ns.adobe.com/exif/1.0/" } + } + fields { + key: "exifEX" + value { string_value: "http://cipa.jp/exif/2.32/" } + } + fields { + key: "photoshop" + value { + string_value: "http://ns.adobe.com/photoshop/1.0/" + } + } + fields { + key: "tiff" + value { string_value: "http://ns.adobe.com/tiff/1.0/" } + } + } + } + } + fields { + key: "Iptc4xmpCore:AltTextAccessibility" + value { + string_value: "Photo of Erika Fictional standing in front of the Golden Gate Bridge at sunset." + } + } + fields { + key: "Iptc4xmpExt:DigitalSourceType" + value { + string_value: "https://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" + } + } + fields { + key: "Iptc4xmpExt:LocationCreated" + value { + struct_value { + fields { + key: "Iptc4xmpExt:City" + value { string_value: "San Francisco" } + } + } + } + } + fields { + key: "Iptc4xmpExt:PersonInImage" + value { + list_value { values { string_value: "Erika Fictional" } } + } + } + fields { + key: "exif:ColorSpace" + value { number_value: 1 } + } + fields { + key: "exif:DigitalZoomRatio" + value { number_value: 2 } + } + fields { + key: "exif:ExposureTime" + value { string_value: "1/100" } + } + fields { + key: "exif:FNumber" + value { number_value: 4 } + } + fields { + key: "exif:GPSAltitude" + value { string_value: "100963/29890" } + } + fields { + key: "exif:GPSAltitudeRef" + value { number_value: 0 } + } + fields { + key: "exif:GPSDestBearing" + value { string_value: "296140/911" } + } + fields { + key: "exif:GPSDestBearingRef" + value { string_value: "T" } + } + fields { + key: "exif:GPSHPositioningError" + value { string_value: "13244/2207" } + } + fields { + key: "exif:GPSImgDirection" + value { string_value: "296140/911" } + } + fields { + key: "exif:GPSImgDirectionRef" + value { string_value: "T" } + } + fields { + key: "exif:GPSLatitude" + value { string_value: "39,21.102N" } + } + fields { + key: "exif:GPSLongitude" + value { string_value: "74,26.5737W" } + } + fields { + key: "exif:GPSSpeed" + value { string_value: "4009/161323" } + } + fields { + key: "exif:GPSSpeedRef" + value { string_value: "K" } + } + fields { + key: "exif:GPSTimeStamp" + value { string_value: "2019-09-22T18:22:57Z" } + } + fields { + key: "exif:GPSVersionID" + value { string_value: "2.2.0.0" } + } + fields { + key: "exifEX:LensMake" + value { string_value: "CameraCompany" } + } + fields { + key: "exifEX:LensModel" + value { string_value: "17.0-35.0 mm" } + } + fields { + key: "exifEX:LensSpecification" + value { + struct_value { + fields { + key: "@list" + value { + list_value { + values { number_value: 1.55 } + values { number_value: 4.2 } + values { number_value: 1.6 } + values { number_value: 2.4 } + } + } + } + } + } + } + fields { + key: "photoshop:DateCreated" + value { string_value: "Aug 31, 2022" } + } + fields { + key: "tiff:Make" + value { string_value: "CameraCompany" } + } + fields { + key: "tiff:Model" + value { string_value: "Shooter S1" } + } + } + } + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateActionsRedacted) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + redacted_assertions: "self#jumbf=c2pa.assertions/c2pa.actions" + )pb"); + jumbf::SuperBox manifest_store = + CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionRedacted)); +} + +TEST_F(AssertionValidatorTest, ValidateActionsV2Redacted) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + redacted_assertions: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + )pb"); + jumbf::SuperBox manifest_store = + CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionRedacted)); +} + +TEST_F(AssertionValidatorTest, ValidateActionsMultiInstanceRedacted) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + redacted_assertions: "self#jumbf=c2pa.assertions/c2pa.actions.v2__1" + )pb"); + jumbf::SuperBox manifest_store = + CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionRedacted)); +} + +TEST_F(AssertionValidatorTest, ValidateUnresolvableUri) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "c2pa.assertions/unresolvable.assertion" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMissing)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateAssertionMissing) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/no.such.assertion" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMissing)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateAssertionMissingNotInAssertionStore) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.somewhere/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMissing)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, + ValidateAssertionMissingNotChildOfAssertionStore) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/somewhere/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMissing)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateAssertionMissingInNestedBox) { + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + jumbf::SuperBox{ + .description = {.label = "nested"}, + .contents = {{.payload = CreateAssertionBoxFromCbor( + "c2pa.hash.data", data_hash_assertion_cbor_)}}, + }, + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + auto manifest = ParseTextProtoOrDie<Manifest>(R"pb( + label: "urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4" + claim { + created_assertions { + url: "self#jumbf=c2pa.assertions/nested/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + } + )pb"); + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMissing)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateThumbnailAssertion) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.png" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.png" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.claim.png", + "image/png", embedded_file_, + "claim.thumbnail.png"), + CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.ingredient.png", + "image/png", embedded_file_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")), + EqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.thumbnail.claim.png" + claim_thumbnail { + media_type: "image/png" + data: "fake embedded file contents" + file_name: "claim.thumbnail.png" + })pb")), + EqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.thumbnail.ingredient.png" + ingredient_thumbnail { + media_type: "image/png" + data: "fake embedded file contents" + })pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateUndeclaredAssertion) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionUndeclared)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateMalformedAssertion) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + // Add a CBOR box to the assertion store. + assertion_store.contents.push_back({.payload = jumbf::CborBox{}}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionUndeclared)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateSoftBindingMissingForWatermarkedBound) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created" + }, + { + "action": "c2pa.watermarked.bound" + } + ] + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionSoftBindingMissing)); +} + +TEST_F(AssertionValidatorTest, ValidateSoftBindingPresentForWatermarkedBound) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.soft-binding" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created" + }, + { + "action": "c2pa.watermarked.bound" + } + ] + } + )json"); + + std::string soft_binding_cbor = cbor::FromJson(R"json( + { + "alg": "soft-algorithm", + "blocks": [ + { + "scope": { + "timespan": { + "start": 100, + "end": 200 + } + }, + "value": "b64'dmFsdWU='" + } + ] + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + CreateAssertionBoxFromCbor("c2pa.soft-binding", soft_binding_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateSoftBindingMissingForWatermarked) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created" + }, + { + "action": "c2pa.watermarked" + } + ] + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionActionSoftBindingMissing)); +} + +TEST_F(AssertionValidatorTest, ValidateSoftBindingPresentForWatermarked) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.soft-binding" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created" + }, + { + "action": "c2pa.watermarked" + } + ] + } + )json"); + std::string soft_binding_cbor = cbor::FromJson(R"json( + { + "alg": "soft-algorithm", + "blocks": [ + { + "scope": { + "timespan": { + "start": 100, + "end": 200 + } + }, + "value": "b64'dmFsdWU='" + } + ] + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + CreateAssertionBoxFromCbor("c2pa.soft-binding", soft_binding_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateInaccessibleAssertion) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "https://foo.bar/my_assertion" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMissing)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateInvalidCbor) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", "not valid cbor"), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionCborInvalid)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateAssertionAbsolutePath) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=/c2pa/urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4/c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateAssertionOutsideManifest) { + // This URI points to the manifest with kManifestLabel2. + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {}), + CreateStandardManifest(kManifestLabel2, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionOutsideManifest)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateInvalidAlgorithm) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "not_an_algo" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAlgorithmUnsupported)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateInvalidHash) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "not_a_hash" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionHashedUriMismatch)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateInvalidHashSha384) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha384" + hash: "not_a_hash" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionHashedUriMismatch)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateInvalidHashSha512) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha512" + hash: "not_a_hash" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionHashedUriMismatch)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateActionsUnknown) { + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.unknown"}] + } + )json"); + + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions", actions_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kGoogleAssertionActionUnknownAction)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateActionsV2Unknown) { + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.unknown"}] + } + )json"); + + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kGoogleAssertionActionUnknownAction)); + EXPECT_THAT(manifest.assertions(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, ValidateActionsV2Created) { + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.created"}] + } + )json"); + + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.hash.data")pb")))); +} + +TEST_F(AssertionValidatorTest, InvalidStandardManifestNoHardBinding) { + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.created"}] + } + )json"); + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimHardBindingsMissing)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb")))); +} + +TEST_F(AssertionValidatorTest, InvalidStandardManifestMultipleHardBindings) { + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.created"}] + } + )json"); + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMultipleHardBindings)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.hash.data")pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.hash.data")pb")))); +} + +TEST_F(AssertionValidatorTest, + InvalidStandardManifestMultipleParentIngredients) { + std::string ingredient_cbor = cbor::FromJson(R"json( + { + "relationship": "parentOf", + "instanceID": "ingredient-instance-id" + } + )json"); + std::string actions_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.created"}] + } + )json"); + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", ingredient_cbor), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3__1", ingredient_cbor), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kManifestMultipleParents)); + EXPECT_THAT( + manifest.assertions(), + ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { actions { action: "c2pa.created" } } + )pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.hash.data")pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.ingredient.v3")pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.ingredient.v3__1")pb")))); +} + +TEST_F(AssertionValidatorTest, ValidUpdateManifest) { + jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + auto std_manifest = + CreateStandardManifest(kManifestLabel1, {std_assertion_store}); + + std::string ingredient_assertion_cbor = cbor::FromJson(absl::Substitute( + R"json( + { + "relationship": "parentOf", + "instanceID": "ingredient-instance-id", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/$0", + "alg": "sha256", + "hash": "b64'$1'" + } + } + )json", + kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest)))); + jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor( + "c2pa.ingredient.v3", ingredient_assertion_cbor); + std::string actions_assertion_cbor = cbor::FromJson(absl::Substitute( + R"json( + { + "actions": [{ + "action": "c2pa.opened", + "parameters": { + "ingredients": [{ + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3", + "alg": "sha256", + "hash": "b64'$0'" + }] + } + }] + } + )json", + absl::Base64Escape(GetHash("sha256", ingredient_assertion_box)))); + + jumbf::SuperBox actions_assertion_box = + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor); + jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox( + {actions_assertion_box, ingredient_assertion_box}); + jumbf::SuperBox update_manifest = + CreateUpdateManifest(kManifestLabel2, {update_assertion_store}); + + jumbf::SuperBox manifest_store = + CreateManifestStore({std_manifest, update_manifest}); + Claim update_claim; + AddCreatedAssertion(&update_claim, actions_assertion_box); + AddCreatedAssertion(&update_claim, ingredient_assertion_box); + + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_is_update_manifest(true); + manifest.set_label(kManifestLabel2); + *manifest.mutable_claim() = update_claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); +} + +TEST_F(AssertionValidatorTest, InvalidUpdateManifestMultipleParentIngredients) { + jumbf::SuperBox std_assertion_store_1 = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox std_assertion_store_2 = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + auto std_manifest_1 = + CreateStandardManifest(kManifestLabel1, {std_assertion_store_1}); + auto std_manifest_2 = + CreateStandardManifest(kManifestLabel2, {std_assertion_store_2}); + + std::string actions_assertion_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.opened"}] + } + )json"); + + std::string ingredient_assertion_1_cbor = cbor::FromJson(absl::Substitute( + R"json( + { + "relationship": "parentOf", + "instanceID": "ingredient-instance-id-1", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/$0", + "alg": "sha256", + "hash": "b64'$1'" + } + } + )json", + kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest_1)))); + std::string ingredient_assertion_2_cbor = cbor::FromJson(absl::Substitute( + R"json( + { + "relationship": "parentOf", + "instanceID": "ingredient-instance-id-2", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/$0", + "alg": "sha256", + "hash": "b64'$1'" + } + } + )json", + kManifestLabel2, absl::Base64Escape(GetHash("sha256", std_manifest_2)))); + jumbf::SuperBox actions_assertion_box = + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor); + jumbf::SuperBox ingredient_assertion_1_box = CreateAssertionBoxFromCbor( + "c2pa.ingredient.v3", ingredient_assertion_1_cbor); + jumbf::SuperBox ingredient_assertion_2_box = CreateAssertionBoxFromCbor( + "c2pa.ingredient.v3__1", ingredient_assertion_2_cbor); + jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox({ + actions_assertion_box, + ingredient_assertion_1_box, + ingredient_assertion_2_box, + }); + jumbf::SuperBox update_manifest = + CreateUpdateManifest(kManifestLabel3, {update_assertion_store}); + + jumbf::SuperBox manifest_store = + CreateManifestStore({std_manifest_1, std_manifest_2, update_manifest}); + Claim update_claim; + AddCreatedAssertion(&update_claim, actions_assertion_box); + AddCreatedAssertion(&update_claim, ingredient_assertion_1_box); + AddCreatedAssertion(&update_claim, ingredient_assertion_2_box); + + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_is_update_manifest(true); + manifest.set_label(kManifestLabel3); + *manifest.mutable_claim() = update_claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kManifestUpdateWrongParents)); +} + +TEST_F(AssertionValidatorTest, InvalidUpdateManifestHardBindingsPresent) { + jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + auto std_manifest = + CreateStandardManifest(kManifestLabel1, {std_assertion_store}); + + std::string actions_assertion_cbor = cbor::FromJson(R"json( + { + "actions": [{"action": "c2pa.opened"}] + } + )json"); + + std::string ingredient_assertion_cbor = cbor::FromJson(absl::Substitute( + R"json( + { + "relationship": "parentOf", + "instanceID": "ingredient-instance-id", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/$0", + "alg": "sha256", + "hash": "b64'$1'" + } + } + )json", + kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest)))); + jumbf::SuperBox actions_assertion_box = + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor); + jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor( + "c2pa.ingredient.v3", ingredient_assertion_cbor); + jumbf::SuperBox binding_assertion_box = + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_); + jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox({ + actions_assertion_box, + ingredient_assertion_box, + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox update_manifest = + CreateUpdateManifest(kManifestLabel2, {update_assertion_store}); + + jumbf::SuperBox manifest_store = + CreateManifestStore({std_manifest, update_manifest}); + Claim update_claim; + AddCreatedAssertion(&update_claim, actions_assertion_box); + AddCreatedAssertion(&update_claim, ingredient_assertion_box); + AddCreatedAssertion(&update_claim, binding_assertion_box); + + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_is_update_manifest(true); + manifest.set_label(kManifestLabel2); + *manifest.mutable_claim() = update_claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kManifestUpdateInvalid)); +} + +TEST_F(AssertionValidatorTest, InvalidUpdateManifestDisallowedAction) { + jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + auto std_manifest = + CreateStandardManifest(kManifestLabel1, {std_assertion_store}); + + // c2pa.cropped is not allowed in an update manifest. + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [{"action": "c2pa.opened"}, {"action": "c2pa.cropped"}] + } + )json"); + + std::string ingredient_assertion_cbor = FromJson(absl::Substitute( + R"json( + { + "relationship": "parentOf", + "instanceID": "ingredient-instance-id", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/$0", + "alg": "sha256", + "hash": "b64'$1'" + } + } + )json", + kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest)))); + jumbf::SuperBox actions_assertion_box = + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor); + jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor( + "c2pa.ingredient.v3", ingredient_assertion_cbor); + jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox( + {actions_assertion_box, ingredient_assertion_box}); + jumbf::SuperBox update_manifest = + CreateUpdateManifest(kManifestLabel2, {update_assertion_store}); + + jumbf::SuperBox manifest_store = + CreateManifestStore({std_manifest, update_manifest}); + Claim update_claim; + AddCreatedAssertion(&update_claim, actions_assertion_box); + AddCreatedAssertion(&update_claim, ingredient_assertion_box); + + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_is_update_manifest(true); + manifest.set_label(kManifestLabel2); + *manifest.mutable_claim() = update_claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kManifestUpdateInvalid)); +} + +TEST_F(AssertionValidatorTest, InvalidUpdateManifestNoIngredients) { + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [{"action": "c2pa.opened"}] + } + )json"); + jumbf::SuperBox actions_assertion_box = + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor); + jumbf::SuperBox update_assertion_store = + CreateAssertionStoreBox({actions_assertion_box}); + jumbf::SuperBox update_manifest = + CreateUpdateManifest(kManifestLabel2, {update_assertion_store}); + + jumbf::SuperBox manifest_store = CreateManifestStore({update_manifest}); + Claim update_claim; + AddCreatedAssertion(&update_claim, actions_assertion_box); + + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_is_update_manifest(true); + manifest.set_label(kManifestLabel2); + *manifest.mutable_claim() = update_claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kManifestUpdateWrongParents)); +} + +TEST_F(AssertionValidatorTest, InvalidUpdateManifestWrongIngredientRelation) { + jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + auto std_manifest = + CreateStandardManifest(kManifestLabel1, {std_assertion_store}); + + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [{"action": "c2pa.opened"}] + } + )json"); + + // Relationship here is "componentOf" instead of "parentOf". + std::string ingredient_assertion_cbor = FromJson(absl::Substitute( + R"json( + { + "relationship": "componentOf", + "instanceID": "ingredient-instance-id", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/$0", + "alg": "sha256", + "hash": "b64'$1'" + } + } + )json", + kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest)))); + jumbf::SuperBox actions_assertion_box = + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor); + jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor( + "c2pa.ingredient.v3", ingredient_assertion_cbor); + jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox( + {actions_assertion_box, ingredient_assertion_box}); + jumbf::SuperBox update_manifest = + CreateUpdateManifest(kManifestLabel2, {update_assertion_store}); + + jumbf::SuperBox manifest_store = + CreateManifestStore({std_manifest, update_manifest}); + Claim update_claim; + AddCreatedAssertion(&update_claim, actions_assertion_box); + AddCreatedAssertion(&update_claim, ingredient_assertion_box); + + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_is_update_manifest(true); + manifest.set_label(kManifestLabel2); + *manifest.mutable_claim() = update_claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kManifestUpdateWrongParents)); +} + +TEST_F(AssertionValidatorTest, IngredientV1Unsupported) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "relationship": "componentOf", + "instanceID": "ingredient-instance-id", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA", + "alg": "sha256", + "hash": "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='" + }, + "thumbnail": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg", + "alg": "sha256", + "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='" + } + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient", ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox thumbnail_box = { + .description = + { + .label = "c2pa.thumbnail.ingredient_1.jpg", + }, + .raw_bytes = "b64'Zm9v'", + }; + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store}), + CreateStandardManifest(kManifestLabel2, + {assertion_store, thumbnail_box})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "com.google.unsupportedSpecVersion" + explanation: "c2pa.ingredient (v1) and c2pa.ingredient.v2 assertions are not supported" + )pb")))); +} + +TEST_F(AssertionValidatorTest, IngredientV2Unsupported) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "relationship": "componentOf", + "c2pa_manifest": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA", + "alg": "sha256", + "hash": "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='" + }, + "thumbnail": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg", + "alg": "sha256", + "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='" + } + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v2", + ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox thumbnail_box = { + .description = + { + .label = "c2pa.thumbnail.ingredient_1.jpg", + }, + .raw_bytes = "b64'Zm9v'", + }; + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store}), + CreateStandardManifest(kManifestLabel2, + {assertion_store, thumbnail_box})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "com.google.unsupportedSpecVersion" + explanation: "c2pa.ingredient (v1) and c2pa.ingredient.v2 assertions are not supported" + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateIngredientV3) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "relationship": "componentOf", + "activeManifest": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA", + "alg": "sha256", + "hash": "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='" + }, + "claimSignature": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.signature", + "alg": "sha256", + "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='" + }, + "thumbnail": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg", + "alg": "sha256", + "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='" + }, + "validationResults": { + "activeManifest": { + "success": [{"code": "assertion.dataHash.match"}], + "informational": [], + "failure": [] + } + } + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox thumbnail_box = { + .description = + { + .label = "c2pa.thumbnail.ingredient_1.jpg", + }, + .raw_bytes = "b64'Zm9v'", + }; + jumbf::SuperBox claim_signature_box = { + .description = + { + .label = "c2pa.signature", + }, + .raw_bytes = "b64'Zm9v'", + }; + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store}), + CreateStandardManifest(kManifestLabel2, {assertion_store, thumbnail_box, + claim_signature_box})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + ASSERT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT( + manifest.assertions(), + ElementsAre( + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA" + } + thumbnail { + url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg" + } + } + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateIngredientV3Failed) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json( + { + "dc:title": "ingredient-title", + "dc:format": "ingredient-format", + "relationship": "componentOf", + "activeManifest": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA", + "alg": "sha256", + "hash": "b64'Zm9v'" + }, + "thumbnail": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg", + "alg": "sha256", + "hash": "b64'BAD/HASH'" + }, + "validationResults": {} + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox thumbnail_box = { + .description = + { + .label = "c2pa.thumbnail.ingredient_1.jpg", + }, + .raw_bytes = "b64'Zm9v'", + }; + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store}), + CreateStandardManifest(kManifestLabel2, + {assertion_store, thumbnail_box})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMismatch)); +} + +TEST_F(AssertionValidatorTest, ValidateIngredientV3FailedBadRelationship) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json( + { + "relationship": "someOtherRelationship" + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.ingredient.malformed" + explanation: "Unrecognized ingredient relationship" + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateIngredientV3FailedMissingRelationship) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json({})json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.ingredient.malformed" + explanation: "Missing ingredient relationship" + )pb")))); +} + +TEST_F(AssertionValidatorTest, + ValidateIngredientV3FailedBothActiveManifestAndDigitalSourceType) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json( + { + "relationship": "componentOf", + "activeManifest": { + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3", + "alg": "sha256", + "hash": "b64'Zm9v'" + }, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.ingredient.malformed" + explanation: "Ingredient assertion must not contain both activeManifest and digitalSourceType" + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateIngredientV3FailedNoHardBindingSuccess) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json({ + "relationship": "componentOf", + "activeManifest": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA", + "alg": "sha256", + "hash": "b64'Zm9v'" + }, + "validationResults": { + "activeManifest": { + "success": [{"code": "assertion.hashedURI.match"}], + "informational": [], + "failure": [] + } + } + })json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.ingredient.malformed" + explanation: "no hard binding check recorded" + )pb")))); +} + +TEST_F(AssertionValidatorTest, + ValidateIngredientV3UpdateManifestFailedNoValidationResults) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string ingredient_assertion_cbor = FromJson(R"json({ + "relationship": "parentOf", + "activeManifest": { + "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA", + "alg": "sha256", + "hash": "b64'Zm9v'" + }, + "validationResults": {} + })json"); + jumbf::SuperBox assertion_store = + CreateAssertionStoreBox({CreateAssertionBoxFromCbor( + "c2pa.ingredient.v3", ingredient_assertion_cbor)}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateUpdateManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + manifest.set_is_update_manifest(true); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.ingredient.malformed" + explanation: "no validation successes or failures recorded" + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateActionsV2Ingredients) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created", + "parameters" : { + "ingredients" : [ + { + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3", + "alg": "sha256", + "hash" : "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='" + }, + { + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1", + "alg": "sha256", + "hash" : "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='" + } + ] + } + } + ] + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3__1", + ingredient_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + ASSERT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT( + manifest.assertions(), + ElementsAre( + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.actions.v2" + actions { + actions { + action: "c2pa.created" + parameters { + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + } + ingredients { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1" + } + } + } + } + )pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.ingredient.v3" + )pb")), + PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.ingredient.v3__1" + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidateActionsV2IngredientsFailed) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient__2" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + std::string actions_assertion_cbor = FromJson(R"json( + { + "actions": [ + { + "action": "c2pa.created", + "parameters" : { + "ingredients" : [ + { + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient__1", + "alg": "sha256", + "hash" : "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='" + }, + { + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient__2", + "alg": "sha256", + "hash" : "b64'Zm9v'" + } + ] + } + } + ] + } + )json"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient__1", + ingredient_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient__2", + ingredient_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMismatch)); +} + +TEST_F(AssertionValidatorTest, MissingAssertionFailure) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.metadata" + algorithm: "sha256" + hash: "hash-is-irrelevant" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMissing)); + EXPECT_THAT(manifest.assertions(), SizeIs(1)); +} + +TEST_F(AssertionValidatorTest, MissingRedactedAssertionOk) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.metadata" + algorithm: "sha256" + hash: "hash-is-irrelevant" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + std::string redacted_assertion_path = absl::Substitute( + "/c2pa/$0/c2pa.assertions/c2pa.metadata", kManifestLabel1); + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path}, + manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT(manifest.assertions(), SizeIs(2)); + EXPECT_THAT(manifest.assertions(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash {} + )pb")))); + EXPECT_THAT(manifest.assertions(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.metadata" + redacted_by_generator {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, RedactedAssertionWithZeroJsonDataOk) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.metadata" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromJson("c2pa.metadata", "\0\0\0"), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + std::string redacted_assertion_path = absl::Substitute( + "/c2pa/$0/c2pa.assertions/c2pa.metadata", kManifestLabel1); + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path}, + manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT(manifest.assertions(), SizeIs(2)); + EXPECT_THAT(manifest.assertions(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.metadata" + redacted_by_generator {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, RedactedAssertionWithNonzeroJsonDataFails) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.metadata" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromJson("c2pa.metadata", metadata_json_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + std::string redacted_assertion_path = absl::Substitute( + "/c2pa/$0/c2pa.assertions/c2pa.metadata", kManifestLabel1); + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path}, + manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionNotRedacted)); + EXPECT_THAT(manifest.assertions(), SizeIs(1)); +} + +TEST_F(AssertionValidatorTest, RedactedAssertionWithZeroCborDataOk) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "hash-is-irrelevant" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", "\0\0\0"), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + std::string redacted_assertion_path = absl::Substitute( + "/c2pa/$0/c2pa.assertions/c2pa.ingredient.v3", kManifestLabel1); + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path}, + manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT(manifest.assertions(), SizeIs(2)); + EXPECT_THAT(manifest.assertions(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.ingredient.v3" + redacted_by_generator {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, RedactedAssertionWithNonZeroCborDataFails) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.ingredient.v3", + ingredient_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + std::string redacted_assertion_path = absl::Substitute( + "/c2pa/$0/c2pa.assertions/c2pa.ingredient.v3", kManifestLabel1); + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path}, + manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionNotRedacted)); + EXPECT_THAT(manifest.assertions(), SizeIs(1)); +} + +TEST_F(AssertionValidatorTest, RedactedAssertionWithZeroEmbeddedFileDataOk) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.png" + algorithm: "sha256" + hash: "hash-is-irrelevant" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.claim.png", + "image/png", "\0\0\0", + "claim.thumbnail.png"), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + std::string redacted_assertion_path = absl::Substitute( + "/c2pa/$0/c2pa.assertions/c2pa.thumbnail.claim.png", kManifestLabel1); + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path}, + manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT(manifest.assertions(), SizeIs(2)); + EXPECT_THAT(manifest.assertions(), + Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.thumbnail.claim.png" + redacted_by_generator {} + )pb")))); +} + +TEST_F(AssertionValidatorTest, + RedactedAssertionWithNonZeroEmbeddedFileDataFails) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.png" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.claim.png", + "image/png", embedded_file_, + "claim.thumbnail.png"), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + std::string redacted_assertion_path = absl::Substitute( + "/c2pa/$0/c2pa.assertions/c2pa.thumbnail.claim.png", kManifestLabel1); + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path}, + manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionNotRedacted)); + EXPECT_THAT(manifest.assertions(), SizeIs(1)); +} + +TEST_F(AssertionValidatorTest, MissingIconUriFails) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + claim_generator_info { + name: "generator_icon" + icon { algorithm: "sha256" hash: "hash-is-irrelevant" } + } + label: "c2pa.claim.v2" + )pb"); + jumbf::SuperBox assertion_store = + CreateAssertionStoreBox({CreateAssertionBoxFromCbor( + "c2pa.hash.data", data_hash_assertion_cbor_)}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "hashedUri.missing" + url: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.claim.v2" + explanation: "Icon URI is missing" + )pb")))); + EXPECT_THAT(manifest.assertions(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + } + )pb")))); +} + +TEST_F(AssertionValidatorTest, ExternalIconUriFails) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + claim_generator_info { + name: "generator_icon" + icon { + url: "https://example.com/icon.png" + algorithm: "sha256" + hash: "hash-is-irrelevant" + } + } + label: "c2pa.claim.v2" + )pb"); + jumbf::SuperBox assertion_store = + CreateAssertionStoreBox({CreateAssertionBoxFromCbor( + "c2pa.hash.data", data_hash_assertion_cbor_)}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "com.google.hashedUri.unsupported" + url: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.claim.v2" + explanation: "Icon URI is pointing to an external resource" + )pb")))); + EXPECT_THAT(manifest.assertions(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + } + )pb")))); +} + +TEST_F(AssertionValidatorTest, IconUriPointsToMissingLocationFails) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + claim_generator_info { + name: "generator_icon" + icon { + url: "self#jumbf=c2pa.missing" + algorithm: "sha256" + hash: "hash-is-irrelevant" + } + } + label: "c2pa.claim.v2" + )pb"); + jumbf::SuperBox assertion_store = + CreateAssertionStoreBox({CreateAssertionBoxFromCbor( + "c2pa.hash.data", data_hash_assertion_cbor_)}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailureStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "hashedUri.missing" + url: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.claim.v2" + )pb")))); + EXPECT_THAT(manifest.assertions(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + } + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidIconUri) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + claim_generator_info { + name: "generator_icon" + icon { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + } + label: "c2pa.claim.v2" + )pb"); + jumbf::SuperBox assertion_store = + CreateAssertionStoreBox({CreateAssertionBoxFromCbor( + "c2pa.hash.data", data_hash_assertion_cbor_)}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT(manifest.assertions(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + } + )pb")))); +} + +TEST_F(AssertionValidatorTest, ValidIconUriFallbacksToDefaultAlgorithm) { + Claim claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + claim_generator_info { + name: "generator_icon" + icon { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + } + default_algorithm: "sha256" + label: "c2pa.claim.v2" + )pb"); + jumbf::SuperBox assertion_store = + CreateAssertionStoreBox({CreateAssertionBoxFromCbor( + "c2pa.hash.data", data_hash_assertion_cbor_)}); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailureStatuses(), IsEmpty()); + EXPECT_THAT(manifest.assertions(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "c2pa.hash.data" + data_hash { + exclusions { start: 9960 length: 4213 } + exclusions { start: 14173 length: 1000 } + alg: "sha256" + hash: "foo" + name: "JUMBF manifest" + } + )pb")))); +} + +TEST_F(AssertionValidatorTest, FailsWhenMultipleHardBindings) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data__1" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.hash.data__1", + data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionMultipleHardBindings)); + EXPECT_THAT(manifest.assertions(), SizeIs(2)); +} + +TEST_F(AssertionValidatorTest, + PassesWhenAdditionalHardAssertionsArePartsOfMultiAssetHash) { + auto claim = ParseTextProtoOrDie<Claim>(R"pb( + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data.part" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + created_assertions { + url: "self#jumbf=c2pa.assertions/c2pa.hash.data" + algorithm: "sha256" + hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8" + } + )pb"); + jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ + CreateAssertionBoxFromCbor("c2pa.hash.data.part", + data_hash_assertion_cbor_), + CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_), + }); + jumbf::SuperBox manifest_store = CreateManifestStore( + {CreateStandardManifest(kManifestLabel1, {assertion_store})}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + AssertionValidator validator; + TestValidationTracker t; + + Manifest manifest; + manifest.set_label(kManifestLabel1); + *manifest.mutable_claim() = claim; + validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker()); + + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT(manifest.assertions(), SizeIs(2)); +} + +} // namespace +} // namespace credentio
diff --git a/async/BUILD b/async/BUILD new file mode 100644 index 0000000..12227c3 --- /dev/null +++ b/async/BUILD
@@ -0,0 +1,76 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "executor", + hdrs = ["executor.h"], + deps = [ + "@abseil-cpp//absl/functional:any_invocable", + ], +) + +cc_library( + name = "async_context", + hdrs = ["async_context.h"], + deps = [ + ":cancellation_token", + ":executor", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + ], +) + +cc_test( + name = "async_context_test", + srcs = ["async_context_test.cc"], + deps = [ + ":async_context", + ":executor", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "cancellation_token", + srcs = ["cancellation_token.cc"], + hdrs = ["cancellation_token.h"], + deps = [ + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/synchronization", + ], +) + +cc_test( + name = "cancellation_token_test", + srcs = ["cancellation_token_test.cc"], + deps = [ + ":cancellation_token", + "@googletest//:gtest_main", + ], +)
diff --git a/async/async_context.h b/async/async_context.h new file mode 100644 index 0000000..c25eef2 --- /dev/null +++ b/async/async_context.h
@@ -0,0 +1,123 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_ +#define THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_ +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "async/cancellation_token.h" +#include "async/executor.h" + +namespace credentio { + +// Provides an execution context for asynchronous operations. +class AsyncContext { + public: + explicit AsyncContext() = default; + + // The caller retains ownership of `executor`, which must outlive the + // `AsyncContext`. + explicit AsyncContext(Executor* absl_nullable executor) + : executor_(executor) {} + + Executor* absl_nullable executor() const { return executor_; } + + void Cancel() { cancellation_source_.Cancel(); } + + CancellationToken GetCancellationToken() const { + return cancellation_source_.GetToken(); + } + + private: + Executor* absl_nullable executor_ = nullptr; + CancellationSource cancellation_source_; +}; + +// Helper class to resolve a callback on the given AsyncContext using a +// StatusOr<T>. If the context is cancelled, the callback is executed +// inline with a CancelledError. If the context (or its executor) is missing, it +// resolves inline. +template <typename T> +class AsyncResolver { + public: + AsyncResolver(AsyncContext context, + absl::AnyInvocable<void(absl::StatusOr<T>) &&> cb) + : context_(std::move(context)), cb_(std::move(cb)) {} + + AsyncResolver(AsyncResolver&&) = default; + AsyncResolver& operator=(AsyncResolver&&) = default; + AsyncResolver(const AsyncResolver&) = delete; + AsyncResolver& operator=(const AsyncResolver&) = delete; + + void Return(absl::StatusOr<T> result) { + if (context_.GetCancellationToken().IsCancelled()) { + std::move(cb_)(absl::CancelledError("AsyncContext cancelled")); + return; + } + if (context_.executor()) { + context_.executor()->Schedule( + [cb = std::move(cb_), r = std::move(result)]() mutable { + std::move(cb)(std::move(r)); + }); + return; + } + std::move(cb_)(std::move(result)); + } + + private: + AsyncContext context_; + absl::AnyInvocable<void(absl::StatusOr<T>) &&> cb_; +}; + +// Specialization for void callbacks (absl::Status). +template <> +class AsyncResolver<void> { + public: + AsyncResolver(AsyncContext context, + absl::AnyInvocable<void(absl::Status) &&> cb) + : context_(std::move(context)), cb_(std::move(cb)) {} + + AsyncResolver(AsyncResolver&&) = default; + AsyncResolver& operator=(AsyncResolver&&) = default; + AsyncResolver(const AsyncResolver&) = delete; + AsyncResolver& operator=(const AsyncResolver&) = delete; + + void Return(absl::Status status) { + if (context_.GetCancellationToken().IsCancelled()) { + std::move(cb_)(absl::CancelledError("AsyncContext cancelled")); + return; + } + if (context_.executor()) { + context_.executor()->Schedule( + [cb = std::move(cb_), s = std::move(status)]() mutable { + std::move(cb)(std::move(s)); + }); + return; + } + std::move(cb_)(std::move(status)); + } + + private: + AsyncContext context_; + absl::AnyInvocable<void(absl::Status) &&> cb_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_
diff --git a/async/async_context_test.cc b/async/async_context_test.cc new file mode 100644 index 0000000..bb9e123 --- /dev/null +++ b/async/async_context_test.cc
@@ -0,0 +1,157 @@ +// 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 "async/async_context.h" + +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "async/executor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; + +class MockExecutor : public Executor { + public: + MOCK_METHOD(void, Schedule, (absl::AnyInvocable<void() &&> task), (override)); +}; + +TEST(AsyncContextTest, ExecutesOnExecutorIfContextProvided) { + MockExecutor mock_executor; + AsyncContext context(&mock_executor); + + bool task_scheduled = false; + EXPECT_CALL(mock_executor, Schedule(testing::_)) + .WillOnce([&task_scheduled](absl::AnyInvocable<void() &&> task) { + task_scheduled = true; + std::move(task)(); + }); + + bool task_run = false; + AsyncResolver<int>(context, [&task_run](absl::StatusOr<int> status) { + task_run = true; + EXPECT_THAT(status, IsOkAndHolds(42)); + }).Return(42); + + EXPECT_TRUE(task_scheduled); + EXPECT_TRUE(task_run); +} + +TEST(AsyncContextTest, ExecutesInlineIfContextIsNull) { + bool task_run = false; + AsyncResolver<int>(AsyncContext(nullptr), [&task_run]( + absl::StatusOr<int> status) { + task_run = true; + EXPECT_THAT(status, IsOkAndHolds(42)); + }).Return(42); + + EXPECT_TRUE(task_run); +} + +TEST(AsyncContextTest, ExecutesInlineIfExecutorIsNull) { + AsyncContext context(nullptr); + + bool task_run = false; + AsyncResolver<int>(context, [&task_run](absl::StatusOr<int> status) { + task_run = true; + EXPECT_THAT(status, IsOkAndHolds(42)); + }).Return(42); + + EXPECT_TRUE(task_run); +} + +TEST(AsyncContextTest, CancellationTokensCheck) { + AsyncContext context(nullptr); + EXPECT_FALSE(context.GetCancellationToken().IsCancelled()); + context.Cancel(); + EXPECT_TRUE(context.GetCancellationToken().IsCancelled()); +} + +TEST(AsyncContextTest, ResolveOnAsyncContextStatusCancelledWhenCancelled) { + MockExecutor mock_executor; + AsyncContext context(&mock_executor); + context.Cancel(); + + EXPECT_CALL(mock_executor, Schedule(testing::_)).Times(0); + + bool task_run = false; + AsyncResolver<void>(context, [&task_run](absl::Status status) { + task_run = true; + EXPECT_THAT(status, StatusIs(absl::StatusCode::kCancelled)); + }).Return(absl::OkStatus()); + + EXPECT_TRUE(task_run); +} + +TEST(AsyncContextTest, ResolveOnAsyncContextStatusOrCancelledWhenCancelled) { + MockExecutor mock_executor; + AsyncContext context(&mock_executor); + context.Cancel(); + + EXPECT_CALL(mock_executor, Schedule(testing::_)).Times(0); + + bool task_run = false; + AsyncResolver<int>(context, [&task_run](absl::StatusOr<int> status) { + task_run = true; + EXPECT_THAT(status.status(), StatusIs(absl::StatusCode::kCancelled)); + }).Return(42); + + EXPECT_TRUE(task_run); +} + +TEST(AsyncContextTest, ExecutesOnExecutorIfContextProvidedVoid) { + MockExecutor mock_executor; + AsyncContext context(&mock_executor); + + bool task_scheduled = false; + EXPECT_CALL(mock_executor, Schedule(testing::_)) + .WillOnce([&task_scheduled](absl::AnyInvocable<void() &&> task) { + task_scheduled = true; + std::move(task)(); + }); + + bool task_run = false; + AsyncResolver<void>(context, [&task_run](absl::Status status) { + task_run = true; + ABSL_EXPECT_OK(status); + }).Return(absl::OkStatus()); + + EXPECT_TRUE(task_scheduled); + EXPECT_TRUE(task_run); +} + +TEST(AsyncContextTest, ExecutesInlineIfExecutorIsNullVoid) { + AsyncContext context(nullptr); + + bool task_run = false; + AsyncResolver<void>(context, [&task_run](absl::Status status) { + task_run = true; + ABSL_EXPECT_OK(status); + }).Return(absl::OkStatus()); + + EXPECT_TRUE(task_run); +} + +} // namespace +} // namespace credentio
diff --git a/async/cancellation_token.cc b/async/cancellation_token.cc new file mode 100644 index 0000000..a402265 --- /dev/null +++ b/async/cancellation_token.cc
@@ -0,0 +1,73 @@ +// 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 "async/cancellation_token.h" + +#include <cstdint> +#include <utility> +#include <vector> + +#include "absl/functional/any_invocable.h" +#include "absl/synchronization/mutex.h" + +namespace credentio { + +void CancellationState::Cancel() { + std::vector<absl::AnyInvocable<void()>> to_run; + + { + absl::MutexLock lock(mutex_); + if (cancelled_) return; + cancelled_ = true; + + to_run.reserve(callbacks_.size()); + for (auto& pair : callbacks_) { + to_run.push_back(std::move(pair.second)); + } + callbacks_.clear(); + } + + // Execute callbacks without holding the lock to avoid deadlocks. + for (auto& cb : to_run) { + if (cb) { + cb(); + } + } +} + +uint64_t CancellationState::RegisterCallback(absl::AnyInvocable<void()> cb) { + { + absl::MutexLock lock(mutex_); + if (!cancelled_) { + uint64_t id = next_id_++; + callbacks_[id] = std::move(cb); + return id; + } + } + + // If already cancelled, execute immediately and return 0 (no unregister + // needed). + if (cb) { + cb(); + } + return 0; +} + +void CancellationState::DeregisterCallback(uint64_t id) { + absl::MutexLock lock(mutex_); + callbacks_.erase(id); +} + +} // namespace credentio
diff --git a/async/cancellation_token.h b/async/cancellation_token.h new file mode 100644 index 0000000..1a50e59 --- /dev/null +++ b/async/cancellation_token.h
@@ -0,0 +1,130 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASYNC_CANCELLATION_TOKEN_H_ +#define THIRD_PARTY_CREDENTIO_ASYNC_CANCELLATION_TOKEN_H_ + +#include <cstdint> +#include <memory> +#include <utility> + +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_map.h" +#include "absl/functional/any_invocable.h" +#include "absl/synchronization/mutex.h" + +namespace credentio { + +class CancellationToken; + +// Internal shared state for cancellation. +class CancellationState { + public: + CancellationState() = default; + + bool IsCancelled() const { + absl::MutexLock lock(mutex_); + return cancelled_; + } + + void Cancel(); + + // Returns >0 registration ID if successfully registered. + // Returns 0 if already cancelled (and executes callback immediately inline). + uint64_t RegisterCallback(absl::AnyInvocable<void()> cb); + + void DeregisterCallback(uint64_t id); + + private: + mutable absl::Mutex mutex_; + bool cancelled_ ABSL_GUARDED_BY(mutex_) = false; + uint64_t next_id_ ABSL_GUARDED_BY(mutex_) = 1; + absl::flat_hash_map<uint64_t, absl::AnyInvocable<void()>> callbacks_ + ABSL_GUARDED_BY(mutex_); +}; + +// Represents a read-only view of a cancellation state. +class CancellationToken { + public: + CancellationToken() = default; + + bool IsCancelled() const { + if (!state_) return false; + return state_->IsCancelled(); + } + + // Copyable and movable. + CancellationToken(const CancellationToken&) = default; + CancellationToken& operator=(const CancellationToken&) = default; + CancellationToken(CancellationToken&&) = default; + CancellationToken& operator=(CancellationToken&&) = default; + + private: + friend class CancellationSource; + friend class CancellationCallback; + + explicit CancellationToken(std::shared_ptr<CancellationState> state) + : state_(std::move(state)) {} + + std::shared_ptr<CancellationState> state_; +}; + +// The source of cancellation. +class CancellationSource { + public: + CancellationSource() : state_(std::make_shared<CancellationState>()) {} + + void Cancel() { + if (state_) { + state_->Cancel(); + } + } + + CancellationToken GetToken() const { return CancellationToken(state_); } + + private: + std::shared_ptr<CancellationState> state_; +}; + +// Represents a registration of a callback against a CancellationToken. +// When this object falls out of scope, the callback is automatically +// deregistered. +class CancellationCallback { + public: + CancellationCallback(CancellationToken token, absl::AnyInvocable<void()> cb) + : state_(std::move(token.state_)) { + if (state_) { + id_ = state_->RegisterCallback(std::move(cb)); + } + } + + ~CancellationCallback() { + if (state_ && id_ != 0) { + state_->DeregisterCallback(id_); + } + } + + // Non-copyable, non-movable. + CancellationCallback(const CancellationCallback&) = delete; + CancellationCallback& operator=(const CancellationCallback&) = delete; + + private: + std::shared_ptr<CancellationState> state_; + uint64_t id_ = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASYNC_CANCELLATION_TOKEN_H_
diff --git a/async/cancellation_token_test.cc b/async/cancellation_token_test.cc new file mode 100644 index 0000000..dff17b4 --- /dev/null +++ b/async/cancellation_token_test.cc
@@ -0,0 +1,119 @@ +// 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 "async/cancellation_token.h" + +#include <optional> + +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +TEST(CancellationTokenTest, IsCancelledFalseInitially) { + CancellationSource source; + CancellationToken token = source.GetToken(); + EXPECT_FALSE(token.IsCancelled()); +} + +TEST(CancellationTokenTest, IsCancelledTrueAfterCancel) { + CancellationSource source; + CancellationToken token = source.GetToken(); + source.Cancel(); + EXPECT_TRUE(token.IsCancelled()); +} + +TEST(CancellationTokenTest, CallbackExecutedOnCancel) { + CancellationSource source; + CancellationToken token = source.GetToken(); + + bool executed = false; + CancellationCallback callback(token, [&executed]() { executed = true; }); + + EXPECT_FALSE(executed); + source.Cancel(); + EXPECT_TRUE(executed); +} + +TEST(CancellationTokenTest, CallbackNotExecutedIfDeregistered) { + CancellationSource source; + CancellationToken token = source.GetToken(); + + bool executed = false; + { + CancellationCallback callback(token, [&executed]() { executed = true; }); + } // callback destroyed here + + source.Cancel(); + EXPECT_FALSE(executed); +} + +TEST(CancellationTokenTest, CallbackExecutedImmediatelyIfAlreadyCancelled) { + CancellationSource source; + CancellationToken token = source.GetToken(); + source.Cancel(); + + bool executed = false; + CancellationCallback callback(token, [&executed]() { executed = true; }); + + // Execute happens inline during registration + EXPECT_TRUE(executed); +} + +TEST(CancellationTokenTest, MultipleCallbacks) { + CancellationSource source; + CancellationToken token = source.GetToken(); + + bool ex1 = false; + bool ex2 = false; + + CancellationCallback cb1(token, [&ex1]() { ex1 = true; }); + CancellationCallback cb2(token, [&ex2]() { ex2 = true; }); + + source.Cancel(); + EXPECT_TRUE(ex1); + EXPECT_TRUE(ex2); +} + +TEST(CancellationTokenTest, UncancellableToken) { + CancellationToken token; // Default constructed + EXPECT_FALSE(token.IsCancelled()); + + bool executed = false; + CancellationCallback callback(token, [&executed]() { executed = true; }); + + // Can never be cancelled + EXPECT_FALSE(executed); +} + +// Ensure the token can outlive the source safely. +TEST(CancellationTokenTest, TokenOutlivesSource) { + std::optional<CancellationToken> token; + { + CancellationSource source; + token = source.GetToken(); + } + + // Checking IsCancelled should be safe and return false + EXPECT_FALSE(token->IsCancelled()); + + // Registering callback should be safe (and it will never fire) + bool executed = false; + CancellationCallback callback(*token, [&executed]() { executed = true; }); + EXPECT_FALSE(executed); +} + +} // namespace +} // namespace credentio
diff --git a/async/executor.h b/async/executor.h new file mode 100644 index 0000000..a7523e0 --- /dev/null +++ b/async/executor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_ASYNC_EXECUTOR_H_ +#define THIRD_PARTY_CREDENTIO_ASYNC_EXECUTOR_H_ + +#include "absl/functional/any_invocable.h" + +namespace credentio { + +// Simple interface for scheduling asynchronous tasks. +class Executor { + public: + virtual ~Executor() = default; + + // Schedules the given task for asynchronous execution. + virtual void Schedule(absl::AnyInvocable<void() &&> task) = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_ASYNC_EXECUTOR_H_
diff --git a/bindings/BUILD b/bindings/BUILD new file mode 100644 index 0000000..995fea2 --- /dev/null +++ b/bindings/BUILD
@@ -0,0 +1,438 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "input_hasher", + srcs = ["input_hasher.cc"], + hdrs = ["input_hasher.h"], + deps = [ + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "input_hasher_test", + srcs = ["input_hasher_test.cc"], + deps = [ + ":input_hasher", + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "binding_hasher", + srcs = ["binding_hasher.cc"], + hdrs = ["binding_hasher.h"], + deps = [ + ":input_hasher", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "binding_hasher_test", + srcs = ["binding_hasher_test.cc"], + deps = [ + ":binding_hasher", + ":input_hasher", + "//crypto/default:hasher", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "merkle", + srcs = ["merkle.cc"], + hdrs = ["merkle.h"], + deps = [ + "//crypto:hash", + "//proto:bmff_based_hash_assertion_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@protobuf//:protobuf_lite", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "merkle_test", + srcs = ["merkle_test.cc"], + deps = [ + ":merkle", + "//crypto:algorithms", + "//crypto:hash", + "//proto:bmff_based_hash_assertion_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "bmff_exclusion_checker", + srcs = ["bmff_exclusion_checker.cc"], + hdrs = ["bmff_exclusion_checker.h"], + deps = [ + "//proto:bmff_based_hash_assertion_cc_proto", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "hard_binding_validator", + hdrs = ["hard_binding_validator.h"], + deps = [ + "//formats:asset_byte_info", + "//proto:ingredient_validation_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "merkle_validator", + srcs = ["merkle_validator.cc"], + hdrs = ["merkle_validator.h"], + deps = [ + ":input_hasher", + ":merkle", + "//cbor", + "//cbor:parse", + "//constants:labels", + "//constants:status_codes", + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "//formats/bmff:box_header", + "//proto:bmff_based_hash_assertion_cbor_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//utils:riegeli", + "//utils:status_tracker", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "boxes_hash_validator", + srcs = ["boxes_hash_validator.cc"], + hdrs = ["boxes_hash_validator.h"], + deps = [ + "//constants:status_codes", + "//crypto:algorithms", + "//proto:boxes_hash_assertion_cc_proto", + "//utils:status_tracker", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + ], +) + +cc_test( + name = "boxes_hash_validator_test", + srcs = ["boxes_hash_validator_test.cc"], + deps = [ + ":boxes_hash_validator", + "//constants:status_codes", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "//testing:test_status_tracker", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@protobuf//:differencer", + ], +) + +cc_test( + name = "merkle_validator_test", + srcs = ["merkle_validator_test.cc"], + deps = [ + ":merkle_validator", + "//constants:status_codes", + "//crypto:algorithms", + "//crypto:hash", + "//formats/bmff:test_utils", + "//proto:bmff_based_hash_assertion_cc_proto", + "//testing:cbor_utils", + "//testing:proto_test_utils", + "//utils:status_tracker", + "//validator:tracker", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "bmff_binding_hasher", + srcs = ["bmff_binding_hasher.cc"], + hdrs = ["bmff_binding_hasher.h"], + deps = [ + ":binding_hasher", + "//formats/bmff:box_header", + "//formats/bmff:box_matcher", + "//proto:bmff_based_hash_assertion_cc_proto", + "//utils:byte_writers", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "bmff_binding_hasher_test", + srcs = ["bmff_binding_hasher_test.cc"], + deps = [ + ":binding_hasher", + ":bmff_binding_hasher", + "//formats/bmff:test_utils", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:manifest_cc_proto", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "boxes_hash_hard_binding_validator", + srcs = ["boxes_hash_hard_binding_validator.cc"], + hdrs = ["boxes_hash_hard_binding_validator.h"], + deps = [ + ":binding_hasher", + ":boxes_hash_validator", + ":hard_binding_validator", + "//constants:status_codes", + "//formats:asset_box", + "//formats:asset_byte_info", + "//formats:byte_range", + "//proto:assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//utils:dual_status_tracker", + "//utils:status_tracker", + "//validator:result", + "//validator:validation_result_internal", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "data_hash_hard_binding_validator", + srcs = ["data_hash_hard_binding_validator.cc"], + hdrs = ["data_hash_hard_binding_validator.h"], + deps = [ + ":binding_hasher", + ":hard_binding_validator", + "//constants:status_codes", + "//formats:asset_byte_info", + "//formats:byte_range", + "//proto:assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:validation_result_cc_proto", + "//utils:dual_status_tracker", + "//utils:status_tracker", + "//validator:result", + "//validator:validation_result_internal", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "bmff_hash_hard_binding_validator", + srcs = ["bmff_hash_hard_binding_validator.cc"], + hdrs = ["bmff_hash_hard_binding_validator.h"], + deps = [ + ":binding_hasher", + ":bmff_binding_hasher", + ":bmff_exclusion_checker", + ":hard_binding_validator", + ":merkle_validator", + "//constants:status_codes", + "//formats:asset_byte_info", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:multi_asset_hash_assertion_cc_proto", + "//proto:validation_result_cc_proto", + "//utils:dual_status_tracker", + "//utils:status_tracker", + "//validator:result", + "//validator:validation_result_internal", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "collection_data_hash_hard_binding_validator", + srcs = ["collection_data_hash_hard_binding_validator.cc"], + hdrs = ["collection_data_hash_hard_binding_validator.h"], + deps = [ + ":binding_hasher", + ":hard_binding_validator", + ":input_hasher", + "//constants:status_codes", + "//crypto:algorithms", + "//formats:asset_byte_info", + "//formats/zip:constants", + "//formats/zip:reader", + "//proto:assertion_cc_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:validation_result_cc_proto", + "//utils:dual_status_tracker", + "//utils:status_tracker", + "//validator:result", + "//validator:validation_result_internal", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "validator", + srcs = ["validator.cc"], + hdrs = ["validator.h"], + deps = [ + ":bmff_hash_hard_binding_validator", + ":boxes_hash_hard_binding_validator", + ":collection_data_hash_hard_binding_validator", + ":data_hash_hard_binding_validator", + "//constants:status_codes", + "//formats:asset_box", + "//formats:byte_range", + "//formats:core_registry", + "//formats:format", + "//formats:registry", + "//formats/bmff:assessor", + "//formats/bmff:box_header", + "//jumbf:uri", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:manifest_cc_proto", + "//proto:multi_asset_hash_assertion_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//utils:dual_status_tracker", + "//utils:status_tracker", + "//utils:two_stage_status_tracker", + "//utils:uri", + "//validator:result", + "//validator:validation_result_internal", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +)
diff --git a/bindings/binding_hasher.cc b/bindings/binding_hasher.cc new file mode 100644 index 0000000..4d6c96e --- /dev/null +++ b/bindings/binding_hasher.cc
@@ -0,0 +1,142 @@ +// 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 "bindings/binding_hasher.h" + +#include <algorithm> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "bindings/input_hasher.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +absl_nullable std::unique_ptr<BindingHasher> BindingHasher::Create( + absl::string_view alg, std::vector<Chunk> chunks, + BindingHasherTracker& tracker) { + auto hasher = InputHasher::Create(alg); + if (!hasher.ok()) { + tracker.RecordAlgorithmUnsupported(); + return nullptr; + } + return std::make_unique<BindingHasher>(*std::move(hasher), std::move(chunks)); +} + +std::optional<std::string> BindingHasher::Digest(riegeli::Reader& input, + BindingHasherTracker& tracker, + uint64_t start_offset, + int64_t end_offset) { + if (!input.SupportsSize() || !input.Size().has_value()) { + tracker.RecordMalformed(); + return std::nullopt; + } + const uint64_t file_size = *input.Size(); + + if (end_offset < 0) { + end_offset = file_size; + } + if (end_offset > file_size) { + tracker.RecordMalformed(); + return std::nullopt; + } + + auto update_input = [&](uint64_t offset, int64_t size) -> bool { + if (size <= 0) { + return true; + } + const absl::Status status = hasher_->Update(input, offset, size); + if (!status.ok()) { + tracker.RecordGeneralError(status); + return false; + } + return true; + }; + + uint64_t pos = start_offset; + std::optional<Chunk::Op> last_op = std::nullopt; + for (const auto& chunk : chunks_) { + // Check Op compatibility. + if (last_op.has_value() && (chunk.op == Chunk::Op::kInclusion) != + (*last_op == Chunk::Op::kInclusion)) { + tracker.RecordMalformed(); + return std::nullopt; + } + last_op = chunk.op; + + // Check out of file or negative length chunk. + if (pos >= end_offset || chunk.length < 0 || chunk.offset > end_offset || + chunk.offset < pos) { + tracker.RecordMalformed(); + return std::nullopt; + } + + // Advance to the chunk offset. + const uint64_t next_pos = std::min<uint64_t>(chunk.offset, end_offset); + if (chunk.op != Chunk::Op::kInclusion) { + if (!update_input(pos, next_pos - pos)) { + return std::nullopt; + } + } + pos = next_pos; + if (pos >= end_offset && chunk.op != Chunk::Op::kExclusion) { + // Appending to the end of file is not allowed. + tracker.RecordMalformed(); + return std::nullopt; + } + + // Advance through the chunk. + int64_t chunk_size = chunk.length; + const int64_t file_left_size = end_offset - pos; + switch (chunk.op) { + case Chunk::Op::kExclusion: + case Chunk::Op::kInclusion: + if (chunk_size > file_left_size) { + tracker.RecordMismatch(); + return std::nullopt; + } + if (chunk_size == 0) { + chunk_size = file_left_size; + } + if (chunk.op == Chunk::Op::kInclusion) { + if (!update_input(pos, chunk_size)) { + return std::nullopt; + } + } + break; + case Chunk::Op::kInsertion: + // Ignore the chunk size. + chunk_size = 0; + hasher_->Update(chunk.additional_data); + break; + } + pos += chunk_size; + } + + if (!last_op.has_value() || *last_op != Chunk::Op::kInclusion) { + // Advance the rest of file. + if (!update_input(pos, end_offset - pos)) { + return std::nullopt; + } + } + return hasher_->Digest(); +} +} // namespace credentio
diff --git a/bindings/binding_hasher.h b/bindings/binding_hasher.h new file mode 100644 index 0000000..36d4056 --- /dev/null +++ b/bindings/binding_hasher.h
@@ -0,0 +1,108 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_BINDING_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_BINDING_HASHER_H_ + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "bindings/input_hasher.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// A tracker to record the errors during the process of digesting a hard binding +// (e.g., a BMFF file). +class BindingHasherTracker { + public: + virtual ~BindingHasherTracker() = default; + virtual void RecordMismatch() = 0; + virtual void RecordMalformed() = 0; + virtual void RecordAlgorithmUnsupported() = 0; + virtual void RecordGeneralError(absl::Status status) = 0; +}; + +// A BindingHasherTracker implementation that produces an absl::Status. +class StatusBindingHasherTracker : public BindingHasherTracker { + public: + void RecordMismatch() override { + status_.Update(absl::InvalidArgumentError("mismatch")); + } + void RecordMalformed() override { + status_.Update(absl::InvalidArgumentError("malformed")); + } + void RecordAlgorithmUnsupported() override { + status_.Update(absl::InvalidArgumentError("algorithm unsupported")); + } + void RecordGeneralError(absl::Status status) override { + status_.Update(status); + } + absl::Status status() const { return status_; } + + private: + absl::Status status_ = absl::OkStatus(); +}; + +// A hasher to digest a file according to the C2PA Spec. In particular, this +// hasher supports excluding, including or inserting data. +class BindingHasher { + public: + struct Chunk { + // Defines the type of operation; whether to exclude, insert or include the + // chunk into the hasher. + enum class Op { kExclusion, kInsertion, kInclusion }; + Op op; // Type of operation, like exclusion or insertion. + uint64_t offset; // The absolute offset of the content to be digested. + // The length of the chunk. If zero and the Op is kExclusion or kInclusion, + // the chunk includes the rest of the content. When the Op is kInsertion, + // the length is ignored, and the whole additional_data will be inserted. + int64_t length; + // Additional data for insertion. Only suitable for small data, like the + // offset of a root box, which is as an 8-byte integer in the big-endian + // format when digesting a BMFF file by the C2PA Spec (`c2pa.hash.bmff.v2`). + std::string additional_data; + }; + + // The chunks must be ordered by offset and non-overlapping. + BindingHasher(std::unique_ptr<InputHasher> hasher, std::vector<Chunk> chunks) + : hasher_(std::move(hasher)), chunks_(std::move(chunks)) {} + + // Digest the input file. The start_offset is the offset of the first byte + // of the data to digest. The end_offset is the offset with respect to the + // start of the entire file of the last byte of the data to digest, -1 means + // the end of the file. + std::optional<std::string> Digest(riegeli::Reader& input, + BindingHasherTracker& tracker, + uint64_t start_offset = 0, + int64_t end_offset = -1); + static absl_nullable std::unique_ptr<BindingHasher> Create( + absl::string_view alg, std::vector<Chunk> chunks, + BindingHasherTracker& tracker); + + private: + std::unique_ptr<InputHasher> hasher_; + std::vector<Chunk> chunks_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_BINDING_HASHER_H_
diff --git a/bindings/binding_hasher_test.cc b/bindings/binding_hasher_test.cc new file mode 100644 index 0000000..c8b5fa2 --- /dev/null +++ b/bindings/binding_hasher_test.cc
@@ -0,0 +1,425 @@ +// 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 "bindings/binding_hasher.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "bindings/input_hasher.h" +#include "crypto/default/hasher.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::credentio::BindingHasher::Chunk::Op::kExclusion; +using ::credentio::BindingHasher::Chunk::Op::kInclusion; +using ::credentio::BindingHasher::Chunk::Op::kInsertion; +using ::testing::HasSubstr; +using ::testing::IsNull; +using ::testing::NotNull; +using ::testing::Optional; + +class ErrorHasher : public InputHasher { + public: + absl::Status Update(riegeli::Reader& input, uint64_t offset, + uint64_t length) override { + return absl::InvalidArgumentError("error"); + } + void Update(absl::string_view data) override {} + std::string Digest() override { return ""; } +}; + +std::string Sha256Digest(absl::string_view contents) { + auto hasher = CreateHasher("sha256"); + if (!hasher.ok()) return ""; + (*hasher)->Update(contents); + return (*hasher)->Digest(); +} + +TEST(BmffHasher, OkayEntireContent) { + StatusBindingHasherTracker tracker; + auto input = "0123456789"; + auto hasher = BindingHasher::Create("sha256", /*chunks=*/{}, tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest("0123456789"))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, OkayLargeContent) { + StatusBindingHasherTracker tracker; + std::string input; + for (int i = 0; i < 2000; ++i) { + absl::StrAppend(&input, "abcdefg"); + } + + auto hasher = BindingHasher::Create("sha256", /*chunks=*/{}, tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest(input))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, OkayExclusions) { + StatusBindingHasherTracker tracker; + auto input = "0123456789"; + + auto hasher = + BindingHasher::Create("sha256", + {{.op = kExclusion, .offset = 1, .length = 1}, + {.op = kExclusion, .offset = 3, .length = 2}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest("0256789"))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, OkayExcludingTheRestOfFile) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kExclusion, .offset = 1, .length = 1}, + {.op = kExclusion, .offset = 3, .length = 0}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest("02"))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, OkayInsertions) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create( + "sha256", + {{.op = kInsertion, .offset = 1, .additional_data = "AAA"}, + {.op = kInsertion, .offset = 7, .additional_data = "BBB"}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest("0AAA123456BBB789"))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, OkayInsertionsAndIgnoreChunkSize) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create( + "sha256", + {{.op = kInsertion, .offset = 1, .length = 100, .additional_data = "AAA"}, + {.op = kInsertion, .offset = 7, .additional_data = "BBB"}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest("0AAA123456BBB789"))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, OkayMixingExclusionsAndInsertions) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create( + "sha256", + {{.op = kExclusion, .offset = 1, .length = 1}, + {.op = kInsertion, .offset = 2, .additional_data = "AAA"}, + {.op = kExclusion, .offset = 3, .length = 2}, + {.op = kInsertion, .offset = 7, .additional_data = "BBB"}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest("0AAA256BBB789"))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, OkayInclusions) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kInclusion, .offset = 1, .length = 1}, + {.op = kInclusion, .offset = 3, .length = 2}, + {.op = kInclusion, + .offset = 6, + /* .length = 0 */}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + EXPECT_THAT(result, Optional(Sha256Digest("1346789"))); + EXPECT_THAT(tracker.status(), IsOk()); +} + +TEST(BmffHasher, ErrorMixedInclusionsAndExclusions) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kExclusion, .offset = 1, .length = 1}, + {.op = kInclusion, .offset = 3, .length = 2}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorInsertingOutOfInput) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create( + "sha256", {{.op = kInsertion, .offset = 10}}, tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorExclusionOverlapping) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kExclusion, + .offset = 1, + .length = 0}, // exclude the rest of file. + {.op = kExclusion, .offset = 2}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorExclusionOverlappingNonEnd) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kExclusion, .offset = 1, .length = 3}, + {.op = kExclusion, .offset = 2, .length = 2}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorExclusionOutOfOrder) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kExclusion, .offset = 5, .length = 2}, + {.op = kExclusion, .offset = 2, .length = 2}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorInclusionOverlapping) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kInclusion, .offset = 1, .length = 3}, + {.op = kInclusion, .offset = 2, .length = 2}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorInclusionOutOfOrder) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = + BindingHasher::Create("sha256", + {{.op = kInclusion, .offset = 5, .length = 2}, + {.op = kInclusion, .offset = 2, .length = 2}}, + tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, DigestUnorderedExclusionsCPUExhaustionLoop) { + std::string input(1000, 'a'); + std::vector<BindingHasher::Chunk> chunks; + for (int i = 0; i < 1000; ++i) { + chunks.push_back( + {.op = kExclusion, .offset = (i % 2 == 0) ? 500u : 10u, .length = 5}); + } + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create("sha256", chunks, tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorChunkMalformed) { + auto input = "0123456789"; + riegeli::StringReader<> reader(input); + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create( + "sha256", {{.op = kExclusion, .offset = 3, .length = -1}}, tracker); + ASSERT_THAT(hasher, NotNull()); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("malformed"))); +} + +TEST(BmffHasher, ErrorExclusionMismatch) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create( + "sha256", {{.op = kExclusion, .offset = 3, .length = 10}}, tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("mismatch"))); +} + +TEST(BmffHasher, ErrorInclusionMismatch) { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create( + "sha256", {{.op = kInclusion, .offset = 3, .length = 10}}, tracker); + ASSERT_THAT(hasher, NotNull()); + riegeli::StringReader<> reader(input); + auto result = hasher->Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("mismatch"))); +} + +TEST(BmffHasher, ErrorAlgorithmUnsupported) { + StatusBindingHasherTracker tracker; + auto hasher = BindingHasher::Create("abcd", {}, tracker); + EXPECT_THAT(hasher, IsNull()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("unsupported"))); +} + +TEST(BmffHasher, ErrorHasherFailed) { + { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + BindingHasher hasher(std::make_unique<ErrorHasher>(), /*chunks=*/{}); + riegeli::StringReader<> reader(input); + auto result = hasher.Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument)); + } + { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + BindingHasher hasher(std::make_unique<ErrorHasher>(), + {{.op = kExclusion, .offset = 1, .length = 1}}); + riegeli::StringReader<> reader(input); + auto result = hasher.Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument)); + } + { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + BindingHasher hasher( + std::make_unique<ErrorHasher>(), + {{.op = kInsertion, .offset = 0, .additional_data = "AAA"}}); + riegeli::StringReader<> reader(input); + auto result = hasher.Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument)); + } + { + auto input = "0123456789"; + + StatusBindingHasherTracker tracker; + BindingHasher hasher(std::make_unique<ErrorHasher>(), + {{.op = kInclusion, .offset = 0}}); + riegeli::StringReader<> reader(input); + auto result = hasher.Digest(reader, tracker); + ASSERT_FALSE(result.has_value()); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument)); + } +} + +} // namespace +} // namespace credentio
diff --git a/bindings/bmff_binding_hasher.cc b/bindings/bmff_binding_hasher.cc new file mode 100644 index 0000000..96d3147 --- /dev/null +++ b/bindings/bmff_binding_hasher.cc
@@ -0,0 +1,98 @@ +// 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 "bindings/bmff_binding_hasher.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "bindings/binding_hasher.h" +#include "formats/bmff/box_header.h" +#include "formats/bmff/box_matcher.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/byte_writers.h" + +namespace credentio { + +namespace { + +std::string Uint64ToBytes(uint64_t value) { + std::vector<uint8_t> bytes; + bytes.reserve(sizeof(uint64_t)); + WriteUint64NetworkOrder(value, &bytes); + return std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size()); +} + +absl_nullable std::unique_ptr<BindingHasher> BuildBmffV2Hasher( + const BmffBasedHashAssertion& bmff_hash, riegeli::Reader& file_contents, + BindingHasherTracker& tracker) { + // Build box matchers from the assertion. + std::vector<BMFFBoxMatcher> matchers; + matchers.reserve(bmff_hash.exclusions_size()); + for (const auto& bmff_range : bmff_hash.exclusions()) { + matchers.emplace_back(bmff_range, &file_contents); + } + + // Match boxes and build exclusion and insertion chunks for the hasher. + std::vector<BindingHasher::Chunk> chunks; + absl::Status iterator_status = IterateOverBmffBoxes( + file_contents, + [&chunks, &matchers](const BmffBoxHeader& box) -> absl::StatusOr<bool> { + std::vector<BMFFBoxMatcher::Range> exclusions; + bool is_entire_box_excluded = false; + for (const auto& matcher : matchers) { + if (matcher.Matches(box)) { + exclusions = matcher.MatchedRanges(box); + is_entire_box_excluded = matcher.IsMatchingEntireBox(); + break; + } + } + if (!is_entire_box_excluded && box.IsRootBox()) { + chunks.push_back({.op = BindingHasher::Chunk::Op::kInsertion, + .offset = box.start, + .additional_data = Uint64ToBytes(box.start)}); + } + for (const auto& exclusion : exclusions) { + chunks.push_back({.op = BindingHasher::Chunk::Op::kExclusion, + .offset = exclusion.offset, + .length = exclusion.length}); + } + return true; + }); + if (!iterator_status.ok()) { + tracker.RecordGeneralError(iterator_status); + return nullptr; + } + return BindingHasher::Create(bmff_hash.alg(), chunks, tracker); +} +} // namespace + +std::optional<std::string> BmffBindingHasher::Digest( + riegeli::Reader& input) const { + auto hasher = BuildBmffV2Hasher(assertion_, input, tracker_); + if (hasher == nullptr) { + return std::nullopt; + } + return hasher->Digest(input, tracker_); +} + +} // namespace credentio
diff --git a/bindings/bmff_binding_hasher.h b/bindings/bmff_binding_hasher.h new file mode 100644 index 0000000..adcf746 --- /dev/null +++ b/bindings/bmff_binding_hasher.h
@@ -0,0 +1,44 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_BINDING_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_BINDING_HASHER_H_ + +#include <optional> +#include <string> + +#include "absl/log/die_if_null.h" +#include "bindings/binding_hasher.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +// A hasher to digest a BMFF file according to a C2PA hash assertion. +class BmffBindingHasher { + public: + BmffBindingHasher(const BmffBasedHashAssertion* assertion, + BindingHasherTracker* tracker) + : assertion_(*ABSL_DIE_IF_NULL(assertion)), + tracker_(*ABSL_DIE_IF_NULL(tracker)) {} + // Digests a BMFF file according to an underlying BmffBasedHashAssertion. + std::optional<std::string> Digest(riegeli::Reader& input) const; + + private: + const BmffBasedHashAssertion& assertion_; + BindingHasherTracker& tracker_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_BINDING_HASHER_H_
diff --git a/bindings/bmff_binding_hasher_test.cc b/bindings/bmff_binding_hasher_test.cc new file mode 100644 index 0000000..200c9fa --- /dev/null +++ b/bindings/bmff_binding_hasher_test.cc
@@ -0,0 +1,79 @@ +// 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 "bindings/bmff_binding_hasher.h" + +#include <optional> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "bindings/binding_hasher.h" +#include "formats/bmff/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::Eq; +using ::testing::Ne; + +TEST(BmffBindingHasherTest, DigestInvalidInput) { + riegeli::StringReader<> input("some fake content"); + BmffBasedHashAssertion assertion; + StatusBindingHasherTracker tracker; + BmffBindingHasher hasher(&assertion, &tracker); + EXPECT_THAT(hasher.Digest(input), Eq(std::nullopt)); + EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kDataLoss)); +} + +TEST(BmffBindingHasherTest, XPathBypassWithBracketBoxType) { + std::string contents1 = credentio_testing::Box("[moo", "payload1_data_here"); + std::string contents2 = credentio_testing::Box("[moo", "payload2_data_here"); + riegeli::StringReader<> input1(contents1); + riegeli::StringReader<> input2(contents2); + + BmffBasedHashAssertion assertion; + assertion.set_alg("sha256"); + auto* exclusion = assertion.add_exclusions(); + exclusion->set_xpath("/moov"); + + StatusBindingHasherTracker tracker1; + BmffBindingHasher hasher1(&assertion, &tracker1); + auto digest1 = hasher1.Digest(input1); + ASSERT_TRUE(digest1.has_value()); + EXPECT_THAT(tracker1.status(), IsOk()); + + StatusBindingHasherTracker tracker2; + BmffBindingHasher hasher2(&assertion, &tracker2); + auto digest2 = hasher2.Digest(input2); + ASSERT_TRUE(digest2.has_value()); + EXPECT_THAT(tracker2.status(), IsOk()); + + // Without the fix, "[moo" was treated as matching "/moov", causing the entire + // box payload to be excluded and making digest1 == digest2. + // With the fix, the box is not excluded, so different payloads produce + // different digests. + EXPECT_THAT(digest1, Ne(digest2)); +} + +} // namespace +} // namespace credentio
diff --git a/bindings/bmff_exclusion_checker.cc b/bindings/bmff_exclusion_checker.cc new file mode 100644 index 0000000..72951fb --- /dev/null +++ b/bindings/bmff_exclusion_checker.cc
@@ -0,0 +1,111 @@ +// 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 "bindings/bmff_exclusion_checker.h" + +#include <algorithm> + +#include "absl/strings/match.h" +#include "absl/strings/string_view.h" +#include "proto/bmff_based_hash_assertion.pb.h" + +namespace credentio { + +namespace { +// Required exclusions as defined in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements +bool IsRootBox(absl::string_view xpath, absl::string_view box_type) { + if (xpath.size() < box_type.size() + 1) { + return false; + } + if (xpath[0] != '/') { + return false; + } + if (xpath.substr(1, box_type.size()) != box_type) { + return false; + } + if (xpath.size() == box_type.size() + 1) { + return true; + } + if (xpath[box_type.size() + 1] == '[' && xpath.back() == ']') { + absl::string_view index_str = + xpath.substr(box_type.size() + 2, xpath.size() - box_type.size() - 3); + if (index_str.empty()) { + return false; + } + for (char c : index_str) { + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + return false; +} + +bool IsRequiredExclusion(const BmffRange& exclusion, bool has_merkle_maps) { + if (IsRootBox(exclusion.xpath(), "ftyp") && exclusion.subsets().empty()) { + return true; + } + if (IsRootBox(exclusion.xpath(), "mfra") && exclusion.subsets().empty()) { + return true; + } + + // These exclusion xpaths must match exactly as the additional constraints + // cannot be applied if the xpath selects a lower level node + if (IsRootBox(exclusion.xpath(), "uuid") && exclusion.subsets().empty() && + exclusion.data_size() == 1 && exclusion.data(0).offset() == 8 && + exclusion.data(0).value() == + "\330\376\303\326\033\016H<\222\227X(\207~\304\201") { + return true; + } + if (has_merkle_maps && exclusion.xpath() == "/mdat" && + exclusion.subsets_size() == 1 && exclusion.subsets(0).offset() == 16 && + exclusion.subsets(0).length() == 0) { + return true; + } + return false; +} + +// Additional allowed exclusions as defined in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validating_a_bmff_hash +bool IsAllowedExclusion(const BmffRange& exclusion) { + if (absl::StartsWith(exclusion.xpath(), "/free")) { + return true; + } + if (absl::StartsWith(exclusion.xpath(), "/skip")) { + return true; + } + return false; +} +} // namespace + +bool HasAdditionalExclusions(const BmffBasedHashAssertion& assertion) { + if (assertion.exclusions().empty()) { + // No exclusions are specified, so there can't be any additional exclusions. + return false; + } + + bool has_merkle_maps = assertion.has_hash() && assertion.merkles_size() > 0; + + return std::any_of( + assertion.exclusions().begin(), assertion.exclusions().end(), + [&has_merkle_maps](const BmffRange& exclusion) { + return !IsRequiredExclusion(exclusion, has_merkle_maps) && + !IsAllowedExclusion(exclusion); + }); +} + +} // namespace credentio
diff --git a/bindings/bmff_exclusion_checker.h b/bindings/bmff_exclusion_checker.h new file mode 100644 index 0000000..1aa3a11 --- /dev/null +++ b/bindings/bmff_exclusion_checker.h
@@ -0,0 +1,31 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_EXCLUSION_CHECKER_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_EXCLUSION_CHECKER_H_ + +#include "proto/bmff_based_hash_assertion.pb.h" + +namespace credentio { + +// Returns true if the given assertion contains any additional exclusions +// beyond the required or allowed exclusions. +// See: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements +bool HasAdditionalExclusions(const BmffBasedHashAssertion& assertion); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_EXCLUSION_CHECKER_H_
diff --git a/bindings/bmff_hash_hard_binding_validator.cc b/bindings/bmff_hash_hard_binding_validator.cc new file mode 100644 index 0000000..e318885 --- /dev/null +++ b/bindings/bmff_hash_hard_binding_validator.cc
@@ -0,0 +1,142 @@ +// 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 "bindings/bmff_hash_hard_binding_validator.h" + +#include <memory> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "bindings/binding_hasher.h" +#include "bindings/bmff_binding_hasher.h" +#include "bindings/bmff_exclusion_checker.h" +#include "bindings/merkle_validator.h" +#include "constants/status_codes.h" +#include "formats/asset_byte_info.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/dual_status_tracker.h" +#include "utils/status_tracker.h" +#include "validator/result.h" +#include "validator/validation_result_internal.h" + +namespace credentio { + +namespace { +class BmffHashBindingHasherTracker : public BindingHasherTracker { + public: + explicit BmffHashBindingHasherTracker(StatusTracker* tracker, + absl::string_view hard_binding_uri) + : tracker_(*ABSL_DIE_IF_NULL(tracker)), + hard_binding_uri_(hard_binding_uri) {} + void RecordMismatch() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {.url = hard_binding_uri_}); + } + void RecordMalformed() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionBmffHashMalformed, + {.url = hard_binding_uri_}); + } + void RecordAlgorithmUnsupported() override { + tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported, + {.url = hard_binding_uri_}); + } + void RecordGeneralError(absl::Status error) override { + tracker_.RecordFailure( + FailureStatusCode::kGeneralError, + {.url = hard_binding_uri_, .explanation = error.message()}); + } + + private: + StatusTracker& tracker_; + absl::string_view hard_binding_uri_; +}; +} // namespace + +absl::StatusOr<std::unique_ptr<ValidationResultProto>> +BmffHashHardBindingValidator::Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const { + const Assertion* absl_nullable assertion = + GetAssertion(partial_validation_result.get(), + partial_validation_result->hard_binding_uri()); + if (assertion == nullptr) { + return absl::InvalidArgumentError(absl::StrCat( + "missing assertion: ", partial_validation_result->hard_binding_uri())); + } + if (!assertion->has_bmff_based_hash()) { + return absl::InvalidArgumentError( + absl::StrCat("assertion is not a bmff hash assertion: ", + partial_validation_result->hard_binding_uri())); + } + + ABSL_ASSIGN_OR_RETURN(auto tracker, + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get())); + + Validate(contents, assertion->bmff_based_hash(), + partial_validation_result->hard_binding_uri(), *tracker); + + return MakeFullValidationResult(std::move(partial_validation_result)); +} + +void BmffHashHardBindingValidator::Validate( + riegeli::Reader& contents, const BmffBasedHashAssertion& assertion, + absl::string_view hard_binding_uri, StatusTracker& tracker) const { + if (!contents.Seek(0) || contents.pos() != 0) { + tracker.RecordFailure(FailureStatusCode::kGoogleInternalError, + {.url = hard_binding_uri}); + return; + } + + if (HasAdditionalExclusions(assertion)) { + tracker.RecordInformational( + InformationalStatusCode::kAssertionBmffHashAdditionalExclusionsPresent, + {.url = hard_binding_uri}); + } + + BmffHashBindingHasherTracker hasher_tracker(&tracker, hard_binding_uri); + BmffBindingHasher hasher(&assertion, &hasher_tracker); + auto digest = hasher.Digest(contents); + if (digest.value_or("") != assertion.hash()) { + tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {.url = hard_binding_uri}); + return; + } + + if (assertion.merkles_size() > 0) { + MerkleValidator merkle_validator(contents, assertion, hard_binding_uri); + + if (!merkle_validator.Validate(tracker).ok()) { + // Failure Codes were already recorded by ValidateMerkleMaps. + return; + } + } + + tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, + {.url = hard_binding_uri}); +} + +} // namespace credentio
diff --git a/bindings/bmff_hash_hard_binding_validator.h b/bindings/bmff_hash_hard_binding_validator.h new file mode 100644 index 0000000..4ee0788 --- /dev/null +++ b/bindings/bmff_hash_hard_binding_validator.h
@@ -0,0 +1,47 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_HASH_HARD_BINDING_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_HASH_HARD_BINDING_VALIDATOR_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "bindings/hard_binding_validator.h" +#include "formats/asset_byte_info.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/status_tracker.h" + +namespace credentio { + +class BmffHashHardBindingValidator : public HardBindingValidator { + public: + absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const override; + + void Validate(riegeli::Reader& contents, + const BmffBasedHashAssertion& assertion, + absl::string_view hard_binding_uri, + StatusTracker& tracker) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/boxes_hash_hard_binding_validator.cc b/bindings/boxes_hash_hard_binding_validator.cc new file mode 100644 index 0000000..22359ea --- /dev/null +++ b/bindings/boxes_hash_hard_binding_validator.cc
@@ -0,0 +1,266 @@ +// 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 "bindings/boxes_hash_hard_binding_validator.h" + +#include <cstdint> +#include <memory> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_map.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "bindings/binding_hasher.h" +#include "bindings/boxes_hash_validator.h" +#include "constants/status_codes.h" +#include "formats/asset_box.h" +#include "formats/asset_byte_info.h" +#include "formats/byte_range.h" +#include "proto/assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/dual_status_tracker.h" +#include "utils/status_tracker.h" +#include "validator/result.h" +#include "validator/validation_result_internal.h" + +namespace credentio { + +namespace { + +class BoxesHashBindingHasherTracker : public BindingHasherTracker { + public: + explicit BoxesHashBindingHasherTracker(StatusTracker* tracker, + absl::string_view hard_binding_uri) + : tracker_(*ABSL_DIE_IF_NULL(tracker)), + hard_binding_uri_(hard_binding_uri) {} + void RecordMismatch() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch, + {.url = hard_binding_uri_}); + } + void RecordMalformed() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + {.url = hard_binding_uri_}); + } + void RecordAlgorithmUnsupported() override { + tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported, + {.url = hard_binding_uri_}); + } + void RecordGeneralError(absl::Status error) override { + tracker_.RecordFailure( + FailureStatusCode::kGeneralError, + {.url = hard_binding_uri_, .explanation = error.message()}); + } + + private: + StatusTracker& tracker_; + absl::string_view hard_binding_uri_; +}; + +absl::Status AddInclusions(const AssetBox& asset_box, + absl::Span<const BoxExclusion> exclusions, + absl::string_view hard_binding_uri, + StatusTracker& tracker, + std::vector<BindingHasher::Chunk>& inclusions) { + uint64_t last_handled_offset = asset_box.byte_range.offset; + for (const auto& exclusion : exclusions) { + uint64_t exclusion_asset_offset = + asset_box.byte_range.offset + exclusion.start(); + + if (exclusion.start() >= asset_box.byte_range.length || + exclusion.length() > + (asset_box.byte_range.length - exclusion.start())) { + // Exclusion starts after the box or extends beyond the end of the box. + tracker.RecordFailure( + FailureStatusCode::kAssertionBoxesHashMalformed, + {.url = hard_binding_uri, + .explanation = "Exclusion starts after the box or extends beyond " + "the end of the box"}); + return absl::InvalidArgumentError( + "Exclusion starts after the box or extends beyond the end of the " + "box"); + } + + if (last_handled_offset < exclusion_asset_offset) { + inclusions.push_back({ + .op = BindingHasher::Chunk::Op::kInclusion, + .offset = last_handled_offset, + .length = static_cast<int64_t>(exclusion_asset_offset) - + static_cast<int64_t>(last_handled_offset), + }); + } + last_handled_offset = exclusion_asset_offset + exclusion.length(); + } + if (last_handled_offset < + (asset_box.byte_range.offset + asset_box.byte_range.length)) { + inclusions.push_back({ + .op = BindingHasher::Chunk::Op::kInclusion, + .offset = last_handled_offset, + .length = static_cast<int64_t>(asset_box.byte_range.offset + + asset_box.byte_range.length) - + static_cast<int64_t>(last_handled_offset), + }); + } + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<std::unique_ptr<ValidationResultProto>> +BoxesHashHardBindingValidator::Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const { + const Assertion* absl_nullable assertion = + GetAssertion(partial_validation_result.get(), + partial_validation_result->hard_binding_uri()); + if (assertion == nullptr) { + return absl::InvalidArgumentError(absl::StrCat( + "missing assertion: ", partial_validation_result->hard_binding_uri())); + } + if (!assertion->has_boxes_hash()) { + return absl::InvalidArgumentError( + absl::StrCat("assertion is not a boxes hash assertion: ", + partial_validation_result->hard_binding_uri())); + } + + ABSL_ASSIGN_OR_RETURN(auto tracker, + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get())); + + Validate(contents, asset_byte_info.boxes, assertion->boxes_hash(), + partial_validation_result->hard_binding_uri(), *tracker); + + return MakeFullValidationResult(std::move(partial_validation_result)); +} + +void BoxesHashHardBindingValidator::Validate( + riegeli::Reader& contents, std::vector<AssetBox> contents_boxes, + const BoxesHashAssertion& assertion, absl::string_view hard_binding_uri, + StatusTracker& tracker) const { + if (!BoxesHashValidator(assertion, hard_binding_uri, + /*claims_algo=*/"", tracker) + .Validate() + .ok()) { + return; + } + + // Needed to determine "unknown box" vs "out of order box" + absl::flat_hash_map<absl::string_view, uint64_t> box_counts; + for (const auto& box : contents_boxes) { + box_counts[box.identifier] += 1; + } + + int asset_box_index = 0; + for (const auto& assertion_box_map : assertion.boxes()) { + auto algorithm(assertion_box_map.alg()); + if (algorithm.empty()) { + algorithm = assertion.alg(); + } + + std::vector<BindingHasher::Chunk> inclusions; + for (int box_name_idx = 0; box_name_idx < assertion_box_map.names_size(); + ++box_name_idx) { + const absl::string_view assertion_box_name = + assertion_box_map.names(box_name_idx); + + if (asset_box_index >= contents_boxes.size()) { + // Additional boxes in the assertion that are not in the asset. + tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch, + {.url = hard_binding_uri}); + return; + } + + const auto& current_asset_box = contents_boxes[asset_box_index]; + if (current_asset_box.identifier != assertion_box_name) { + tracker.RecordFailure( + box_counts[assertion_box_name] > 0 + ? FailureStatusCode::kAssertionBoxesHashMismatch + : FailureStatusCode::kAssertionBoxesHashUnknownBox, + {.url = hard_binding_uri}); + return; + } + --box_counts[assertion_box_name]; + + if (assertion_box_name != "C2PA") { + std::vector<BoxExclusion> exclusions; + if (!assertion_box_map.excluded()) { + for (const auto& exclusion : assertion_box_map.exclusions()) { + if (exclusion.has_box_index() && + exclusion.box_index() > box_name_idx) { + // Exclusion is for a box further down the line. + break; + } + if (!exclusion.has_box_index() || + exclusion.box_index() == box_name_idx) { + exclusions.push_back(exclusion); + } + } + } + + if (!AddInclusions(current_asset_box, exclusions, hard_binding_uri, + tracker, inclusions) + .ok()) { + return; + } + } + ++asset_box_index; + } + BoxesHashBindingHasherTracker boxes_hash_tracker(&tracker, + hard_binding_uri); + if (!inclusions.empty()) { + auto hasher = BindingHasher::Create(algorithm, std::move(inclusions), + boxes_hash_tracker); + if (hasher == nullptr) { + return; + } + auto digest = hasher->Digest(contents, boxes_hash_tracker); + if (!digest.has_value()) { + tracker.RecordFailure( + FailureStatusCode::kGoogleInternalError, + {.url = hard_binding_uri, .explanation = "Digest failed"}); + return; + } + // If the box_set is excluded, we don't care if the hash matches, just + // that it can be hashed. + bool required = + !(assertion_box_map.has_excluded() && assertion_box_map.excluded()); + if (required && *digest != assertion_box_map.hash()) { + tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch, + {.url = hard_binding_uri}); + return; + } + } + } + if (asset_box_index < contents_boxes.size()) { + // Additional boxes in the asset that are not in the assertion. + tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashUnknownBox, + {.url = hard_binding_uri}); + return; + } + tracker.RecordSuccess(SuccessStatusCode::kAssertionBoxesHashMatch, + {.url = hard_binding_uri}); +} + +} // namespace credentio
diff --git a/bindings/boxes_hash_hard_binding_validator.h b/bindings/boxes_hash_hard_binding_validator.h new file mode 100644 index 0000000..ca19786 --- /dev/null +++ b/bindings/boxes_hash_hard_binding_validator.h
@@ -0,0 +1,48 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_HARD_BINDING_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_HARD_BINDING_VALIDATOR_H_ + +#include <memory> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "bindings/hard_binding_validator.h" +#include "formats/asset_byte_info.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/status_tracker.h" + +namespace credentio { + +class BoxesHashHardBindingValidator : public HardBindingValidator { + public: + absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const override; + + void Validate(riegeli::Reader& contents, std::vector<AssetBox> contents_boxes, + const BoxesHashAssertion& assertion, + absl::string_view hard_binding_uri, + StatusTracker& tracker) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/boxes_hash_validator.cc b/bindings/boxes_hash_validator.cc new file mode 100644 index 0000000..80c4bd6 --- /dev/null +++ b/bindings/boxes_hash_validator.cc
@@ -0,0 +1,190 @@ +// 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 "bindings/boxes_hash_validator.h" + +#include <cstdint> +#include <limits> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "constants/status_codes.h" +#include "crypto/algorithms.h" +#include "proto/boxes_hash_assertion.pb.h" + +namespace credentio { +namespace { + +constexpr absl::string_view kSingleZeroByte("\0", 1); + +absl::string_view DetermineAlgorithm( + absl::Span<const absl::string_view> algos) { + absl::string_view algo_to_use = ""; + for (const auto& alg : algos) { + if (!alg.empty()) { + algo_to_use = alg; + break; + } + } + return algo_to_use; +} + +} // namespace + +absl::Status BoxesHashValidator::Validate() { + if (assertion_.boxes().empty()) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxes is empty"); + } + + for (const auto& box_hash_map : assertion_.boxes()) { + ABSL_RETURN_IF_ERROR(ValidateBoxHashMap(box_hash_map)); + } + + return absl::OkStatus(); +} + +absl::Status BoxesHashValidator::LogFailure(FailureStatusCode failure_code, + absl::string_view explanation) { + tracker_.RecordFailure( + failure_code, {.url = hard_binding_uri_, .explanation = explanation}); + return absl::InvalidArgumentError(explanation); +} + +void BoxesHashValidator::LogAdditionalExclusionsPresent() { + if (!found_additional_exclusions_) { + found_additional_exclusions_ = true; + tracker_.RecordInformational( + InformationalStatusCode::kAssertionBoxesHashAdditionalExclusionsPresent, + {.url = hard_binding_uri_}); + } +} + +absl::Status BoxesHashValidator::ValidateBoxHashMap( + const BoxHash& box_hash_map) { + if (box_hash_map.names().empty()) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.names is empty"); + } + + if (box_hash_map.hash().empty()) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMismatch, + "box-map.hash is empty"); + } + + absl::string_view algo = + DetermineAlgorithm({box_hash_map.alg(), assertion_.alg(), claims_algo_}); + if (!ParseHashAlgorithm(algo).ok()) { + return LogFailure(FailureStatusCode::kAlgorithmUnsupported, + absl::StrCat("Algorithm not supported: ", algo)); + } + + bool contains_c2pa_box = false; + for (const auto& name : box_hash_map.names()) { + if (name == "C2PA") { + contains_c2pa_box = true; + if (box_hash_map.names_size() != 1) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "C2PA box must be the only name in the names array"); + } + if (box_hash_map.hash() != kSingleZeroByte) { + // Is this the right error code? It's not really a mismatch. + return LogFailure(FailureStatusCode::kAssertionBoxesHashMismatch, + "C2PA box hash is not a single 0 byte"); + } + } + } + + if (box_hash_map.excluded()) { + return ValidateExclusion(box_hash_map, contains_c2pa_box); + } else if (!box_hash_map.exclusions().empty()) { + return ValidateExclusionRanges(box_hash_map, contains_c2pa_box); + } + + return absl::OkStatus(); +} + +absl::Status BoxesHashValidator::ValidateExclusion(const BoxHash& box_hash_map, + bool contains_c2pa_box) { + if (!contains_c2pa_box) { + LogAdditionalExclusionsPresent(); + } + return absl::OkStatus(); +} + +absl::Status BoxesHashValidator::ValidateExclusionRanges( + const BoxHash& box_hash_map, bool contains_c2pa_box) { + int64_t last_box_index = -1; + int64_t last_start = -1; + int64_t last_end_offset = -1; + + for (const auto& exclusion : box_hash_map.exclusions()) { + if (exclusion.start() < 0 || exclusion.length() < 0) { + return LogFailure( + FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start and box-map.length must be non-negative"); + } + if (std::numeric_limits<int64_t>::max() - exclusion.start() < + exclusion.length()) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start + box-map.length overflows"); + } + + if (!exclusion.has_box_index() && box_hash_map.names_size() > 1) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxIndex is required when there is more " + "than one box"); + } else if (exclusion.has_box_index() && + (exclusion.box_index() < 0 || + exclusion.box_index() >= box_hash_map.names_size())) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxIndex is out of range"); + } + + int64_t new_box_index = + exclusion.has_box_index() ? exclusion.box_index() : 0; + if (last_box_index == -1 || last_box_index < new_box_index) { + // First exclusion of this box, so just set the last box index. + last_box_index = new_box_index; + last_start = exclusion.start(); + last_end_offset = last_start + exclusion.length(); + continue; + } else if (new_box_index < last_box_index) { + // Next box index should be greater or equal to the last one. + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxIndex is not in order"); + } else if (new_box_index == last_box_index) { + if (last_start >= exclusion.start() || + last_end_offset > exclusion.start()) { + return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start is overlapping with the last " + "box-map.start"); + } + last_start = exclusion.start(); + last_end_offset = last_start + exclusion.length(); + } + } + + if (!contains_c2pa_box) { + LogAdditionalExclusionsPresent(); + } + + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/bindings/boxes_hash_validator.h b/bindings/boxes_hash_validator.h new file mode 100644 index 0000000..b4a2e62 --- /dev/null +++ b/bindings/boxes_hash_validator.h
@@ -0,0 +1,77 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_VALIDATOR_H_ + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "utils/status_tracker.h" + +namespace credentio { + +// Validates the structure of a BoxesHashAssertion. Only things such as the +// presence of required fields, and ranges are validated. The hash values are +// not validated. +class BoxesHashValidator { + public: + explicit BoxesHashValidator(const BoxesHashAssertion& assertion, + absl::string_view hard_binding_uri, + absl::string_view claims_algo, + StatusTracker& tracker) + : assertion_(assertion), + hard_binding_uri_(hard_binding_uri), + claims_algo_(claims_algo), + tracker_(tracker) {} + + // Validates the structure of the BoxesHashAssertion. Returns an + // InvalidArgumentError if the structure is invalid. This method should be + // called before attempting to validate the hashes contained in this + // assertion. + absl::Status Validate(); + + private: + // Helper method to log a failure code and explanation to the tracker, and + // return an InvalidArgumentError. + absl::Status LogFailure(FailureStatusCode failure_code, + absl::string_view explanation); + + // Helper method to log an informational code to the tracker if additional + // exclusions are present in the assertion. + void LogAdditionalExclusionsPresent(); + + // Validates a single box hash map in the assertion. + absl::Status ValidateBoxHashMap(const BoxHash& box_hash_map); + + // Validates an excluded box hash map in the assertion. + absl::Status ValidateExclusion(const BoxHash& box_hash_map, + bool contains_c2pa_box); + + // Validates a box hash map with exclusion ranges in the assertion. + absl::Status ValidateExclusionRanges(const BoxHash& box_hash_map, + bool contains_c2pa_box); + + const BoxesHashAssertion& assertion_; + absl::string_view hard_binding_uri_; + absl::string_view claims_algo_; + StatusTracker& tracker_; + bool found_additional_exclusions_ = false; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_VALIDATOR_H_
diff --git a/bindings/boxes_hash_validator_test.cc b/bindings/boxes_hash_validator_test.cc new file mode 100644 index 0000000..5245668 --- /dev/null +++ b/bindings/boxes_hash_validator_test.cc
@@ -0,0 +1,423 @@ +// 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 "bindings/boxes_hash_validator.h" + +#include <string> + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "google/protobuf/util/message_differencer.h" +#include "gtest/gtest.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/proto_test_utils.h" +#include "testing/test_status_tracker.h" + +namespace credentio { +namespace { + +using ::credentio_testing::ParseTextProtoOrDie; + +struct ExpectedValues { + absl::Status status; + ValidationStatusSet validation; +}; + +// Matcher for ValidationStatusSet that ignores the `url` field. +MATCHER_P(EqualsValidationStatusSetIgnoringUrl, expected, "") { + google::protobuf::util::MessageDifferencer differencer; + differencer.IgnoreField( + ValidationStatus::descriptor()->FindFieldByName("url")); + return differencer.Compare(arg, expected); +} + +struct BoxesHashValidatorTestParams { + std::string name; + BoxesHashAssertion assertion; + ExpectedValues expected; +}; + +ExpectedValues CreateStatusSet(bool has_additional_exclusions = false) { + ExpectedValues expected; + + expected.status = absl::OkStatus(); + + if (has_additional_exclusions) { + expected.validation.add_informationals()->set_code(absl::StrFormat( + "%v", InformationalStatusCode:: + kAssertionBoxesHashAdditionalExclusionsPresent)); + } + return expected; +} + +ExpectedValues CreateStatusSet(FailureStatusCode failure_code, + absl::string_view explanation, + bool has_additional_exclusions = false) { + ExpectedValues expected; + + ValidationStatus* failure = expected.validation.add_failures(); + failure->set_code(absl::StrFormat("%v", failure_code)); + failure->set_explanation(explanation); + + expected.status = absl::InvalidArgumentError(explanation); + + if (has_additional_exclusions) { + expected.validation.add_informationals()->set_code(absl::StrFormat( + "%v", InformationalStatusCode:: + kAssertionBoxesHashAdditionalExclusionsPresent)); + } + return expected; +} + +using BoxesHashValidatorTest = + testing::TestWithParam<BoxesHashValidatorTestParams>; + +TEST_P(BoxesHashValidatorTest, Validate) { + TestStatusTracker tracker; + BoxesHashValidator validator(GetParam().assertion, "test_uri", "", tracker); + EXPECT_EQ(validator.Validate(), GetParam().expected.status); + EXPECT_THAT(tracker.GetStatusSet(), EqualsValidationStatusSetIgnoringUrl( + GetParam().expected.validation)); +} + +INSTANTIATE_TEST_SUITE_P( + BoxesHashValidatorTest, BoxesHashValidatorTest, + testing::ValuesIn<BoxesHashValidatorTestParams>({ + { + .name = "empty_assertion", + .assertion = BoxesHashAssertion(), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxes is empty"), + }, + { + .name = "empty_names", + .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { hash: "hash" } + )pb"), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.names is empty"), + }, + { + .name = "empty_hash", + .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "box1" } + )pb"), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMismatch, + "box-map.hash is empty"), + }, + { + .name = "no_algo", + .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "box1" hash: "hash" } + )pb"), + .expected = + CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported, + "Algorithm not supported: "), + }, + { + .name = "bad_algo_box_map", + .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "box1" hash: "hash" alg: "bad_algo" } + )pb"), + .expected = + CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported, + "Algorithm not supported: bad_algo"), + }, + { + .name = "bad_algo_assertion", + .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "box1" hash: "hash" } + alg: "bad_algo" + )pb"), + .expected = + CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported, + "Algorithm not supported: bad_algo"), + }, + { + .name = "c2pa_not_only_box", + .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "box1" names: "C2PA" hash: "hash" } + alg: "sha256" + )pb"), + .expected = CreateStatusSet( + FailureStatusCode::kAssertionBoxesHashMalformed, + "C2PA box must be the only name in the names array"), + }, + { + .name = "c2pa_not_zero_byte_hash", + .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "C2PA" hash: "hash" } + alg: "sha256" + )pb"), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMismatch, + "C2PA box hash is not a single 0 byte"), + }, + { + .name = "only_c2pa_excluded", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "box1" hash: "hash" } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet(), + }, + { + .name = "box_1_excluded", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { names: "box1" hash: "hash", excluded: true } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet(/*has_additional_exclusions=*/true), + }, + { + .name = "single_box_exclusion_ranges_out_of_order", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + hash: "hash", + exclusions { start: 100 length: 100 } + exclusions { start: 10 length: 50 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet( + FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start is overlapping with the last box-map.start"), + }, + { + .name = "single_box_exclusion_ranges_overlapping", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + hash: "hash", + exclusions { start: 100 length: 100 } + exclusions { start: 150 length: 100 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet( + FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start is overlapping with the last box-map.start"), + }, + { + .name = "two_boxes_exclusion_ranges_missing_box_index", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + names: "box2" + hash: "hash", + exclusions { start: 100 length: 100 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet( + FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxIndex is required when there is more than one box"), + }, + { + .name = "two_boxes_exclusion_ranges_out_of_range", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + names: "box2" + hash: "hash", + exclusions { start: 100 length: 100 box_index: 2 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxIndex is out of range"), + }, + { + .name = "two_boxes_exclusion_ranges_negative_box_index", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + names: "box2" + hash: "hash", + exclusions { start: 100 length: 100 box_index: -1 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxIndex is out of range"), + }, + { + .name = "two_boxes_exclusion_ranges_box_index_not_in_order", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + names: "box2" + hash: "hash", + exclusions { start: 100 length: 100 box_index: 1 } + exclusions { start: 100 length: 100 box_index: 0 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.boxIndex is not in order"), + }, + { + .name = "two_boxes_exclusion_ranges_box_index_in_order", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + names: "box2" + hash: "hash" + exclusions { start: 100 length: 100 box_index: 0 } + exclusions { start: 100 length: 100 box_index: 1 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet(/*has_additional_exclusions=*/true), + }, + { + .name = "one_box_exclusion_ranges_valid", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + hash: "hash", + exclusions { start: 10 length: 10 } + exclusions { start: 30 length: 10 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet(/*has_additional_exclusions=*/true), + }, + { + .name = "negative_exclusion_start", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + hash: "hash", + exclusions { start: -10 length: 10 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet( + FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start and box-map.length must be non-negative"), + }, + { + .name = "negative_exclusion_length", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + hash: "hash", + exclusions { start: 10 length: -10 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = CreateStatusSet( + FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start and box-map.length must be non-negative"), + }, + { + .name = "exclusion_range_overflow", + .assertion = []() -> BoxesHashAssertion { + auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb( + boxes { + names: "box1" + hash: "hash", + exclusions { start: 9223372036854775800 length: 100 } + } + boxes { names: "C2PA" hash: "" } + alg: "sha256" + )pb"); + assertion.mutable_boxes(1)->set_hash(std::string("\0", 1)); + return assertion; + }(), + .expected = + CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed, + "box-map.start + box-map.length overflows"), + }, + }), + [](const testing::TestParamInfo<BoxesHashValidatorTestParams>& info) { + return info.param.name; + }); + +} // namespace +} // namespace credentio
diff --git a/bindings/collection_data_hash_hard_binding_validator.cc b/bindings/collection_data_hash_hard_binding_validator.cc new file mode 100644 index 0000000..2f64d64 --- /dev/null +++ b/bindings/collection_data_hash_hard_binding_validator.cc
@@ -0,0 +1,291 @@ +// 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 "bindings/collection_data_hash_hard_binding_validator.h" + +#include <cstdint> +#include <map> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "bindings/binding_hasher.h" +#include "bindings/input_hasher.h" +#include "constants/status_codes.h" +#include "crypto/algorithms.h" +#include "formats/asset_byte_info.h" +#include "formats/zip/constants.h" +#include "formats/zip/reader.h" +#include "proto/assertion.pb.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/dual_status_tracker.h" +#include "utils/status_tracker.h" +#include "validator/result.h" +#include "validator/validation_result_internal.h" + +namespace credentio { + +namespace { + +class CollectionDataHashBindingHasherTracker : public BindingHasherTracker { + public: + explicit CollectionDataHashBindingHasherTracker( + StatusTracker* tracker, absl::string_view hard_binding_uri) + : tracker_(*ABSL_DIE_IF_NULL(tracker)), + hard_binding_uri_(hard_binding_uri) {} + + void RecordMismatch() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionCollectionHashMismatch, + {.url = hard_binding_uri_}); + } + void RecordMalformed() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionCollectionHashMalformed, + {.url = hard_binding_uri_}); + } + void RecordAlgorithmUnsupported() override { + tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported, + {.url = hard_binding_uri_}); + } + void RecordGeneralError(absl::Status error) override { + tracker_.RecordFailure( + FailureStatusCode::kGeneralError, + {.url = hard_binding_uri_, .explanation = error.message()}); + } + void RecordInvalidUri() { + tracker_.RecordFailure( + FailureStatusCode::kAssertionCollectionHashInvalidUri, + {.url = hard_binding_uri_}); + } + void RecordIncorrectFileCount() { + tracker_.RecordFailure( + FailureStatusCode::kAssertionCollectionHashIncorrectFileCount, + {.url = hard_binding_uri_}); + } + + private: + StatusTracker& tracker_; + absl::string_view hard_binding_uri_; +}; + +bool HasRelativePathComponents(absl::string_view uri_str) { + return uri_str == "." || uri_str == ".." || absl::StartsWith(uri_str, "./") || + absl::StartsWith(uri_str, "../") || + absl::StrContains(uri_str, "/./") || + absl::StrContains(uri_str, "/../") || absl::EndsWith(uri_str, "/.") || + absl::EndsWith(uri_str, "/.."); +} +} // namespace + +absl::StatusOr<std::unique_ptr<ValidationResultProto>> +CollectionDataHashHardBindingValidator::Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const { + const Assertion* absl_nullable assertion = + GetAssertion(partial_validation_result.get(), + partial_validation_result->hard_binding_uri()); + if (assertion == nullptr) { + return absl::InvalidArgumentError(absl::StrCat( + "missing assertion: ", partial_validation_result->hard_binding_uri())); + } + if (!assertion->has_collection_data_hash()) { + return absl::InvalidArgumentError( + absl::StrCat("assertion is not a collection data hash assertion: ", + partial_validation_result->hard_binding_uri())); + } + + ABSL_ASSIGN_OR_RETURN(auto tracker, + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get())); + + Validate(contents, assertion->collection_data_hash(), + partial_validation_result->hard_binding_uri(), *tracker); + + return MakeFullValidationResult(std::move(partial_validation_result)); +} + +void CollectionDataHashHardBindingValidator::Validate( + riegeli::Reader& contents, const CollectionDataHashAssertion& assertion, + absl::string_view hard_binding_uri, StatusTracker& tracker) const { + if (!contents.SupportsSize() || !contents.Size().has_value()) { + tracker.RecordFailure( + FailureStatusCode::kGeneralError, + {.url = hard_binding_uri, + .explanation = "Failed to get the size of the file"}); + return; + } + uint64_t file_size = *contents.Size(); + + CollectionDataHashBindingHasherTracker hasher_tracker(&tracker, + hard_binding_uri); + if (assertion.uris().empty()) { + hasher_tracker.RecordMalformed(); + return; + } + + auto alg = ParseHashAlgorithm(assertion.alg()); + if (!alg.ok()) { + hasher_tracker.RecordAlgorithmUnsupported(); + return; + } + + auto zip_reader = ZipReader::Create(&contents); + if (!zip_reader.ok()) { + LOG(ERROR) << "Failed to open the file: " << zip_reader.status(); + hasher_tracker.RecordGeneralError( + absl::InternalError("Failed to open the file")); + return; + } + + std::vector<ZipReader::FileEntry> file_entries; + while ((*zip_reader)->HasNext()) { + auto entry = (*zip_reader)->Next(); + if (!entry.ok()) { + hasher_tracker.RecordGeneralError( + absl::InternalError("Failed to read ZIP entry")); + return; + } + file_entries.push_back(*entry); + } + + std::map<std::string, const UriHashedData*> expected_uris; + for (const auto& uri_data : assertion.uris()) { + if (uri_data.uri().empty() || uri_data.hash().empty()) { + hasher_tracker.RecordMalformed(); + return; + } + // Validate that the URI has no relative path components (i.e. no "." or + // "..") + if (HasRelativePathComponents(uri_data.uri())) { + hasher_tracker.RecordInvalidUri(); + return; + } + expected_uris[std::string(uri_data.uri())] = &uri_data; + } + + // Validate URI hashes + const ZipReader::FileEntry* manifest_file_entry = nullptr; + for (const auto& entry : file_entries) { + if (entry.file_name == kZipManifestFileName) { + manifest_file_entry = &entry; + continue; + } + auto it = expected_uris.find(entry.file_name); + if (it == expected_uris.end()) { + hasher_tracker.RecordIncorrectFileCount(); + return; + } + const auto& expected = *it->second; + + auto file_hasher = InputHasher::Create(*alg); + if (!file_hasher.ok()) { + hasher_tracker.RecordGeneralError(file_hasher.status()); + return; + } + + uint32_t entry_start = entry.local_file_header_offset; + uint32_t entry_end = entry.file_range.offset + entry.file_range.length; + if (entry.has_data_descriptor) { + if (!contents.Seek(entry_end)) { + hasher_tracker.RecordGeneralError( + absl::InternalError("Failed to seek to data descriptor")); + return; + } + std::string sig; + if (!contents.Read(4, sig)) { + hasher_tracker.RecordGeneralError( + absl::InternalError("Failed to read data descriptor signature")); + return; + } + if (sig == kZipDataDescriptorSignature) { + entry_end += kDataDescriptorWithSignatureSize; + } else { + entry_end += kDataDescriptorSize; + } + } + + auto update_status = + (*file_hasher)->Update(contents, entry_start, entry_end - entry_start); + if (!update_status.ok()) { + hasher_tracker.RecordGeneralError(update_status); + return; + } + std::string file_hash = (*file_hasher)->Digest(); + if (expected.hash() != file_hash) { + hasher_tracker.RecordMismatch(); + return; + } + + expected_uris.erase(it); + } + + if (!expected_uris.empty()) { + hasher_tracker.RecordIncorrectFileCount(); + return; + } + + // Validate ZIP central directory hash + if (manifest_file_entry == nullptr) { + hasher_tracker.RecordGeneralError( + absl::InternalError("Manifest file entry not found")); + return; + } + auto cd_hasher = InputHasher::Create(*alg); + if (!cd_hasher.ok()) { + hasher_tracker.RecordGeneralError(cd_hasher.status()); + return; + } + uint32_t cd_offset = (*zip_reader)->central_directory_offset(); + uint32_t manifest_crc32_offset = + manifest_file_entry->central_directory_header_offset + 16; + + auto update_status = + (*cd_hasher) + ->Update(contents, cd_offset, manifest_crc32_offset - cd_offset); + if (!update_status.ok()) { + hasher_tracker.RecordGeneralError(update_status); + return; + } + update_status = (*cd_hasher) + ->Update(contents, manifest_crc32_offset + 4, + file_size - (manifest_crc32_offset + 4)); + if (!update_status.ok()) { + hasher_tracker.RecordGeneralError(update_status); + return; + } + + std::string cd_hash = (*cd_hasher)->Digest(); + if (assertion.zip_central_directory_hash() != cd_hash) { + hasher_tracker.RecordMismatch(); + return; + } + + tracker.RecordSuccess(SuccessStatusCode::kAssertionCollectionHashMatch, + {.url = hard_binding_uri}); +} + +} // namespace credentio
diff --git a/bindings/collection_data_hash_hard_binding_validator.h b/bindings/collection_data_hash_hard_binding_validator.h new file mode 100644 index 0000000..fdd197d --- /dev/null +++ b/bindings/collection_data_hash_hard_binding_validator.h
@@ -0,0 +1,49 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_COLLECTION_DATA_HASH_HARD_BINDING_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_COLLECTION_DATA_HASH_HARD_BINDING_VALIDATOR_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "bindings/hard_binding_validator.h" +#include "formats/asset_byte_info.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/status_tracker.h" + +namespace credentio { + +class CollectionDataHashHardBindingValidator : public HardBindingValidator { + public: + absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const override; + + // Validates the collection data hash assertion and records the result in the + // provided `tracker`. + void Validate(riegeli::Reader& contents, + const CollectionDataHashAssertion& assertion, + absl::string_view hard_binding_uri, + StatusTracker& tracker) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_COLLECTION_DATA_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/data_hash_hard_binding_validator.cc b/bindings/data_hash_hard_binding_validator.cc new file mode 100644 index 0000000..d776271 --- /dev/null +++ b/bindings/data_hash_hard_binding_validator.cc
@@ -0,0 +1,204 @@ +// 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 "bindings/data_hash_hard_binding_validator.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "bindings/binding_hasher.h" +#include "constants/status_codes.h" +#include "formats/asset_byte_info.h" +#include "formats/byte_range.h" +#include "proto/assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/dual_status_tracker.h" +#include "utils/status_tracker.h" +#include "validator/result.h" +#include "validator/validation_result_internal.h" + +namespace credentio { + +namespace { +class DataHashBindingHasherTracker : public BindingHasherTracker { + public: + explicit DataHashBindingHasherTracker(StatusTracker* tracker, + absl::string_view hard_binding_uri) + : tracker_(*ABSL_DIE_IF_NULL(tracker)), + hard_binding_uri_(hard_binding_uri) {} + void RecordMismatch() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionDataHashMismatch, + {.url = hard_binding_uri_}); + } + void RecordMalformed() override { + tracker_.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed, + {.url = hard_binding_uri_}); + } + void RecordAlgorithmUnsupported() override { + tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported, + {.url = hard_binding_uri_}); + } + void RecordGeneralError(absl::Status error) override { + tracker_.RecordFailure( + FailureStatusCode::kGeneralError, + {.url = hard_binding_uri_, .explanation = error.message()}); + } + + private: + StatusTracker& tracker_; + absl::string_view hard_binding_uri_; +}; +} // namespace + +absl::StatusOr<std::unique_ptr<ValidationResultProto>> +DataHashHardBindingValidator::Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const { + const Assertion* absl_nullable assertion = + GetAssertion(partial_validation_result.get(), + partial_validation_result->hard_binding_uri()); + if (assertion == nullptr) { + return absl::InvalidArgumentError(absl::StrCat( + "missing assertion: ", partial_validation_result->hard_binding_uri())); + } + if (!assertion->has_data_hash()) { + return absl::InvalidArgumentError( + absl::StrCat("assertion is not a data hash assertion: ", + partial_validation_result->hard_binding_uri())); + } + + ABSL_ASSIGN_OR_RETURN(auto tracker, + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get())); + Validate(contents, asset_byte_info.manifest_store_location, + assertion->data_hash(), + partial_validation_result->hard_binding_uri(), *tracker, + tracker->WritesToIngredientManifest()); + + return MakeFullValidationResult(std::move(partial_validation_result)); +} + +void DataHashHardBindingValidator::Validate( + riegeli::Reader& contents, std::optional<ByteRange> manifest_store_location, + const DataHashAssertion& assertion, absl::string_view hard_binding_uri, + StatusTracker& tracker, bool assertion_in_ingredient_manifest, + uint64_t start_offset, int64_t end_offset) const { + std::vector<BindingHasher::Chunk> exclusions; + + if (!contents.SupportsSize() || !contents.Size().has_value()) { + tracker.RecordFailure(FailureStatusCode::kGoogleInternalError, + {.url = hard_binding_uri, + .explanation = "Asset size cannot be determined"}); + return; + } + + const int64_t c2pa_start = manifest_store_location.has_value() + ? manifest_store_location->offset + : -1; + const uint64_t asset_end_offset = + end_offset == -1 ? *contents.Size() : end_offset; + + uint64_t valid_next_exclusion_start = start_offset; + int64_t offset_adjustment = 0; + bool has_additional_exclusions = assertion.exclusions_size() > 1; + for (const auto& exclusion : assertion.exclusions()) { + if (exclusion.start() < 0 || exclusion.length() < 0) { + tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed, + {.url = hard_binding_uri}); + return; + } + const uint64_t ex_start = + start_offset + exclusion.start() + offset_adjustment; + int64_t ex_length = exclusion.length(); + if (assertion_in_ingredient_manifest && ex_start == c2pa_start) { + // Note: fluffy_puppy.webp manifest exclusion range is 1 byte shorter than + // the box and if this case is triggered, it would fail validation. + ex_length = manifest_store_location->length; + offset_adjustment = manifest_store_location->length - exclusion.length(); + } + + // ex_start is now with respect to the start of the file + if (ex_start < start_offset || exclusion.length() < 0) { + // Starts before the start of this asset or negative length. + tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed, + {.url = hard_binding_uri}); + return; + } + if (ex_start < valid_next_exclusion_start) { + // Starts before the end of the previous exclusion range. + tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed, + {.url = hard_binding_uri}); + return; + } + valid_next_exclusion_start = ex_start + ex_length; + + if (ex_start + ex_length > asset_end_offset) { + // Ends past the end of the asset. + tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMismatch, + {.url = hard_binding_uri}); + return; + } + + exclusions.push_back({.op = BindingHasher::Chunk::Op::kExclusion, + .offset = ex_start, + .length = ex_length}); + + if (ex_start < c2pa_start) { + has_additional_exclusions = true; + } + } + if (manifest_store_location.has_value() && has_additional_exclusions) { + tracker.RecordInformational( + InformationalStatusCode::kAssertionDataHashAdditionalExclusionsPresent, + {.url = hard_binding_uri}); + } + + DataHashBindingHasherTracker data_hash_tracker(&tracker, hard_binding_uri); + auto hasher = + BindingHasher::Create(assertion.alg(), exclusions, data_hash_tracker); + if (hasher == nullptr) { + return; + } + + auto digest = + hasher->Digest(contents, data_hash_tracker, start_offset, end_offset); + if (!digest.has_value()) { + return; + } + + if (*digest != assertion.hash()) { + tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMismatch, + {.url = hard_binding_uri}); + return; + } + + tracker.RecordSuccess(SuccessStatusCode::kAssertionDataHashMatch, + {.url = hard_binding_uri}); +} + +} // namespace credentio
diff --git a/bindings/data_hash_hard_binding_validator.h b/bindings/data_hash_hard_binding_validator.h new file mode 100644 index 0000000..1b55e45 --- /dev/null +++ b/bindings/data_hash_hard_binding_validator.h
@@ -0,0 +1,60 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_DATA_HASH_HARD_BINDING_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_DATA_HASH_HARD_BINDING_VALIDATOR_H_ + +#include <cstdint> +#include <memory> +#include <optional> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "bindings/hard_binding_validator.h" +#include "formats/asset_byte_info.h" +#include "formats/byte_range.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/status_tracker.h" + +namespace credentio { + +class DataHashHardBindingValidator : public HardBindingValidator { + public: + absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const override; + + // Validates the data hash assertion. The start_offset is the offset of the + // first byte of the data to validate. The end_offset is the offset of the + // last byte of the data to validate, -1 means the end of the file. The + // manifest_store_location is the offset and length of the manifest store + // based on the entire asset. The offsets specified within the assertion's + // exclusions are offsets based upon the start of the portion of the asset + // corresponding to the binding. The offset to exclude that is sent to the + // hasher is the assertion's exclusion offset PLUS the start_offset. + void Validate(riegeli::Reader& contents, + std::optional<ByteRange> manifest_store_location, + const DataHashAssertion& assertion, + absl::string_view hard_binding_uri, StatusTracker& tracker, + bool assertion_in_ingredient_manifest, + uint64_t start_offset = 0, int64_t end_offset = -1) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_DATA_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/hard_binding_validator.h b/bindings/hard_binding_validator.h new file mode 100644 index 0000000..438d710 --- /dev/null +++ b/bindings/hard_binding_validator.h
@@ -0,0 +1,42 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_HARD_BINDING_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_HARD_BINDING_VALIDATOR_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/asset_byte_info.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +class HardBindingValidator { + public: + virtual ~HardBindingValidator() = default; + + virtual absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate( + riegeli::Reader& contents, const AssetByteInfo& asset_byte_info, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/input_hasher.cc b/bindings/input_hasher.cc new file mode 100644 index 0000000..e191bfb --- /dev/null +++ b/bindings/input_hasher.cc
@@ -0,0 +1,99 @@ +// 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 "bindings/input_hasher.h" + +#include <algorithm> +#include <cstdint> +#include <memory> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +constexpr uint64_t kMaxChunkBytes = 1024 * 1024; // 1 MiB + +absl::Status AddToHash(Hasher& hasher, riegeli::Reader& asset, + uint64_t read_start, uint64_t read_length) { + if (!asset.Seek(read_start) || asset.pos() != read_start) { + return absl::InternalError(absl::Substitute( + "Failed to seek to $0 in the file. Current position is " + "$1.", + read_start, asset.pos())); + } + + for (uint64_t i = 0; i < read_length; i += kMaxChunkBytes) { + uint64_t length = std::min(read_length - i, kMaxChunkBytes); + + std::string contents; + if (!asset.Read(length, contents)) { + return asset.StatusOrAnnotate( + absl::DataLossError("Failed to read from source")); + } + hasher.Update(contents); + } + return absl::OkStatus(); +} + +class SimpleInputHasher : public InputHasher { + public: + explicit SimpleInputHasher(std::unique_ptr<Hasher> absl_nonnull hasher) + : hasher_(std::move(hasher)) {} + + absl::Status Update(riegeli::Reader& input, uint64_t offset, + uint64_t length) override { + return AddToHash(*hasher_, input, offset, length); + } + + void Update(absl::string_view data) override { hasher_->Update(data); } + + std::string Digest() override { return hasher_->Digest(); } + + private: + std::unique_ptr<Hasher> hasher_; +}; + +} // namespace + +absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> InputHasher::Create( + absl::string_view alg) { + ABSL_ASSIGN_OR_RETURN(auto hasher, CreateHasher(alg)); + return std::make_unique<SimpleInputHasher>(std::move(hasher)); +} + +absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> InputHasher::Create( + HashAlgorithm alg) { + ABSL_ASSIGN_OR_RETURN(auto hasher, CreateHasher(alg)); + return std::make_unique<SimpleInputHasher>(std::move(hasher)); +} + +absl::StatusOr<std::unique_ptr<InputHasher>> InputHasher::Create( + std::unique_ptr<Hasher> absl_nonnull hasher) { + return std::make_unique<SimpleInputHasher>(std::move(hasher)); +} + +} // namespace credentio
diff --git a/bindings/input_hasher.h b/bindings/input_hasher.h new file mode 100644 index 0000000..8de914c --- /dev/null +++ b/bindings/input_hasher.h
@@ -0,0 +1,60 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_INPUT_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_INPUT_HASHER_H_ + +#include <stdint.h> + +#include <memory> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// A simple hasher for C2PA hash computing. +class InputHasher { + public: + virtual ~InputHasher() = default; + + // Reads `length` bytes from `input` starting at `offset` and updates the + // hash. + virtual absl::Status Update(riegeli::Reader& input, uint64_t offset, + uint64_t length) = 0; + + // Updates the hash with `data`. + virtual void Update(absl::string_view data) = 0; + + // Returns the hash digest. + virtual std::string Digest() = 0; + + static absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> Create( + absl::string_view alg); + static absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> Create( + HashAlgorithm alg); + static absl::StatusOr<std::unique_ptr<InputHasher>> Create( + std::unique_ptr<Hasher> absl_nonnull hasher); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_INPUT_HASHER_H_
diff --git a/bindings/input_hasher_test.cc b/bindings/input_hasher_test.cc new file mode 100644 index 0000000..9839bc5 --- /dev/null +++ b/bindings/input_hasher_test.cc
@@ -0,0 +1,189 @@ +// 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 "bindings/input_hasher.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::Return; + +std::string Sha256Digest(absl::string_view data) { + absl::StatusOr<std::unique_ptr<Hasher>> hasher = CreateHasher("sha256"); + if (!hasher.ok()) return ""; + (*hasher)->Update(data); + return (*hasher)->Digest(); +} + +TEST(InputHasherTest, CreateWithValidAlgString) { + EXPECT_THAT(InputHasher::Create("sha256"), IsOk()); + EXPECT_THAT(InputHasher::Create("sha384"), IsOk()); + EXPECT_THAT(InputHasher::Create("sha512"), IsOk()); +} + +TEST(InputHasherTest, CreateWithInvalidAlgString) { + EXPECT_THAT(InputHasher::Create("invalid"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(InputHasherTest, CreateWithValidAlgEnum) { + EXPECT_THAT(InputHasher::Create(HashAlgorithm::kSha256), IsOk()); + EXPECT_THAT(InputHasher::Create(HashAlgorithm::kSha384), IsOk()); + EXPECT_THAT(InputHasher::Create(HashAlgorithm::kSha512), IsOk()); +} + +class MockHasher : public Hasher { + public: + MOCK_METHOD(void, Update, (absl::string_view data), (override)); + MOCK_METHOD(std::string, Digest, (), (override)); +}; + +TEST(InputHasherTest, CreateWithCustomHasher) { + auto mock_hasher = std::make_unique<MockHasher>(); + EXPECT_CALL(*mock_hasher, Update("test")); + EXPECT_CALL(*mock_hasher, Digest()).WillOnce(Return("mock_digest")); + + auto input_hasher = InputHasher::Create(std::move(mock_hasher)); + ASSERT_THAT(input_hasher, IsOk()); + (*input_hasher)->Update("test"); + EXPECT_THAT((*input_hasher)->Digest(), Eq("mock_digest")); +} + +TEST(InputHasherTest, UpdateStringView) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + (*hasher)->Update("hello"); + (*hasher)->Update(" "); + (*hasher)->Update("world"); + + EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()), + Eq(absl::BytesToHexString(Sha256Digest("hello world")))); +} + +TEST(InputHasherTest, UpdateRiegeliEmpty) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + std::string data = ""; + riegeli::StringReader reader(data); + EXPECT_THAT((*hasher)->Update(reader, 0, 0), IsOk()); + + EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()), + Eq(absl::BytesToHexString(Sha256Digest(data)))); +} + +TEST(InputHasherTest, UpdateRiegeliSmall) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + std::string data = "small data"; + riegeli::StringReader reader(data); + EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk()); + + EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()), + Eq(absl::BytesToHexString(Sha256Digest(data)))); +} + +TEST(InputHasherTest, UpdateRiegeliLarge) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + std::string data(20000, 'a'); // Larger than default chunk size + riegeli::StringReader reader(data); + EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk()); + + EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()), + Eq(absl::BytesToHexString(Sha256Digest(data)))); +} + +TEST(InputHasherTest, UpdateRiegeliWithOffset) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + std::string data = "prefix_and_data"; + riegeli::StringReader reader(data); + EXPECT_THAT((*hasher)->Update(reader, 7, 3), IsOk()); // "and" + + EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()), + Eq(absl::BytesToHexString(Sha256Digest("and")))); +} + +TEST(InputHasherTest, UpdateRiegeliMultiple) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + std::string data1 = "first part"; + std::string data2 = " second part"; + + riegeli::StringReader reader1(data1); + riegeli::StringReader reader2(data2); + + EXPECT_THAT((*hasher)->Update(reader1, 0, data1.length()), IsOk()); + (*hasher)->Update(" middle "); + EXPECT_THAT((*hasher)->Update(reader2, 0, data2.length()), IsOk()); + + EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()), + Eq(absl::BytesToHexString( + Sha256Digest("first part middle second part")))); +} + +TEST(InputHasherTest, UpdateRiegeliClosedReaderFails) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + riegeli::StringReader reader("test"); + reader.Close(); + + EXPECT_THAT((*hasher)->Update(reader, 0, 4), + StatusIs(absl::StatusCode::kFailedPrecondition, + HasSubstr("Object closed"))); +} + +TEST(InputHasherTest, UpdateRiegeliOffsetOutOfRange) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + riegeli::StringReader reader("test"); + + EXPECT_THAT( + (*hasher)->Update(reader, 10, 4), + StatusIs(absl::StatusCode::kInternal, HasSubstr("Failed to seek to 10"))); +} + +TEST(InputHasherTest, UpdateRiegeliLengthExceedsSourceFails) { + auto hasher = InputHasher::Create("sha256"); + ASSERT_THAT(hasher, IsOk()); + riegeli::StringReader reader("test"); + + EXPECT_THAT((*hasher)->Update(reader, 0, 100), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read from source"))); +} + +} // namespace +} // namespace credentio
diff --git a/bindings/merkle.cc b/bindings/merkle.cc new file mode 100644 index 0000000..237a0f1 --- /dev/null +++ b/bindings/merkle.cc
@@ -0,0 +1,330 @@ +// 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 "bindings/merkle.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "crypto/hash.h" +#include "google/protobuf/repeated_field.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +const int64_t kMaxPowerOfTwo = 0x8000'0000; +const int64_t kMaxBlockCount = 1048576; + +absl::StatusOr<int64_t> NextPowerOfTwo(int64_t value) { + if ((value & (value - 1)) == 0) { + // value is already a power of two + return value; + } + if (value < 0 || value >= kMaxPowerOfTwo) { + return absl::InvalidArgumentError( + absl::StrFormat("Value is negative or too large: %d", value)); + } + int64_t next_power_of_two = 1; + while (next_power_of_two < value) { + next_power_of_two <<= 1; + } + return next_power_of_two; +} + +absl::Status PadToPowerOfTwo(std::vector<std::string>& entries) { + ABSL_ASSIGN_OR_RETURN(auto next_power_of_two, NextPowerOfTwo(entries.size())); + if (next_power_of_two > entries.size()) { + entries.resize(next_power_of_two, ""); + } + return absl::OkStatus(); +} + +absl::StatusOr<std::vector<std::string>> ReduceLayer( + HasherFactory* factory, std::vector<std::string> layer) { + std::vector<std::string> reduced_layer; + for (int64_t i = 0; i + 1 < layer.size(); i += 2) { + ABSL_ASSIGN_OR_RETURN(auto hasher, factory->Create()); + hasher->Update(layer[i]); + hasher->Update(layer[i + 1]); + reduced_layer.push_back(hasher->Digest()); + } + return reduced_layer; +} + +absl::StatusOr<MerkleTree> PopulateLayers( + HasherFactory* factory, std::vector<std::string> leaf_hashes) { + MerkleTree tree = {leaf_hashes}; + while (tree[tree.size() - 1].size() > 1) { + ABSL_ASSIGN_OR_RETURN(auto reduced_layer, + ReduceLayer(factory, tree.back())); + tree.push_back(std::move(reduced_layer)); + } + return tree; +} + +// Returns the number of rows between leaf_row_size and hashes_row_size. +// For example, if leaf_row_size is 16 and hashes_row_size is 4, the function +// will return 4. +absl::StatusOr<int64_t> RowsAway(int64_t leaf_row_size, + int64_t hashes_row_size) { + if (leaf_row_size <= 0 || hashes_row_size <= 0) { + return absl::InvalidArgumentError( + "leaf_row_size and hashes_row_size must be positive"); + } + if (hashes_row_size > leaf_row_size) { + return absl::InvalidArgumentError( + "hashes row size is larger than the leaf row size"); + } + ABSL_ASSIGN_OR_RETURN(auto leaf_pow2, NextPowerOfTwo(leaf_row_size)); + ABSL_ASSIGN_OR_RETURN(auto hashes_pow2, NextPowerOfTwo(hashes_row_size)); + int64_t rows_away = 0; + while (leaf_pow2 != hashes_pow2) { + leaf_pow2 >>= 1; + rows_away++; + } + return rows_away; +} + +absl::StatusOr<std::vector<int64_t>> GetFixedBlockSizes( + uint64_t mdat_box_size, int64_t fixed_block_size) { + if (fixed_block_size <= 0) { + return absl::InvalidArgumentError( + "merkle map has an invalid fixed block size"); + } + + uint64_t num_blocks = mdat_box_size / fixed_block_size; + if (mdat_box_size % fixed_block_size != 0) { + num_blocks++; + } + + if (num_blocks > kMaxBlockCount) { + return absl::InvalidArgumentError(absl::StrFormat( + "merkle map block count exceeds maximum allowable limit: %d > %d", + num_blocks, kMaxBlockCount)); + } + + std::vector<int64_t> block_sizes; + block_sizes.reserve(num_blocks); + block_sizes.insert(block_sizes.end(), mdat_box_size / fixed_block_size, + fixed_block_size); + if (mdat_box_size % fixed_block_size != 0) { + block_sizes.push_back(mdat_box_size % fixed_block_size); + } + return block_sizes; +} + +absl::StatusOr<std::vector<int64_t>> GetVariableBlockSizes( + uint64_t mdat_box_size, + const google::protobuf::RepeatedField<int64_t>& variable_block_sizes) { + if (variable_block_sizes.size() > kMaxBlockCount) { + return absl::InvalidArgumentError(absl::StrFormat( + "merkle map block count exceeds maximum allowable limit: %d > %d", + variable_block_sizes.size(), kMaxBlockCount)); + } + + int64_t total_block_size = 0; + std::vector<int64_t> block_sizes; + block_sizes.reserve(variable_block_sizes.size()); + for (int64_t block_size : variable_block_sizes) { + if (block_size <= 0) { + return absl::InvalidArgumentError( + "merkle map has an invalid variable block size"); + } + if (total_block_size > INT64_MAX - block_size) { + return absl::InvalidArgumentError( + "merkle map's variable block sizes overflow int64_t"); + } + total_block_size += block_size; + block_sizes.push_back(block_size); + } + if (total_block_size != mdat_box_size) { + return absl::InvalidArgumentError( + "merkle map's variable block sizes do not sum to the mdat box size"); + } + return block_sizes; +} + +absl::StatusOr<std::vector<int64_t>> GetSingleBoxSizes(uint64_t mdat_box_size) { + if (mdat_box_size > INT64_MAX) { + return absl::InvalidArgumentError( + "mdat atom's box size is too large to fit in an int64_t"); + } + return std::vector<int64_t>{static_cast<int64_t>(mdat_box_size)}; +} + +} // namespace + +absl::StatusOr<std::vector<int64_t>> DeriveMerkleBlockSizes( + const BmffMerkle& merkle, uint64_t mdat_box_size) { + if (merkle.has_fixed_block_size() && merkle.variable_block_sizes_size() > 0) { + return absl::InvalidArgumentError( + "merkle map has both fixed and variable block sizes"); + } + + std::vector<int64_t> block_sizes; + if (merkle.has_fixed_block_size()) { + int64_t fixed_block_size = merkle.fixed_block_size(); + if (fixed_block_size <= 0) { + return absl::InvalidArgumentError( + "merkle map has an invalid fixed block size"); + } + uint64_t num_blocks = mdat_box_size / fixed_block_size; + if (mdat_box_size % fixed_block_size != 0) { + num_blocks++; + } + if (num_blocks != merkle.count()) { + return absl::InvalidArgumentError( + "merkle map's block sizes do not match the expected leaf count"); + } + + ABSL_ASSIGN_OR_RETURN( + block_sizes, + GetFixedBlockSizes(mdat_box_size, merkle.fixed_block_size())); + } else if (merkle.variable_block_sizes_size() > 0) { + ABSL_ASSIGN_OR_RETURN( + block_sizes, + GetVariableBlockSizes(mdat_box_size, merkle.variable_block_sizes())); + } else { + ABSL_ASSIGN_OR_RETURN(block_sizes, GetSingleBoxSizes(mdat_box_size)); + } + + if (block_sizes.size() != merkle.count()) { + return absl::InvalidArgumentError( + "merkle map's block sizes do not match the expected leaf count"); + } + + return block_sizes; +} + +absl::StatusOr<DerivedTreeData> DeriveMerkleTreeData(int64_t leaf_count, + int64_t hashes_count, + int64_t auxiliary_count) { + ABSL_ASSIGN_OR_RETURN(auto leaf_pow2, NextPowerOfTwo(leaf_count)); + ABSL_ASSIGN_OR_RETURN(auto hashes_pow2, NextPowerOfTwo(hashes_count)); + ABSL_ASSIGN_OR_RETURN(auto delta_rows, RowsAway(leaf_pow2, hashes_pow2)); + int64_t expected_hashes_count = leaf_count; + for (int64_t r = 0; r < delta_rows; ++r) { + expected_hashes_count = (expected_hashes_count + 1) >> 1; + } + + if (hashes_count != expected_hashes_count) { + return absl::InvalidArgumentError(absl::StrFormat( + "hashes count is not equal to the expected hashes count: " + "Hashes Count: %d, Leaf Count: %d, Expected Hashes Count: %d", + hashes_count, leaf_count, expected_hashes_count)); + } + + if ((delta_rows > 0 || auxiliary_count > 0) && + auxiliary_count != leaf_count) { + return absl::InvalidArgumentError( + absl::StrFormat("incorrect count of auxiliary data boxes: " + "Auxiliary Data Count: %d, Leaf Count: %d", + auxiliary_count, leaf_count)); + } + + return DerivedTreeData{ + .full_leaf_count = leaf_pow2, + .full_hashes_count = hashes_pow2, + .delta_rows = delta_rows, + }; +} + +absl::StatusOr<MerkleTree> CreateMerkleTree(HasherFactory* factory, + riegeli::Reader& input, + int64_t starting_offset, + int64_t length, + CreateMerkleTreeOptions options) { + if (options.fixed_block_size.has_value() && + !options.variable_block_sizes.empty()) { + return absl::InvalidArgumentError( + "Must specify a fixed block size or variable block sizes, not both."); + } + + if (!options.fixed_block_size.has_value() && + options.variable_block_sizes.empty()) { + // The entire input is a single leaf node. + return CreateMerkleTree(factory, input, starting_offset, length, + std::vector<int64_t>{length}); + } + + if (options.fixed_block_size.has_value()) { + if (options.fixed_block_size.value() <= 0) { + return absl::InvalidArgumentError( + absl::StrFormat("Fixed block size must be greater than 0. Found: %d", + options.fixed_block_size.value())); + } + std::vector<int64_t> block_sizes(length / options.fixed_block_size.value(), + options.fixed_block_size.value()); + if (length % options.fixed_block_size.value() != 0) { + block_sizes.push_back(length % options.fixed_block_size.value()); + } + return CreateMerkleTree(factory, input, starting_offset, length, + block_sizes); + } + + return CreateMerkleTree(factory, input, starting_offset, length, + options.variable_block_sizes); +} + +absl::StatusOr<MerkleTree> CreateMerkleTree(HasherFactory* factory, + riegeli::Reader& input, + int64_t starting_offset, + int64_t length, + std::vector<int64_t> block_sizes) { + int64_t total_blocks_length = 0; + std::vector<std::string> leaf_hashes; + leaf_hashes.reserve(block_sizes.size()); + if (!input.Seek(starting_offset) || input.pos() != starting_offset) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to starting offset")); + } + for (int64_t block_size : block_sizes) { + if (block_size <= 0) { + return absl::InvalidArgumentError(absl::StrFormat( + "Block size must be greater than 0. Found: %d", block_size)); + } + total_blocks_length += block_size; + if (total_blocks_length > length) { + return absl::InvalidArgumentError(absl::StrFormat( + "Total block size is too large. Total Block size: %d, Length: %d", + total_blocks_length, length)); + } + std::string leaf_data; + if (!input.Read(block_size, leaf_data)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read leaf data")); + } + ABSL_ASSIGN_OR_RETURN(auto hasher, factory->Create()); + hasher->Update(leaf_data); + leaf_hashes.push_back(hasher->Digest()); + } + ABSL_RETURN_IF_ERROR(PadToPowerOfTwo(leaf_hashes)); + + return PopulateLayers(factory, leaf_hashes); +} + +} // namespace credentio
diff --git a/bindings/merkle.h b/bindings/merkle.h new file mode 100644 index 0000000..365b2ca --- /dev/null +++ b/bindings/merkle.h
@@ -0,0 +1,81 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_H_ + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "crypto/hash.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +struct DerivedTreeData { + int64_t full_leaf_count; + int64_t full_hashes_count; + int64_t delta_rows; + + bool operator==(const DerivedTreeData& other) const { + return full_leaf_count == other.full_leaf_count && + full_hashes_count == other.full_hashes_count && + delta_rows == other.delta_rows; + } +}; + +// Derives the data needed to validate a Merkle Tree from the leaf count, hashes +// count, and auxiliary data count. +absl::StatusOr<DerivedTreeData> DeriveMerkleTreeData(int64_t leaf_count, + int64_t hashes_count, + int64_t auxiliary_count); + +// Derives the block sizes for a given Merkle map and total leaf data size. +absl::StatusOr<std::vector<int64_t>> DeriveMerkleBlockSizes( + const BmffMerkle& merkle, uint64_t mdat_box_size); + +using MerkleTree = std::vector<std::vector<std::string>>; + +// Options for creating a Merkle Tree. +// - `fixed_block_size`: blocks of equal sizes +// - `variable_block_sizes`: blocks of the specified sizes +// - neither: the entire input string will treated as a single block +// - both: then an error will be returned +struct CreateMerkleTreeOptions { + std::optional<int64_t> fixed_block_size = std::nullopt; + std::vector<int64_t> variable_block_sizes = {}; +}; + +// Creates a Merkle Tree from a riegeli::Reader with the options indicating +// how the input should be broken into blocks. +absl::StatusOr<MerkleTree> CreateMerkleTree( + HasherFactory* factory, riegeli::Reader& input, int64_t starting_offset, + int64_t length, CreateMerkleTreeOptions options = {}); + +// Creates a Merkle Tree from a vector of strings, each which represent a single +// leaf node. +absl::StatusOr<MerkleTree> CreateMerkleTree(HasherFactory* factory, + riegeli::Reader& input, + int64_t starting_offset, + int64_t length, + std::vector<int64_t> block_sizes); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_H_
diff --git a/bindings/merkle_test.cc b/bindings/merkle_test.cc new file mode 100644 index 0000000..cda2fb3 --- /dev/null +++ b/bindings/merkle_test.cc
@@ -0,0 +1,474 @@ +// 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 "bindings/merkle.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <variant> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +class JoiningHasher : public Hasher { + public: + void Update(absl::string_view content) override { + digest_ = absl::StrCat(digest_, content); + } + + std::string Digest() override { return digest_; } + + protected: + std::string digest_ = ""; +}; + +class JoiningHasherFactory : public HasherFactory { + public: + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override { + return std::make_unique<JoiningHasher>(); + } + HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; } +}; + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::Eq; +using ::testing::HasSubstr; + +struct BlockSizesCase { + std::string name; + BmffMerkle merkle_map; + uint64_t mdat_size; + absl::StatusOr<std::vector<int64_t>> expected; +}; + +using DeriveBlockSizesTest = testing::TestWithParam<BlockSizesCase>; + +TEST_P(DeriveBlockSizesTest, DerivesBlockSizes) { + const BlockSizesCase& params = GetParam(); + + if (params.expected.ok()) { + EXPECT_THAT(DeriveMerkleBlockSizes(params.merkle_map, params.mdat_size), + IsOkAndHolds(Eq(params.expected.value()))); + } else { + EXPECT_THAT(DeriveMerkleBlockSizes(params.merkle_map, params.mdat_size), + StatusIs(params.expected.status().code(), + params.expected.status().message())); + } +} + +INSTANTIATE_TEST_SUITE_P( + DeriveMerkleDataTests, DeriveBlockSizesTest, + ::testing::Values( + BlockSizesCase{ + .name = "BothSizeTypes", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 5 + fixed_block_size: 10 + variable_block_sizes: 1 + variable_block_sizes: 3 + )pb"), + .mdat_size = 10, + .expected = absl::InvalidArgumentError( + "merkle map has both fixed and variable block sizes"), + }, + BlockSizesCase{ + .name = "FixedSizeZero", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 5 + fixed_block_size: 0 + )pb"), + .mdat_size = 10, + .expected = absl::InvalidArgumentError( + "merkle map has an invalid fixed block size"), + }, + BlockSizesCase{ + .name = "FixedSizeBlocksNotMatchingCount", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 3 + fixed_block_size: 3 + )pb"), + .mdat_size = 10, + .expected = absl::InvalidArgumentError( + "merkle map's block sizes do not " + "match the expected leaf count"), + }, + BlockSizesCase{ + .name = "VariableHasInvalidSize", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 4 + variable_block_sizes: 3 + variable_block_sizes: 0 + variable_block_sizes: 2 + )pb"), + .mdat_size = 10, + .expected = absl::InvalidArgumentError( + "merkle map has an invalid variable block size"), + }, + BlockSizesCase{ + .name = "VariableBlockSumTooLarge", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 4 + variable_block_sizes: 3 + variable_block_sizes: 4 + variable_block_sizes: 2 + variable_block_sizes: 6 + )pb"), + .mdat_size = 10, + .expected = absl::InvalidArgumentError( + "merkle map's variable block sizes " + "do not sum to the mdat box size"), + }, + BlockSizesCase{ + .name = "ValidVariableBlockSizes", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 4 + variable_block_sizes: 3 + variable_block_sizes: 2 + variable_block_sizes: 3 + variable_block_sizes: 2 + )pb"), + .mdat_size = 10, + .expected = std::vector<int64_t>{3, 2, 3, 2}, + }, + BlockSizesCase{ + .name = "NoBlockSizesSpecifiedTooLargeMdat", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 1 + )pb"), + .mdat_size = static_cast<uint64_t>(INT64_MAX) + 1, + .expected = absl::InvalidArgumentError( + "mdat atom's box size is too large to fit in an int64_t"), + }, + BlockSizesCase{ + .name = "FixedBlockSizeExceedsLimit", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 1048577 + fixed_block_size: 1 + )pb"), + .mdat_size = 1048577, + .expected = absl::InvalidArgumentError( + "merkle map block count exceeds maximum allowable limit: " + "1048577 > 1048576"), + }, + BlockSizesCase{ + .name = "VariableBlockSizesOverflow", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 2 + variable_block_sizes: 9223372036854775807 + variable_block_sizes: 1 + )pb"), + .mdat_size = 10, + .expected = absl::InvalidArgumentError( + "merkle map's variable block sizes overflow int64_t"), + }, + BlockSizesCase{ + .name = "ValidNoBlockSizesSpecified", + .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb( + count: 1 + )pb"), + .mdat_size = 100, + .expected = std::vector<int64_t>{100}, + }), + [](const testing::TestParamInfo<BlockSizesCase>& info) { + return info.param.name; + }); + +TEST(MerkleTest, VariableBlockSizesExceedsLimit) { + BmffMerkle merkle_map; + merkle_map.set_count(1048577); + for (int i = 0; i < 1048577; ++i) { + merkle_map.add_variable_block_sizes(1); + } + EXPECT_THAT(DeriveMerkleBlockSizes(merkle_map, 1048577), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("merkle map block count exceeds maximum " + "allowable limit: 1048577 > 1048576"))); +} + +struct TreeDataCase { + std::string name; + int64_t leaf_count; + int64_t hashes_count; + int64_t auxiliary_count; + absl::StatusOr<DerivedTreeData> expected; +}; + +using DeriveTreeDataTest = testing::TestWithParam<TreeDataCase>; + +TEST_P(DeriveTreeDataTest, DerivesTreeData) { + const TreeDataCase& params = GetParam(); + + if (params.expected.ok()) { + EXPECT_THAT(DeriveMerkleTreeData(params.leaf_count, params.hashes_count, + params.auxiliary_count), + IsOkAndHolds(Eq(params.expected.value()))); + } else { + EXPECT_THAT(DeriveMerkleTreeData(params.leaf_count, params.hashes_count, + params.auxiliary_count), + StatusIs(params.expected.status().code(), + params.expected.status().message())); + } +} + +INSTANTIATE_TEST_SUITE_P( + DeriveTreeDataTests, DeriveTreeDataTest, + ::testing::Values( + TreeDataCase{ + .name = "LeafCountTooLarge", + .leaf_count = 0x8000'0001, + .hashes_count = 3, + .auxiliary_count = 0, + .expected = absl::InvalidArgumentError(absl::StrFormat( + "Value is negative or too large: %d", 0x8000'0001)), + }, + TreeDataCase{ + .name = "HashesCountTooLarge", + .leaf_count = 5, + .hashes_count = 0x8000'0001, + .auxiliary_count = 0, + .expected = absl::InvalidArgumentError(absl::StrFormat( + "Value is negative or too large: %d", 0x8000'0001)), + }, + TreeDataCase{ + .name = "HashesBelowLeafRow", + .leaf_count = 3, + .hashes_count = 5, + .auxiliary_count = 0, + .expected = absl::InvalidArgumentError( + "hashes row size is larger than the leaf row size"), + }, + TreeDataCase{ + .name = "IncorrectHashesCountSameRow", + .leaf_count = 5, + .hashes_count = 6, + .auxiliary_count = 0, + .expected = absl::InvalidArgumentError( + "hashes count is not equal to the expected hashes count: " + "Hashes Count: 6, Leaf Count: 5, Expected Hashes Count: 5"), + }, + TreeDataCase{ + .name = "IncorrectHashesCountDifferentRows", + .leaf_count = 5, + .hashes_count = 4, + .auxiliary_count = 0, + .expected = absl::InvalidArgumentError( + "hashes count is not equal to the expected hashes count: " + "Hashes Count: 4, Leaf Count: 5, Expected Hashes Count: 3"), + }, + TreeDataCase{ + .name = "IncorrectAuxiliaryCountSameRows", + .leaf_count = 5, + .hashes_count = 5, + .auxiliary_count = 3, + .expected = absl::InvalidArgumentError( + "incorrect count of auxiliary data boxes: Auxiliary Data " + "Count: 3, Leaf Count: 5"), + }, + TreeDataCase{ + .name = "IncorrectAuxiliaryCountDifferentRows", + .leaf_count = 5, + .hashes_count = 3, + .auxiliary_count = 3, + .expected = absl::InvalidArgumentError( + "incorrect count of auxiliary data boxes: Auxiliary Data " + "Count: 3, Leaf Count: 5"), + }, + TreeDataCase{ + .name = "ValidTreeDataSameRowNoAuxiliary", + .leaf_count = 5, + .hashes_count = 5, + .auxiliary_count = 0, + .expected = + DerivedTreeData{ + .full_leaf_count = 8, + .full_hashes_count = 8, + .delta_rows = 0, + }, + }, + TreeDataCase{ + .name = "ValidTreeDataSameRowWithAuxiliary", + .leaf_count = 5, + .hashes_count = 5, + .auxiliary_count = 5, + .expected = + DerivedTreeData{ + .full_leaf_count = 8, + .full_hashes_count = 8, + .delta_rows = 0, + }, + }, + TreeDataCase{ + .name = "ValidTreeDataDifferentRows", + .leaf_count = 5, + .hashes_count = 3, + .auxiliary_count = 5, + .expected = + DerivedTreeData{ + .full_leaf_count = 8, + .full_hashes_count = 4, + .delta_rows = 1, + }, + }), + [](const testing::TestParamInfo<TreeDataCase>& info) { + return info.param.name; + }); + +struct CreateWithOptionsCase { + std::string name; + std::string contents; + int64_t starting_offset; + int64_t length; + CreateMerkleTreeOptions options; + std::variant<MerkleTree, absl::Status> expected; +}; + +using CreateWithOptionsTest = testing::TestWithParam<CreateWithOptionsCase>; + +TEST_P(CreateWithOptionsTest, CreatesMerkleTree) { + const CreateWithOptionsCase& params = GetParam(); + + riegeli::StringReader<> input(params.contents); + + JoiningHasherFactory factory; + + if (std::holds_alternative<absl::Status>(params.expected)) { + EXPECT_THAT( + CreateMerkleTree(&factory, input, params.starting_offset, params.length, + params.options), + StatusIs(std::get<absl::Status>(params.expected).code(), + HasSubstr(std::get<absl::Status>(params.expected).message()))); + } else { + EXPECT_THAT(CreateMerkleTree(&factory, input, params.starting_offset, + params.length, params.options), + IsOkAndHolds(Eq(std::get<MerkleTree>(params.expected)))); + } +} + +INSTANTIATE_TEST_SUITE_P( + OptionBasedTests, CreateWithOptionsTest, + ::testing::Values( + CreateWithOptionsCase{.name = "EmptyOptions", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {}, + .expected = + MerkleTree{ + {"abcde"}, + }}, + CreateWithOptionsCase{.name = "FixedBlockSizeSingleByte", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {.fixed_block_size = 1}, + .expected = + MerkleTree{ + {"a", "b", "c", "d", "e", "", "", ""}, + {"ab", "cd", "e", ""}, + {"abcd", "e"}, + {"abcde"}, + }}, + CreateWithOptionsCase{.name = "FixedBlockSizeTwoBytes", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {.fixed_block_size = 2}, + .expected = + MerkleTree{ + {"ab", "cd", "e", ""}, + {"abcd", "e"}, + {"abcde"}, + }}, + CreateWithOptionsCase{.name = "VariableBlockSizes", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {.variable_block_sizes = {3, 1, 1}}, + .expected = + MerkleTree{ + {"abc", "d", "e", ""}, + {"abcd", "e"}, + {"abcde"}, + }}, + CreateWithOptionsCase{ + .name = "FailsWithBothFixedAndVariableBlockSizes", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {.fixed_block_size = 1, + .variable_block_sizes = {3, 1, 1}}, + .expected = absl::InvalidArgumentError( + "Must specify a fixed block size or " + "variable block sizes, not both."), + }, + CreateWithOptionsCase{ + .name = "FailsWithZeroFixedBlockSize", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {.fixed_block_size = 0}, + .expected = absl::InvalidArgumentError( + "Fixed block size must be greater than 0. Found: 0"), + }, + CreateWithOptionsCase{ + .name = "FailsWithInputTooSmall", + .contents = "abcde", + .starting_offset = 3, + .length = 5, + .options = {.fixed_block_size = 10}, + .expected = absl::DataLossError("Failed to read leaf data"), + }, + CreateWithOptionsCase{ + .name = "FixedBlockSizeLargerThanInput", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {.fixed_block_size = 10}, + .expected = MerkleTree{{"abcde"}}, + }, + CreateWithOptionsCase{ + .name = "FailsWithVariableBlockSizesTooLarge", + .contents = "abcde", + .starting_offset = 0, + .length = 5, + .options = {.variable_block_sizes = {2, 3, 5}}, + .expected = absl::InvalidArgumentError( + "Total block size is too large. " + "Total Block size: 10, Length: 5"), + }), + [](const testing::TestParamInfo<CreateWithOptionsCase>& info) { + return info.param.name; + }); + +} // namespace +} // namespace credentio
diff --git a/bindings/merkle_validator.cc b/bindings/merkle_validator.cc new file mode 100644 index 0000000..9ec8efe --- /dev/null +++ b/bindings/merkle_validator.cc
@@ -0,0 +1,288 @@ +// 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 "bindings/merkle_validator.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "bindings/input_hasher.h" +#include "bindings/merkle.h" +#include "cbor/cbor.h" +#include "cbor/parse.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "crypto/hash.h" +#include "formats/bmff/box_header.h" +#include "proto/bmff_based_hash_assertion.cbor.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/riegeli.h" +#include "utils/status_tracker.h" + +namespace credentio { + +namespace { + +std::string GetMerkleKey(int64_t unique_id, int64_t local_id) { + return absl::StrFormat("%d-%d", unique_id, local_id); +} + +absl::StatusOr<std::string> ReadPurpose(riegeli::Reader& contents) { + std::string purpose; + if (!ReadNullTerminatedString(contents, 20, purpose)) { + return contents.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof")); + } + return purpose; +} + +absl::StatusOr<BmffMerkleMap> ReadBmffMerkleMap(riegeli::Reader& contents, + int64_t length) { + constexpr int64_t kMaxMerkleMapSize = 10 * 1024 * 1024; // 10 MiB + if (length < 0 || length > kMaxMerkleMapSize) { + return absl::InvalidArgumentError( + absl::StrFormat("invalid length for merkle map: %d", length)); + } + std::string aux_box_raw; + if (!contents.Read(length, aux_box_raw)) { + return absl::InternalError("failed to read auxiliary merkle map"); + } + + BmffMerkleMap aux_box; + ABSL_ASSIGN_OR_RETURN(auto aux_box_cbor, cbor::Parse(aux_box_raw)); + ABSL_ASSIGN_OR_RETURN(auto aux_box_cbor_map, aux_box_cbor->AsMap()); + ABSL_RETURN_IF_ERROR(cbor::ToProto(aux_box_cbor_map, &aux_box)); + return aux_box; +} + +absl::StatusOr<std::string> ComputeLeafHash(const HasherFactory& factory, + riegeli::Reader& contents, + int64_t offset, int64_t length) { + ABSL_ASSIGN_OR_RETURN(auto hasher, factory.Create()); + ABSL_ASSIGN_OR_RETURN(auto input_hasher, + InputHasher::Create(std::move(hasher))); + ABSL_RETURN_IF_ERROR(input_hasher->Update(contents, offset, length)); + return input_hasher->Digest(); +} + +absl::StatusOr<std::string> JoinHashes(const HasherFactory& factory, + absl::string_view hash1, + absl::string_view hash2) { + ABSL_ASSIGN_OR_RETURN(auto hasher, factory.Create()); + hasher->Update(hash1); + hasher->Update(hash2); + return hasher->Digest(); +} + +struct LeafData { + std::vector<BmffBoxHeader> mdat_atoms; + absl::flat_hash_map<std::string, std::vector<BmffMerkleMap>> auxiliary_data; +}; + +absl::StatusOr<LeafData> ExtractLeafData(riegeli::Reader& contents) { + LeafData result; + + if (!contents.Seek(0) || contents.pos() != 0) { + return absl::InternalError("failed to seek to start of file"); + } + auto status = IterateOverBmffBoxes( + contents, + [&result, + &contents](const BmffBoxHeader& header) -> absl::StatusOr<bool> { + // Non-fragmented assets have 1 merkle tree per `mdat` + // Fragmented assets could have merkle tree per track + // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_general_20 + if (header.type == "mdat") { + // Non-fragmented assets require auxiliary data after the last mdat. + // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_non_fragmented_asset_that_can_be_validated_piecewise + if (!result.auxiliary_data.empty()) { + return absl::InvalidArgumentError( + "encountered auxiliary atom before the first mdat atom"); + } + result.mdat_atoms.push_back(header); + } + if (header.type == "uuid" && + header.user_type == credentio::kC2paBmffBoxUuid) { + // Read next string to determine the purpose of the uuid atom. + ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(contents)); + + if (purpose == "merkle") { + // Leverage the offset found within the manifest to find first aux + // 4 bytes are the version and flag data + // 1 byte is the null terminator of the purpose string + const uint64_t metadata_header_size = purpose.size() + 4 + 1; + if (header.box_size < header.header_size || + header.box_size - header.header_size < metadata_header_size) { + return absl::InvalidArgumentError( + "box size too small for metadata headers"); + } + int64_t remaining_data = + header.box_size - header.header_size - metadata_header_size; + ABSL_ASSIGN_OR_RETURN(auto aux_box, + ReadBmffMerkleMap(contents, remaining_data)); + + result + .auxiliary_data[GetMerkleKey(aux_box.unique_id(), + aux_box.local_id())] + .push_back(aux_box); + } + } + return true; + }); + ABSL_RETURN_IF_ERROR(status); + return result; +} + +} // namespace + +absl::Status MerkleValidator::ValidateMerkleMap( + const BmffMerkle& merkle, const BmffBoxHeader& mdat_atom, + std::vector<BmffMerkleMap> auxiliary_merkle_maps, + absl::string_view fallback_algo, StatusTracker& tracker) const { + if (merkle.has_init_hash()) { + tracker.RecordFailure( + FailureStatusCode::kGoogleInternalError, + {.url = assertion_uri_, + .explanation = "fragmented merkle validations are not supported yet"}); + return absl::UnimplementedError( + "fragmented merkle validations are not supported yet"); + } + + // Derive the full row sizes and number of rows away and validates the counts. + absl::StatusOr<DerivedTreeData> derived_data = DeriveMerkleTreeData( + merkle.count(), merkle.hashes_size(), auxiliary_merkle_maps.size()); + if (!derived_data.ok()) { + tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, + .explanation = derived_data.status().message()}); + return derived_data.status(); + } + + if (mdat_atom.box_size < mdat_atom.header_size) { + tracker.RecordFailure( + FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, + .explanation = "mdat atom box_size is smaller than header_size"}); + return absl::InvalidArgumentError( + "mdat atom box_size is smaller than header_size"); + } + + // Calculate and validate the lengths of each leaf. + absl::StatusOr<std::vector<int64_t>> leaf_sizes = DeriveMerkleBlockSizes( + merkle, mdat_atom.box_size - mdat_atom.header_size); + if (!leaf_sizes.ok()) { + tracker.RecordFailure( + FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, .explanation = leaf_sizes.status().message()}); + return leaf_sizes.status(); + } + + // Based on the above calculations, we ensure the assertion is not malformed, + // now compute and compare the leaf hashes. + int64_t offset = mdat_atom.start + mdat_atom.header_size; + for (int64_t i = 0; i < merkle.count(); ++i) { + absl::string_view algo = merkle.has_alg() ? merkle.alg() : fallback_algo; + absl::StatusOr<std::unique_ptr<HasherFactory>> factory = + factory_provider_->Create(algo); + if (!factory.ok()) { + tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, + .explanation = absl::StrFormat( + "unsupported hash algorithm: %s", algo)}); + return absl::InvalidArgumentError( + absl::StrFormat("unsupported hash algorithm: %s", algo)); + } + + absl::StatusOr<std::string> leaf_hash = + ComputeLeafHash(**factory, contents_, offset, leaf_sizes.value()[i]); + offset += leaf_sizes.value()[i]; + if (!leaf_hash.ok()) { + tracker.RecordFailure( + FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, .explanation = leaf_hash.status().message()}); + return leaf_hash.status(); + } + + std::string computed_hash = std::move(leaf_hash.value()); + + if (!auxiliary_merkle_maps.empty()) { + int64_t running_row_index = i; + BmffMerkleMap auxiliary = auxiliary_merkle_maps[i]; + for (const auto& hash : auxiliary.hashes()) { + if (running_row_index % 2 == 1) { + ABSL_ASSIGN_OR_RETURN(computed_hash, + JoinHashes(**factory, hash, computed_hash)); + } else { + ABSL_ASSIGN_OR_RETURN(computed_hash, + JoinHashes(**factory, computed_hash, hash)); + } + running_row_index >>= 1; + } + } + + int64_t hashes_index = i >> derived_data->delta_rows; + if (merkle.hashes(hashes_index) != computed_hash) { + tracker.RecordFailure( + FailureStatusCode::kAssertionBmffHashMismatch, + {.url = assertion_uri_, + .explanation = absl::StrFormat( + "merkle map hash mismatch at index %d: Expected: %s, Actual: %s", + hashes_index, merkle.hashes(hashes_index), computed_hash)}); + return absl::InternalError("merkle map hash mismatch"); + } + } + + return absl::OkStatus(); +} + +absl::Status MerkleValidator::Validate(StatusTracker& tracker) const { + absl::StatusOr<LeafData> atoms = ExtractLeafData(contents_); + if (!atoms.ok()) { + tracker.RecordFailure( + FailureStatusCode::kGoogleInternalError, + {.url = assertion_uri_, .explanation = atoms.status().message()}); + return absl::InternalError("failed to extract leaf data"); + } + if (atoms->mdat_atoms.size() != assertion_.merkles_size()) { + // This should only apply to non-fragmented assets. + tracker.RecordFailure( + FailureStatusCode::kAssertionBmffHashMalformed, + {.url = assertion_uri_, + .explanation = + "number of mdat atoms does not match the number of merkle maps"}); + return absl::InternalError( + "number of mdat atoms does not match the number of merkle maps"); + } + + for (int64_t i = 0; i < atoms->mdat_atoms.size(); ++i) { + BmffMerkle merkle = assertion_.merkles(i); + const auto& aux_maps = atoms->auxiliary_data[GetMerkleKey( + merkle.unique_id(), merkle.local_id())]; + ABSL_RETURN_IF_ERROR(ValidateMerkleMap( + merkle, atoms->mdat_atoms[i], aux_maps, assertion_.alg(), tracker)); + } + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/bindings/merkle_validator.h b/bindings/merkle_validator.h new file mode 100644 index 0000000..3a103b4 --- /dev/null +++ b/bindings/merkle_validator.h
@@ -0,0 +1,101 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_VALIDATOR_H_ + +#include <memory> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "formats/bmff/box_header.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/status_tracker.h" + +namespace credentio { + +class HasherFactoryProvider { + public: + virtual ~HasherFactoryProvider() = default; + virtual absl::StatusOr<std::unique_ptr<HasherFactory> absl_nonnull> Create( + absl::string_view algo) const = 0; +}; + +class DefaultHasherFactoryProvider : public HasherFactoryProvider { + public: + absl::StatusOr<std::unique_ptr<HasherFactory> absl_nonnull> Create( + absl::string_view algo) const override { + ABSL_ASSIGN_OR_RETURN(auto algorithm, ParseHashAlgorithm(algo)); + switch (algorithm) { + case HashAlgorithm::kSha256: + return std::make_unique<Sha256HasherFactory>(); + case HashAlgorithm::kSha384: + return std::make_unique<Sha384HasherFactory>(); + case HashAlgorithm::kSha512: + return std::make_unique<Sha512HasherFactory>(); + } + return nullptr; + } +}; + +class MerkleValidator { + public: + // Creates a MerkleValidator that will validate the merkle-maps contained + // within the BmffBasedHashAssertion. The validator will use the provided + // factory to create hashers for each hash algorithm it encounters. + MerkleValidator( + riegeli::Reader& contents, const BmffBasedHashAssertion& assertion, + absl::string_view assertion_uri, + std::unique_ptr<HasherFactoryProvider> absl_nonnull factory_provider = + std::make_unique<DefaultHasherFactoryProvider>()) + : contents_(contents), + factory_provider_(std::move(factory_provider)), + assertion_(assertion), + assertion_uri_(assertion_uri) {} + + ~MerkleValidator() = default; + + // Validates all the merkle-maps contained within the BmffBasedHashAssertion, + // recording any failures in the StatusTracker. This method will not add any + // success codes to the tracker, as that is handled by the main + // BmffHashHardBindingValidator. + absl::Status Validate(StatusTracker& tracker) const; + + private: + riegeli::Reader& contents_; + std::unique_ptr<HasherFactoryProvider> factory_provider_; + const BmffBasedHashAssertion& assertion_; + absl::string_view assertion_uri_; + + // Validates a single merkle map, recording any failures in the + // StatusTracker. + absl::Status ValidateMerkleMap( + const BmffMerkle& merkle, const BmffBoxHeader& mdat_atom, + std::vector<BmffMerkleMap> auxiliary_merkle_maps, + absl::string_view fallback_algo, StatusTracker& tracker) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_VALIDATOR_H_
diff --git a/bindings/merkle_validator_test.cc b/bindings/merkle_validator_test.cc new file mode 100644 index 0000000..993575b --- /dev/null +++ b/bindings/merkle_validator_test.cc
@@ -0,0 +1,624 @@ +// 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 "bindings/merkle_validator.h" + +#include <algorithm> +#include <cctype> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "formats/bmff/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/cbor_utils.h" +#include "testing/proto_test_utils.h" +#include "utils/status_tracker.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAreArray; +using ::testing::IsEmpty; + +class TrackingStatusTracker : public StatusTracker { + public: + TrackingStatusTracker() = default; + ~TrackingStatusTracker() override = default; + + struct SuccessRecord { + SuccessStatusCode code; + std::string url = ""; + std::string explanation = ""; + + bool operator==(const SuccessRecord& other) const { + return code == other.code && url == other.url && + explanation == other.explanation; + } + }; + + struct FailureRecord { + FailureStatusCode code; + std::string url = ""; + std::string explanation = ""; + + bool operator==(const FailureRecord& other) const { + return code == other.code && url == other.url && + explanation == other.explanation; + } + }; + + struct InformationalRecord { + InformationalStatusCode code; + std::string url = ""; + std::string explanation = ""; + + bool operator==(const InformationalRecord& other) const { + return code == other.code && url == other.url && + explanation == other.explanation; + } + }; + + void RecordSuccess(SuccessStatusCode code, + ValidationTracker::RecordOptions options) override { + successes_.push_back(SuccessRecord{ + .code = code, + .url = std::string(options.url), + .explanation = std::string(options.explanation), + }); + }; + void RecordFailure(FailureStatusCode code, + ValidationTracker::RecordOptions options) override { + failures_.push_back(FailureRecord{ + .code = code, + .url = std::string(options.url), + .explanation = std::string(options.explanation), + }); + }; + void RecordInformational(InformationalStatusCode code, + ValidationTracker::RecordOptions options) override { + informationals_.push_back(InformationalRecord{ + .code = code, + .url = std::string(options.url), + .explanation = std::string(options.explanation), + }); + }; + + std::vector<SuccessRecord> successes_; + std::vector<FailureRecord> failures_; + std::vector<InformationalRecord> informationals_; +}; + +class JoiningHasherFactoryProvider : public HasherFactoryProvider { + public: + class JoiningHasherFactory : public HasherFactory { + public: + class JoiningHasher : public Hasher { + public: + void Update(absl::string_view content) override { + digest_ = absl::StrCat(digest_, content); + + std::replace_if( + digest_.begin(), digest_.end(), + [](char c) { return !std::isalnum(static_cast<unsigned char>(c)); }, + '_'); + } + + std::string Digest() override { return digest_; } + + protected: + std::string digest_ = ""; + }; + + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override { + return std::make_unique<JoiningHasher>(); + }; + HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; }; + }; + + absl::StatusOr<std::unique_ptr<HasherFactory>> Create( + absl::string_view algo) const override { + if (algo == "error") { + return absl::InvalidArgumentError( + absl::StrFormat("unsupported hash algorithm: %s", algo)); + } + return std::make_unique<JoiningHasherFactory>(); + } +}; + +struct MerkleValidatorTestCase { + std::string name; + std::string file_contents; + BmffBasedHashAssertion assertion; + absl::Status expected_status; + std::vector<TrackingStatusTracker::FailureRecord> expected_failures; +}; + +class MerkleValidatorTest + : public ::testing::TestWithParam<MerkleValidatorTestCase> { + public: + void SetUp() override { + auto input_reader = + std::make_unique<riegeli::StringReader<>>(GetParam().file_contents); + ASSERT_THAT(input_reader->status(), IsOk()); + input_ = std::move(input_reader); + + factory_ = std::make_unique<JoiningHasherFactoryProvider>(); + assertion_uri_ = "assertion_uri"; + + validator_ = std::make_unique<MerkleValidator>( + *input_, GetParam().assertion, assertion_uri_, + std::make_unique<JoiningHasherFactoryProvider>()); + } + + std::unique_ptr<riegeli::Reader> input_; + std::unique_ptr<HasherFactoryProvider> factory_; + std::string assertion_uri_; + std::unique_ptr<MerkleValidator> validator_; +}; + +TEST_P(MerkleValidatorTest, Validate) { + TrackingStatusTracker tracker; + + EXPECT_THAT(validator_->Validate(tracker), + StatusIs(GetParam().expected_status.code(), + GetParam().expected_status.message())); + + // Successes are not marked within the MerkleValidator. + EXPECT_THAT(tracker.successes_, IsEmpty()); + EXPECT_THAT(tracker.failures_, + ElementsAreArray(GetParam().expected_failures)); + EXPECT_THAT(tracker.informationals_, IsEmpty()); +} + +INSTANTIATE_TEST_SUITE_P( + MerkleValidatorTests, MerkleValidatorTest, + ::testing::Values( + MerkleValidatorTestCase{ + .name = "FailsWithInvalidInput", + .file_contents = "this_is_not_a_valid_bmff_file", + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { count: 1 fixed_block_size: 10 } + )pb"), + .expected_status = + absl::InternalError("failed to extract leaf data"), + .expected_failures = + {{.code = FailureStatusCode::kGoogleInternalError, + .url = "assertion_uri", + .explanation = "truncated BMFF box; at byte 8"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsMdatCountNotMatching", + .file_contents = + absl::StrCat(credentio_testing::Box("mdat", "data_one"), + credentio_testing::Box("mdat", "data_two")), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { count: 1 fixed_block_size: 10 } + )pb"), + .expected_status = absl::InternalError( + "number of mdat atoms does not match the " + "number of merkle maps"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMalformed, + .url = "assertion_uri", + .explanation = "number of mdat atoms does not match " + "the number of merkle maps"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsFragmentedUnsupported", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 2 + init_hash: "only_for_fragmented" + fixed_block_size: 3 + } + )pb"), + .expected_status = absl::UnimplementedError( + "fragmented merkle validations are not supported yet"), + .expected_failures = + {{.code = FailureStatusCode::kGoogleInternalError, + .url = "assertion_uri", + .explanation = + "fragmented merkle validations are not supported yet"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsHashesBelowLeafs", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 3 + fixed_block_size: 10 + hashes: "a" + hashes: "b" + hashes: "c" + hashes: "d" + hashes: "e" + hashes: "f" + } + )pb"), + .expected_status = absl::InvalidArgumentError( + "hashes row size is larger than the leaf row size"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMalformed, + .url = "assertion_uri", + .explanation = + "hashes row size is larger than the leaf row size"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsLeafSizesTooLarge", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { count: 1 variable_block_sizes: 10 hashes: "abcdef" } + )pb"), + .expected_status = absl::InvalidArgumentError( + "merkle map's variable block sizes do " + "not sum to the mdat box size"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMalformed, + .url = "assertion_uri", + .explanation = "merkle map's variable block sizes do not sum " + "to the mdat box size"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsLeafHashMismatch", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 3 + fixed_block_size: 2 + hashes: "wrong_hash" + hashes: "cd" + hashes: "ef" + } + )pb"), + .expected_status = absl::InternalError("merkle map hash mismatch"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMismatch, + .url = "assertion_uri", + .explanation = "merkle map hash mismatch at index 0: " + "Expected: wrong_hash, Actual: ab"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsAuxiliaryDataBeforeLastMdat", + .file_contents = []() -> std::string { + std::string aux_box = cbor::FromJson(R"json({ + "hashes": [ "b64'Zm9v'" ], + "localId": 1, + "location": 0, + "uniqueId": 1 +})json"); + return absl::StrCat(credentio_testing::Box( + "uuid", credentio_testing::C2paBoxPayload( + "merkle", aux_box)), + credentio_testing::Box("mdat", "abcdef")); + }(), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 7 + fixed_block_size: 2 + hashes: "wrong_hash" + hashes: "cd" + hashes: "ef" + } + )pb"), + .expected_status = + absl::InternalError("failed to extract leaf data"), + .expected_failures = + {{.code = FailureStatusCode::kGoogleInternalError, + .url = "assertion_uri", + .explanation = + "encountered auxiliary atom before the first mdat atom"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsIncorrectAuxiliaryCount", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 3 + fixed_block_size: 2 + hashes: "wrong_hash" + hashes: "cd" + hashes: "ef" + } + )pb"), + .expected_status = absl::InternalError("merkle map hash mismatch"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMismatch, + .url = "assertion_uri", + .explanation = "merkle map hash mismatch at index 0: " + "Expected: wrong_hash, Actual: ab"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsUnsupportedHashAlgorithm", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "error" + hash: "unused" + merkles { + count: 3 + fixed_block_size: 2 + hashes: "ab" + hashes: "cd" + hashes: "ef" + } + )pb"), + .expected_status = + absl::InvalidArgumentError("unsupported hash algorithm: error"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMalformed, + .url = "assertion_uri", + .explanation = "unsupported hash algorithm: error"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsInvalidHashWithAuxiliaryData", + .file_contents = []() -> std::string { + std::string aux_box_1 = cbor::FromJson(R"json({ + "hashes": [ "b64'ZGVm'" ], + "localId": 1, + "location": 0, + "uniqueId": 1 +})json"); + std::string aux_box_2 = cbor::FromJson(R"json({ + "hashes": [ "b64'YWJj'" ], + "localId": 1, + "location": 1, + "uniqueId": 1 +})json"); + return absl::StrCat(credentio_testing::Box("mdat", "abcdef"), + credentio_testing::Box( + "uuid", credentio_testing::C2paBoxPayload( + "merkle", aux_box_1)), + credentio_testing::Box( + "uuid", credentio_testing::C2paBoxPayload( + "merkle", aux_box_2))); + }(), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + unique_id: 1 + local_id: 1 + count: 2 + fixed_block_size: 3 + hashes: "wrong_hash" + } + )pb"), + .expected_status = absl::InternalError("merkle map hash mismatch"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMismatch, + .url = "assertion_uri", + .explanation = "merkle map hash mismatch at index 0: " + "Expected: wrong_hash, Actual: abcdef"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsBoxSizeTooSmallForMetadataHeaders", + .file_contents = []() -> std::string { + std::string aux_box_1 = cbor::FromJson(R"json({ + "hashes": [ "b64'ZGVm'" ], + "localId": 1, + "location": 0, + "uniqueId": 1 + })json"); + return absl::StrCat( + credentio_testing::Box("mdat", "abcdef"), + credentio_testing::BadBox( + "uuid", + credentio_testing::C2paBoxPayload("merkle", aux_box_1), + 28)); + }(), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + unique_id: 1 + local_id: 1 + count: 2 + fixed_block_size: 3 + hashes: "wrong_hash" + } + )pb"), + .expected_status = + absl::InternalError("failed to extract leaf data"), + .expected_failures = + {{.code = FailureStatusCode::kGoogleInternalError, + .url = "assertion_uri", + .explanation = "box size too small for metadata headers"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsMdatBoxSizeSmallerThanHeaderSize", + .file_contents = std::string( + "\x00\x00\x00\x01mdat\x00\x00\x00\x00\x00\x00\x00\x08", 16), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { count: 1 fixed_block_size: 2 hashes: "11" } + )pb"), + .expected_status = + absl::InternalError("failed to extract leaf data"), + .expected_failures = + {{.code = FailureStatusCode::kGoogleInternalError, + .url = "assertion_uri", + .explanation = "kInvalidData; box size too small for header; " + "at byte 16"}}, + }, + MerkleValidatorTestCase{ + .name = "FailsTruncatedMerkleCrashPrevention", + .file_contents = credentio_testing::Box("mdat", + std::string(28, 'a')), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 14 + fixed_block_size: 2 + hashes: "11" + hashes: "22" + hashes: "33" + } + )pb"), + .expected_status = absl::InvalidArgumentError( + "hashes count is not equal to the expected hashes count: " + "Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: 4"), + .expected_failures = + {{.code = FailureStatusCode::kAssertionBmffHashMalformed, + .url = "assertion_uri", + .explanation = + "hashes count is not equal to the expected hashes count: " + "Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: " + "4"}}, + }, + MerkleValidatorTestCase{ + .name = "SuccessFixedBlockSizeWithHashesAsLeafRow", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 3 + fixed_block_size: 2 + hashes: "ab" + hashes: "cd" + hashes: "ef" + } + )pb"), + .expected_status = absl::OkStatus(), + .expected_failures = {}, + }, + MerkleValidatorTestCase{ + .name = "SuccessVariableBlockSizeWithHashesAsLeafRow", + .file_contents = credentio_testing::Box("mdat", "abcdef"), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + count: 2 + variable_block_sizes: 4 + variable_block_sizes: 2 + hashes: "abcd" + hashes: "ef" + } + )pb"), + .expected_status = absl::OkStatus(), + .expected_failures = {}, + }, + MerkleValidatorTestCase{ + .name = "SuccessHashWithAuxiliaryData", + .file_contents = []() -> std::string { + std::string aux_box_1 = cbor::FromJson(R"json({ + "hashes": [ "b64'ZGVm'" ], + "localId": 1, + "location": 0, + "uniqueId": 1 +})json"); + std::string aux_box_2 = cbor::FromJson(R"json({ + "hashes": [ "b64'YWJj'" ], + "localId": 1, + "location": 1, + "uniqueId": 1 +})json"); + return absl::StrCat(credentio_testing::Box("mdat", "abcdef"), + credentio_testing::Box( + "uuid", credentio_testing::C2paBoxPayload( + "merkle", aux_box_1)), + credentio_testing::Box( + "uuid", credentio_testing::C2paBoxPayload( + "merkle", aux_box_2))); + }(), + .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( + alg: "sha256" + hash: "unused" + merkles { + unique_id: 1 + local_id: 1 + count: 2 + fixed_block_size: 3 + hashes: "abcdef" + } + )pb"), + .expected_status = absl::OkStatus(), + .expected_failures = {}}), + [](const testing::TestParamInfo<MerkleValidatorTestCase>& info) { + return info.param.name; + }); + +using ::testing::NotNull; + +TEST(DefaultHasherFactoryProviderTest, CreateSha256) { + DefaultHasherFactoryProvider provider; + auto factory = provider.Create("sha256"); + ASSERT_THAT(factory, IsOk()); + EXPECT_THAT(*factory, NotNull()); + EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha256); +} + +TEST(DefaultHasherFactoryProviderTest, CreateSha384) { + DefaultHasherFactoryProvider provider; + auto factory = provider.Create("sha384"); + ASSERT_THAT(factory, IsOk()); + EXPECT_THAT(*factory, NotNull()); + EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha384); +} + +TEST(DefaultHasherFactoryProviderTest, CreateSha512) { + DefaultHasherFactoryProvider provider; + auto factory = provider.Create("sha512"); + ASSERT_THAT(factory, IsOk()); + EXPECT_THAT(*factory, NotNull()); + EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha512); +} + +TEST(DefaultHasherFactoryProviderTest, CreateInvalid) { + DefaultHasherFactoryProvider provider; + EXPECT_THAT(provider.Create("invalid"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +} // namespace +} // namespace credentio
diff --git a/bindings/validator.cc b/bindings/validator.cc new file mode 100644 index 0000000..9478d11 --- /dev/null +++ b/bindings/validator.cc
@@ -0,0 +1,668 @@ +// 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 "bindings/validator.h" + +#include <algorithm> +#include <cstdint> +#include <iterator> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <variant> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_map.h" +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "bindings/bmff_hash_hard_binding_validator.h" +#include "bindings/boxes_hash_hard_binding_validator.h" +#include "bindings/collection_data_hash_hard_binding_validator.h" +#include "bindings/data_hash_hard_binding_validator.h" +#include "constants/status_codes.h" +#include "formats/asset_box.h" +#include "formats/bmff/assessor.h" +#include "formats/bmff/box_header.h" +#include "formats/byte_range.h" +#include "formats/format.h" +#include "formats/registry.h" +#include "jumbf/uri.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/manifest.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/dual_status_tracker.h" +#include "utils/status_tracker.h" +#include "utils/two_stage_status_tracker.h" +#include "utils/uri.h" +#include "validator/result.h" +#include "validator/validation_result_internal.h" + +namespace credentio { + +namespace { + +struct OffsetLocator { + int64_t offset; + int64_t length; +}; + +struct BmffLocator { + std::string path; +}; + +absl::StatusOr<std::variant<OffsetLocator, BmffLocator>> GetLocator( + const MultiAssetPartLocation& part) { + if (!part.has_byte_offset() && !part.has_length() && !part.has_bmff_box()) { + return absl::InvalidArgumentError("no location specified"); + } + if (part.has_bmff_box() && (part.has_byte_offset() || part.has_length())) { + return absl::InvalidArgumentError( + "bmff box cannot be used with byte range locator"); + } + if (part.has_bmff_box()) { + return BmffLocator{.path = std::string(part.bmff_box())}; + } + if (!part.has_byte_offset() || !part.has_length()) { + return absl::InvalidArgumentError( + "byte range locator must have both offset and length"); + } + return OffsetLocator{.offset = part.byte_offset(), .length = part.length()}; +} + +std::unique_ptr<PartialValidationResultProto> CopyManifestLabels( + const PartialValidationResultProto& source) { + auto target = std::make_unique<PartialValidationResultProto>(); + target->set_hard_binding_uri(source.hard_binding_uri()); + target->set_multi_asset_hash_uri(source.multi_asset_hash_uri()); + + target->mutable_active_manifest()->set_label( + source.active_manifest().label()); + for (const Manifest& manifest : source.ingredient_manifests()) { + target->add_ingredient_manifests()->set_label(manifest.label()); + } + return target; +} + +absl::StatusOr<std::unique_ptr<ValidationResultProto>> +CompleteValidationResults( + std::unique_ptr<PartialValidationResultProto> target, + std::unique_ptr<PartialValidationResultProto> source) { + target->mutable_active_manifest()->mutable_validation()->MergeFrom( + std::move(*source->mutable_active_manifest()->mutable_validation())); + + absl::flat_hash_map<absl::string_view, ValidationStatusSet> source_results; + for (const Manifest& source_manifest : source->ingredient_manifests()) { + source_results[source_manifest.label()] = + std::move(source_manifest.validation()); + } + for (Manifest& target_manifest : *target->mutable_ingredient_manifests()) { + if (source_results.contains(target_manifest.label())) { + target_manifest.mutable_validation()->MergeFrom( + std::move(source_results[target_manifest.label()])); + } + } + return MakeFullValidationResult(std::move(target)); +} + +// Validates the parts of a multi-asset hash assertion. This ensures that the +// parts do not overlap and that the total size of the required parts does not +// exceed the size of the asset. Only problems accessing the files return as +// non-OK status, other errors are recorded in the status tracker. +absl::Status ValidateParts(const MultiAssetHashAssertion& assertion, + absl::string_view assertion_uri, int64_t asset_size, + StatusTracker& tracker) { + int64_t next_expected_offset = 0; + int64_t minimum_required_size = 0; + for (const auto& part : assertion.parts()) { + absl::StatusOr<std::variant<OffsetLocator, BmffLocator>> locator = + GetLocator(part.location()); + if (!locator.ok()) { + tracker.RecordFailure( + FailureStatusCode::kAssertionMultiAssetHashMalformed, + {.url = assertion_uri}); + return absl::OkStatus(); + } + if (std::holds_alternative<BmffLocator>(*locator)) { + // Contains a BMFF locator, cannot ensure bytes included based on offsets. + return absl::OkStatus(); + } + OffsetLocator location = std::get<OffsetLocator>(*locator); + if (location.offset != next_expected_offset) { + tracker.RecordFailure( + FailureStatusCode::kAssertionMultiAssetHashMalformed, + {.url = assertion_uri}); + return absl::OkStatus(); + } + next_expected_offset = location.offset + location.length; + if (!part.is_optional()) { + minimum_required_size += location.length; + } + } + if (minimum_required_size > asset_size) { + tracker.RecordFailure( + FailureStatusCode::kAssertionMultiAssetHashMissingPart, + {.url = assertion_uri}); + } + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<ContentBindingValidator::Structure> +ContentBindingValidator::ExtractAssetStructure(riegeli::Reader& contents, + const Format* format, + int64_t end_offset) const { + ContentBindingValidator::Structure structure{ + .asset = ByteRange{.offset = contents.pos(), + .length = end_offset - contents.pos()}}; + + absl::StatusOr<const Format*> inferred_format; + if (format == nullptr) { + inferred_format = format_registry_->GetFormat(contents); + if (!inferred_format.ok()) { + if (structure.asset.offset == 0) { + // Invalid format on the first asset therefore unsupported format. + return inferred_format.status(); + } + return structure; + } + format = *inferred_format; + } + + absl::StatusOr<std::vector<AssetBox>> boxes = + format->extractor()->ExtractBoxes(contents, {.requires_c2pa = false}); + if (boxes.ok() && !boxes->empty()) { + ABSL_ASSIGN_OR_RETURN( + int64_t end_of_asset_offset, + PopulateStructureFromBoxes(*std::move(boxes), structure)); + + // Only assets which contain boxes can support multiple assets (by finding + // the c2pa.after box). Navigate to the start of the next potential asset + // and continue to extract the structure. + if (!contents.Seek(end_of_asset_offset) || + contents.pos() != end_of_asset_offset) { + return contents.StatusOrAnnotate( + absl::InternalError("failed to seek to end of asset")); + } + return structure; + } + + // The determined format does not support multiple assets at this point, + // attempt to find the manifest store location and treat the remainder of + // the file as the last asset. + absl::StatusOr<std::optional<ByteRange>> manifest_store_location = + format->extractor()->ExtractManifestStoreLocation( + contents, {.requires_c2pa = false, .end_offset = end_offset}); + if (manifest_store_location.ok() && manifest_store_location->has_value()) { + structure.manifest_store_location = *std::move(manifest_store_location); + } + + return structure; +} + +absl::StatusOr<int64_t> ContentBindingValidator::PopulateStructureFromBoxes( + std::vector<AssetBox> boxes, + ContentBindingValidator::Structure& structure) const { + if (boxes.empty()) { + return absl::InvalidArgumentError("no boxes found"); + } + + structure.boxes = std::move(boxes); + + const AssetBox* last_box = nullptr; + for (const AssetBox& box : structure.boxes) { + last_box = &box; + + if (box.identifier == "C2PA") { + structure.manifest_store_location = ByteRange{ + .offset = box.byte_range.offset, .length = box.byte_range.length}; + } + } + + if (last_box != nullptr && last_box->identifier == "c2pa.after") { + structure.asset.length = + last_box->byte_range.offset - structure.asset.offset; + } + + return structure.asset.offset + structure.asset.length; +} + +absl::StatusOr<std::vector<ContentBindingValidator::Structure>> +ContentBindingValidator::GetStructure(riegeli::Reader& contents, + const Format& format) const { + std::vector<ContentBindingValidator::Structure> structures; + + if (!contents.SupportsSize() || !contents.Size().has_value()) { + return absl::InvalidArgumentError("asset does not support size"); + } + + if (contents.Size() == 0) { + return absl::InvalidArgumentError("asset has no contents"); + } + + if (!contents.Seek(0) || contents.pos() != 0) { + return contents.StatusOrAnnotate( + absl::InternalError("failed to seek to start of asset")); + } + + uint64_t end_offset = *contents.Size(); + bool at_end_of_file = false; + while (!at_end_of_file) { + // Format is always inferred for non-first parts. + const Format* part_format = structures.empty() ? &format : nullptr; + ABSL_ASSIGN_OR_RETURN( + ContentBindingValidator::Structure structure, + ExtractAssetStructure(contents, part_format, end_offset)); + + at_end_of_file = + (structure.asset.offset + structure.asset.length) == end_offset; + + structures.push_back(std::move(structure)); + } + + return structures; +} + +absl::StatusOr<std::unique_ptr<ValidationResultProto>> +ContentBindingValidator::Validate(riegeli::Reader& contents, + const Format& format, + std::unique_ptr<PartialValidationResultProto> + partial_validation_result) const { + std::unique_ptr<PartialValidationResultProto> result = + CopyManifestLabels(*partial_validation_result); + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<DualStatusTracker> tracker, + DualStatusTracker::FromPartialValidationResult(&*result)); + bool assertion_in_ingredient_manifest = tracker->WritesToIngredientManifest(); + TwoStageStatusTracker staging_tracker(*tracker); + + ABSL_ASSIGN_OR_RETURN( + std::vector<ContentBindingValidator::Structure> asset_structure, + GetStructure(contents, format)); + auto assertion_fetcher = [&](absl::string_view uri) { + return GetAssertion(partial_validation_result.get(), uri); + }; + // Getting the structure causes the Tell to be at the end of the file, reset. + if (!contents.Seek(0) || contents.pos() != 0) { + return contents.StatusOrAnnotate( + absl::InternalError("failed to seek to start of asset")); + } + ABSL_RETURN_IF_ERROR(ValidateAssertion( + contents, assertion_fetcher, + partial_validation_result->hard_binding_uri(), asset_structure, + /*requires_c2pa=*/true, staging_tracker, + assertion_in_ingredient_manifest)); + + if (!staging_tracker.HasFailuresInStage1() || + partial_validation_result->multi_asset_hash_uri().empty()) { + // Success or no multi-asset hard binding, finished. + staging_tracker.WriteStage1(); + return CompleteValidationResults(std::move(partial_validation_result), + std::move(result)); + } + + staging_tracker.MoveStage1ToStage2(); + + // Validate the multi-asset hard binding assertion. + if (!contents.Seek(0) || contents.pos() != 0) { + return contents.StatusOrAnnotate( + absl::InternalError("failed to seek to start of asset")); + } + ABSL_RETURN_IF_ERROR(ValidateAssertion( + contents, assertion_fetcher, + partial_validation_result->multi_asset_hash_uri(), asset_structure, + /*requires_c2pa=*/true, staging_tracker, + assertion_in_ingredient_manifest)); + if (staging_tracker.HasFailuresInStage1()) { + // Failed, add all the staged codes to the result. + staging_tracker.WriteStage2(); + } + staging_tracker.WriteStage1(); + return CompleteValidationResults(std::move(partial_validation_result), + std::move(result)); +} + +absl::Status ContentBindingValidator::ValidateAssertion( + riegeli::Reader& contents, AssertionFetcherRef assertion_fetcher, + absl::string_view assertion_uri, + std::vector<ContentBindingValidator::Structure> asset_structure, + bool requires_c2pa, TwoStageStatusTracker& tracker, + bool assertion_in_ingredient_manifest, int64_t end_offset) const { + const Assertion* absl_nullable assertion = assertion_fetcher(assertion_uri); + if (assertion == nullptr) { + return absl::InvalidArgumentError( + absl::StrCat("missing assertion: ", assertion_uri)); + } + + switch (assertion->assertion_case()) { + case Assertion::kBoxesHash: + if (asset_structure.empty()) { + return absl::InvalidArgumentError( + "asset structure cannot be determined"); + } + return ValidateBoxesHash(contents, assertion_uri, assertion->boxes_hash(), + asset_structure, requires_c2pa, tracker, + end_offset); + case Assertion::kDataHash: + return ValidateDataHash( + contents, assertion_uri, assertion->data_hash(), + asset_structure.empty() + ? std::nullopt + : asset_structure.front().manifest_store_location, + requires_c2pa, tracker, assertion_in_ingredient_manifest, end_offset); + case Assertion::kCollectionDataHash: + return ValidateCollectionDataHash(contents, assertion_uri, + assertion->collection_data_hash(), + requires_c2pa, tracker); + case Assertion::kBmffBasedHash: + return ValidateBmffHash(contents, assertion_uri, + assertion->bmff_based_hash(), requires_c2pa, + tracker); + case Assertion::kMultiAssetHash: + return ValidateMultiAssetHash( + contents, assertion_uri, assertion->multi_asset_hash(), + asset_structure, requires_c2pa, assertion_fetcher, tracker, + assertion_in_ingredient_manifest); + default: + return absl::InvalidArgumentError( + absl::StrCat("assertion is not a hard binding assertion: ", + assertion->assertion_case())); + } +} + +absl::Status ContentBindingValidator::ValidateDataHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const DataHashAssertion& assertion, + std::optional<ByteRange> manifest_store_location, bool requires_c2pa, + StatusTracker& tracker, bool assertion_in_ingredient_manifest, + int64_t end_offset) const { + DataHashHardBindingValidator().Validate( + contents, manifest_store_location, assertion, assertion_uri, tracker, + assertion_in_ingredient_manifest, contents.pos(), end_offset); + return absl::OkStatus(); +} + +absl::Status ContentBindingValidator::ValidateCollectionDataHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const CollectionDataHashAssertion& assertion, bool requires_c2pa, + StatusTracker& tracker) const { + CollectionDataHashHardBindingValidator().Validate(contents, assertion, + assertion_uri, tracker); + return absl::OkStatus(); +} + +absl::Status ContentBindingValidator::ValidateBoxesHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const BoxesHashAssertion& assertion, + std::vector<ContentBindingValidator::Structure> remaining_structure, + bool requires_c2pa, StatusTracker& tracker, int64_t end_offset) const { + if (assertion.boxes().empty()) { + // No boxes in assertion. + tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + {.url = assertion_uri}); + return absl::OkStatus(); + } + for (const auto& box : assertion.boxes()) { + if (box.names().empty()) { + // Boxes Assertion has no names. + tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMalformed, + {.url = assertion_uri}); + return absl::OkStatus(); + } + } + bool assertion_has_c2pa_after = + *assertion.boxes().rbegin()->names().rbegin() == "c2pa.after"; + + if (remaining_structure.empty()) { + // No Asset Data. + tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch, + {.url = assertion_uri}); + return absl::OkStatus(); + } + std::vector<AssetBox> asset_boxes = remaining_structure.front().boxes; + if (asset_boxes.empty()) { + // No boxes in asset. + tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch, + {.url = assertion_uri}); + return absl::OkStatus(); + } + bool extraction_has_c2pa_after = + asset_boxes.back().identifier == "c2pa.after"; + + if (!assertion_has_c2pa_after && extraction_has_c2pa_after) { + int64_t part_idx = 1; + while (part_idx < remaining_structure.size() && + !remaining_structure[part_idx].boxes.empty()) { + // Remove the c2pa.after box. + asset_boxes.pop_back(); + + // Add the boxes from the next asset. + Structure& next_asset = remaining_structure[part_idx]; + asset_boxes.reserve(asset_boxes.size() + next_asset.boxes.size()); + asset_boxes.insert(asset_boxes.end(), next_asset.boxes.begin(), + next_asset.boxes.end()); + + // Increment the index to the next asset. + ++part_idx; + } + } + + BoxesHashHardBindingValidator().Validate(contents, asset_boxes, assertion, + assertion_uri, tracker); + return absl::OkStatus(); +} + +absl::Status ContentBindingValidator::ValidateBmffHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const BmffBasedHashAssertion& assertion, bool requires_c2pa, + StatusTracker& tracker) const { + BmffHashHardBindingValidator().Validate(contents, assertion, assertion_uri, + tracker); + return absl::OkStatus(); +} + +absl::Status ContentBindingValidator::ValidateMultiAssetHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const MultiAssetHashAssertion& assertion, + std::vector<ContentBindingValidator::Structure> asset_structure, + bool requires_c2pa, AssertionFetcherRef assertion_fetcher, + TwoStageStatusTracker& tracker, + bool assertion_in_ingredient_manifest) const { + bool recorded_multi_asset_failure = false; + int64_t max_offset_validated = 0; + + if (!contents.SupportsSize() || !contents.Size().has_value()) { + return absl::InvalidArgumentError( + "multi-asset hash requires an asset with a known size"); + } + uint64_t asset_size = *contents.Size(); + + // multi-asset hash should drop all `c2pa.after` boxes + for (auto& structure : asset_structure) { + if (!structure.boxes.empty()) { + if (structure.boxes.back().identifier == "c2pa.after") { + structure.boxes.pop_back(); + } + } + } + + ABSL_RETURN_IF_ERROR( + ValidateParts(assertion, assertion_uri, asset_size, tracker)); + if (tracker.HasFailuresInStage1()) { + // No need to validate if we already have failures. + return absl::OkStatus(); + } + + TwoStageStatusTracker multi_asset_tracker( + *static_cast<StatusTracker*>(&tracker)); + + for (int part_index = 0; part_index < assertion.parts_size(); ++part_index) { + const auto& part = assertion.parts(part_index); + ABSL_ASSIGN_OR_RETURN(auto locator, GetLocator(part.location())); + + std::optional<int64_t> offset = std::nullopt; + std::optional<int64_t> length = std::nullopt; + + if (std::holds_alternative<OffsetLocator>(locator)) { + OffsetLocator location = std::get<OffsetLocator>(locator); + offset = location.offset; + length = location.length; + } else { + BmffLocator location = std::get<BmffLocator>(locator); + + if (!contents.Seek(0) || contents.pos() != 0) { + return contents.StatusOrAnnotate( + absl::InternalError("failed to seek to start of asset")); + } + ABSL_ASSIGN_OR_RETURN(bool is_supported, + BmffAssessor().IsSupported(contents)); + if (!is_supported) { + return absl::InvalidArgumentError( + "Uses a BMFF locator, but file is not a BMFF file"); + } + ABSL_RETURN_IF_ERROR(IterateOverBmffBoxes( + contents, [&location, &offset, &length](const BmffBoxHeader& header) { + if (header.xpath == location.path) { + offset = header.start; + length = header.box_size; + return false; // Terminate loop + } + return true; // Continue + })); + if (!offset.has_value()) { + // Did not find the location + multi_asset_tracker.RecordFailure( + FailureStatusCode::kAssertionMultiAssetHashMalformed, + {.url = assertion_uri}); + recorded_multi_asset_failure = true; + } + } + + if (!offset.has_value() || !length.has_value()) { + multi_asset_tracker.RecordFailure( + FailureStatusCode::kAssertionMultiAssetHashMissingPart, + {.url = assertion_uri, + .explanation = + absl::Substitute("missing location for part $0 of $1", + part_index + 1, assertion.parts_size())}); + recorded_multi_asset_failure = true; + if (part.is_optional()) { + multi_asset_tracker.MoveStage1ToStage2(); + } else { + multi_asset_tracker.WriteStage1(); + } + requires_c2pa = false; + continue; + } + + if (!contents.Seek(*offset) || contents.pos() != *offset || + (*offset + *length) > asset_size) { + multi_asset_tracker.RecordFailure( + FailureStatusCode::kAssertionMultiAssetHashMissingPart, + {.url = assertion_uri, + .explanation = + absl::Substitute("missing data for part $0 of $1", + part_index + 1, assertion.parts_size())}); + if (part.is_optional()) { + multi_asset_tracker.MoveStage1ToStage2(); + } else { + multi_asset_tracker.WriteStage1(); + } + requires_c2pa = false; + continue; + } + + ABSL_ASSIGN_OR_RETURN(absl::string_view manifest_label, + GetManifestLabelFromAbsoluteUri(assertion_uri)); + ABSL_ASSIGN_OR_RETURN(std::string part_hash_assertion_path, + jumbf::UriResolver::GetAbsolutePathFromUri( + part.hash_assertion().url(), + absl::StrCat("/c2pa/", manifest_label))); + std::string part_hash_assertion_uri = + absl::StrCat("self#jumbf=", part_hash_assertion_path); + + std::vector<ContentBindingValidator::Structure> part_structure = + (asset_structure.size() - 1) < part_index + ? std::vector<ContentBindingValidator::Structure>{} + : std::vector<ContentBindingValidator::Structure>( + asset_structure.begin() + part_index, asset_structure.end()); + ABSL_RETURN_IF_ERROR( + ValidateAssertion(contents, assertion_fetcher, part_hash_assertion_uri, + part_structure, requires_c2pa, multi_asset_tracker, + assertion_in_ingredient_manifest, *offset + *length)); + + if (part.is_optional()) { + if (multi_asset_tracker.HasFailuresInStage1()) { + // Optional, but failed, move to stage 2. + multi_asset_tracker.MoveStage1ToStage2(); + } else { + // Optional, but succeeded, write. + multi_asset_tracker.WriteStage1(); + max_offset_validated = + std::max(max_offset_validated, *offset + *length); + } + } else { + if (multi_asset_tracker.HasFailuresInStage1()) { + // Encountered a failure, but not optional, write all status codes. + multi_asset_tracker.WriteStage2(); + } + multi_asset_tracker.WriteStage1(); + max_offset_validated = std::max(max_offset_validated, *offset + *length); + } + + // We only need to ensure C2PA exists on the first part. + requires_c2pa = false; + } + + if (!recorded_multi_asset_failure && max_offset_validated != asset_size) { + // Less than the whole file was validated, if the whole file was read it's + // a mismatch, otherwise it's malformed. + multi_asset_tracker.RecordFailure( + contents.pos() == asset_size + ? FailureStatusCode::kAssertionMultiAssetHashMismatch + : FailureStatusCode::kAssertionMultiAssetHashMalformed, + {.url = assertion_uri}); + recorded_multi_asset_failure = true; + } + + // Write all status codes to the tracker. + multi_asset_tracker.WriteStage1(); + if (tracker.HasFailuresInStage1()) { + // Failures were added during validation, write all stage two codes. + multi_asset_tracker.WriteStage2(); + if (!recorded_multi_asset_failure) { + tracker.RecordFailure(FailureStatusCode::kAssertionMultiAssetHashMismatch, + {.url = assertion_uri}); + } + } else { + tracker.RecordSuccess(SuccessStatusCode::kAssertionMultiAssetHashMatch, + {.url = assertion_uri}); + } + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/bindings/validator.h b/bindings/validator.h new file mode 100644 index 0000000..8579e6a --- /dev/null +++ b/bindings/validator.h
@@ -0,0 +1,136 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_BINDINGS_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_BINDINGS_VALIDATOR_H_ + +#include <cstdint> +#include <memory> +#include <optional> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/core_registry.h" +#include "formats/format.h" +#include "formats/registry.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/multi_asset_hash_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "utils/status_tracker.h" +#include "utils/two_stage_status_tracker.h" + +namespace credentio { + +// ContentBindingValidator validates that the hard binding assertions in a +// manifest match the structure and data of the associated asset binary content. +class ContentBindingValidator { + public: + ContentBindingValidator() : format_registry_(CreateCoreFormatRegistry()) {} + ~ContentBindingValidator() = default; + + // Validates the hard binding assertions in the given partial validation + // result and returns a full validation result. + absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate( + riegeli::Reader& contents, const Format& format, + std::unique_ptr<PartialValidationResultProto> partial_validation_result) + const; + + protected: + struct Structure { + ByteRange asset; + std::optional<ByteRange> manifest_store_location; + std::vector<AssetBox> boxes; + + bool operator==(const Structure& other) const { + return asset == other.asset && + manifest_store_location == other.manifest_store_location && + boxes == other.boxes; + } + bool operator!=(const Structure& other) const { return !(*this == other); } + }; + + absl::StatusOr<std::vector<ContentBindingValidator::Structure>> GetStructure( + riegeli::Reader& contents, const Format& format) const; + + private: + // A FunctionRef that retrieves an assertion by its absolute JUMBF URI. + using AssertionFetcherRef = + absl::FunctionRef<const Assertion* absl_nullable(absl::string_view uri)>; + + std::unique_ptr<FormatRegistry> format_registry_; + + absl::StatusOr<int64_t> PopulateStructureFromBoxes( + std::vector<AssetBox> boxes, + ContentBindingValidator::Structure& structure) const; + + absl::StatusOr<Structure> ExtractAssetStructure(riegeli::Reader& contents, + const Format* format, + int64_t end_offset) const; + + absl::Status ValidateAssertion( + riegeli::Reader& contents, AssertionFetcherRef assertion_fetcher, + absl::string_view assertion_uri, + std::vector<ContentBindingValidator::Structure> asset_structure, + bool requires_c2pa, TwoStageStatusTracker& tracker, + bool assertion_in_ingredient_manifest, int64_t end_offset = -1) const; + + absl::Status ValidateDataHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const DataHashAssertion& assertion, + std::optional<ByteRange> manifest_store_location, bool requires_c2pa, + StatusTracker& tracker, bool assertion_in_ingredient_manifest, + int64_t end_offset = -1) const; + + absl::Status ValidateCollectionDataHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const CollectionDataHashAssertion& assertion, bool requires_c2pa, + StatusTracker& tracker) const; + + absl::Status ValidateBoxesHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const BoxesHashAssertion& assertion, + std::vector<ContentBindingValidator::Structure> remaining_structure, + bool requires_c2pa, StatusTracker& tracker, + int64_t end_offset = -1) const; + + absl::Status ValidateBmffHash(riegeli::Reader& contents, + absl::string_view assertion_uri, + const BmffBasedHashAssertion& assertion, + bool requires_c2pa, + StatusTracker& tracker) const; + + absl::Status ValidateMultiAssetHash( + riegeli::Reader& contents, absl::string_view assertion_uri, + const MultiAssetHashAssertion& assertion, + std::vector<ContentBindingValidator::Structure> asset_structure, + bool requires_c2pa, AssertionFetcherRef assertion_fetcher, + TwoStageStatusTracker& tracker, + bool assertion_in_ingredient_manifest) const; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_BINDINGS_VALIDATOR_H_
diff --git a/cbor/BUILD b/cbor/BUILD new file mode 100644 index 0000000..144ef83 --- /dev/null +++ b/cbor/BUILD
@@ -0,0 +1,120 @@ +# 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. +# + +load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library") +load("@protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "tags_proto", + srcs = ["tags.proto"], + deps = ["@protobuf//:descriptor_proto"], +) + +cc_proto_library( + name = "tags_cc_proto", + deps = [":tags_proto"], +) + +cc_library( + name = "cbor", + srcs = ["cbor.cc"], + hdrs = [ + "cbor.h", + "options.h", + ], + deps = [ + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@libcppbor", + ], +) + +cc_test( + name = "cbor_test", + srcs = ["cbor_test.cc"], + deps = [ + ":cbor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + "@libcppbor", + ], +) + +cc_library( + name = "cbor_generator", + srcs = ["cbor_generator.cc"], + hdrs = ["cbor_generator.h"], + deps = [ + ":tags_cc_proto", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/strings", + "@protobuf", + "@protobuf//:protoc_lib", + ], +) + +cc_binary( + name = "cbor_generator_main", + srcs = ["cbor_generator_main.cc"], + deps = [ + ":cbor_generator", + "@protobuf//:protoc_lib", + ], +) + +cc_library( + name = "parse", + srcs = ["parse.cc"], + hdrs = ["parse.h"], + deps = [ + ":cbor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@libcppbor", + ], +) + +cc_test( + name = "parse_test", + srcs = ["parse_test.cc"], + deps = [ + ":cbor", + ":parse", + "//testing:cbor_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@libcppbor", + ], +)
diff --git a/cbor/cbor.cc b/cbor/cbor.cc new file mode 100644 index 0000000..e247ff3 --- /dev/null +++ b/cbor/cbor.cc
@@ -0,0 +1,419 @@ +// 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 "cbor/cbor.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <string_view> + +#include "absl/base/attributes.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "cppbor/cppbor.h" + +namespace cbor { + +ItemView::Type ItemView::type() const { + switch (item_->type()) { + case cppbor::SIMPLE: + switch (item_->asSimple()->simpleType()) { + case cppbor::BOOLEAN: + return Type::kBool; + case cppbor::FLOAT: + return Type::kFloat; + case cppbor::DOUBLE: + return Type::kDouble; + default: + return Type::kUnknown; + } + case cppbor::TSTR: + return Type::kString; + case cppbor::BSTR: + return Type::kByteString; + case cppbor::NINT: + return Type::kInt64; + case cppbor::UINT: + return Type::kUint64; + case cppbor::MAP: + return Type::kMap; + case cppbor::ARRAY: + return Type::kArray; + default: + return Type::kUnknown; + } +} + +bool ItemView::IsNull() const { + if (const auto* s = item_->asSimple(); s != nullptr) { + if (const auto* v = s->asNull(); v != nullptr) { + return true; + } + } + return false; +} + +absl::StatusOr<bool> ItemView::GetBool() const { + if (const auto* s = item_->asSimple(); s != nullptr) { + if (const auto* v = s->asBool(); v != nullptr) { + return v->value(); + } + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not a bool. Type: ", item_->type())); +} + +absl::StatusOr<float> ItemView::GetFloat() const { + if (const auto* s = item_->asSimple(); s != nullptr) { + if (const auto* v = s->asFloat(); v != nullptr) { + return v->value(); + } + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not a float. Type: ", item_->type())); +} + +absl::StatusOr<double> ItemView::GetDouble() const { + if (const auto* s = item_->asSimple(); s != nullptr) { + if (const auto* v = s->asDouble(); v != nullptr) { + return v->value(); + } + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not a double. Type: ", item_->type())); +} + +absl::StatusOr<int64_t> ItemView::GetInt64() const { + if (const auto* v = item_->asInt(); v != nullptr) { + return v->value(); + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not an int. Type: ", item_->type())); +} + +absl::StatusOr<uint64_t> ItemView::GetUint64() const { + if (const auto* v = item_->asUint(); v != nullptr) { + return v->value(); + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not an uint. Type: ", item_->type())); +} + +absl::StatusOr<absl::string_view> ItemView::GetString() const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (const auto* v = item_->asTstr(); v != nullptr) { + return v->value(); + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not a string. Type: ", item_->type())); +} + +absl::StatusOr<absl::string_view> ItemView::GetByteString() const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (const auto* v = item_->asBstr(); v != nullptr) { + absl::string_view str(reinterpret_cast<const char*>(v->value().data()), + v->value().size()); + return str; + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not a byte string. Type: ", item_->type())); +} + +absl::StatusOr<MapView> ItemView::GetMap() const ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (const auto* v = item_->asMap(); v != nullptr) { + return MapView(v); + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not a map. Type: ", item_->type())); +} + +absl::StatusOr<ArrayView> ItemView::GetArray() const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (const auto* v = item_->asArray(); v != nullptr) { + return ArrayView(v); + } + return absl::InvalidArgumentError( + absl::StrCat("CBOR item is not an array. Type: ", item_->type())); +} + +std::string ItemView::ToString() const { return item_->toString(); } + +absl::StatusOr<ItemView> MapView::Get(uint32_t key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + const auto& item = map_->get(key); + if (!item) { + return absl::InvalidArgumentError(absl::StrCat("Key not found: ", key)); + } + return ItemView(item.get()); +} + +absl::StatusOr<ItemView> MapView::Get(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + const auto& item = map_->get(std::string_view(key)); + if (!item) { + return absl::InvalidArgumentError(absl::StrCat("Key not found: ", key)); + } + return ItemView(item.get()); +} + +std::optional<ItemView> MapView::GetOptional(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + const auto& item = map_->get(std::string_view(key)); + if (!item) { + return std::nullopt; + } + return ItemView(item.get()); +} + +absl::StatusOr<bool> MapView::GetBool(absl::string_view key) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + return item.GetBool(); +} + +std::optional<bool> MapView::GetOptionalBool(absl::string_view key) const { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetBool(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +absl::StatusOr<float> MapView::GetFloat(absl::string_view key) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + return item.GetFloat(); +} + +std::optional<float> MapView::GetOptionalFloat(absl::string_view key) const { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetFloat(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +absl::StatusOr<double> MapView::GetDouble(absl::string_view key) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + return item.GetDouble(); +} + +std::optional<double> MapView::GetOptionalDouble(absl::string_view key) const { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetDouble(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +absl::StatusOr<absl::string_view> MapView::GetString( + absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + ABSL_ASSIGN_OR_RETURN(auto str, item.GetString()); + return str; +} + +std::optional<absl::string_view> MapView::GetOptionalString( + absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetString(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +absl::StatusOr<absl::string_view> MapView::GetByteString(uint32_t key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + ABSL_ASSIGN_OR_RETURN(auto str, item.GetByteString()); + return str; +} + +absl::StatusOr<absl::string_view> MapView::GetByteString( + absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + ABSL_ASSIGN_OR_RETURN(auto str, item.GetByteString()); + return str; +} + +std::optional<absl::string_view> MapView::GetOptionalByteString( + absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetByteString(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +std::optional<MapView> MapView::GetOptionalMap(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetMap(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +absl::StatusOr<int64_t> MapView::GetInt64(absl::string_view key) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + return item.GetInt64(); +} + +std::optional<int64_t> MapView::GetOptionalInt64(absl::string_view key) const { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetInt64(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +absl::StatusOr<int64_t> MapView::GetInt64(uint32_t key) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + return item.GetInt64(); +} + +absl::StatusOr<uint64_t> MapView::GetUint64(absl::string_view key) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + return item.GetUint64(); +} + +std::optional<int64_t> MapView::GetOptionalUint64(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetUint64(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +absl::StatusOr<MapView> MapView::GetMap(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + ABSL_ASSIGN_OR_RETURN(auto map, item.GetMap()); + return map; +} + +absl::StatusOr<ArrayView> MapView::GetArray(uint32_t key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + ABSL_ASSIGN_OR_RETURN(auto array, item.GetArray()); + return array; +} + +absl::StatusOr<ArrayView> MapView::GetArray(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(key)); + ABSL_ASSIGN_OR_RETURN(auto array, item.GetArray()); + return array; +} + +std::optional<ArrayView> MapView::GetOptionalArray(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (auto item = GetOptional(key); item.has_value()) { + if (auto value = item->GetArray(); value.ok()) { + return *value; + } + } + return std::nullopt; +} + +uint32_t ArrayView::size() const { return array_->size(); } + +ItemView::Type ArrayView::value_type(uint32_t index) const { + if (index >= size()) { + return ItemView::Type::kUnknown; + } + ItemView item(array_->get(index).get()); + return item.type(); +} + +absl::StatusOr<ItemView> ArrayView::Get(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (index >= array_->size()) { + return absl::InvalidArgumentError( + absl::StrCat("Index out of range: ", index, " >= ", array_->size())); + } + return ItemView(array_->get(index).get()); +} + +absl::StatusOr<bool> ArrayView::GetBool(uint32_t index) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + return item.GetBool(); +} + +absl::StatusOr<float> ArrayView::GetFloat(uint32_t index) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + return item.GetFloat(); +} + +absl::StatusOr<double> ArrayView::GetDouble(uint32_t index) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + return item.GetDouble(); +} + +absl::StatusOr<absl::string_view> ArrayView::GetString(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + ABSL_ASSIGN_OR_RETURN(auto str, item.GetString()); + return str; +} + +absl::StatusOr<absl::string_view> ArrayView::GetByteString(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + ABSL_ASSIGN_OR_RETURN(auto str, item.GetByteString()); + return str; +} + +absl::StatusOr<int64_t> ArrayView::GetInt64(uint32_t index) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + return item.GetInt64(); +} + +absl::StatusOr<uint64_t> ArrayView::GetUint64(uint32_t index) const { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + return item.GetUint64(); +} + +absl::StatusOr<MapView> ArrayView::GetMap(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + ABSL_ASSIGN_OR_RETURN(auto map, item.GetMap()); + return map; +} + +absl::StatusOr<ArrayView> ArrayView::GetArray(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + ABSL_ASSIGN_OR_RETURN(auto array, item.GetArray()); + return array; +} + +absl::StatusOr<bool> ArrayView::IsNull(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + ABSL_ASSIGN_OR_RETURN(auto item, Get(index)); + return item.IsNull(); +} +} // namespace cbor
diff --git a/cbor/cbor.h b/cbor/cbor.h new file mode 100644 index 0000000..b66f91f --- /dev/null +++ b/cbor/cbor.h
@@ -0,0 +1,181 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CBOR_CBOR_H_ +#define THIRD_PARTY_CREDENTIO_CBOR_CBOR_H_ + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/base/attributes.h" +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "cppbor/cppbor.h" + +namespace cbor { + +class MapView; +class ArrayView; + +class ItemView { + public: + enum class Type { + kUnknown, + kBool, + kFloat, + kDouble, + kString, + kByteString, + kInt64, + kUint64, + kMap, + kArray, + }; + explicit ItemView( + const cppbor::Item* absl_nonnull item ABSL_ATTRIBUTE_LIFETIME_BOUND) + : item_(ABSL_DIE_IF_NULL(item)) {} + Type type() const; + absl::StatusOr<bool> GetBool() const; + absl::StatusOr<float> GetFloat() const; + absl::StatusOr<double> GetDouble() const; + absl::StatusOr<int64_t> GetInt64() const; + absl::StatusOr<uint64_t> GetUint64() const; + absl::StatusOr<absl::string_view> GetString() const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<absl::string_view> GetByteString() const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<MapView> GetMap() const ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<ArrayView> GetArray() const ABSL_ATTRIBUTE_LIFETIME_BOUND; + // Gets the full encoded representation of this item. + std::string ToString() const; + bool IsNull() const; + + private: + const cppbor::Item* absl_nonnull item_; +}; + +class MapView { + public: + explicit MapView( + const cppbor::Map* absl_nonnull map ABSL_ATTRIBUTE_LIFETIME_BOUND) + : map_(ABSL_DIE_IF_NULL(map)) {} + absl::StatusOr<bool> GetBool(absl::string_view key) const; + std::optional<bool> GetOptionalBool(absl::string_view key) const; + absl::StatusOr<float> GetFloat(absl::string_view key) const; + std::optional<float> GetOptionalFloat(absl::string_view key) const; + absl::StatusOr<double> GetDouble(absl::string_view key) const; + std::optional<double> GetOptionalDouble(absl::string_view key) const; + absl::StatusOr<absl::string_view> GetString(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + // Returns std::nullopt if the key is not found or the value is not a string. + std::optional<absl::string_view> GetOptionalString( + absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<absl::string_view> GetByteString(uint32_t key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<absl::string_view> GetByteString(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + std::optional<absl::string_view> GetOptionalByteString( + absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<int64_t> GetInt64(absl::string_view key) const; + absl::StatusOr<int64_t> GetInt64(uint32_t key) const; + std::optional<int64_t> GetOptionalInt64(absl::string_view key) const; + absl::StatusOr<uint64_t> GetUint64(absl::string_view key) const; + std::optional<int64_t> GetOptionalUint64(absl::string_view key) const; + absl::StatusOr<MapView> GetMap(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + std::optional<MapView> GetOptionalMap(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<ArrayView> GetArray(uint32_t key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<ArrayView> GetArray(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + std::optional<ArrayView> GetOptionalArray(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + // Gets the full encoded representation of this map. + std::string ToString() const { return map_->toString(); } + + private: + absl::StatusOr<ItemView> Get(uint32_t key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<ItemView> Get(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + std::optional<ItemView> GetOptional(absl::string_view key) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + const cppbor::Map* absl_nonnull map_; +}; + +class ArrayView { + public: + explicit ArrayView( + const cppbor::Array* absl_nonnull array ABSL_ATTRIBUTE_LIFETIME_BOUND) + : array_(ABSL_DIE_IF_NULL(array)) {} + uint32_t size() const; + bool empty() const { return size() == 0; } + ItemView::Type value_type(uint32_t index) const; + absl::StatusOr<bool> GetBool(uint32_t index) const; + absl::StatusOr<float> GetFloat(uint32_t index) const; + absl::StatusOr<double> GetDouble(uint32_t index) const; + absl::StatusOr<absl::string_view> GetString(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<absl::string_view> GetByteString(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<int64_t> GetInt64(uint32_t index) const; + absl::StatusOr<uint64_t> GetUint64(uint32_t index) const; + absl::StatusOr<MapView> GetMap(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<ArrayView> GetArray(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + absl::StatusOr<bool> IsNull(uint32_t index) const; + absl::StatusOr<ItemView> Get(uint32_t index) const + ABSL_ATTRIBUTE_LIFETIME_BOUND; + // Gets the full encoded representation of this array. + std::string ToString() const { return array_->toString(); } + + private: + const cppbor::Array* absl_nonnull array_; +}; + +class ParseResult { + public: + explicit ParseResult(std::unique_ptr<cppbor::Item> item) + : item_(std::move(item)) {} + absl::StatusOr<ArrayView> AsArray() const ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (!item_->asArray()) { + return absl::InvalidArgumentError("CBOR parsed result is not an array"); + } + return ArrayView(item_->asArray()); + } + absl::StatusOr<MapView> AsMap() const ABSL_ATTRIBUTE_LIFETIME_BOUND { + if (!item_->asMap()) { + return absl::InvalidArgumentError("CBOR parsed result is not a map"); + } + return MapView(item_->asMap()); + } + // Gets the full encoded representation of this item. + std::string ToString() const { return item_->toString(); } + + private: + std::unique_ptr<cppbor::Item> item_; +}; + +} // namespace cbor + +#endif // THIRD_PARTY_CREDENTIO_CBOR_CBOR_H_
diff --git a/cbor/cbor_codegen.bzl b/cbor/cbor_codegen.bzl new file mode 100644 index 0000000..d6c8d1c --- /dev/null +++ b/cbor/cbor_codegen.bzl
@@ -0,0 +1,170 @@ +# 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. +# + +"""Starlark build rules for CBOR code generation.""" + +load("@protobuf//bazel/common:proto_info.bzl", "ProtoInfo") +load("@rules_cc//cc:defs.bzl", "cc_library") + +_PROTOC = "@protobuf//:protoc" +_PLUGIN = "//cbor:cbor_generator_main" +_CLANG_FORMAT = "@protobuf//:protoc" + +def cc_cbor_proto_library( + *, + name, + srcs, + deps = [], + **kwargs): + """Generates C++ CBOR code from a proto_library. + + Args: + name: name of the generated cc_library + srcs: proto_library rules that wrap the *.proto files; must contain exactly one entry + deps: cc_proto_library rules that wrap the proto_library above + **kwargs: extra attributes for the generated cc_library + """ + if len(srcs) != 1: + fail("srcs must contain exactly one entry", attr = "srcs") + + proto_rule_name = srcs[0] + if proto_rule_name.startswith(":"): + proto_rule_name = proto_rule_name[1:] + + proto_rule = native.existing_rule(proto_rule_name) + if not proto_rule: + fail("'%s' not found. Define it before cbor_proto_library." % proto_rule_name) + + if proto_rule["kind"] != "proto_library": + fail("src '%s' has kind '%s', expecting 'proto_library'." % (proto_rule_name, proto_rule["kind"])) + + proto_srcs = list(proto_rule["srcs"]) + + generated_srcs = [src.removesuffix(".proto") + ".cbor.cc" for src in proto_srcs] + generated_hdrs = [src.removesuffix(".proto") + ".cbor.h" for src in proto_srcs] + + # Automatically infer corresponding `_cbor_proto` dependencies for the `proto_library` dependencies + # within the same package. For example, if the proto_library depends on :foo_proto, we check if + # :foo_cbor_proto exists in the same package and add it to ensure transitive header resolution. + auto_deps = [] + current_pkg = "//" + native.package_name() + ":" + for dep in proto_rule.get("deps", []): + target_name = None + if dep.startswith(current_pkg): + target_name = dep.removeprefix(current_pkg) + elif dep.startswith(":"): + target_name = dep[1:] + + if target_name and target_name.endswith("_proto"): + cbor_name = target_name.removesuffix("_proto") + "_cbor_proto" + if native.existing_rule(cbor_name): + auto_deps.append(":" + cbor_name) + + generated_target_name = name + "_gen" + + _generate_cbor_code( + name = generated_target_name, + srcs = srcs, + generated_files = generated_srcs + generated_hdrs, + visibility = ["//visibility:private"], + ) + + cc_library( + name = name, + srcs = generated_srcs, + hdrs = generated_hdrs, + deps = deps + auto_deps + [ + "//cbor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@libcppbor", + ], + **kwargs + ) + +def _generate_cbor_code_impl(ctx): + proto_sources = depset( + [f for dep in ctx.attr.srcs for f in dep[ProtoInfo].direct_sources], + ) + proto_imports = depset( + transitive = [dep[ProtoInfo].transitive_sources for dep in ctx.attr.srcs], + ) + + unique_roots = {} + all_files = depset(transitive = [proto_imports, proto_sources]).to_list() + for f in all_files: + idx = f.path.rfind("google/protobuf/") + if idx != -1: + root = f.path[:idx] + if root: + unique_roots[root] = True + + protoc_paths = ["-I."] + ["-I" + r for r in unique_roots] + ["-I" + f.short_path + "=" + f.path for f in all_files] + + args = ctx.actions.args() + args.add("--plugin=protoc-gen-cbor=" + ctx.executable._plugin.path) + args.add_all(protoc_paths) + args.add("--cbor_out", ctx.bin_dir.path) + args.add_all(proto_sources) + args.set_param_file_format("multiline") + args.use_param_file(param_file_arg = "@%s") + + output_paths = [f.path for f in ctx.outputs.generated_files] + outputs_str = " ".join(output_paths) + + command = "protoc_path=$1; clang_format_path=$2; shift 2; \"$protoc_path\" \"$@\"" + + ctx.actions.run_shell( + arguments = [ctx.executable._proto_compiler.path, ctx.executable._clang_format.path, args], + command = command, + inputs = depset(transitive = [proto_sources, proto_imports]), + mnemonic = "GenerateCborCode", + outputs = ctx.outputs.generated_files, + progress_message = "Generating and formatting CBOR code for %{label}", + tools = depset([ + ctx.executable._proto_compiler, + ctx.executable._plugin, + ctx.executable._clang_format, + ]), + ) + return [DefaultInfo(files = depset(ctx.outputs.generated_files))] + +_generate_cbor_code = rule( + attrs = { + "srcs": attr.label_list( + allow_empty = False, + mandatory = True, + providers = [ProtoInfo], + ), + "_proto_compiler": attr.label( + default = Label(_PROTOC), + executable = True, + cfg = "exec", + ), + "_plugin": attr.label( + default = Label(_PLUGIN), + executable = True, + cfg = "exec", + ), + "_clang_format": attr.label( + default = Label(_CLANG_FORMAT), + executable = True, + cfg = "exec", + ), + "generated_files": attr.output_list(), + }, + implementation = _generate_cbor_code_impl, +)
diff --git a/cbor/cbor_generator.cc b/cbor/cbor_generator.cc new file mode 100644 index 0000000..2faba71 --- /dev/null +++ b/cbor/cbor_generator.cc
@@ -0,0 +1,574 @@ +// 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 "cbor/cbor_generator.h" + +#include <memory> +#include <string> + +#include "absl/base/no_destructor.h" +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/strings/ascii.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_replace.h" +#include "absl/strings/string_view.h" +#include "cbor/tags.pb.h" +#include "google/protobuf/compiler/code_generator.h" +#include "google/protobuf/descriptor.h" +#include "google/protobuf/io/printer.h" +#include "google/protobuf/io/zero_copy_stream.h" + +namespace cbor { +namespace { + +std::string QualifiedCppName(const google::protobuf::Descriptor* descriptor) { + return absl::StrReplaceAll(descriptor->full_name(), {{".", "::"}}); +} + +absl::string_view StripProtoExtension(absl::string_view filename) { + if (absl::EndsWith(filename, ".proto")) { + return filename.substr(0, filename.size() - 6); + } + return filename; +} + +const cbor::Tags& GetTags(const google::protobuf::FieldDescriptor* field) { + if (field->options().HasExtension(cbor::tags)) { + return field->options().GetExtension(cbor::tags); + } + static const absl::NoDestructor<cbor::Tags> default_tags; + return *default_tags; +} + +std::string GetCborKey(const google::protobuf::FieldDescriptor* field) { + const auto& tags = GetTags(field); + if (!tags.name().empty()) { + return std::string(tags.name()); + } + return std::string(field->name()); +} + +const absl::flat_hash_map<google::protobuf::FieldDescriptor::Type, + absl::string_view>& +GetProtoTypeToCborType() { + static const absl::NoDestructor<absl::flat_hash_map< + google::protobuf::FieldDescriptor::Type, absl::string_view>> + proto_type_to_cbor_type({ + {google::protobuf::FieldDescriptor::TYPE_MESSAGE, "Map"}, + {google::protobuf::FieldDescriptor::TYPE_STRING, "String"}, + {google::protobuf::FieldDescriptor::TYPE_UINT64, "Uint64"}, + {google::protobuf::FieldDescriptor::TYPE_INT64, "Int64"}, + {google::protobuf::FieldDescriptor::TYPE_UINT32, "Uint64"}, + {google::protobuf::FieldDescriptor::TYPE_INT32, "Int64"}, + {google::protobuf::FieldDescriptor::TYPE_BOOL, "Bool"}, + {google::protobuf::FieldDescriptor::TYPE_FLOAT, "Float"}, + {google::protobuf::FieldDescriptor::TYPE_DOUBLE, "Double"}, + {google::protobuf::FieldDescriptor::TYPE_BYTES, "ByteString"}, + }); + return *proto_type_to_cbor_type; +} + +absl::string_view GetCborTypeName( + const google::protobuf::FieldDescriptor* field) { + const auto& tags = GetTags(field); + if (tags.type() != cbor::Tags::TYPE_UNSPECIFIED) { + switch (tags.type()) { + case cbor::Tags::TYPE_UINT: + return "Uint64"; + case cbor::Tags::TYPE_NINT: + return "Int64"; + case cbor::Tags::TYPE_BYTE_STRING: + return "ByteString"; + case cbor::Tags::TYPE_TEXT_STRING: + return "String"; + default: + break; + } + } + auto it = GetProtoTypeToCborType().find(field->type()); + if (it != GetProtoTypeToCborType().end()) { + return it->second; + } + return ""; +} + +void GenerateMessageHeader(const google::protobuf::Descriptor* desc, + google::protobuf::io::Printer& printer) { + printer.Emit({{"name", QualifiedCppName(desc)}}, R"cc( + absl::Status ToProto(const cbor::MapView& map, $name$* proto); + absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto(const $name$& proto); + absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto( + const $name$& proto, FromProtoOptions options); + )cc"); + for (int i = 0; i < desc->nested_type_count(); ++i) { + GenerateMessageHeader(desc->nested_type(i), printer); + } +} + +void GenerateToProtoBody(const google::protobuf::Descriptor* desc, + google::protobuf::io::Printer& printer) { + printer.Emit({{"name", QualifiedCppName(desc)}}, R"cc( + absl::Status ToProto(const cbor::MapView& map, $name$* proto) { + )cc"); + { + auto indent = printer.WithIndent(); + for (int i = 0; i < desc->field_count(); ++i) { + const auto* field = desc->field(i); + const auto& tags = GetTags(field); + if (tags.mode() == cbor::Tags::MODE_WRITE_ONLY || + tags.mode() == cbor::Tags::MODE_SKIP) { + continue; + } + + std::string key = GetCborKey(field); + printer.Emit(R"cc( + { + )cc"); + { + auto scope_indent = printer.WithIndent(); + if (field->is_repeated()) { + if (field->type() == + google::protobuf::FieldDescriptor::TYPE_MESSAGE) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto array_view = map.GetOptionalArray("$key$"); + )cc"); + if (tags.is_required()) { + printer.Emit({{"key", key}}, R"cc( + if (!array_view.has_value()) { + return absl::InvalidArgumentError( + "Missing required field: " + "$key$"); + } + )cc"); + } + if (tags.disallow_empty()) { + printer.Emit({{"key", key}}, R"cc( + if (array_view.has_value() && array_view->size() == 0) { + return absl::InvalidArgumentError( + "Field $key$ cannot be " + "empty"); + } + )cc"); + } + printer.Emit({{"field", field->name()}}, R"cc( + if (array_view.has_value()) { + for (uint32_t i = 0; i < array_view->size(); ++i) { + ABSL_ASSIGN_OR_RETURN(auto item, array_view->GetMap(i)); + ABSL_RETURN_IF_ERROR(ToProto(std::move(item), proto->add_$field$())); + } + } + )cc"); + } else if (!GetCborTypeName(field).empty()) { + absl::string_view type = GetCborTypeName(field); + printer.Emit( + {{"field", field->name()}, {"key", key}, {"type", type}}, R"cc( + auto array_view = map.GetOptionalArray("$key$"); + )cc"); + if (tags.is_required()) { + printer.Emit({{"key", key}}, R"cc( + if (!array_view.has_value()) { + return absl::InvalidArgumentError( + "Missing required field: " + "$key$"); + } + )cc"); + } + if (tags.disallow_empty()) { + printer.Emit({{"key", key}}, R"cc( + if (array_view.has_value() && array_view->size() == 0) { + return absl::InvalidArgumentError( + "Field $key$ cannot be " + "empty"); + } + )cc"); + } + printer.Emit({{"field", field->name()}, {"type", type}}, R"cc( + if (array_view.has_value()) { + for (uint32_t i = 0; i < array_view->size(); ++i) { + ABSL_ASSIGN_OR_RETURN(auto item, array_view->Get$type$(i)); + proto->add_$field$(std::move(item)); + } + } + )cc"); + } + } else { + if (field->type() == + google::protobuf::FieldDescriptor::TYPE_MESSAGE) { + if (tags.is_required()) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto map_view = map.GetOptionalMap("$key$"); + if (!map_view.has_value()) { + return absl::InvalidArgumentError( + "Missing required field: " + "$key$"); + } + ABSL_RETURN_IF_ERROR(ToProto(*map_view, proto->mutable_$field$())); + )cc"); + } else { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto map_view = map.GetOptionalMap("$key$"); + if (map_view.has_value()) { + ABSL_RETURN_IF_ERROR(ToProto(*map_view, proto->mutable_$field$())); + } + )cc"); + } + } else if (!GetCborTypeName(field).empty()) { + absl::string_view type = GetCborTypeName(field); + if (tags.is_required()) { + printer.Emit( + {{"field", field->name()}, {"key", key}, {"type", type}}, + R"cc( + auto val = map.GetOptional$type$("$key$"); + if (!val.has_value()) { + return absl::InvalidArgumentError( + "Missing required " + "field: $key$"); + } + proto->set_$field$(*val); + )cc"); + } else { + printer.Emit( + {{"field", field->name()}, {"key", key}, {"type", type}}, + R"cc( + auto val = map.GetOptional$type$("$key$"); + if (val.has_value()) { + proto->set_$field$(*val); + } + )cc"); + } + } + } + } + printer.Emit(R"cc( + } + )cc"); + } + printer.Emit(R"cc( + return absl::OkStatus(); + )cc"); + } + printer.Emit(R"cc( + } + )cc"); + for (int i = 0; i < desc->nested_type_count(); ++i) { + GenerateToProtoBody(desc->nested_type(i), printer); + } +} + +void GenerateFromProtoBody(const google::protobuf::Descriptor* desc, + google::protobuf::io::Printer& printer) { + printer.Emit({{"name", QualifiedCppName(desc)}}, R"cc( + absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto( + const $name$& proto) { + return FromProto(proto, FromProtoOptions{}); + } + + absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto( + const $name$& proto, FromProtoOptions options) { + auto map = std::make_unique<cppbor::Map>(); + )cc"); + { + auto indent = printer.WithIndent(); + for (int i = 0; i < desc->field_count(); ++i) { + const auto* field = desc->field(i); + const auto& tags = GetTags(field); + + if (tags.mode() == cbor::Tags::MODE_READ_ONLY || + tags.mode() == cbor::Tags::MODE_SKIP) { + continue; + } + + std::string key = GetCborKey(field); + if (field->is_repeated()) { + if (tags.is_required() && tags.disallow_empty()) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (!options.skip_validation && proto.$field$_size() == 0) { + return absl::InvalidArgumentError("Field $key$ cannot be empty"); + } + )cc"); + } + if (tags.is_required()) { + printer.Emit(R"cc( + { + )cc"); + } else { + printer.Emit({{"field", field->name()}}, R"cc( + if (proto.$field$_size() > 0) { + )cc"); + } + + if (field->type() == google::protobuf::FieldDescriptor::TYPE_MESSAGE) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto array = std::make_unique<cppbor::Array>(); + for (int i = 0; i < proto.$field$_size(); ++i) { + ABSL_ASSIGN_OR_RETURN(auto item, FromProto(proto.$field$(i))); + array->add(std::move(item)); + } + map->add("$key$", std::move(array)); + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_BYTES) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto array = std::make_unique<cppbor::Array>(); + for (int i = 0; i < proto.$field$_size(); ++i) { + array->add(cppbor::Bstr(std::string(proto.$field$(i)))); + } + map->add("$key$", std::move(array)); + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_STRING) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto array = std::make_unique<cppbor::Array>(); + for (int i = 0; i < proto.$field$_size(); ++i) { + array->add(cppbor::Tstr(std::string(proto.$field$(i)))); + } + map->add("$key$", std::move(array)); + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_FLOAT) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto array = std::make_unique<cppbor::Array>(); + for (int i = 0; i < proto.$field$_size(); ++i) { + array->add(cppbor::Float(proto.$field$(i))); + } + map->add("$key$", std::move(array)); + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_DOUBLE) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto array = std::make_unique<cppbor::Array>(); + for (int i = 0; i < proto.$field$_size(); ++i) { + array->add(cppbor::Double(proto.$field$(i))); + } + map->add("$key$", std::move(array)); + )cc"); + } else { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + auto array = std::make_unique<cppbor::Array>(); + for (int i = 0; i < proto.$field$_size(); ++i) { + array->add(proto.$field$(i)); + } + map->add("$key$", std::move(array)); + )cc"); + } + + printer.Emit(R"cc( + } + )cc"); + } else { + if (tags.is_required()) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (!options.skip_validation && !proto.has_$field$()) { + return absl::InvalidArgumentError( + "Missing required field: " + "$key$"); + } + )cc"); + } + if (field->type() == google::protobuf::FieldDescriptor::TYPE_MESSAGE) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (proto.has_$field$()) { + ABSL_ASSIGN_OR_RETURN(auto item, FromProto(proto.$field$())); + map->add("$key$", std::move(item)); + } + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_BYTES) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (proto.has_$field$()) { + map->add("$key$", cppbor::Bstr(std::string(proto.$field$()))); + } + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_STRING) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (proto.has_$field$()) { + map->add("$key$", cppbor::Tstr(std::string(proto.$field$()))); + } + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_FLOAT) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (proto.has_$field$()) { + map->add("$key$", cppbor::Float(proto.$field$())); + } + )cc"); + } else if (field->type() == + google::protobuf::FieldDescriptor::TYPE_DOUBLE) { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (proto.has_$field$()) { + map->add("$key$", cppbor::Double(proto.$field$())); + } + )cc"); + } else { + printer.Emit({{"field", field->name()}, {"key", key}}, R"cc( + if (proto.has_$field$()) { + map->add("$key$", proto.$field$()); + } + )cc"); + } + } + } + printer.Emit(R"cc( + return map; + )cc"); + } + printer.Emit(R"cc( + } + )cc"); + for (int i = 0; i < desc->nested_type_count(); ++i) { + GenerateFromProtoBody(desc->nested_type(i), printer); + } +} + +void CollectUsedDeps( + const google::protobuf::Descriptor* descriptor, + const google::protobuf::FileDescriptor* current_file, + absl::flat_hash_set<const google::protobuf::FileDescriptor*>& used_deps) { + for (int i = 0; i < descriptor->field_count(); ++i) { + const auto* f = descriptor->field(i); + if (f->type() == google::protobuf::FieldDescriptor::TYPE_MESSAGE) { + if (GetTags(f).mode() == cbor::Tags::MODE_SKIP) { + continue; + } + const auto* m = f->message_type(); + if (m->file() != current_file) { + used_deps.insert(m->file()); + } + } + } + for (int i = 0; i < descriptor->nested_type_count(); ++i) { + CollectUsedDeps(descriptor->nested_type(i), current_file, used_deps); + } +} + +void GenerateHeader(const google::protobuf::FileDescriptor* file, + absl::string_view base_name, + google::protobuf::compiler::GeneratorContext* context) { + std::unique_ptr<google::protobuf::io::ZeroCopyOutputStream> h_stream( + context->Open(absl::StrCat(base_name, ".cbor.h"))); + google::protobuf::io::Printer h_printer(h_stream.get()); + + std::string guard = absl::AsciiStrToUpper(absl::StrReplaceAll( + absl::StrCat(base_name, "_CBOR_H_"), {{"/", "_"}, {".", "_"}})); + h_printer.Emit({{"guard", guard}}, R"cc( +#ifndef $guard$ +#define $guard$ + )cc"); + + if (file->message_type_count() > 0) { + h_printer.Emit(R"cc( +#include <memory> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "cbor/cbor.h" +#include "cbor/options.h" +#include "cppbor/cppbor.h" + )cc"); + } + + h_printer.Emit({{"base", base_name}}, R"cc( +#include "$base$.pb.h" + )cc"); + + for (int i = 0; i < file->public_dependency_count(); ++i) { + absl::string_view pub_base = + StripProtoExtension(file->public_dependency(i)->name()); + h_printer.Emit({{"pub", pub_base}}, R"cc( +#include "$pub$.cbor.h" // IWYU pragma: export + )cc"); + } + + h_printer.Emit(R"cc( + namespace cbor { + )cc"); + + for (int i = 0; i < file->message_type_count(); ++i) { + GenerateMessageHeader(file->message_type(i), h_printer); + } + + h_printer.Emit({{"guard", guard}}, R"cc( + } // namespace cbor + +#endif // $guard$ + )cc"); +} + +void GenerateCc(const google::protobuf::FileDescriptor* file, + absl::string_view base_name, + google::protobuf::compiler::GeneratorContext* context) { + std::unique_ptr<google::protobuf::io::ZeroCopyOutputStream> cc_stream( + context->Open(absl::StrCat(base_name, ".cbor.cc"))); + google::protobuf::io::Printer cc_printer(cc_stream.get()); + + cc_printer.Emit({{"base", base_name}}, R"cc( +#include "$base$.cbor.h" + )cc"); + + if (file->message_type_count() > 0) { + cc_printer.Emit({{"base", base_name}}, R"cc( +#include "$base$.pb.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "cbor/cbor.h" +#include "cppbor/cppbor.h" + )cc"); + } + + absl::flat_hash_set<const google::protobuf::FileDescriptor*> used_deps; + for (int i = 0; i < file->message_type_count(); ++i) { + CollectUsedDeps(file->message_type(i), file, used_deps); + } + + for (const auto* dep : used_deps) { + absl::string_view dep_base = StripProtoExtension(dep->name()); + cc_printer.Emit({{"dep", dep_base}}, R"cc( +#include "$dep$.cbor.h" + )cc"); + } + + cc_printer.Emit(R"cc( + namespace cbor { + )cc"); + + for (int i = 0; i < file->message_type_count(); ++i) { + GenerateToProtoBody(file->message_type(i), cc_printer); + GenerateFromProtoBody(file->message_type(i), cc_printer); + } + + cc_printer.Emit(R"cc( + } // namespace cbor + )cc"); +} + +} // namespace + +bool CborGenerator::Generate( + const google::protobuf::FileDescriptor* file, const std::string& parameter, + google::protobuf::compiler::GeneratorContext* context, + std::string* error) const { + absl::string_view base_name = StripProtoExtension(file->name()); + + GenerateHeader(file, base_name, context); + GenerateCc(file, base_name, context); + + return true; +} + +} // namespace cbor
diff --git a/cbor/cbor_generator.h b/cbor/cbor_generator.h new file mode 100644 index 0000000..389ab10 --- /dev/null +++ b/cbor/cbor_generator.h
@@ -0,0 +1,51 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CBOR_CBOR_GENERATOR_H_ +#define THIRD_PARTY_CREDENTIO_CBOR_CBOR_GENERATOR_H_ + +#include <cstdint> +#include <string> + +#include "google/protobuf/compiler/code_generator.h" + +namespace cbor { + +class CborGenerator : public google::protobuf::compiler::CodeGenerator { + public: + CborGenerator() = default; + ~CborGenerator() override = default; + + bool Generate(const google::protobuf::FileDescriptor* file, + const std::string& parameter, + google::protobuf::compiler::GeneratorContext* generator_context, + std::string* error) const override; + + uint64_t GetSupportedFeatures() const override { + return FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS; + } + + google::protobuf::Edition GetMinimumEdition() const override { + return google::protobuf::Edition::EDITION_PROTO2; + } + + google::protobuf::Edition GetMaximumEdition() const override { + return google::protobuf::Edition::EDITION_2024; + } +}; + +} // namespace cbor + +#endif // THIRD_PARTY_CREDENTIO_CBOR_CBOR_GENERATOR_H_
diff --git a/cbor/cbor_generator_main.cc b/cbor/cbor_generator_main.cc new file mode 100644 index 0000000..cdeb1a5 --- /dev/null +++ b/cbor/cbor_generator_main.cc
@@ -0,0 +1,22 @@ +// 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 "cbor/cbor_generator.h" +#include "google/protobuf/compiler/plugin.h" + +int main(int argc, char* argv[]) { + cbor::CborGenerator generator; + return google::protobuf::compiler::PluginMain(argc, argv, &generator); +}
diff --git a/cbor/cbor_test.cc b/cbor/cbor_test.cc new file mode 100644 index 0000000..e23aff2 --- /dev/null +++ b/cbor/cbor_test.cc
@@ -0,0 +1,217 @@ +// 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 "cbor/cbor.h" + +#include <memory> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "cppbor/cppbor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace cbor { +namespace { + +using ::testing::Eq; + +TEST(ItemViewTest, BoolType) { + auto item = std::make_unique<cppbor::Bool>(true); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kBool); + auto val = view.GetBool(); + ABSL_ASSERT_OK(val); + EXPECT_TRUE(*val); +} + +TEST(ItemViewTest, FloatType) { + auto item = std::make_unique<cppbor::Float>(1.5f); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kFloat); + auto val = view.GetFloat(); + ABSL_ASSERT_OK(val); + EXPECT_FLOAT_EQ(*val, 1.5f); +} + +TEST(ItemViewTest, DoubleType) { + auto item = std::make_unique<cppbor::Double>(2.5); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kDouble); + auto val = view.GetDouble(); + ABSL_ASSERT_OK(val); + EXPECT_DOUBLE_EQ(*val, 2.5); +} + +TEST(ItemViewTest, Int64Type) { + auto item = std::make_unique<cppbor::Nint>(-42); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kInt64); + auto val = view.GetInt64(); + ABSL_ASSERT_OK(val); + EXPECT_EQ(*val, -42); +} + +TEST(ItemViewTest, Uint64Type) { + auto item = std::make_unique<cppbor::Uint>(42); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kUint64); + auto val = view.GetUint64(); + ABSL_ASSERT_OK(val); + EXPECT_EQ(*val, 42); +} + +TEST(ItemViewTest, StringType) { + auto item = std::make_unique<cppbor::Tstr>("hello"); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kString); + auto val = view.GetString(); + ABSL_ASSERT_OK(val); + EXPECT_EQ(*val, "hello"); +} + +TEST(ItemViewTest, ByteStringType) { + auto item = std::make_unique<cppbor::Bstr>("world"); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kByteString); + auto val = view.GetByteString(); + ABSL_ASSERT_OK(val); + EXPECT_EQ(*val, "world"); +} + +TEST(ItemViewTest, MapType) { + auto item = std::make_unique<cppbor::Map>(); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kMap); + auto val = view.GetMap(); + ABSL_ASSERT_OK(val); +} + +TEST(ItemViewTest, ArrayType) { + auto item = std::make_unique<cppbor::Array>(); + ItemView view(item.get()); + EXPECT_EQ(view.type(), ItemView::Type::kArray); + auto val = view.GetArray(); + ABSL_ASSERT_OK(val); +} + +TEST(ItemViewTest, IsNull) { + auto item = std::make_unique<cppbor::Null>(); + ItemView view(item.get()); + EXPECT_TRUE(view.IsNull()); +} + +TEST(ItemViewTest, Failures) { + auto item = std::make_unique<cppbor::Uint>(42); + ItemView view(item.get()); + + EXPECT_THAT(view.GetBool().status().code(), + Eq(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(view.GetFloat().status().code(), + Eq(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(view.GetDouble().status().code(), + Eq(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(view.GetString().status().code(), + Eq(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(view.GetByteString().status().code(), + Eq(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(view.GetMap().status().code(), + Eq(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(view.GetArray().status().code(), + Eq(absl::StatusCode::kInvalidArgument)); +} + +TEST(MapViewTest, GetMethods) { + auto map = std::make_unique<cppbor::Map>(); + map->add("bool", cppbor::Bool(true)); + map->add("int", cppbor::Uint(42)); + map->add("string", cppbor::Tstr("value")); + map->add(42, cppbor::Uint(24)); + + MapView view(map.get()); + + auto bool_val = view.GetBool("bool"); + ABSL_ASSERT_OK(bool_val); + EXPECT_TRUE(*bool_val); + + auto int_val = view.GetUint64("int"); + ABSL_ASSERT_OK(int_val); + EXPECT_EQ(*int_val, 42); + + auto str_val = view.GetString("string"); + ABSL_ASSERT_OK(str_val); + EXPECT_EQ(*str_val, "value"); + + auto int_key_val = view.GetInt64(42); + ABSL_ASSERT_OK(int_key_val); + EXPECT_EQ(*int_key_val, 24); +} + +TEST(MapViewTest, GetOptional) { + auto map = std::make_unique<cppbor::Map>(); + map->add("bool", cppbor::Bool(true)); + + MapView view(map.get()); + + EXPECT_TRUE(view.GetOptionalBool("bool").has_value()); + EXPECT_TRUE(view.GetOptionalBool("bool").value()); + EXPECT_FALSE(view.GetOptionalBool("nonexistent").has_value()); +} + +TEST(MapViewTest, Failures) { + auto map = std::make_unique<cppbor::Map>(); + MapView view(map.get()); + + EXPECT_THAT(view.GetBool("nonexistent").status().code(), + Eq(absl::StatusCode::kInvalidArgument)); +} + +TEST(ArrayViewTest, GetMethods) { + auto array = std::make_unique<cppbor::Array>(); + array->add(cppbor::Bool(true)); + array->add(cppbor::Uint(42)); + + ArrayView view(array.get()); + + EXPECT_EQ(view.size(), 2); + EXPECT_FALSE(view.empty()); + + auto bool_val = view.GetBool(0); + ABSL_ASSERT_OK(bool_val); + EXPECT_TRUE(*bool_val); + + auto int_val = view.GetUint64(1); + ABSL_ASSERT_OK(int_val); + EXPECT_EQ(*int_val, 42); +} + +TEST(ArrayViewTest, Failures) { + auto array = std::make_unique<cppbor::Array>(); + ArrayView view(array.get()); + + EXPECT_THAT(view.GetBool(0).status().code(), + Eq(absl::StatusCode::kInvalidArgument)); + EXPECT_EQ(view.value_type(0), ItemView::Type::kUnknown); + EXPECT_EQ(view.value_type(1), ItemView::Type::kUnknown); + + array->add(cppbor::Uint(42)); + EXPECT_EQ(view.value_type(0), ItemView::Type::kUint64); + EXPECT_EQ(view.value_type(1), ItemView::Type::kUnknown); +} + +} // namespace +} // namespace cbor
diff --git a/cbor/options.h b/cbor/options.h new file mode 100644 index 0000000..d39b61a --- /dev/null +++ b/cbor/options.h
@@ -0,0 +1,29 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CBOR_OPTIONS_H_ +#define THIRD_PARTY_CREDENTIO_CBOR_OPTIONS_H_ + +namespace cbor { + +struct FromProtoOptions { + // If true, validation checks like is_required and disallow_empty are + // skipped when creating CBOR from Proto. + bool skip_validation = false; +}; + +} // namespace cbor + +#endif // THIRD_PARTY_CREDENTIO_CBOR_OPTIONS_H_
diff --git a/cbor/parse.cc b/cbor/parse.cc new file mode 100644 index 0000000..bb3de64 --- /dev/null +++ b/cbor/parse.cc
@@ -0,0 +1,46 @@ +// 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 "cbor/parse.h" + +#include <cstdint> +#include <memory> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "cbor/cbor.h" +#include "cppbor/cppbor_parse.h" + +namespace cbor { + +absl::StatusOr<std::unique_ptr<ParseResult>> Parse(absl::string_view s) { + const uint8_t* data = reinterpret_cast<const uint8_t*>(s.data()); + const uint8_t* end = data + s.size(); + auto [item, new_position, error] = cppbor::parse(data, end); + if (!error.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("CBOR parsing failed: ", error)); + } + if (new_position != end) { + return absl::InvalidArgumentError( + "Trailing bytes after the parsed CBOR item"); + } + return std::make_unique<ParseResult>(std::move(item)); +} + +} // namespace cbor
diff --git a/cbor/parse.h b/cbor/parse.h new file mode 100644 index 0000000..e5f0001 --- /dev/null +++ b/cbor/parse.h
@@ -0,0 +1,31 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CBOR_PARSE_H_ +#define THIRD_PARTY_CREDENTIO_CBOR_PARSE_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "cbor/cbor.h" + +namespace cbor { + +absl::StatusOr<std::unique_ptr<ParseResult>> Parse(absl::string_view s); + +} // namespace cbor + +#endif // THIRD_PARTY_CREDENTIO_CBOR_PARSE_H_
diff --git a/cbor/parse_test.cc b/cbor/parse_test.cc new file mode 100644 index 0000000..78aafd5 --- /dev/null +++ b/cbor/parse_test.cc
@@ -0,0 +1,495 @@ +// 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 "cbor/parse.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "cbor/cbor.h" +#include "cppbor/cppbor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "testing/cbor_utils.h" + +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::cbor::FromJson; +using ::testing::Eq; +using ::testing::Gt; +using ::testing::HasSubstr; +using ::testing::IsFalse; +using ::testing::IsTrue; + +TEST(ParseTest, Okay) { + auto cbor = FromJson(R"json({ + "str": "Google", + "uint": 0, + "int": -1, + "nest_map": {"a": 1}, + "empty_map": {}, + "uint_array": [1, 2, 3], + "int_array": [-1, -2, -3], + "str_array": ["a", "b", "c"], + "array_of_map": [{"a": 1}, {"a": 2}], + "array_of_array": [[1, 2], [3, 4]], + "empty_array": [], + "null_array": [null] + })json"); + auto value1 = cbor::Parse(cbor); + ASSERT_TRUE(value1.ok()); + auto result = std::move(value1).value(); + auto value2 = result->AsMap(); + ASSERT_TRUE(value2.ok()); + auto map = std::move(value2).value(); + + EXPECT_THAT(map.GetString("str"), IsOkAndHolds("Google")); + EXPECT_THAT(map.GetUint64("uint"), IsOkAndHolds(0)); + EXPECT_THAT(map.GetInt64("int"), IsOkAndHolds(-1)); + + auto value3 = map.GetMap("nest_map"); + ASSERT_TRUE(value3.ok()); + auto nest_map = std::move(value3).value(); + EXPECT_THAT(nest_map.GetInt64("a"), IsOkAndHolds(1)); + + auto value4 = map.GetMap("empty_map"); + ASSERT_TRUE(value4.ok()); + auto empty_map = std::move(value4).value(); + EXPECT_THAT(empty_map.GetInt64("a"), + StatusIs(absl::StatusCode::kInvalidArgument)); + + auto value5 = map.GetArray("uint_array"); + ASSERT_TRUE(value5.ok()); + auto uint_array = std::move(value5).value(); + EXPECT_THAT(uint_array.size(), Eq(3)); + for (int i = 0; i < 3; ++i) { + EXPECT_THAT(uint_array.GetUint64(i), IsOkAndHolds(i + 1)); + } + + auto value6 = map.GetArray("int_array"); + ASSERT_TRUE(value6.ok()); + auto int_array = std::move(value6).value(); + EXPECT_THAT(int_array.size(), Eq(3)); + for (int i = 0; i < 3; ++i) { + EXPECT_THAT(int_array.GetInt64(i), IsOkAndHolds(-1 - i)); + } + + auto value7 = map.GetArray("str_array"); + ASSERT_TRUE(value7.ok()); + auto str_array = std::move(value7).value(); + EXPECT_THAT(str_array.size(), Eq(3)); + EXPECT_THAT(str_array.empty(), IsFalse()); + for (int i = 0; i < 3; ++i) { + EXPECT_THAT(str_array.GetString(i), IsOkAndHolds(std::string(1, 'a' + i))); + } + + auto value8 = map.GetArray("array_of_map"); + ASSERT_TRUE(value8.ok()); + auto array_of_map = std::move(value8).value(); + EXPECT_THAT(array_of_map.size(), Eq(2)); + auto value9 = array_of_map.GetMap(0); + ASSERT_TRUE(value9.ok()); + auto array_of_map_0 = std::move(value9).value(); + EXPECT_THAT(array_of_map_0.GetInt64("a"), IsOkAndHolds(1)); + auto value10 = array_of_map.GetMap(1); + ASSERT_TRUE(value10.ok()); + auto array_of_map_1 = std::move(value10).value(); + EXPECT_THAT(array_of_map_1.GetInt64("a"), IsOkAndHolds(2)); + + auto value11 = map.GetArray("array_of_array"); + ASSERT_TRUE(value11.ok()); + auto array_of_array = std::move(value11).value(); + EXPECT_THAT(array_of_array.size(), Eq(2)); + auto value12 = array_of_array.GetArray(0); + ASSERT_TRUE(value12.ok()); + auto array_of_array_0 = std::move(value12).value(); + EXPECT_THAT(array_of_array_0.size(), Eq(2)); + EXPECT_THAT(array_of_array_0.GetUint64(0), IsOkAndHolds(1)); + EXPECT_THAT(array_of_array_0.GetUint64(1), IsOkAndHolds(2)); + auto value13 = array_of_array.GetArray(1); + ASSERT_TRUE(value13.ok()); + auto array_of_array_1 = std::move(value13).value(); + EXPECT_THAT(array_of_array_1.size(), Eq(2)); + EXPECT_THAT(array_of_array_1.GetUint64(0), IsOkAndHolds(3)); + EXPECT_THAT(array_of_array_1.GetUint64(1), IsOkAndHolds(4)); + + auto value14 = map.GetArray("empty_array"); + ASSERT_TRUE(value14.ok()); + auto empty_array = std::move(value14).value(); + EXPECT_THAT(empty_array.size(), Eq(0)); + EXPECT_THAT(empty_array.empty(), IsTrue()); + + auto value15 = map.GetArray("null_array"); + ASSERT_TRUE(value15.ok()); + auto null_array = std::move(value15).value(); + EXPECT_THAT(null_array.size(), Eq(1)); + EXPECT_THAT(null_array.IsNull(0), IsOkAndHolds(IsTrue())); + EXPECT_THAT(str_array.IsNull(0), IsOkAndHolds(IsFalse())); +} + +TEST(ParseTest, SemanticTag) { + cppbor::Map orig_map; + orig_map.add("when", + cppbor::SemanticTag(0, cppbor::Tstr("2026-05-19T00:00:00Z"))); + orig_map.add("epoch", cppbor::SemanticTag(1, cppbor::Uint(1684454400))); + auto bytes = orig_map.encode(); + absl::string_view bytes_view(reinterpret_cast<const char*>(bytes.data()), + bytes.size()); + auto value = cbor::Parse(bytes_view); + ASSERT_TRUE(value.ok()); + auto result = std::move(value).value(); + auto map_or = result->AsMap(); + ASSERT_TRUE(map_or.ok()); + auto map = std::move(map_or).value(); + EXPECT_THAT(map.GetString("when"), IsOkAndHolds("2026-05-19T00:00:00Z")); + EXPECT_THAT(map.GetUint64("epoch"), IsOkAndHolds(1684454400)); +} + +TEST(ParseTest, OptionalString) { + auto cbor = FromJson(R"json({"str": "Google", "int": 0})json"); + auto value16 = cbor::Parse(cbor); + ASSERT_TRUE(value16.ok()); + auto result = std::move(value16).value(); + auto value17 = result->AsMap(); + ASSERT_TRUE(value17.ok()); + auto map = std::move(value17).value(); + EXPECT_THAT(map.GetOptionalString("str"), Eq("Google")); + EXPECT_THAT(map.GetOptionalString("not_found"), Eq(std::nullopt)); + EXPECT_THAT(map.GetOptionalString("int"), Eq(std::nullopt)); +} + +TEST(ParseTest, OptionalMap) { + auto cbor = FromJson( + R"json({"not_map": "Google", "map": {"a": 1}, "empty_map": {}})json"); + auto value18 = cbor::Parse(cbor); + ASSERT_TRUE(value18.ok()); + auto result = std::move(value18).value(); + auto value19 = result->AsMap(); + ASSERT_TRUE(value19.ok()); + auto map = std::move(value19).value(); + EXPECT_THAT(map.GetOptionalMap("not_map"), Eq(std::nullopt)); + EXPECT_THAT(map.GetOptionalMap("not_found"), Eq(std::nullopt)); + EXPECT_TRUE(map.GetOptionalMap("map").has_value()); + EXPECT_TRUE(map.GetOptionalMap("empty_map").has_value()); +} + +TEST(ParseTest, OptionalArray) { + auto cbor = FromJson( + R"json({"not_array": "Google", "array": [1, 2], "empty_array": []})json"); + auto value20 = cbor::Parse(cbor); + ASSERT_TRUE(value20.ok()); + auto result = std::move(value20).value(); + auto value21 = result->AsMap(); + ASSERT_TRUE(value21.ok()); + auto map = std::move(value21).value(); + EXPECT_THAT(map.GetOptionalArray("not_array"), Eq(std::nullopt)); + EXPECT_THAT(map.GetOptionalArray("not_found"), Eq(std::nullopt)); + EXPECT_TRUE(map.GetOptionalArray("array").has_value()); + EXPECT_TRUE(map.GetOptionalArray("empty_array").has_value()); +} + +TEST(ParseTest, OptionalUint64) { + auto cbor = FromJson(R"json({"not_uint": "Google", "uint": 1})json"); + auto value22 = cbor::Parse(cbor); + ASSERT_TRUE(value22.ok()); + auto result = std::move(value22).value(); + auto value23 = result->AsMap(); + ASSERT_TRUE(value23.ok()); + auto map = std::move(value23).value(); + EXPECT_THAT(map.GetOptionalUint64("not_uint"), Eq(std::nullopt)); + EXPECT_THAT(map.GetOptionalUint64("not_found"), Eq(std::nullopt)); + EXPECT_THAT(map.GetOptionalUint64("uint").value(), Eq(1)); +} + +TEST(ParseTest, UintMapKeys) { + // FromJson doesn't support creating integer-keyed maps -- would need + // special support like the "b64'blah" bytestring hack because JSON doesn't + // support integer map keys. So we'll create the map directly using cppbor. + cppbor::Map orig_map; + orig_map.add(cppbor::Uint(0), cppbor::Bstr("zero")); + orig_map.add(cppbor::Uint(3), cppbor::Nint(-333)); + orig_map.add(cppbor::Uint(10), + cppbor::Array(cppbor::Tstr("a"), cppbor::Nint(-12))); + auto bytes = orig_map.encode(); + absl::string_view bytes_view(reinterpret_cast<char*>(bytes.data()), + bytes.size()); + auto value24 = cbor::Parse(bytes_view); + ASSERT_TRUE(value24.ok()); + auto result = std::move(value24).value(); + auto value25 = result->AsMap(); + ASSERT_TRUE(value25.ok()); + auto map = std::move(value25).value(); + EXPECT_THAT(map.GetByteString(0), IsOkAndHolds("zero")); + EXPECT_THAT(map.GetInt64(3), IsOkAndHolds(-333)); + auto value26 = map.GetArray(10); + ASSERT_TRUE(value26.ok()); + auto arr = std::move(value26).value(); + EXPECT_THAT(arr.size(), Eq(2)); + EXPECT_THAT(arr.GetString(0), IsOkAndHolds("a")); + EXPECT_THAT(arr.GetInt64(1), IsOkAndHolds(-12)); +} + +TEST(ParseTest, ErrorResultNotAMap) { + auto cbor = FromJson(R"json([1, 2, 3])json"); + auto value27 = cbor::Parse(cbor); + ASSERT_TRUE(value27.ok()); + auto result = std::move(value27).value(); + EXPECT_THAT(result->AsMap(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR parsed result is not a map"))); +} + +TEST(ParseTest, ErrorResultNotAnArray) { + auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); + auto value28 = cbor::Parse(cbor); + ASSERT_TRUE(value28.ok()); + auto result = std::move(value28).value(); + EXPECT_THAT(result->AsArray(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR parsed result is not an array"))); +} + +TEST(ParseTest, ErrorMapGetStringNotAString) { + auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); + auto value29 = cbor::Parse(cbor); + ASSERT_TRUE(value29.ok()); + auto result = std::move(value29).value(); + auto value30 = result->AsMap(); + ASSERT_TRUE(value30.ok()); + auto map = std::move(value30).value(); + EXPECT_THAT(map.GetString("schema"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR item is not a string"))); +} + +TEST(ParseTest, ErrorMapGetInt64NotAnInt) { + auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); + auto value31 = cbor::Parse(cbor); + ASSERT_TRUE(value31.ok()); + auto result = std::move(value31).value(); + auto value32 = result->AsMap(); + ASSERT_TRUE(value32.ok()); + auto map = std::move(value32).value(); + EXPECT_THAT(map.GetInt64("vendor"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR item is not an int"))); +} + +TEST(ParseTest, ErrorMapGetUint64NotAUint) { + auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); + auto value33 = cbor::Parse(cbor); + ASSERT_TRUE(value33.ok()); + auto result = std::move(value33).value(); + auto value34 = result->AsMap(); + ASSERT_TRUE(value34.ok()); + auto map = std::move(value34).value(); + EXPECT_THAT(map.GetUint64("vendor"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR item is not an uint"))); +} + +TEST(ParseTest, ErrorNotAUintInArray) { + auto cbor = FromJson(R"json(["vendor", "Google"])json"); + auto value35 = cbor::Parse(cbor); + ASSERT_TRUE(value35.ok()); + auto result = std::move(value35).value(); + auto value36 = result->AsArray(); + ASSERT_TRUE(value36.ok()); + auto array = std::move(value36).value(); + EXPECT_THAT(array.GetUint64(0), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR item is not an uint"))); +} + +TEST(ParseTest, ErrorNotAMapInArray) { + auto cbor = FromJson(R"json([{"vendor": "Google"}, "schema"])json"); + auto value37 = cbor::Parse(cbor); + ASSERT_TRUE(value37.ok()); + auto result = std::move(value37).value(); + auto value38 = result->AsArray(); + ASSERT_TRUE(value38.ok()); + auto array = std::move(value38).value(); + EXPECT_THAT(array.GetMap(1), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR item is not a map"))); +} + +TEST(ParseTest, ErrorKeyNotFound) { + auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); + auto value39 = cbor::Parse(cbor); + ASSERT_TRUE(value39.ok()); + auto result = std::move(value39).value(); + auto value40 = result->AsMap(); + ASSERT_TRUE(value40.ok()); + auto map = std::move(value40).value(); + EXPECT_THAT( + map.GetString("not_found"), + StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("Key not found"))); +} + +TEST(ParseTest, DuplicateKey) { + std::string cbor; + // This is a CBOR map {"color": "red", "color": "green"}. + ASSERT_TRUE(absl::HexStringToBytes( + "a265636f6c6f726372656465636f6c6f7265677265656e", &cbor)); + auto value41 = cbor::Parse(cbor); + ASSERT_TRUE(value41.ok()); + auto result = std::move(value41).value(); + auto value42 = result->AsMap(); + ASSERT_TRUE(value42.ok()); + auto map = std::move(value42).value(); + EXPECT_THAT(map.GetString("color"), "red"); +} + +TEST(ParseTest, ErrorIndexOutOfRange) { + auto cbor = FromJson(R"json([0, 1, 2])json"); + auto value43 = cbor::Parse(cbor); + ASSERT_TRUE(value43.ok()); + auto result = std::move(value43).value(); + auto value44 = result->AsArray(); + ASSERT_TRUE(value44.ok()); + auto array = std::move(value44).value(); + EXPECT_THAT(array.GetUint64(3), StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Index out of range"))); +} + +TEST(ParseTest, ErrorFailedToParse) { + auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); + EXPECT_THAT(cbor::Parse(cbor.substr(0, 10)), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("CBOR parsing failed"))); +} + +TEST(ParseTest, ErrorTrailingBytes) { + auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json"); + EXPECT_THAT(cbor::Parse(cbor + "trailing bytes"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Trailing bytes"))); +} + +void CheckMap(const cbor::MapView& map, absl::string_view str_key, + uint32_t int_key); + +void CheckArray(const cbor::ArrayView& array, absl::string_view str_key, + uint32_t int_key) { + if (array.empty()) { + ASSERT_THAT(array.size(), Eq(0)); + ASSERT_THAT(array.value_type(0), Eq(cbor::ItemView::Type::kUnknown)); + } else { + ASSERT_THAT(array.size(), Gt(0)); + } + for (auto i = 0; i < array.size(); ++i) { + SCOPED_TRACE(absl::StrCat(i)); + switch (array.value_type(i)) { + case cbor::ItemView::Type::kUnknown: + EXPECT_THAT(array.GetBool(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetFloat(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetDouble(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetString(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetByteString(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetInt64(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetUint64(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetMap(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(array.GetArray(i), + StatusIs(absl::StatusCode::kInvalidArgument)); + break; + case cbor::ItemView::Type::kBool: + EXPECT_THAT(array.GetBool(i), StatusIs(absl::StatusCode::kOk)); + break; + case cbor::ItemView::Type::kFloat: + EXPECT_THAT(array.GetFloat(i), StatusIs(absl::StatusCode::kOk)); + break; + case cbor::ItemView::Type::kDouble: + EXPECT_THAT(array.GetDouble(i), StatusIs(absl::StatusCode::kOk)); + break; + case cbor::ItemView::Type::kString: { + auto value45 = array.GetString(i); + ASSERT_TRUE(value45.ok()); + absl::string_view str = std::move(value45).value(); + std::string force_copy(str); + break; + } + case cbor::ItemView::Type::kByteString: { + auto value46 = array.GetByteString(i); + ASSERT_TRUE(value46.ok()); + absl::string_view str = std::move(value46).value(); + std::string force_copy(str); + break; + } + case cbor::ItemView::Type::kInt64: + EXPECT_THAT(array.GetInt64(i), StatusIs(absl::StatusCode::kOk)); + break; + case cbor::ItemView::Type::kUint64: + EXPECT_THAT(array.GetUint64(i), StatusIs(absl::StatusCode::kOk)); + break; + case cbor::ItemView::Type::kMap: { + auto value47 = array.GetMap(i); + ASSERT_TRUE(value47.ok()); + auto child = std::move(value47).value(); + CheckMap(child, str_key, int_key); + break; + } + case cbor::ItemView::Type::kArray: { + auto value48 = array.GetArray(i); + ASSERT_TRUE(value48.ok()); + auto child = std::move(value48).value(); + CheckArray(child, str_key, int_key); + break; + } + } + } +} + +void CheckMap(const cbor::MapView& map, absl::string_view str_key, + uint32_t int_key) { + { + // Bool: + const auto status_bool = map.GetBool(str_key); + const auto opt_bool = map.GetOptionalBool(str_key); + ASSERT_THAT(status_bool.ok(), Eq(opt_bool.has_value())); + if (status_bool.ok()) { + EXPECT_THAT(*status_bool, Eq(*opt_bool)); + } + } + { + // String: + const auto status_str = map.GetString(str_key); + const auto opt_str = map.GetOptionalString(str_key); + ASSERT_THAT(status_str.ok(), Eq(opt_str.has_value())); + if (status_str.ok()) { + EXPECT_THAT(*status_str, Eq(*opt_str)); + } + } +} + +} // namespace
diff --git a/cbor/tags.proto b/cbor/tags.proto new file mode 100644 index 0000000..2d62f00 --- /dev/null +++ b/cbor/tags.proto
@@ -0,0 +1,72 @@ +// 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. +// + +edition = "2024"; + +package cbor; + +import "google/protobuf/descriptor.proto"; + +// Options for mapping fields from CBOR (CDDL) to Protobuf. +message Tags { + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_UINT = 1; + TYPE_NINT = 2; + TYPE_BYTE_STRING = 3; + TYPE_TEXT_STRING = 4; + TYPE_ARRAY = 5; + TYPE_MAP = 6; + TYPE_TAG = 7; + TYPE_SIMPLE = 8; + } + + enum Mode { + // Field is read from CBOR and written to CBOR (default). + MODE_UNSPECIFIED = 0; + + // Field is only read from CBOR (ignored when writing). + MODE_READ_ONLY = 1; + + // Field is only written to CBOR (ignored when parsing). + MODE_WRITE_ONLY = 2; + + // Field is ignored in both reading and writing. + MODE_SKIP = 3; + } + + // The name of the field in the CBOR (CDDL) spec. + // If not set, the field name is same as the proto field name. + string name = 1; + + // If set, the field is required. + bool is_required = 2; + + // If set, the field cannot be empty. + // Should only be used for repeated fields. + bool disallow_empty = 3; + + // The CBOR type of the field. + // If not set, the type is inferred from the Protobuf type. + Type type = 4; + + // The access mode of the field. + // If not set, the default is read and write (MODE_UNSPECIFIED). + Mode mode = 5; +} + +extend google.protobuf.FieldOptions { + Tags tags = 8953939; +}
diff --git a/cbor/test/BUILD b/cbor/test/BUILD new file mode 100644 index 0000000..1ff1ff3 --- /dev/null +++ b/cbor/test/BUILD
@@ -0,0 +1,123 @@ +# 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. +# + +load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library") +load("@protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") +load("//cbor:cbor_codegen.bzl", "cc_cbor_proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "external_proto", + srcs = ["external.proto"], +) + +proto_library( + name = "external_skip_new_proto", + srcs = ["external_skip_new.proto"], +) + +cc_proto_library( + name = "external_skip_new_cc_proto", + deps = [":external_skip_new_proto"], +) + +proto_library( + name = "external_skip_proto", + srcs = ["external_skip.proto"], + exports = [":external_skip_new_proto"], + deps = [ + ":external_skip_new_proto", + ], +) + +cc_proto_library( + name = "external_skip_cc_proto", + deps = [":external_skip_proto"], +) + +cc_proto_library( + name = "external_cc_proto", + deps = [":external_proto"], +) + +cc_cbor_proto_library( + name = "external_cbor_proto", + srcs = [":external_proto"], + deps = [":external_cc_proto"], +) + +proto_library( + name = "migrated_proto", + srcs = ["migrated.proto"], + exports = ["//cbor/test/subdir:imported_public_proto"], + deps = ["//cbor/test/subdir:imported_public_proto"], +) + +cc_proto_library( + name = "migrated_cc_proto", + deps = [":migrated_proto"], +) + +cc_cbor_proto_library( + name = "migrated_cbor_proto", + srcs = [":migrated_proto"], + deps = [ + ":migrated_cc_proto", + "//cbor/test/subdir:imported_public_cbor_proto", + ], +) + +proto_library( + name = "test_proto", + srcs = ["test.proto"], + deps = [ + ":external_proto", + ":external_skip_proto", + "//cbor:tags_proto", + "@protobuf//:timestamp_proto", + ], +) + +cc_proto_library( + name = "test_cc_proto", + deps = [":test_proto"], +) + +cc_cbor_proto_library( + name = "test_cbor_proto", + srcs = [":test_proto"], + deps = [ + ":test_cc_proto", + ], +) + +cc_test( + name = "cbor_generator_test", + srcs = ["cbor_generator_test.cc"], + deps = [ + ":external_cc_proto", + ":migrated_cbor_proto", + ":test_cbor_proto", + ":test_cc_proto", + "//cbor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@libcppbor", + ], +)
diff --git a/cbor/test/cbor_generator_test.cc b/cbor/test/cbor_generator_test.cc new file mode 100644 index 0000000..1779716 --- /dev/null +++ b/cbor/test/cbor_generator_test.cc
@@ -0,0 +1,409 @@ +// 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 <memory> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "cbor/cbor.h" +#include "cbor/options.h" +#include "cbor/test/external.pb.h" +#include "cbor/test/migrated.cbor.h" // IWYU pragma: keep +#include "cbor/test/test.cbor.h" +#include "cbor/test/test.pb.h" +#include "cppbor/cppbor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace cbor_test { +namespace { + +using ::testing::Eq; + +TEST(CborGeneratorTest, RoundTrip) { + TestMessage msg; + msg.set_name("hello"); + msg.set_version(1); + + // Serialize to CBOR map + auto map_or = cbor::FromProto(msg); + ABSL_ASSERT_OK(map_or); + + auto map = std::move(map_or.value()); + ASSERT_NE(map, nullptr); + + // Deserialize from CBOR map + TestMessage parsed_msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &parsed_msg); + ABSL_ASSERT_OK(status); + + EXPECT_EQ(parsed_msg.name(), "hello"); + EXPECT_EQ(parsed_msg.version(), 1); +} + +TEST(CborGeneratorTest, ComplexRoundTrip) { + ComplexMessage msg; + msg.set_int32_field(10); + msg.set_int64_field(20); + msg.set_uint32_field(30); + msg.set_uint64_field(40); + msg.set_string_field("test_string"); + msg.set_bytes_field("test_bytes"); + msg.set_bool_field(true); + msg.set_float_field(1.5f); + msg.set_double_field(2.5); + + msg.add_repeated_int32(100); + msg.add_repeated_int32(200); + msg.add_repeated_string("item1"); + msg.add_repeated_string("item2"); + + msg.mutable_nested()->set_value("nested_val"); + msg.add_repeated_nested()->set_value("rn1"); + msg.add_repeated_nested()->set_value("rn2"); + + msg.add_repeated_bytes("b1"); + msg.add_repeated_bytes("b2"); + + msg.add_repeated_string_array("s1"); + msg.add_repeated_string_array("s2"); + + msg.add_repeated_int64(2000000000L); + msg.add_repeated_int64(3000000000L); + + msg.add_repeated_uint32(300); + msg.add_repeated_uint32(400); + + msg.add_repeated_uint64(4000000000L); + msg.add_repeated_uint64(5000000000L); + + msg.add_repeated_bool(true); + msg.add_repeated_bool(false); + + msg.add_repeated_float(1.5f); + msg.add_repeated_float(2.5f); + + msg.add_repeated_double(3.5); + msg.add_repeated_double(4.5); + + msg.set_read_only_field("ro"); + msg.set_write_only_field("wo"); + msg.set_skip_field("skip"); + msg.mutable_external_field()->set_external_value("ext_val"); + + // Serialize to CBOR map + auto map_or = cbor::FromProto(msg); + ABSL_ASSERT_OK(map_or); + + auto map = std::move(map_or.value()); + ASSERT_NE(map, nullptr); + + // Deserialize from CBOR map + ComplexMessage parsed_msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &parsed_msg); + ABSL_ASSERT_OK(status); + + EXPECT_EQ(parsed_msg.int32_field(), 10); + EXPECT_EQ(parsed_msg.int64_field(), 20); + EXPECT_EQ(parsed_msg.uint32_field(), 30); + EXPECT_EQ(parsed_msg.uint64_field(), 40); + EXPECT_EQ(parsed_msg.string_field(), "test_string"); + EXPECT_EQ(parsed_msg.bytes_field(), "test_bytes"); + EXPECT_EQ(parsed_msg.bool_field(), true); + EXPECT_FLOAT_EQ(parsed_msg.float_field(), 1.5f); + EXPECT_DOUBLE_EQ(parsed_msg.double_field(), 2.5); + + ASSERT_EQ(parsed_msg.repeated_int32_size(), 2); + EXPECT_EQ(parsed_msg.repeated_int32(0), 100); + EXPECT_EQ(parsed_msg.repeated_int32(1), 200); + + ASSERT_EQ(parsed_msg.repeated_string_size(), 2); + EXPECT_EQ(parsed_msg.repeated_string(0), "item1"); + EXPECT_EQ(parsed_msg.repeated_string(1), "item2"); + + EXPECT_EQ(parsed_msg.nested().value(), "nested_val"); + + ASSERT_EQ(parsed_msg.repeated_nested_size(), 2); + EXPECT_EQ(parsed_msg.repeated_nested(0).value(), "rn1"); + EXPECT_EQ(parsed_msg.repeated_nested(1).value(), "rn2"); + + ASSERT_EQ(parsed_msg.repeated_bytes_size(), 2); + EXPECT_EQ(parsed_msg.repeated_bytes(0), "b1"); + EXPECT_EQ(parsed_msg.repeated_bytes(1), "b2"); + + ASSERT_EQ(parsed_msg.repeated_string_array_size(), 2); + EXPECT_EQ(parsed_msg.repeated_string_array(0), "s1"); + EXPECT_EQ(parsed_msg.repeated_string_array(1), "s2"); + + ASSERT_EQ(parsed_msg.repeated_int64_size(), 2); + EXPECT_EQ(parsed_msg.repeated_int64(0), 2000000000L); + EXPECT_EQ(parsed_msg.repeated_int64(1), 3000000000L); + + ASSERT_EQ(parsed_msg.repeated_uint32_size(), 2); + EXPECT_EQ(parsed_msg.repeated_uint32(0), 300); + EXPECT_EQ(parsed_msg.repeated_uint32(1), 400); + + ASSERT_EQ(parsed_msg.repeated_uint64_size(), 2); + EXPECT_EQ(parsed_msg.repeated_uint64(0), 4000000000L); + EXPECT_EQ(parsed_msg.repeated_uint64(1), 5000000000L); + + ASSERT_EQ(parsed_msg.repeated_bool_size(), 2); + EXPECT_EQ(parsed_msg.repeated_bool(0), true); + EXPECT_EQ(parsed_msg.repeated_bool(1), false); + + ASSERT_EQ(parsed_msg.repeated_float_size(), 2); + EXPECT_FLOAT_EQ(parsed_msg.repeated_float(0), 1.5f); + EXPECT_FLOAT_EQ(parsed_msg.repeated_float(1), 2.5f); + + ASSERT_EQ(parsed_msg.repeated_double_size(), 2); + EXPECT_DOUBLE_EQ(parsed_msg.repeated_double(0), 3.5); + EXPECT_DOUBLE_EQ(parsed_msg.repeated_double(1), 4.5); + + EXPECT_EQ(parsed_msg.read_only_field(), ""); + EXPECT_EQ(parsed_msg.write_only_field(), ""); + EXPECT_EQ(parsed_msg.skip_field(), ""); + EXPECT_EQ(parsed_msg.external_field().external_value(), "ext_val"); +} + +TEST(CborGeneratorTest, RequiredFieldMissing) { + auto map = std::make_unique<cppbor::Map>(); + + RequiredMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument)); +} + +TEST(CborGeneratorTest, RequiredFieldPresent) { + auto map = std::make_unique<cppbor::Map>(); + map->add("RequiredField", cppbor::Tstr("value")); + + RequiredMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + ABSL_EXPECT_OK(status); + EXPECT_EQ(msg.required_field(), "value"); +} + +TEST(CborGeneratorTest, RequiredRepeatedFieldEmpty) { + RequiredRepeatedMessage msg; + + auto map_or = cbor::FromProto(msg); + ABSL_ASSERT_OK(map_or); + + auto map = std::move(map_or.value()); + ASSERT_NE(map, nullptr); + + cbor::MapView map_view(map.get()); + auto array_view = map_view.GetOptionalArray("RequiredRepeatedField"); + ASSERT_TRUE(array_view.has_value()); + EXPECT_EQ(array_view->size(), 0); +} + +TEST(CborGeneratorTest, RequiredDisallowEmptyPresent) { + auto map = std::make_unique<cppbor::Map>(); + auto array = std::make_unique<cppbor::Array>(); + array->add(cppbor::Tstr("item")); + map->add("RequiredNonEmptyList", std::move(array)); + + RequiredDisallowEmptyMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + ABSL_EXPECT_OK(status); + ASSERT_EQ(msg.required_non_empty_list_size(), 1); + EXPECT_EQ(msg.required_non_empty_list(0), "item"); +} + +TEST(CborGeneratorTest, RequiredDisallowEmptyEmpty) { + auto map = std::make_unique<cppbor::Map>(); + auto array = std::make_unique<cppbor::Array>(); + map->add("RequiredNonEmptyList", std::move(array)); + + RequiredDisallowEmptyMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument)); +} + +TEST(CborGeneratorTest, RequiredDisallowEmptyMissing) { + auto map = std::make_unique<cppbor::Map>(); + + RequiredDisallowEmptyMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument)); +} + +TEST(CborGeneratorTest, OptionalDisallowEmptyPresent) { + auto map = std::make_unique<cppbor::Map>(); + auto array = std::make_unique<cppbor::Array>(); + array->add(cppbor::Tstr("item")); + map->add("OptionalNonEmptyList", std::move(array)); + + OptionalDisallowEmptyMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + ABSL_EXPECT_OK(status); + ASSERT_EQ(msg.optional_non_empty_list_size(), 1); + EXPECT_EQ(msg.optional_non_empty_list(0), "item"); +} + +TEST(CborGeneratorTest, OptionalDisallowEmptyEmpty) { + auto map = std::make_unique<cppbor::Map>(); + auto array = std::make_unique<cppbor::Array>(); + map->add("OptionalNonEmptyList", std::move(array)); + + OptionalDisallowEmptyMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument)); +} + +TEST(CborGeneratorTest, OptionalDisallowEmptyMissing) { + auto map = std::make_unique<cppbor::Map>(); + + OptionalDisallowEmptyMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + ABSL_EXPECT_OK(status); + // Proto2 does not distinguish between empty and missing, so this is 0. + EXPECT_EQ(msg.optional_non_empty_list_size(), 0); +} + +TEST(CborGeneratorTest, TypeOverrideTextString) { + auto map = std::make_unique<cppbor::Map>(); + map->add("TextAsBytes", cppbor::Tstr("text_value")); + + TypeOverrideMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + ABSL_EXPECT_OK(status); + EXPECT_EQ(msg.text_as_bytes(), "text_value"); +} + +TEST(CborGeneratorTest, TypeOverrideByteString) { + auto map = std::make_unique<cppbor::Map>(); + map->add("BytesAsText", cppbor::Bstr("byte_value")); + + TypeOverrideMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + ABSL_EXPECT_OK(status); + EXPECT_EQ(msg.bytes_as_text(), "byte_value"); +} + +TEST(CborGeneratorTest, TypeOverrideUint) { + auto map = std::make_unique<cppbor::Map>(); + map->add("UintAsInt", cppbor::Uint(42)); + + TypeOverrideMessage msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &msg); + ABSL_EXPECT_OK(status); + EXPECT_EQ(msg.uint_as_int(), 42); +} + +TEST(CborGeneratorTest, SkipValidationMissingRequired) { + RequiredMessage msg; + cbor::FromProtoOptions options; + options.skip_validation = true; + auto map_or = cbor::FromProto(msg, options); + ABSL_EXPECT_OK(map_or); +} + +TEST(CborGeneratorTest, SkipValidationEmptyRequiredDisallowEmpty) { + RequiredDisallowEmptyMessage msg; + cbor::FromProtoOptions options; + options.skip_validation = true; + auto map_or = cbor::FromProto(msg, options); + ABSL_EXPECT_OK(map_or); +} + +TEST(CborGeneratorTest, SkipValidationEmptyOptionalDisallowEmpty) { + // Proto2 does not distinguish between empty and missing, so an empty list in + // the proto should be considered "not present" when converting to cbor. + OptionalDisallowEmptyMessage msg; + cbor::FromProtoOptions options; + options.skip_validation = true; + auto map_or = cbor::FromProto(msg, options); + ABSL_ASSERT_OK(map_or); + auto map = std::move(map_or.value()); + cbor::MapView map_view(map.get()); + EXPECT_FALSE(map_view.GetOptionalArray("OptionalNonEmptyList").has_value()); +} + +TEST(CborGeneratorTest, FromProtoMissingRequired) { + RequiredMessage msg; + auto map_or = cbor::FromProto(msg); + EXPECT_FALSE(map_or.ok()); + EXPECT_EQ(map_or.status().code(), absl::StatusCode::kInvalidArgument); +} + +TEST(CborGeneratorTest, FromProtoEmptyRequiredDisallowEmpty) { + RequiredDisallowEmptyMessage msg; + auto map_or = cbor::FromProto(msg); + EXPECT_FALSE(map_or.ok()); + EXPECT_EQ(map_or.status().code(), absl::StatusCode::kInvalidArgument); +} + +TEST(CborGeneratorTest, FromProtoEmptyOptionalDisallowEmpty) { + OptionalDisallowEmptyMessage msg; + auto map_or = cbor::FromProto(msg); + ABSL_ASSERT_OK(map_or); + auto map = std::move(map_or.value()); + cbor::MapView map_view(map.get()); + EXPECT_FALSE(map_view.GetOptionalArray("OptionalNonEmptyList").has_value()); +} + +TEST(CborGeneratorTest, FromProtoNonEmptyOptionalDisallowEmpty) { + OptionalDisallowEmptyMessage msg; + msg.add_optional_non_empty_list("item"); + + auto map_or = cbor::FromProto(msg); + ABSL_ASSERT_OK(map_or); + auto map = std::move(map_or.value()); + cbor::MapView map_view(map.get()); + auto list_opt = map_view.GetOptionalArray("OptionalNonEmptyList"); + ASSERT_TRUE(list_opt.has_value()); + EXPECT_EQ(list_opt->size(), 1); + auto first_item_or = list_opt->GetString(0); + ABSL_ASSERT_OK(first_item_or); + EXPECT_EQ(first_item_or.value(), "item"); +} + +TEST(CborGeneratorTest, ImportedPublicRoundTrip) { + ImportedPublicMessage msg; + msg.set_public_value("imported_val"); + + auto map_or = cbor::FromProto(msg); + ABSL_ASSERT_OK(map_or); + + auto map = std::move(map_or.value()); + ASSERT_NE(map, nullptr); + + ImportedPublicMessage parsed_msg; + cbor::MapView map_view(map.get()); + auto status = cbor::ToProto(map_view, &parsed_msg); + ABSL_ASSERT_OK(status); + + EXPECT_EQ(parsed_msg.public_value(), "imported_val"); +} + +} // namespace +} // namespace cbor_test
diff --git a/cbor/test/external.proto b/cbor/test/external.proto new file mode 100644 index 0000000..050aacd --- /dev/null +++ b/cbor/test/external.proto
@@ -0,0 +1,22 @@ +// 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. +// + +edition = "2024"; + +package cbor_test; + +message ExternalMessage { + string external_value = 1; +}
diff --git a/cbor/test/external_skip.proto b/cbor/test/external_skip.proto new file mode 100644 index 0000000..89f3b31 --- /dev/null +++ b/cbor/test/external_skip.proto
@@ -0,0 +1,20 @@ +// 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. +// + +edition = "2024"; + +package cbor_test; + +import public "cbor/test/external_skip_new.proto";
diff --git a/cbor/test/external_skip_new.proto b/cbor/test/external_skip_new.proto new file mode 100644 index 0000000..e14cbdf --- /dev/null +++ b/cbor/test/external_skip_new.proto
@@ -0,0 +1,22 @@ +// 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. +// + +edition = "2024"; + +package cbor_test; + +message ExternalSkipMessage { + string skip_value = 1; +}
diff --git a/cbor/test/migrated.proto b/cbor/test/migrated.proto new file mode 100644 index 0000000..ace68ec --- /dev/null +++ b/cbor/test/migrated.proto
@@ -0,0 +1,20 @@ +// 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. +// + +edition = "2024"; + +package cbor_test; + +import public "cbor/test/subdir/imported_public.proto";
diff --git a/cbor/test/subdir/BUILD b/cbor/test/subdir/BUILD new file mode 100644 index 0000000..b561d49 --- /dev/null +++ b/cbor/test/subdir/BUILD
@@ -0,0 +1,36 @@ +# 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. +# + +load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library") +load("@protobuf//bazel:proto_library.bzl", "proto_library") +load("//cbor:cbor_codegen.bzl", "cc_cbor_proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "imported_public_proto", + srcs = ["imported_public.proto"], +) + +cc_proto_library( + name = "imported_public_cc_proto", + deps = [":imported_public_proto"], +) + +cc_cbor_proto_library( + name = "imported_public_cbor_proto", + srcs = [":imported_public_proto"], + deps = [":imported_public_cc_proto"], +)
diff --git a/cbor/test/subdir/imported_public.proto b/cbor/test/subdir/imported_public.proto new file mode 100644 index 0000000..3b79b47 --- /dev/null +++ b/cbor/test/subdir/imported_public.proto
@@ -0,0 +1,22 @@ +// 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. +// + +edition = "2024"; + +package cbor_test; + +message ImportedPublicMessage { + string public_value = 1; +}
diff --git a/cbor/test/test.proto b/cbor/test/test.proto new file mode 100644 index 0000000..ff51b3d --- /dev/null +++ b/cbor/test/test.proto
@@ -0,0 +1,109 @@ +// 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. +// + +edition = "2024"; + +package cbor_test; + +import "cbor/tags.proto"; +import "cbor/test/external.proto"; +import "cbor/test/external_skip.proto"; +import "google/protobuf/timestamp.proto"; + +message TestMessage { + string name = 1 [(cbor.tags) = { name: "Name" }]; + int32 version = 2 [(cbor.tags) = { name: "Version" }]; +} + +message RequiredMessage { + string required_field = 1 + [(cbor.tags) = { name: "RequiredField", is_required: true }]; +} + +message RequiredDisallowEmptyMessage { + repeated string required_non_empty_list = 1 [(cbor.tags) = { + name: "RequiredNonEmptyList", + is_required: true, + disallow_empty: true + }]; +} + +message OptionalDisallowEmptyMessage { + repeated string optional_non_empty_list = 1 + [(cbor.tags) = { name: "OptionalNonEmptyList", disallow_empty: true }]; +} + +message RequiredRepeatedMessage { + repeated string required_repeated_field = 1 + [(cbor.tags) = { name: "RequiredRepeatedField", is_required: true }]; + repeated string not_required_repeated_field = 2 + [(cbor.tags) = { name: "NotRequiredRepeatedField" }]; +} + +message TypeOverrideMessage { + bytes text_as_bytes = 1 + [(cbor.tags) = { name: "TextAsBytes", type: TYPE_TEXT_STRING }]; + string bytes_as_text = 2 + [(cbor.tags) = { name: "BytesAsText", type: TYPE_BYTE_STRING }]; + int32 uint_as_int = 3 [(cbor.tags) = { name: "UintAsInt", type: TYPE_UINT }]; +} + +message NestedMessage { + string value = 1 [(cbor.tags) = { name: "Value" }]; +} + +message ComplexMessage { + int32 int32_field = 1 [(cbor.tags) = { name: "Int32Field" }]; + int64 int64_field = 2 [(cbor.tags) = { name: "Int64Field" }]; + uint32 uint32_field = 3 [(cbor.tags) = { name: "Uint32Field" }]; + uint64 uint64_field = 4 [(cbor.tags) = { name: "Uint64Field" }]; + string string_field = 5 [(cbor.tags) = { name: "StringField" }]; + bytes bytes_field = 6 [(cbor.tags) = { name: "BytesField" }]; + bool bool_field = 7 [(cbor.tags) = { name: "BoolField" }]; + float float_field = 8 [(cbor.tags) = { name: "FloatField" }]; + double double_field = 9 [(cbor.tags) = { name: "DoubleField" }]; + + repeated int32 repeated_int32 = 10 [(cbor.tags) = { name: "RepeatedInt32" }]; + repeated string repeated_string = 11 + [(cbor.tags) = { name: "RepeatedString" }]; + + NestedMessage nested = 12 [(cbor.tags) = { name: "Nested" }]; + repeated NestedMessage repeated_nested = 13 + [(cbor.tags) = { name: "RepeatedNested" }]; + + repeated bytes repeated_bytes = 20 [(cbor.tags) = { name: "RepeatedBytes" }]; + repeated string repeated_string_array = 21 + [(cbor.tags) = { name: "RepeatedStringArray" }]; + repeated int64 repeated_int64 = 22 [(cbor.tags) = { name: "RepeatedInt64" }]; + repeated uint32 repeated_uint32 = 23 + [(cbor.tags) = { name: "RepeatedUint32" }]; + repeated uint64 repeated_uint64 = 24 + [(cbor.tags) = { name: "RepeatedUint64" }]; + repeated bool repeated_bool = 25 [(cbor.tags) = { name: "RepeatedBool" }]; + repeated float repeated_float = 26 [(cbor.tags) = { name: "RepeatedFloat" }]; + repeated double repeated_double = 27 + [(cbor.tags) = { name: "RepeatedDouble" }]; + + string read_only_field = 14 + [(cbor.tags) = { name: "ReadOnly", mode: MODE_READ_ONLY }]; + string write_only_field = 15 + [(cbor.tags) = { name: "WriteOnly", mode: MODE_WRITE_ONLY }]; + string skip_field = 16 [(cbor.tags) = { mode: MODE_SKIP }]; + ExternalMessage external_field = 17 [(cbor.tags) = { name: "ExternalField" }]; + ExternalSkipMessage skip_external_field = 18 + [(cbor.tags) = { mode: MODE_SKIP }]; + google.protobuf.Timestamp timestamp_field = 19 + [(cbor.tags) = { mode: MODE_SKIP }]; +}
diff --git a/claim/BUILD b/claim/BUILD new file mode 100644 index 0000000..82d2821 --- /dev/null +++ b/claim/BUILD
@@ -0,0 +1,125 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "manifest_store_view", + srcs = ["manifest_store_view.cc"], + hdrs = ["manifest_store_view.h"], + deps = [ + "//constants:labels", + "//cose:sig_structure", + "//jumbf:box", + "//jumbf:parse", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "manifest_store_view_test", + srcs = ["manifest_store_view_test.cc"], + deps = [ + ":manifest_store_view", + "//constants:labels", + "//cose:sig_structure", + "//jumbf:box", + "//jumbf:test_utils", + "//testing:jumbf_utils", + "//uuid", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "claim_cbor_encoder", + srcs = ["claim_cbor_encoder.cc"], + hdrs = ["claim_cbor_encoder.h"], + deps = [ + "//cbor", + "//proto:manifest_cbor_proto", + "//proto:manifest_cc_proto", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + ], +) + +cc_library( + name = "validator", + srcs = ["validator.cc"], + hdrs = ["validator.h"], + deps = [ + "//cbor", + "//cbor:parse", + "//constants:labels", + "//constants:status_codes", + "//cose:verifier", + "//jumbf:box", + "//proto:assertion_cc_proto", + "//proto:cose_verification_result_cc_proto", + "//proto:generator_info_cbor_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//uuid", + "//validator:tracker", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + ], +) + +cc_test( + name = "validator_test", + srcs = ["validator_test.cc"], + deps = [ + ":validator", + "//constants:labels", + "//constants:status_codes", + "//jumbf:box_builder", + "//jumbf:constants", + "//jumbf:encode_params", + "//proto:cose_verification_result_cc_proto", + "//proto:manifest_cc_proto", + "//proto:signature_info_cc_proto", + "//testing:cbor_utils", + "//testing:jumbf_utils", + "//testing:mock_cose_verifier", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "//uuid", + "//validator:tracker", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@googletest//:gtest_main", + ], +)
diff --git a/claim/claim_cbor_encoder.cc b/claim/claim_cbor_encoder.cc new file mode 100644 index 0000000..2e1379a --- /dev/null +++ b/claim/claim_cbor_encoder.cc
@@ -0,0 +1,61 @@ +// 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/claim_cbor_encoder.h" + +#include <string> + +#include "absl/base/no_destructor.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "cbor/options.h" +#include "proto/manifest.cbor.h" +#include "proto/manifest.pb.h" + +namespace credentio { +namespace { + +class StandardClaimCborEncoder : public ClaimCborEncoder { + public: + explicit StandardClaimCborEncoder(bool skip_validity_checks_for_test = false) + : options_({.skip_validation = skip_validity_checks_for_test}) {} + ~StandardClaimCborEncoder() override = default; + + absl::StatusOr<std::string> ToCbor(const Claim& claim) const override { + ABSL_ASSIGN_OR_RETURN(auto cbor, cbor::FromProto(claim, options_)); + return cbor->toString(); + } + + private: + cbor::FromProtoOptions options_; +}; + +} // namespace + +const ClaimCborEncoder& GetClaimCborEncoder( + bool skip_validity_checks_for_test) { + if (skip_validity_checks_for_test) { + static const absl::NoDestructor<StandardClaimCborEncoder> encoder_for_test( + /*skip_validity_checks_for_test=*/true); + return *encoder_for_test; + } else { + static const absl::NoDestructor<StandardClaimCborEncoder> encoder( + /*skip_validity_checks_for_test=*/false); + return *encoder; + } +} + +} // namespace credentio
diff --git a/claim/claim_cbor_encoder.h b/claim/claim_cbor_encoder.h new file mode 100644 index 0000000..d54c3f9 --- /dev/null +++ b/claim/claim_cbor_encoder.h
@@ -0,0 +1,39 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_CLAIM_CBOR_ENCODER_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_CLAIM_CBOR_ENCODER_H_ + +#include <string> + +#include "absl/status/statusor.h" +#include "proto/manifest.pb.h" + +namespace credentio { + +// An encoder to serialize a C2PA Claim as CBOR. +class ClaimCborEncoder { + public: + virtual ~ClaimCborEncoder() = default; + + virtual absl::StatusOr<std::string> ToCbor(const Claim& claim) const = 0; +}; + +// Returns a standard singleton ClaimCborEncoder. +const ClaimCborEncoder& GetClaimCborEncoder(bool skip_validity_checks_for_test); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_CLAIM_CBOR_ENCODER_H_
diff --git a/claim/manifest_store_view.cc b/claim/manifest_store_view.cc new file mode 100644 index 0000000..97bde31 --- /dev/null +++ b/claim/manifest_store_view.cc
@@ -0,0 +1,129 @@ +// 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/manifest_store_view.h" + +#include <optional> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "cose/sig_structure.h" +#include "jumbf/box.h" +#include "jumbf/parse.h" + +namespace credentio { +namespace { +bool IsManifestStore(const jumbf::SuperBox& superbox) { + return superbox.description.type_uuid == kManifestStoreUuid && + superbox.description.label == kManifestStoreLabel; +} + +bool IsManifest(const jumbf::SuperBox& superbox) { + return superbox.description.type_uuid == kStandardManifestUuid || + superbox.description.type_uuid == kUpdateManifestUuid || + superbox.description.type_uuid == kTimestampManifestUuid || + superbox.description.type_uuid == kCompressedManifestUuid; +} + +bool IsClaimSignature(const jumbf::SuperBox& superbox) { + return superbox.description.type_uuid == kClaimSignatureUuid && + superbox.description.label == kClaimSignatureLabel; +} +} // namespace + +absl::StatusOr<ManifestStoreView> ManifestStoreView::Create( + absl::string_view manifest_store) { + ABSL_ASSIGN_OR_RETURN( + auto super_box_res, + jumbf::ConsumeSuperBox(&manifest_store, /*recursion_limit=*/4)); + jumbf::SuperBox super_box = std::move(super_box_res); + if (!IsManifestStore(super_box)) { + return absl::InvalidArgumentError("No C2PA manifest store"); + } + return ManifestStoreView(std::move(super_box)); +} + +absl::StatusOr<ManifestView> ManifestStoreView::GetActiveManifest() const { + const jumbf::SuperBox* active_manifest = nullptr; + for (const auto& box : superbox_.contents) { + if (box.Holds<jumbf::SuperBox>()) { + const auto& superbox = box.Get<jumbf::SuperBox>(); + if (IsManifest(superbox)) { + active_manifest = &superbox; + } + } + } + if (active_manifest == nullptr) { + return absl::InvalidArgumentError("No active manifest found"); + } + return ManifestView::Create(*active_manifest); +} + +absl::StatusOr<ManifestView> ManifestView::Create(jumbf::SuperBox superbox) { + if (!IsManifest(superbox)) { + return absl::InvalidArgumentError("Not a manifest box"); + } + if (superbox.description.type_uuid == kCompressedManifestUuid) { + return absl::InvalidArgumentError("Compressed manifests are not supported"); + } + return ManifestView(std::move(superbox)); +} + +absl::StatusOr<ClaimSignatureView> ManifestView::GetClaimSignature() const { + std::optional<jumbf::SuperBox> claim_signature_superbox = std::nullopt; + for (const auto& content : superbox_.contents) { + if (content.Holds<jumbf::SuperBox>() && + IsClaimSignature(content.Get<jumbf::SuperBox>())) { + claim_signature_superbox = content.Get<jumbf::SuperBox>(); + break; + } + } + if (!claim_signature_superbox.has_value()) { + return absl::InvalidArgumentError( + "No claim signature found in the manifest"); + } + return ClaimSignatureView::Create(claim_signature_superbox.value()); +} + +absl::string_view ManifestView::label() const { + return superbox_.description.label.value_or(""); +} + +absl::StatusOr<ClaimSignatureView> ClaimSignatureView::Create( + jumbf::SuperBox superbox) { + if (!IsClaimSignature(superbox)) { + return absl::InvalidArgumentError("Not a claim signature box"); + } + return ClaimSignatureView(std::move(superbox)); +} + +absl::StatusOr<absl::string_view> ClaimSignatureView::GetPayload() const { + if (superbox_.contents.size() != 1 || + !superbox_.contents[0].Holds<jumbf::CborBox>()) { + return absl::InvalidArgumentError("Invalid claim signature box"); + } + return superbox_.contents[0].Get<jumbf::CborBox>().payload; +} + +absl::StatusOr<CoseSign1TaggedStructure> ClaimSignatureView::GetStruct() const { + ABSL_ASSIGN_OR_RETURN(auto payload_res, GetPayload()); + return DecodeCoseSign1TaggedStructure(payload_res); +} + +} // namespace credentio
diff --git a/claim/manifest_store_view.h b/claim/manifest_store_view.h new file mode 100644 index 0000000..15c8c98 --- /dev/null +++ b/claim/manifest_store_view.h
@@ -0,0 +1,67 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_VIEW_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_VIEW_H_ + +#include <utility> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "cose/sig_structure.h" +#include "jumbf/box.h" + +namespace credentio { + +class ClaimSignatureView { + public: + static absl::StatusOr<ClaimSignatureView> Create(jumbf::SuperBox superbox); + absl::StatusOr<absl::string_view> GetPayload() const; + absl::StatusOr<CoseSign1TaggedStructure> GetStruct() const; + jumbf::SuperBox superbox() const { return superbox_; } + + private: + explicit ClaimSignatureView(jumbf::SuperBox superbox) + : superbox_(std::move(superbox)) {} + jumbf::SuperBox superbox_; +}; + +class ManifestView { + public: + static absl::StatusOr<ManifestView> Create(jumbf::SuperBox superbox); + absl::StatusOr<ClaimSignatureView> GetClaimSignature() const; + absl::string_view label() const; + jumbf::SuperBox superbox() const { return superbox_; } + + private: + explicit ManifestView(jumbf::SuperBox superbox) + : superbox_(std::move(superbox)) {} + jumbf::SuperBox superbox_; +}; + +class ManifestStoreView { + public: + static absl::StatusOr<ManifestStoreView> Create( + absl::string_view manifest_store); + absl::StatusOr<ManifestView> GetActiveManifest() const; + jumbf::SuperBox superbox() const { return superbox_; } + + private: + explicit ManifestStoreView(jumbf::SuperBox superbox) : superbox_(superbox) {} + jumbf::SuperBox superbox_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_VIEW_H_
diff --git a/claim/manifest_store_view_test.cc b/claim/manifest_store_view_test.cc new file mode 100644 index 0000000..c8e0b3d --- /dev/null +++ b/claim/manifest_store_view_test.cc
@@ -0,0 +1,249 @@ +// 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/manifest_store_view.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "constants/labels.h" +#include "cose/sig_structure.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/test_utils.h" +#include "testing/jumbf_utils.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(ManifestStoreViewTest, WrongManifestStoreUuid) { + std::string manifest_store = jumbf::EncodeSuperBox( + kClaimUuid, kManifestStoreLabel, {jumbf::EncodeCborBox("")}); + EXPECT_THAT(ManifestStoreView::Create(manifest_store), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No C2PA manifest store"))); +} + +TEST(ManifestStoreViewTest, WrongManifestStoreLabel) { + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, "wrong_label", {jumbf::EncodeCborBox("")}); + EXPECT_THAT(ManifestStoreView::Create(manifest_store), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No C2PA manifest store"))); +} + +TEST(ManifestStoreViewTest, SingleStandardManifest) { + std::string manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest1", {jumbf::EncodeCborBox("")}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + EXPECT_EQ(manifest_view->label(), "manifest1"); +} + +TEST(ManifestStoreViewTest, MultipleManifests) { + std::string manifest1 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest1", {jumbf::EncodeCborBox("")}); + std::string manifest2 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest2", {jumbf::EncodeCborBox("")}); + std::string manifest3 = jumbf::EncodeSuperBox( + kUpdateManifestUuid, "manifest3", {jumbf::EncodeCborBox("")}); + std::string manifest_store = + jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, + {manifest1, manifest2, manifest3}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + EXPECT_EQ(manifest_view->label(), "manifest3"); +} + +TEST(ManifestStoreViewTest, NonManifestBoxesIgnored) { + std::string manifest1 = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest1", {jumbf::EncodeCborBox("")}); + std::string manifest2 = jumbf::EncodeSuperBox( + kUpdateManifestUuid, "manifest2", {jumbf::EncodeCborBox("")}); + std::string other_box1 = + jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1", + {jumbf::EncodeCborBox("")}); + std::string other_box2 = + jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box2", + {jumbf::EncodeCborBox("")}); + std::string manifest_store = + jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, + {manifest1, other_box1, manifest2, other_box2}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + EXPECT_EQ(manifest_view->label(), "manifest2"); +} + +TEST(ManifestStoreViewTest, NoManifests) { + std::string other_box1 = + jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1", + {jumbf::EncodeCborBox("")}); + std::string other_box2 = + jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box2", + {jumbf::EncodeCborBox("")}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {other_box1, other_box2}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + EXPECT_THAT(view->GetActiveManifest(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No active manifest found"))); +} + +TEST(ManifestStoreViewTest, CompressedManifestNotSupported) { + std::string standard_manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "label1", {jumbf::EncodeCborBox("")}); + std::string compressed_manifest = jumbf::EncodeSuperBox( + kCompressedManifestUuid, "label2", {jumbf::EncodeCborBox("")}); + std::string manifest_store = + jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel, + {standard_manifest, compressed_manifest}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + EXPECT_THAT(view->GetActiveManifest(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Compressed manifests are not supported"))); +} + +TEST(ManifestViewTest, NotManifest) { + std::string encoded_box = + jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1", + {jumbf::EncodeCborBox("")}); + jumbf::SuperBox box = ParseSuperBoxOrDie(&encoded_box); + EXPECT_THAT(ManifestView::Create(box), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Not a manifest box"))); +} + +TEST(ManifestViewTest, WrongClaimSignatureUuid) { + std::string claim_signature = jumbf::EncodeSuperBox( + kClaimUuid, kClaimSignatureLabel, {jumbf::EncodeCborBox("")}); + std::string manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest_label", {claim_signature}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + auto result = manifest_view->GetClaimSignature(); + EXPECT_THAT(result.status(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No claim signature found in the manifest"))); +} + +TEST(ManifestViewTest, WrongClaimSignatureLabel) { + std::string claim_signature = jumbf::EncodeSuperBox( + kClaimSignatureUuid, "wrong_label", {jumbf::EncodeCborBox("")}); + std::string manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest_label", {claim_signature}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + auto result = manifest_view->GetClaimSignature(); + EXPECT_THAT(result.status(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No claim signature found in the manifest"))); +} + +TEST(ClaimSignatureViewTest, NotClaimSignature) { + std::string encoded_box = + jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1", + {jumbf::EncodeCborBox("")}); + jumbf::SuperBox box = ParseSuperBoxOrDie(&encoded_box); + EXPECT_THAT(ClaimSignatureView::Create(box), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Not a claim signature box"))); +} + +TEST(ClaimSignatureViewTest, InvalidMultipleClaimSignature) { + std::string claim_signature = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox("abc"), jumbf::EncodeCborBox("def")}); + std::string manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest_label", {claim_signature}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + auto claim_signature_view = manifest_view->GetClaimSignature(); + ASSERT_THAT(claim_signature_view, IsOk()); + EXPECT_THAT(claim_signature_view->GetPayload(), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid claim signature box"))); +} + +TEST(ClaimSignatureViewTest, GetPayloadSuccess) { + std::string claim_signature = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, {jumbf::EncodeCborBox("abc")}); + std::string manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest_label", {claim_signature}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + auto claim_signature_view = manifest_view->GetClaimSignature(); + ASSERT_THAT(claim_signature_view, IsOk()); + EXPECT_THAT(claim_signature_view->GetPayload(), IsOkAndHolds("abc")); +} + +TEST(ClaimSignatureViewTest, GetStructSuccess) { + CoseSign1TaggedStructure cose_sign1{ + .unprotected_header = {.sig_tst2 = + TstContainer{ + .tst_tokens = {{.val = "timestamp"}}}}, + .signature = "abc", + }; + std::string claim_signature = jumbf::EncodeSuperBox( + kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox(EncodeCoseSign1TaggedStructure(cose_sign1))}); + std::string manifest = jumbf::EncodeSuperBox( + kStandardManifestUuid, "manifest_label", {claim_signature}); + std::string manifest_store = jumbf::EncodeSuperBox( + kManifestStoreUuid, kManifestStoreLabel, {manifest}); + auto view = ManifestStoreView::Create(manifest_store); + ASSERT_THAT(view, IsOk()); + auto manifest_view = view->GetActiveManifest(); + ASSERT_THAT(manifest_view, IsOk()); + auto claim_signature_view = manifest_view->GetClaimSignature(); + ASSERT_THAT(claim_signature_view, IsOk()); + EXPECT_THAT(claim_signature_view->GetStruct(), IsOkAndHolds(cose_sign1)); +} + +} // namespace +} // namespace credentio
diff --git a/claim/validator.cc b/claim/validator.cc new file mode 100644 index 0000000..777cd27 --- /dev/null +++ b/claim/validator.cc
@@ -0,0 +1,439 @@ +// 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
diff --git a/claim/validator.h b/claim/validator.h new file mode 100644 index 0000000..fd5d45f --- /dev/null +++ b/claim/validator.h
@@ -0,0 +1,83 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CLAIM_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_CLAIM_VALIDATOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/log/die_if_null.h" +#include "cbor/cbor.h" +#include "cose/verifier.h" +#include "jumbf/box.h" +#include "proto/assertion.pb.h" +#include "proto/manifest.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +class ClaimValidator { + public: + virtual ~ClaimValidator() = default; + // Parses and validates a `Claim` JUMBF box of the provided `Manifest` JUMBF, + // recording status codes in the provided tracker. If the validation is + // successful, returns a `Claim` proto containing the validated claim. + virtual std::optional<Claim> Validate( + const jumbf::SuperBox& manifest, + ValidationTracker& validation_tracker) const = 0; +}; + +class ClaimValidatorImpl : public ClaimValidator { + public: + explicit ClaimValidatorImpl(const CoseVerifier* cose_verifier) + : cose_verifier_(*ABSL_DIE_IF_NULL(cose_verifier)) {} + ~ClaimValidatorImpl() override = default; + // Parses and validates a `Claim` JUMBF box of the provided `Manifest` JUMBF, + // recording status codes in the provided tracker. If the validation is + // successful, returns a `Claim` proto containing the validated claim. + std::optional<Claim> Validate( + const jumbf::SuperBox& manifest, + ValidationTracker& validation_tracker) const override; + + private: + // Holder for created, gathered and redacted assertion URIs inside the claim. + struct AssertionUris { + std::optional<std::string> default_algorithm; + std::optional<cbor::ArrayView> created_assertions; + std::optional<cbor::ArrayView> gathered_assertions; + std::optional<cbor::ArrayView> redacted_assertions; + }; + + struct ValidateAssertionResult { + std::vector<credentio::HashedUri> created_assertions; + std::vector<credentio::HashedUri> gathered_assertions; + std::vector<std::string> redacted_assertions; + }; + + // Extracts hashed assertion URIs from the Claim. Records the C2PA failure + // status codes in the provided tracker. + // + // Returns a set of URI lists for created, gathered and redacted assertions. + ValidateAssertionResult ValidateAssertions( + const AssertionUris& assertion_uris, + ValidationTracker& validation_tracker) const; + const CoseVerifier& cose_verifier_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CLAIM_VALIDATOR_H_
diff --git a/claim/validator_test.cc b/claim/validator_test.cc new file mode 100644 index 0000000..cc948c1 --- /dev/null +++ b/claim/validator_test.cc
@@ -0,0 +1,1106 @@ +// 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 <utility> + +#include "absl/log/check.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "proto/cose_verification_result.pb.h" +#include "proto/manifest.pb.h" +#include "proto/signature_info.pb.h" +#include "testing/cbor_utils.h" +#include "testing/jumbf_utils.h" +#include "testing/mock_cose_verifier.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" +#include "uuid/uuid.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::cbor::FromJson; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::jumbf::SuperBoxBuilder; +using ::testing::Eq; +using ::testing::IsEmpty; +using ::testing::Optional; +using ::testing::Return; +using ::testing::SizeIs; + +struct ClaimBoxOptions { + std::string signature; +}; + +jumbf::BuiltSuperBox CreateClaimBox(const ClaimBoxOptions& options) { + auto cbor = FromJson(absl::Substitute( + R"json({"signature": "$0", "created_assertions": []})json", + options.signature)); + SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{ + .label = absl::Cord(kClaimV2Label)}); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor))); + auto built_box = std::move(builder).Finalize(); + CHECK_OK(built_box); + return *std::move(built_box); +} + +jumbf::BuiltSuperBox CreateClaimBoxFromJson(absl::string_view json) { + auto cbor = FromJson(json); + SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{ + .label = absl::Cord(kClaimV2Label)}); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor))); + absl::StatusOr<jumbf::BuiltSuperBox> built_box = + std::move(builder).Finalize(); + CHECK_OK(built_box); + return *std::move(built_box); +} + +jumbf::BuiltSuperBox CreateOldClaimBoxFromJson(absl::string_view json) { + auto cbor = FromJson(json); + SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{ + .label = absl::Cord(kClaimV1Label)}); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor))); + absl::StatusOr<jumbf::BuiltSuperBox> box = std::move(builder).Finalize(); + CHECK_OK(box); + return *std::move(box); +} + +jumbf::BuiltSuperBox CreateClaimBoxFromCbor(absl::string_view cbor) { + SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{ + .label = absl::Cord(kClaimV2Label)}); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor))); + auto built_box = std::move(builder).Finalize(); + CHECK_OK(built_box); + return *std::move(built_box); +} + +jumbf::BuiltSuperBox CreateClaimBoxWithMultipleContents( + absl::string_view cbor, absl::string_view other_cbor) { + SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{ + .label = absl::Cord(kClaimV2Label)}); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor))); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(other_cbor))); + auto built_box = std::move(builder).Finalize(); + CHECK_OK(built_box); + return *std::move(built_box); +} + +jumbf::BuiltSuperBox CreateClaimSignatureBox( + absl::string_view cose_certificate) { + SuperBoxBuilder builder( + kClaimSignatureUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimSignatureLabel)}); + CHECK_OK( + builder.AddContent(jumbf::kCborBoxType, absl::Cord(cose_certificate))); + absl::StatusOr<jumbf::BuiltSuperBox> built_box = + std::move(builder).Finalize(); + CHECK_OK(built_box); + return *std::move(built_box); +} + +jumbf::BuiltSuperBox CreateClaimSignatureBoxWithMultipleContents( + absl::string_view cose_certificate, + absl::string_view other_cose_certificate) { + SuperBoxBuilder builder( + kClaimSignatureUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimSignatureLabel)}); + CHECK_OK( + builder.AddContent(jumbf::kCborBoxType, absl::Cord(cose_certificate))); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, + absl::Cord(other_cose_certificate))); + auto built_box = std::move(builder).Finalize(); + CHECK_OK(built_box); + return *std::move(built_box); +} + +jumbf::BuiltSuperBox CreateAssertionStoreBox() { + SuperBoxBuilder builder( + kAssertionStoreUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kAssertionStoreLabel)}); + CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord("foo"))); + auto built_box = std::move(builder).Finalize(); + CHECK_OK(built_box); + return *std::move(built_box); +} + +SuperBoxBuilder CreateManifestBuilder() { + return SuperBoxBuilder( + kStandardManifestUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord("c2pa.manifest")}); +} + +std::optional<Claim> ValidateWithBuilder(const ClaimValidatorImpl& validator, + SuperBoxBuilder builder, + ValidationTracker& tracker) { + auto built_super_box = std::move(builder).Finalize(); + CHECK_OK(built_super_box); + std::string manifest_bytes = + std::string(std::move(*built_super_box).AsCord()); + return validator.Validate(ParseSuperBoxOrDie(&manifest_bytes), tracker); +} + +TEST(ValidateClaimTest, ClaimMissing) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimSignatureMissing) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimSignatureMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, BothClaimAndSignatureMissing) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + auto failures = t.GetFailures(); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMissing)); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimSignatureMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, MultipleClaims) { + auto manifest = CreateManifestBuilder(); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk()); + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some other cbor")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMultiple)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimHasMultipleContents) { + auto manifest = CreateManifestBuilder(); + auto claim_box = + CreateClaimBoxWithMultipleContents("some cbor", "some other cbor"); + ASSERT_THAT(manifest.AddChild(std::move(claim_box)), IsOk()); + ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimCborInvalid)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimIsNotCborBox) { + auto manifest = CreateManifestBuilder(); + SuperBoxBuilder claim_box( + kClaimUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimV2Label)}); + SuperBoxBuilder child_box(kClaimSignatureUuid, {}); + ASSERT_THAT(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")), + IsOk()); + ASSERT_THAT(claim_box.AddChild(std::move(child_box)), IsOk()); + ASSERT_THAT(manifest.AddChild(std::move(claim_box)), IsOk()); + ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimCborInvalid)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimSignatureHasMultipleContents) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk()); + auto claim_signature_box = CreateClaimSignatureBoxWithMultipleContents( + "some cbor", "some other cbor"); + ASSERT_THAT(manifest.AddChild(std::move(claim_signature_box)), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimSignatureMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimSignatureIsNotCborBox) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some_cbor")), IsOk()); + SuperBoxBuilder claim_signature_box( + kClaimSignatureUuid, + jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimSignatureLabel)}); + SuperBoxBuilder child_box(kClaimUuid, {}); + ASSERT_THAT(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")), + IsOk()); + ASSERT_THAT(claim_signature_box.AddChild(std::move(child_box)), IsOk()); + ASSERT_THAT(manifest.AddChild(std::move(claim_signature_box)), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimSignatureMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimCborIsNotAMap) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(R"json([1, 2, 3])json")), + IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimCborInvalid)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimHasIncorrectSignature) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild(CreateClaimBox({.signature = "not_valid"})), + IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimSignatureMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, CoseVerificationFails) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild( + CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})), + IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce([](absl::string_view cose_certificate, + absl::string_view claim_cbor) { + CoseVerificationResult result; + result.mutable_validation_status()->mutable_failures()->Add()->set_code( + absl::StrCat(FailureStatusCode::kGeneralError)); + return result; + }); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(result, Eq(std::nullopt)); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kGeneralError)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); +} + +TEST(ValidateClaimTest, CoseVerifierFailsWithoutRecordingStatus) { + auto manifest = CreateManifestBuilder(); + ASSERT_THAT(manifest.AddChild( + CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})), + IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(result, Eq(std::nullopt)); + // Internal error recorded because the mock verifier didn't record one. + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kGoogleInternalError)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); +} + +TEST(ValidateClaimTest, CreatedAssertionIsNotMap) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "signature": "self#jumbf=c2pa.signature", + "created_assertions": [{"url":"self#jumbf=c2pa.some_assertion"}], + "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, AssertionUriIsNotString) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "signature": "self#jumbf=c2pa.signature", + "created_assertions": [{ + "url": 12, + "hash": "b64'abcd'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, HashedUriMissingRequiredFieldInCreated) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "signature": "self#jumbf=c2pa.signature", + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_assertion" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, HashedUriMissingRequiredFieldInGathered) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "signature": "self#jumbf=c2pa.signature", + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_assertion", + "hash": "b64'abcd'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_assertion" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, HashedUriMissingRequiredFieldInRedacted) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "signature": "self#jumbf=c2pa.signature", + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_assertion", + "hash": "b64'abcd'" + }], + "redacted_assertions": [12] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kHashedUriMissing)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimV1ProvidedWithClaimV2Label) { + auto manifest = CreateManifestBuilder(); + // Note use of v1 `assertions`. + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "dc:format": "image/jpeg", + "assertions": [ + {"url": "self#jumbf=c2pa.some_created_assertion", "hash": "b64'aGVsbG8'"}, + {"url": "self#jumbf=c2pa.some_gathered_assertion", "hash": "b64'aGVsbG8'"} + ] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), + IsOk()); // Uses c2pa.claim.v2 label. + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailureStatuses(), + Contains(C2paStatusIs(FailureStatusCode::kClaimMalformed, + "Claim is missing created assertions"))); +} + +TEST(ValidateClaimTest, ClaimV1Unsupported) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "dc:format": "image/jpeg", + "assertions": [ + {"url": "self#jumbf=c2pa.some_created_assertion", "hash": "b64'aGVsbG8'"}, + {"url": "self#jumbf=c2pa.some_gathered_assertion", "hash": "b64'aGVsbG8'"} + ], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kGoogleUnsupportedSpecVersion)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimV2HasNoSignature) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "dc:format": "image/jpeg", + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_created_assertion", + "hash": "b64'aGVsbG8'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + EXPECT_CALL(mock_cose_verifier, Verify) + .WillOnce(Return(CoseVerificationResult())); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMalformed)); + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(result, Eq(std::nullopt)); +} + +TEST(ValidateClaimTest, ClaimV2HasNoInstanceId) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "dc:format": "image/jpeg", + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_created_assertion", + "hash": "b64'aGVsbG8'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMalformed)); + EXPECT_THAT( + result, Optional(EqualsProto(ParseTextProtoOrDie<Claim>(R"pb( + claim_generator_info { + name: "test_generator" + version: "1.0" + icon { url: "some_icon_link" hash: "hello" } + operating_system: "linux" + } + signature_info { cert_serial_number: "1234" issuer: "fake_issuer" } + created_assertions { + url: "self#jumbf=c2pa.some_created_assertion" + algorithm: "" + hash: "hello" + } + gathered_assertions { + url: "self#jumbf=c2pa.some_gathered_assertion" + algorithm: "" + hash: "hello" + } + label: "c2pa.claim.v2" + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + )pb")))); +} + +TEST(ValidateClaimTest, ClaimV2HasNoGeneratorInfo) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "dc:format": "image/jpeg", + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_created_assertion", + "hash": "b64'aGVsbG8'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMalformed)); + EXPECT_THAT( + result, Optional(EqualsProto(ParseTextProtoOrDie<Claim>(R"pb( + instance_id: "1234" + label: "c2pa.claim.v2" + signature_info { cert_serial_number: "1234" issuer: "fake_issuer" } + claim_generator_info {} + created_assertions { + url: "self#jumbf=c2pa.some_created_assertion" + algorithm: "" + hash: "hello" + } + gathered_assertions { + url: "self#jumbf=c2pa.some_gathered_assertion" + algorithm: "" + hash: "hello" + } + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + )pb")))); +} + +TEST(ValidateClaimTest, ClaimV2ProvidedInLegacyBox) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_created_assertion", + "hash": "b64'aGVsbG8'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT( + t.GetFailures(), + ContainsFailure(FailureStatusCode::kGoogleUnsupportedSpecVersion)); +} + +TEST(ValidateClaimTest, ClaimV2WithDcFormat) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "dc:format": "image/jpeg", + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_created_assertion", + "hash": "b64'aGVsbG8'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + // We ignore `dc:format` fields in non-legacy claim boxes. + EXPECT_THAT(t.GetFailures(), IsEmpty()); +} + +TEST(ValidateClaimTest, ClaimV2LegacyAssertionsIgnored) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "assertions": [ + {"url": "self#jumbf=c2pa.some_created_assertion", "hash": "b64'aGVsbG8'"}, + {"url": "self#jumbf=c2pa.some_gathered_assertion", "hash": "b64'aGVsbG8'"} + ], + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_created_assertion", + "hash": "b64'aGVsbG8'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), IsEmpty()); + ASSERT_TRUE(result.has_value()); + EXPECT_THAT(result->created_assertions(), SizeIs(1)); + EXPECT_THAT(result->gathered_assertions(), SizeIs(1)); +} + +TEST(ValidateClaimTest, ClaimV2MissingSignature) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMalformed)); +} + +TEST(ValidateClaimTest, ClaimV2MissingCreatedAssertions) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimMalformed)); +} + +TEST(ValidateClaimTest, ClaimV2Ok) { + auto manifest = CreateManifestBuilder(); + absl::string_view claim_json = R"json({ + "instanceID": "1234", + "signature": "self#jumbf=c2pa.signature", + "signature_info": { + "cert_serial_number": "1234", + "issuer": "fake_issuer" + }, + "claim_generator_info": { + "name": "test_generator", + "version": "1.0", + "icon": { + "url": "some_icon_link", + "hash": "b64'aGVsbG8'" + }, + "operating_system": "linux" + }, + "created_assertions": [{ + "url": "self#jumbf=c2pa.some_created_assertion", + "hash": "b64'aGVsbG8'" + }], + "gathered_assertions": [{ + "url": "self#jumbf=c2pa.some_gathered_assertion", + "hash": "b64'aGVsbG8'" + }], + "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"] + })json"; + ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk()); + ASSERT_THAT( + manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")), + IsOk()); + ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk()); + MockCoseVerifier mock_cose_verifier; + SignatureInfo signature_info; + signature_info.set_issuer("fake_issuer"); + signature_info.set_cert_serial_number("1234"); + CoseVerificationResult verification_result; + *verification_result.mutable_signature_info() = signature_info; + EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result)); + TestValidationTracker t; + ClaimValidatorImpl validator(&mock_cose_verifier); + auto result = + ValidateWithBuilder(validator, std::move(manifest), t.tracker()); + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT( + result, Optional(EqualsProto(ParseTextProtoOrDie<Claim>(R"pb( + instance_id: "1234" + claim_generator_info { + name: "test_generator" + version: "1.0" + icon { url: "some_icon_link" hash: "hello" } + operating_system: "linux" + } + signature_info { cert_serial_number: "1234" issuer: "fake_issuer" } + created_assertions { + url: "self#jumbf=c2pa.some_created_assertion" + algorithm: "" + hash: "hello" + } + gathered_assertions { + url: "self#jumbf=c2pa.some_gathered_assertion" + algorithm: "" + hash: "hello" + } + redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion" + label: "c2pa.claim.v2" + )pb")))); +} + +} // namespace +} // namespace credentio
diff --git a/constants/BUILD b/constants/BUILD new file mode 100644 index 0000000..657d09e --- /dev/null +++ b/constants/BUILD
@@ -0,0 +1,50 @@ +# 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. +# + +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "status_codes", + hdrs = ["status_codes.h"], + deps = ["@abseil-cpp//absl/strings"], +) + +cc_library( + name = "labels", + hdrs = ["labels.h"], + deps = [ + "//jumbf:constants", + "//uuid", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "ingredient_relationships", + hdrs = ["ingredient_relationships.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "spec_version", + hdrs = ["spec_version.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +)
diff --git a/constants/ingredient_relationships.h b/constants/ingredient_relationships.h new file mode 100644 index 0000000..f05de07 --- /dev/null +++ b/constants/ingredient_relationships.h
@@ -0,0 +1,32 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CONSTANTS_INGREDIENT_RELATIONSHIPS_H_ +#define THIRD_PARTY_CREDENTIO_CONSTANTS_INGREDIENT_RELATIONSHIPS_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +// Ingredient relationships see +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_relationship. +inline constexpr absl::string_view kIngredientRelationshipParentOf = "parentOf"; +inline constexpr absl::string_view kIngredientRelationshipComponentOf = + "componentOf"; +inline constexpr absl::string_view kIngredientRelationshipInputTo = "inputTo"; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CONSTANTS_INGREDIENT_RELATIONSHIPS_H_
diff --git a/constants/labels.h b/constants/labels.h new file mode 100644 index 0000000..748d9ae --- /dev/null +++ b/constants/labels.h
@@ -0,0 +1,134 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CONSTANTS_LABELS_H_ +#define THIRD_PARTY_CREDENTIO_CONSTANTS_LABELS_H_ + +#include <cstdint> + +#include "absl/strings/string_view.h" +#include "jumbf/constants.h" +#include "uuid/uuid.h" + +namespace credentio { + +// Delimiter used to separate the various components of a manifest label. +inline constexpr absl::string_view kManifestLabelDelimiter = "/"; + +// Minimum Jumbf description toggles required to support C2PA. See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_labels_2. +inline constexpr uint8_t kMinimumJumbfDescriptionToggles = + jumbf::kDescriptionToggleLabelPresent | + jumbf::kDescriptionToggleRequestable; + +// Box label and UUID of the C2PA Manifest Store box. See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_manifest_store. +inline constexpr absl::string_view kManifestStoreLabel = "c2pa"; +inline constexpr absl::string_view kManifestStoreUrlPrefix = + "self#jumbf=/c2pa/"; +inline constexpr Uuid kManifestStoreUuid( + Uuid::FromStringOrDie("63327061-0011-0010-8000-00AA00389B71")); + +// Box UUIDs of the C2PA Manifest box. See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_manifest_store +inline constexpr Uuid kStandardManifestUuid( + Uuid::FromStringOrDie("63326D61-0011-0010-8000-00AA00389B71")); +inline constexpr Uuid kCompressedManifestUuid( + Uuid::FromStringOrDie("6332636D-0011-0010-8000-00AA00389B71")); +inline constexpr Uuid kUpdateManifestUuid( + Uuid::FromStringOrDie("6332756D-0011-0010-8000-00AA00389B71")); +inline constexpr Uuid kTimestampManifestUuid( + Uuid::FromStringOrDie("6332746D-0011-0010-8000-00AA00389B71")); + +// Box labels and UUID of the C2PA Claim box. +// The deprecated (v1) format shares the same UUID as the current (v2) format. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_claim_and_claim_signature. +inline constexpr absl::string_view kClaimV1Label = "c2pa.claim"; +inline constexpr absl::string_view kClaimV2Label = "c2pa.claim.v2"; +inline constexpr Uuid kClaimUuid( + Uuid::FromStringOrDie("6332636C-0011-0010-8000-00AA00389B71")); + +// Box label and UUID of the C2PA Claim Signature box. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_claim_and_claim_signature. +inline constexpr absl::string_view kClaimSignatureLabel = "c2pa.signature"; +inline constexpr absl::string_view kClaimSignatureUrl = + "self#jumbf=c2pa.signature"; +inline constexpr Uuid kClaimSignatureUuid( + Uuid::FromStringOrDie("63326373-0011-0010-8000-00AA00389B71")); + +// Box label and UUID of the C2PA Assertion Store box. See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_assertion_store_2. +inline constexpr absl::string_view kAssertionStoreLabel = "c2pa.assertions"; +inline constexpr absl::string_view kAssertionStoreUrlPrefix = + "self#jumbf=c2pa.assertions/"; +inline constexpr Uuid kAssertionStoreUuid( + Uuid::FromStringOrDie("63326173-0011-0010-8000-00AA00389B71")); + +// C2PA assertion labels. +inline constexpr absl::string_view kActionsAssertionV2Label = "c2pa.actions.v2"; +inline constexpr absl::string_view kActionsAssertionV1Label = "c2pa.actions"; +inline constexpr absl::string_view kBmffBasedHashAssertionV2Label = + "c2pa.hash.bmff.v2"; +inline constexpr absl::string_view kBmffBasedHashAssertionV3Label = + "c2pa.hash.bmff.v3"; +inline constexpr absl::string_view kDataHashAssertionLabel = "c2pa.hash.data"; +inline constexpr absl::string_view kBoxesHashAssertionLabel = "c2pa.hash.boxes"; +inline constexpr absl::string_view kCollectionDataHashAssertionLabel = + "c2pa.hash.collection.data"; +inline constexpr absl::string_view kBmffBasedHashAssertionV2PartLabel = + "c2pa.hash.bmff.v2.part"; +inline constexpr absl::string_view kBmffBasedHashAssertionV3PartLabel = + "c2pa.hash.bmff.v3.part"; +inline constexpr absl::string_view kDataHashAssertionPartLabel = + "c2pa.hash.data.part"; +inline constexpr absl::string_view kBoxesHashAssertionPartLabel = + "c2pa.hash.boxes.part"; +inline constexpr absl::string_view kCollectionDataHashAssertionPartLabel = + "c2pa.hash.collection.data.part"; +inline constexpr absl::string_view kMultiAssetHashAssertionLabel = + "c2pa.hash.multi-asset"; +inline constexpr absl::string_view kIngredientAssertionV1Label = + "c2pa.ingredient"; +inline constexpr absl::string_view kIngredientAssertionV2Label = + "c2pa.ingredient.v2"; +inline constexpr absl::string_view kIngredientAssertionV3Label = + "c2pa.ingredient.v3"; +inline constexpr absl::string_view kGDepthAssertionLabel = + "c2pa.depthmap.GDepth"; +inline constexpr absl::string_view kAiDisclosureAssertionLabel = + "c2pa.ai-disclosure"; +inline constexpr absl::string_view kEnvironmentalSustainabilityAssertionLabel = + "c2pa.environmental-sustainability"; +inline constexpr absl::string_view kSoftBindingAssertionLabel = + "c2pa.soft-binding"; +inline constexpr absl::string_view kRepositoryReceiptAssertionLabel = + "c2pa.repository-receipt"; +inline constexpr absl::string_view kThumbnailClaimAssertionLabelPrefix = + "c2pa.thumbnail.claim."; +inline constexpr absl::string_view kThumbnailIngredientAssertionLabelPrefix = + "c2pa.thumbnail.ingredient."; +inline constexpr absl::string_view kMetadataAssertionLabelSuffix = ".metadata"; + +// The UUID of the C2PA BMFF box is D8FEC3D6-1B0E-483C-9297-5828877EC481. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_definition. +inline constexpr absl::string_view kC2paBmffBoxUuid( + "\xD8\xFE\xC3\xD6\x1B\x0E\x48\x3C\x92\x97\x58\x28\x87\x7E\xC4\x81", 16); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CONSTANTS_LABELS_H_
diff --git a/constants/spec_version.h b/constants/spec_version.h new file mode 100644 index 0000000..348c374 --- /dev/null +++ b/constants/spec_version.h
@@ -0,0 +1,42 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CONSTANTS_SPEC_VERSION_H_ +#define THIRD_PARTY_CREDENTIO_CONSTANTS_SPEC_VERSION_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +enum class SpecVersion { + kUnspecified = 0, + kC2pa_2_2 = 20200, // C2PA 2.2.0 + kC2pa_2_4 = 20400, // C2PA 2.4.0 +}; + +inline absl::string_view SpecVersionToString(SpecVersion version) { + switch (version) { + case SpecVersion::kC2pa_2_2: + return "2.2.0"; + case SpecVersion::kC2pa_2_4: + return "2.4.0"; + default: + return ""; + } +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CONSTANTS_SPEC_VERSION_H_
diff --git a/constants/status_codes.h b/constants/status_codes.h new file mode 100644 index 0000000..1c70668 --- /dev/null +++ b/constants/status_codes.h
@@ -0,0 +1,510 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CONSTANTS_STATUS_CODES_H_ +#define THIRD_PARTY_CREDENTIO_CONSTANTS_STATUS_CODES_H_ + +#include "absl/strings/str_cat.h" + +namespace credentio { + +// Constants corresponding to C2PA status codes. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_standard_status_codes +// for more information. about the C2PA status codes. Integer ranges for +// success/informational/failure are disjoint to be safe. + +// Success status codes. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_success_codes. +enum class SuccessStatusCode { + kClaimSignatureValidated = 201, // claimSignature.validated + kClaimSignatureInsideValidity, // claimSignature.insideValidity + + // Custom success codes, specific to this validator. + kGoogleAssertionDataHashMatch, // com.google.assertion.dataHash.match + kGoogleAssertionBmffHashMatch, // com.google.assertion.bmffHash.match + kGoogleAssertionBoxesHashMatch, // com.google.assertion.boxesHash.match + // com.google.assertion.multiAssetHash.match + kGoogleAssertionMultiAssetHashMatch, + // com.google.assertion.collectionHash.match + kGoogleAssertionCollectionHashMatch, + kAssertionDataHashMatch, // assertion.dataHash.match + kIngredientClaimSignatureValidated, // ingredient.claimSignature.validated + kIngredientManifestValidated, // ingredient.manifest.validated + kAssertionHashedUriMatch, // assertion.hashedURI.match + kAssertionBmffHashMatch, // assertion.bmffHash.match + kAssertionBoxesHashMatch, // assertion.boxesHash.match + kAssertionCollectionHashMatch, // assertion.collectionHash.match + kAssertionMultiAssetHashMatch, // assertion.multiAssetHash.match + kSigningCredentialTrusted, // signingCredential.trusted + kTimestampTrusted, // timeStamp.trusted + kTimestampValidated, // timeStamp.validated + kSigningCredentialOcspNotRevoked, // signingCredential.ocsp.notRevoked +}; + +// Informational status codes. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_informational_codes. +enum class InformationalStatusCode { + // Custom informational codes, specific to this validator + // go/keep-sorted start + // Indicates that the OCSP response's `producedAt` timestamp is outside of the + // certificate's validity period or the timestamp's time is outside of the + // validity window of the stapled OCSP response. + kSigningCredentialOcspOutsideValidity = 601, + // com.google.signingCredential.ocsp.outsideValidity + // Indicates that the timestamp's time is outside of the range that can be + // handled by this validator. The timestamp may be considered valid by + // other implementations. + kTimestampOutOfRange, // com.google.timeStamp.outOfRange + kTimestampV1FormatInV2Header, // com.google.timeStamp.V1FormatInV2Header + kTimestampV2MessageImprintInV1Format, + // com.google.timeStamp.V2MessageImprintInV1Format + // go/keep-sorted end + + // Standard informational codes + // go/keep-sorted start + kIngredientUnknownProvenance, // ingredient.unknownProvenance + kSigningCredentialOcspSkipped, // signingCredential.ocsp.skipped + kTimestampMalformed, // timeStamp.malformed + kTimestampMismatch, // timeStamp.mismatch + kTimestampOutsideValidity, // timeStamp.outsideValidity + kTimestampUntrusted, // timeStamp.untrusted + // go/keep-sorted end + + // Assertion informational codes + // go/keep-sorted start + // assertion.bmffHash.additionalExclusionsPresent + kAssertionBmffHashAdditionalExclusionsPresent, + // assertion.boxesHash.additionalExclusionsPresent + kAssertionBoxesHashAdditionalExclusionsPresent, + // assertion.dataHash.additionalExclusionsPresent + kAssertionDataHashAdditionalExclusionsPresent, + // go/keep-sorted end +}; + +// Failure status codes. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_failure_codes. +enum class FailureStatusCode { + kGeneralError = 401, // (custom, name TBD) + + // Custom failure codes, specific to this validator. + kGoogleInternalError, // com.google.internal.error + kGoogleUnsupportedSpecVersion, // com.google.unsupportedSpecVersion + kGoogleUnsupportedManifestType, // com.google.unsupportedManifestType + // com.google.assertion.action.unknownAction + kGoogleAssertionActionUnknownAction, + kGoogleAssertionDataHashMismatch, // com.google.assertion.dataHash.mismatch + kGoogleAssertionDataHashMalformed, // com.google.assertion.dataHash.malformed + kGoogleAssertionBmffHashMalformed, // com.google.assertion.bmffHash.malformed + kGoogleAssertionBmffHashMismatch, // com.google.assertion.bmffHash.mismatch + // com.google.assertion.boxesHash.unknownBox + kGoogleAssertionBoxesHashUnknownBox, + kGoogleAssertionBoxesHashMismatch, // com.google.assertion.boxesHash.mismatch + // com.google.assertion.boxesHash.malformed + kGoogleAssertionBoxesHashMalformed, + // com.google.assertion.multiAssetHash.mismatch + kGoogleAssertionMultiAssetHashMismatch, + // com.google.assertion.multiAssetHash.malformed + kGoogleAssertionMultiAssetHashMalformed, + // com.google.assertion.multiAssetHash.missingPart + kGoogleAssertionMultiAssetHashMissingPart, + kGoogleHashedUriUnsupported, // com.google.hashedUri.unsupported + + // Failure codes used for C2PA Claim validation. + kAlgorithmUnsupported, // algorithm.unsupported + kClaimHardBindingsMissing, // claim.hardBindings.missing + kClaimMalformed, // claim.malformed + kClaimMissing, // claim.missing + kClaimMultiple, // claim.multiple + kClaimCborInvalid, // claim.cbor.invalid + kClaimSignatureMissing, // claimSignature.missing + kClaimSignatureMismatch, // claimSignature.mismatch + kClaimSignatureOutsideValidity, // claimSignature.outsideValidity + kHashedUriMissing, // hashedUri.missing + kSigningCredentialInvalid, // signingCredential.invalid + kSigningCredentialOcspRevoked, // signingCredential.ocsp.revoked + kSigningCredentialUntrusted, // signingCredential.untrusted + + // Failure codes used for C2PA Assertion validation. + kAssertionCborInvalid, // assertion.cbor.invalid + kAssertionJsonInvalid, // assertion.json.invalid + kAssertionMultipleHardBindings, // assertion.multipleHardBindings + kAssertionDataHashMismatch, // assertion.dataHash.mismatch + kAssertionDataHashMalformed, // assertion.dataHash.malformed + kAssertionUndeclared, // assertion.undeclared + kAssertionOutsideManifest, // assertion.outsideManifest + kAssertionMissing, // assertion.missing + kAssertionHashedUriMismatch, // assertion.hashedURI.mismatch + kAssertionNotRedacted, // assertion.notRedacted + kAssertionSelfRedacted, // assertion.selfRedacted + kAssertionBmffHashMalformed, // assertion.bmffHash.malformed + kAssertionBmffHashMismatch, // assertion.bmffHash.mismatch + kAssertionBoxesHashUnknownBox, // assertion.boxesHash.unknownBox + kAssertionBoxesHashMismatch, // assertion.boxesHash.mismatch + kAssertionBoxesHashMalformed, // assertion.boxesHash.malformed + kAssertionMultiAssetHashMismatch, // assertion.multiAssetHash.mismatch + kAssertionMultiAssetHashMalformed, // assertion.multiAssetHash.malformed + kAssertionMultiAssetHashMissingPart, // assertion.multiAssetHash.missingPart + // assertion.collectionHash.incorrectFileCount + kAssertionCollectionHashIncorrectFileCount, + // assertion.collectionHash.invalidURI + kAssertionCollectionHashInvalidUri, + kAssertionCollectionHashMismatch, // assertion.collectionHash.mismatch + kAssertionCollectionHashMalformed, // assertion.collectionHash.malformed + kAssertionActionRedacted, // assertion.action.redacted + kAssertionActionMalformed, // assertion.action.malformed + kAssertionActionSoftBindingMissing, // assertion.action.softBindingMissing + kAssertionActionIngredientMismatch, // assertion.action.ingredientMismatch + kAssertionActionRedactionMismatch, // assertion.action.redactionMismatch + kAssertionIngredientMalformed, // assertion.ingredient.malformed + kHashedUriMismatch, // hashedUri.mismatch + + // Failure codes related to C2PA ingredient manifests + kIngredientClaimSignatureMissing, // ingredient.claimSignature.missing + kIngredientClaimSignatureMismatch, // ingredient.claimSignature.mismatch + kIngredientManifestMissing, // ingredient.manifest.missing + kIngredientManifestMismatch, // ingredient.manifest.mismatch + + kManifestMultipleParents, // manifest.multipleParents + kManifestUpdateInvalid, // manifest.update.invalid + kManifestUpdateWrongParents, // manifest.update.wrongParents +}; + +template <typename Sink> +void AbslStringify(Sink& sink, SuccessStatusCode status) { + switch (status) { + case SuccessStatusCode::kClaimSignatureValidated: + sink.Append("claimSignature.validated"); + return; + case SuccessStatusCode::kClaimSignatureInsideValidity: + sink.Append("claimSignature.insideValidity"); + return; + case SuccessStatusCode::kAssertionDataHashMatch: + sink.Append("assertion.dataHash.match"); + return; + case SuccessStatusCode::kIngredientManifestValidated: + sink.Append("ingredient.manifest.validated"); + return; + case SuccessStatusCode::kIngredientClaimSignatureValidated: + sink.Append("ingredient.claimSignature.validated"); + return; + case SuccessStatusCode::kAssertionHashedUriMatch: + sink.Append("assertion.hashedURI.match"); + return; + case SuccessStatusCode::kAssertionBmffHashMatch: + sink.Append("assertion.bmffHash.match"); + return; + case SuccessStatusCode::kAssertionBoxesHashMatch: + sink.Append("assertion.boxesHash.match"); + return; + case SuccessStatusCode::kAssertionCollectionHashMatch: + sink.Append("assertion.collectionHash.match"); + return; + case SuccessStatusCode::kSigningCredentialTrusted: + sink.Append("signingCredential.trusted"); + return; + case SuccessStatusCode::kTimestampTrusted: + sink.Append("timeStamp.trusted"); + return; + case SuccessStatusCode::kTimestampValidated: + sink.Append("timeStamp.validated"); + return; + case SuccessStatusCode::kSigningCredentialOcspNotRevoked: + sink.Append("signingCredential.ocsp.notRevoked"); + return; + case SuccessStatusCode::kGoogleAssertionDataHashMatch: + sink.Append("com.google.assertion.dataHash.match"); + return; + case SuccessStatusCode::kGoogleAssertionCollectionHashMatch: + sink.Append("com.google.assertion.collectionHash.match"); + return; + case SuccessStatusCode::kGoogleAssertionBmffHashMatch: + sink.Append("com.google.assertion.bmffHash.match"); + return; + case SuccessStatusCode::kGoogleAssertionBoxesHashMatch: + sink.Append("com.google.assertion.boxesHash.match"); + return; + case SuccessStatusCode::kGoogleAssertionMultiAssetHashMatch: + sink.Append("com.google.assertion.multiAssetHash.match"); + return; + case SuccessStatusCode::kAssertionMultiAssetHashMatch: + sink.Append("assertion.multiAssetHash.match"); + return; + } + sink.Append( + absl::StrCat("UNKNOWN_SUCCESS_STATUS_", static_cast<int>(status))); +} + +template <typename Sink> +void AbslStringify(Sink& sink, InformationalStatusCode status) { + switch (status) { + case credentio::InformationalStatusCode:: + kSigningCredentialOcspOutsideValidity: + sink.Append("com.google.signingCredential.ocsp.outsideValidity"); + return; + case credentio::InformationalStatusCode::kTimestampOutOfRange: + sink.Append("com.google.timeStamp.outOfRange"); + return; + case InformationalStatusCode::kTimestampV1FormatInV2Header: + sink.Append("com.google.timeStamp.V1FormatInV2Header"); + return; + case InformationalStatusCode::kTimestampV2MessageImprintInV1Format: + sink.Append("com.google.timeStamp.V2MessageImprintInV1Format"); + return; + case InformationalStatusCode::kIngredientUnknownProvenance: + sink.Append("ingredient.unknownProvenance"); + return; + case InformationalStatusCode::kTimestampMalformed: + sink.Append("timeStamp.malformed"); + return; + case InformationalStatusCode::kTimestampUntrusted: + sink.Append("timeStamp.untrusted"); + return; + case credentio::InformationalStatusCode::kTimestampOutsideValidity: + sink.Append("timeStamp.outsideValidity"); + return; + case InformationalStatusCode::kSigningCredentialOcspSkipped: + sink.Append("signingCredential.ocsp.skipped"); + return; + case InformationalStatusCode::kTimestampMismatch: + sink.Append("timeStamp.mismatch"); + return; + case InformationalStatusCode::kAssertionDataHashAdditionalExclusionsPresent: + sink.Append("assertion.dataHash.additionalExclusionsPresent"); + return; + case InformationalStatusCode:: + kAssertionBoxesHashAdditionalExclusionsPresent: + sink.Append("assertion.boxesHash.additionalExclusionsPresent"); + return; + case InformationalStatusCode::kAssertionBmffHashAdditionalExclusionsPresent: + sink.Append("assertion.bmffHash.additionalExclusionsPresent"); + return; + } + sink.Append( + absl::StrCat("UNKNOWN_INFORMATIONAL_STATUS_", static_cast<int>(status))); +} + +template <typename Sink> +void AbslStringify(Sink& sink, FailureStatusCode status) { + switch (status) { + case FailureStatusCode::kGeneralError: + sink.Append("general.error"); + return; + case FailureStatusCode::kGoogleInternalError: + sink.Append("com.google.internal.error"); + return; + case FailureStatusCode::kGoogleUnsupportedSpecVersion: + sink.Append("com.google.unsupportedSpecVersion"); + return; + case FailureStatusCode::kGoogleUnsupportedManifestType: + sink.Append("com.google.unsupportedManifestType"); + return; + case FailureStatusCode::kClaimMissing: + sink.Append("claim.missing"); + return; + case FailureStatusCode::kClaimMultiple: + sink.Append("claim.multiple"); + return; + case FailureStatusCode::kClaimCborInvalid: + sink.Append("claim.cbor.invalid"); + return; + case FailureStatusCode::kClaimSignatureMissing: + sink.Append("claimSignature.missing"); + return; + case FailureStatusCode::kClaimSignatureMismatch: + sink.Append("claimSignature.mismatch"); + return; + case FailureStatusCode::kClaimSignatureOutsideValidity: + sink.Append("claimSignature.outsideValidity"); + return; + case FailureStatusCode::kHashedUriMissing: + sink.Append("hashedUri.missing"); + return; + case FailureStatusCode::kSigningCredentialInvalid: + sink.Append("signingCredential.invalid"); + return; + case FailureStatusCode::kSigningCredentialUntrusted: + sink.Append("signingCredential.untrusted"); + return; + case FailureStatusCode::kAssertionCborInvalid: + sink.Append("assertion.cbor.invalid"); + return; + case FailureStatusCode::kAssertionJsonInvalid: + sink.Append("assertion.json.invalid"); + return; + case FailureStatusCode::kClaimHardBindingsMissing: + sink.Append("claim.hardBindings.missing"); + return; + case FailureStatusCode::kClaimMalformed: + sink.Append("claim.malformed"); + return; + case FailureStatusCode::kAssertionMultipleHardBindings: + sink.Append("assertion.multipleHardBindings"); + return; + case FailureStatusCode::kAssertionDataHashMismatch: + sink.Append("assertion.dataHash.mismatch"); + return; + case FailureStatusCode::kAlgorithmUnsupported: + sink.Append("algorithm.unsupported"); + return; + case FailureStatusCode::kAssertionDataHashMalformed: + sink.Append("assertion.dataHash.malformed"); + return; + case FailureStatusCode::kAssertionUndeclared: + sink.Append("assertion.undeclared"); + return; + case FailureStatusCode::kAssertionOutsideManifest: + sink.Append("assertion.outsideManifest"); + return; + case FailureStatusCode::kAssertionMissing: + sink.Append("assertion.missing"); + return; + case FailureStatusCode::kIngredientClaimSignatureMissing: + sink.Append("ingredient.claimSignature.missing"); + return; + case FailureStatusCode::kIngredientClaimSignatureMismatch: + sink.Append("ingredient.claimSignature.mismatch"); + return; + case FailureStatusCode::kIngredientManifestMissing: + sink.Append("ingredient.manifest.missing"); + return; + case FailureStatusCode::kIngredientManifestMismatch: + sink.Append("ingredient.manifest.mismatch"); + return; + case FailureStatusCode::kAssertionHashedUriMismatch: + sink.Append("assertion.hashedURI.mismatch"); + return; + case FailureStatusCode::kAssertionNotRedacted: + sink.Append("assertion.notRedacted"); + return; + case FailureStatusCode::kAssertionSelfRedacted: + sink.Append("assertion.selfRedacted"); + return; + case FailureStatusCode::kAssertionBmffHashMalformed: + sink.Append("assertion.bmffHash.malformed"); + return; + case FailureStatusCode::kAssertionBmffHashMismatch: + sink.Append("assertion.bmffHash.mismatch"); + return; + case FailureStatusCode::kAssertionBoxesHashUnknownBox: + sink.Append("assertion.boxesHash.unknownBox"); + return; + case FailureStatusCode::kAssertionBoxesHashMismatch: + sink.Append("assertion.boxesHash.mismatch"); + return; + case FailureStatusCode::kGoogleAssertionActionUnknownAction: + sink.Append("com.google.assertion.action.unknownAction"); + return; + case FailureStatusCode::kAssertionActionRedacted: + sink.Append("assertion.action.redacted"); + return; + case FailureStatusCode::kAssertionActionMalformed: + sink.Append("assertion.action.malformed"); + return; + case FailureStatusCode::kAssertionActionSoftBindingMissing: + sink.Append("assertion.action.softBindingMissing"); + return; + case FailureStatusCode::kAssertionActionIngredientMismatch: + sink.Append("assertion.action.ingredientMismatch"); + return; + case FailureStatusCode::kAssertionActionRedactionMismatch: + sink.Append("assertion.action.redactionMismatch"); + return; + case FailureStatusCode::kAssertionIngredientMalformed: + sink.Append("assertion.ingredient.malformed"); + return; + case FailureStatusCode::kHashedUriMismatch: + sink.Append("hashedUri.mismatch"); + return; + case FailureStatusCode::kManifestMultipleParents: + sink.Append("manifest.multipleParents"); + return; + case FailureStatusCode::kManifestUpdateInvalid: + sink.Append("manifest.update.invalid"); + return; + case FailureStatusCode::kManifestUpdateWrongParents: + sink.Append("manifest.update.wrongParents"); + return; + case FailureStatusCode::kSigningCredentialOcspRevoked: + sink.Append("signingCredential.ocsp.revoked"); + return; + case FailureStatusCode::kGoogleAssertionDataHashMismatch: + sink.Append("com.google.assertion.dataHash.mismatch"); + return; + case FailureStatusCode::kGoogleAssertionDataHashMalformed: + sink.Append("com.google.assertion.dataHash.malformed"); + return; + case FailureStatusCode::kGoogleAssertionBmffHashMalformed: + sink.Append("com.google.assertion.bmffHash.malformed"); + return; + case FailureStatusCode::kGoogleAssertionBmffHashMismatch: + sink.Append("com.google.assertion.bmffHash.mismatch"); + return; + case FailureStatusCode::kGoogleAssertionBoxesHashUnknownBox: + sink.Append("com.google.assertion.boxesHash.unknownBox"); + return; + case FailureStatusCode::kGoogleAssertionBoxesHashMismatch: + sink.Append("com.google.assertion.boxesHash.mismatch"); + return; + case FailureStatusCode::kGoogleHashedUriUnsupported: + sink.Append("com.google.hashedUri.unsupported"); + return; + case FailureStatusCode::kAssertionBoxesHashMalformed: + sink.Append("assertion.boxesHash.malformed"); + return; + case FailureStatusCode::kGoogleAssertionBoxesHashMalformed: + sink.Append("com.google.assertion.boxesHash.malformed"); + return; + case FailureStatusCode::kGoogleAssertionMultiAssetHashMismatch: + sink.Append("com.google.assertion.multiAssetHash.mismatch"); + return; + case FailureStatusCode::kGoogleAssertionMultiAssetHashMalformed: + sink.Append("com.google.assertion.multiAssetHash.malformed"); + return; + case FailureStatusCode::kGoogleAssertionMultiAssetHashMissingPart: + sink.Append("com.google.assertion.multiAssetHash.missingPart"); + return; + case FailureStatusCode::kAssertionMultiAssetHashMismatch: + sink.Append("assertion.multiAssetHash.mismatch"); + return; + case FailureStatusCode::kAssertionMultiAssetHashMalformed: + sink.Append("assertion.multiAssetHash.malformed"); + return; + case FailureStatusCode::kAssertionMultiAssetHashMissingPart: + sink.Append("assertion.multiAssetHash.missingPart"); + return; + case FailureStatusCode::kAssertionCollectionHashIncorrectFileCount: + sink.Append("assertion.collectionHash.incorrectFileCount"); + return; + case FailureStatusCode::kAssertionCollectionHashInvalidUri: + sink.Append("assertion.collectionHash.invalidURI"); + return; + case FailureStatusCode::kAssertionCollectionHashMismatch: + sink.Append("assertion.collectionHash.mismatch"); + return; + case FailureStatusCode::kAssertionCollectionHashMalformed: + sink.Append("assertion.collectionHash.malformed"); + return; + } + sink.Append( + absl::StrCat("UNKNOWN_FAILURE_STATUS_", static_cast<int>(status))); +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CONSTANTS_STATUS_CODES_H_
diff --git a/cose/BUILD b/cose/BUILD new file mode 100644 index 0000000..1968d65 --- /dev/null +++ b/cose/BUILD
@@ -0,0 +1,206 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "validation_status_util", + hdrs = ["validation_status_util.h"], + deps = [ + "//constants:status_codes", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/strings", + ], +) + +cc_library( + name = "sig_structure", + srcs = ["sig_structure.cc"], + hdrs = ["sig_structure.h"], + deps = [ + "//cbor", + "//cbor:parse", + "//crypto:algorithms", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:absl_log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@libcppbor", + ], +) + +cc_test( + name = "sig_structure_test", + srcs = ["sig_structure_test.cc"], + deps = [ + ":sig_structure", + "//cbor", + "//cbor:parse", + "//crypto:algorithms", + "//testing:cbor_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "ocsp_verifier", + srcs = ["ocsp_verifier.cc"], + hdrs = ["ocsp_verifier.h"], + deps = [ + ":validation_status_util", + "//constants:labels", + "//constants:status_codes", + "//crypto:crypto_read_handler", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + ], +) + +cc_test( + name = "ocsp_verifier_test", + srcs = ["ocsp_verifier_test.cc"], + deps = [ + ":ocsp_verifier", + "//constants:status_codes", + "//crypto/default:pem", + "//proto:validation_status_cc_proto", + "//testing:pem", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "cose_sign1", + srcs = ["cose_sign1.cc"], + hdrs = ["cose_sign1.h"], + deps = [ + ":ocsp_verifier", + ":sig_structure", + ":validation_status_util", + "//constants:labels", + "//constants:status_codes", + "//crypto:crypto_read_handler", + "//crypto:hash", + "//proto:assurance_level_cc_proto", + "//proto:signature_info_cc_proto", + "//proto:validation_status_cc_proto", + "//tsp:timestamp_parsing", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/memory", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/time:clock_interface", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@protobuf//:timestamp_cc_proto", + ], +) + +cc_library( + name = "verifier", + srcs = ["verifier.cc"], + hdrs = ["verifier.h"], + deps = [ + ":cose_sign1", + ":sig_structure", + ":validation_status_util", + "//constants:labels", + "//constants:status_codes", + "//crypto:crypto_read_handler", + "//crypto:hash", + "//crypto/default:hasher", + "//proto:cose_verification_result_cc_proto", + "//proto:signature_info_cc_proto", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/time:clock_interface", + "@protobuf//:timestamp_cc_proto", + ], +) + +cc_library( + name = "simple_cms_parser", + srcs = ["simple_cms_parser.cc"], + hdrs = ["simple_cms_parser.h"], + deps = [ + "//crypto:algorithms", + "//crypto/default/cms:certificates", + "//crypto/default/cms:cms_parser", + "//crypto/default/cms:verify_signature", + "//tsp:cms_parser", + "//tsp:constants", + "//tsp:parsed_timestamp_token", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/memory", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "simple_cms_parser_test", + srcs = ["simple_cms_parser_test.cc"], + deps = [ + ":simple_cms_parser", + "//crypto:algorithms", + "//tsp:parsed_timestamp_token", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +)
diff --git a/cose/cose_sign1.cc b/cose/cose_sign1.cc new file mode 100644 index 0000000..5af913c --- /dev/null +++ b/cose/cose_sign1.cc
@@ -0,0 +1,510 @@ +// 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 "cose/cose_sign1.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/log/check.h" +#include "absl/log/log.h" +#include "absl/memory/memory.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "absl/time/clock_interface.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "cose/ocsp_verifier.h" +#include "cose/sig_structure.h" +#include "cose/validation_status_util.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/hash.h" +#include "google/protobuf/timestamp.pb.h" +#include "proto/assurance_level.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_status.pb.h" +#include "tsp/timestamp_parsing.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +std::vector<absl::string_view> StringViewVector( + absl::Span<const std::string> v) { + std::vector<absl::string_view> result; + result.reserve(v.size()); + for (const auto& e : v) { + result.push_back(e); + } + return result; +} + +// Validators shall accept the header from either the protected or unprotected +// bucket, to maintain compatibility with previous versions of this +// specification. In compliance with Section 14.2, “Identity of Signers”, if +// this header appears in both the protected and unprotected buckets with the +// same label, a validator shall reject the claim signature as malformed due to +// the presence of multiple credentials. +// See +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#x509_certificates. +absl::StatusOr<std::vector<std::string>> GetCertificateChain( + const ProtectedHeader& protected_header, + const UnprotectedHeader& unprotected_header) { + if (!protected_header.certificate_chain.empty() && + !unprotected_header.certificate_chain.empty()) { + return absl::InvalidArgumentError( + "Certificate chain is present in both protected and unprotected " + "headers"); + } + if (!protected_header.certificate_chain.empty()) { + return protected_header.certificate_chain; + } + return unprotected_header.certificate_chain; +} + +absl::StatusOr<absl::string_view> ExtractTimestampHeaderValue( + const TstContainer& tst_container) { + // The `tstTokens` array is expected to contain a single token. If this is + // not the case, we need to return a `timeStamp.malformed` status and ignore + // the timestamp. See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_time_stamp. + if (tst_container.tst_tokens.size() != 1) { + return absl::InvalidArgumentError( + "there are zero or multiple timestamp tokens in tstTokens"); + } + return tst_container.tst_tokens[0].val; +} + +std::string ConstructSigStructure(absl::string_view body_protected, + absl::string_view external_payload, + absl::string_view context) { + return EncodeSig1Structure(Sig1Structure{ + .context = std::string(context), + .body_protected = std::string(body_protected), + .external_aad = "", + .payload = std::string(external_payload), + }); +} + +bool CheckMessageImprintHash(const HashCheckerFactory& hash_checker_factory, + ValidationStatusSet* status_set, + const VerifiedTimestamp& oldest_trusted_timestamp, + absl::string_view timestamp_sig_structure) { + absl::StatusOr<std::unique_ptr<HashChecker>> messageimprint_hash_checker = + hash_checker_factory.Create( + oldest_trusted_timestamp.message_imprint_hash_algorithm()); + if (!messageimprint_hash_checker.ok()) { + RecordStatus(status_set, InformationalStatusCode::kTimestampMalformed, + {.url = kClaimSignatureLabel, + .explanation = absl::StrCat( + "cannot verify MessageImprint hash; failed to create " + "hash checker: ", + messageimprint_hash_checker.status())}); + return false; + } + (*messageimprint_hash_checker)->Update(timestamp_sig_structure); + if (!(*messageimprint_hash_checker) + ->Check(oldest_trusted_timestamp.message_imprint_hash())) { + RecordStatus(status_set, InformationalStatusCode::kTimestampMismatch, + {.url = kClaimSignatureLabel}); + return false; + } + return true; +} + +// The format of a timestamp. +// See v1/v2 descriptions in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp +enum class TimestampVersion { + // A timestamp in the "v1" format, normally found in `sigTst`. + kV1, + // A timestamp in the "v2" format, normally found in `sigTst2`. + kV2, +}; + +struct VersionedVerifiedTimestamp { + VerifiedTimestamp timestamp; + TimestampVersion version; +}; + +bool MessageImprintMatch( + absl::string_view& external_payload, + const HashCheckerFactory& hash_checker_factory, + ValidationStatusSet* status_set, absl::string_view protected_headers_bstr, + absl::string_view signature_bstr, absl::string_view signature, + const VersionedVerifiedTimestamp& oldest_trusted_timestamp) { + switch (oldest_trusted_timestamp.version) { + case TimestampVersion::kV1: { + ValidationStatusSet v1_status; + if (CheckMessageImprintHash( + hash_checker_factory, &v1_status, + oldest_trusted_timestamp.timestamp, + ConstructSigStructure(protected_headers_bstr, external_payload, + "CounterSignature"))) { + status_set->MergeFrom(v1_status); + return true; + } + + ValidationStatusSet v2_status; + if (CheckMessageImprintHash( + hash_checker_factory, &v2_status, + oldest_trusted_timestamp.timestamp, + ConstructSigStructure(protected_headers_bstr, signature, + "CounterSignature"))) { + status_set->MergeFrom(v2_status); + RecordStatus( + status_set, + InformationalStatusCode::kTimestampV2MessageImprintInV1Format, + {.url = kClaimSignatureLabel}); + return true; + } + + status_set->MergeFrom(v1_status); + return false; + } + case TimestampVersion::kV2: + return CheckMessageImprintHash( + hash_checker_factory, status_set, oldest_trusted_timestamp.timestamp, + ConstructSigStructure(protected_headers_bstr, signature_bstr, + "CounterSignature")); + } + LOG(DFATAL) << "Fell through end of exhaustive switch statement."; + return false; +} + +[[nodiscard]] std::optional<SignatureInfo> RecordInvalidSigningCredential( + absl::string_view explanation, ValidationStatusSet* status_set) { + RecordStatus(status_set, FailureStatusCode::kSigningCredentialInvalid, + {.url = kClaimSignatureLabel, .explanation = explanation}); + return std::nullopt; +} + +InformationalStatusCode GetTimestampStatusCode(absl::StatusCode status_code) { + switch (status_code) { + case absl::StatusCode::kUnauthenticated: + return InformationalStatusCode::kTimestampUntrusted; + case absl::StatusCode::kOutOfRange: + return InformationalStatusCode::kTimestampOutsideValidity; + case absl::StatusCode::kInvalidArgument: + default: + return InformationalStatusCode::kTimestampMalformed; + } +} + +absl::Status RecordInformationalAndReturnStatus( + absl::Status status, ValidationStatusSet* status_set) { + RecordStatus(status_set, GetTimestampStatusCode(status.code()), + {.url = kClaimSignatureLabel, .explanation = status.message()}); + return status; +} + +absl::StatusOr<VersionedVerifiedTimestamp> VerifyV1TimestampInternal( + absl::string_view timestamp_resp, + const CryptoReadHandler& crypto_read_handler) { + ABSL_ASSIGN_OR_RETURN(auto timestamp_token, + ParseTimestampResp(timestamp_resp)); + ABSL_ASSIGN_OR_RETURN(auto verified_timestamp, + crypto_read_handler.VerifyTimestamp(timestamp_token)); + return VersionedVerifiedTimestamp{.timestamp = std::move(verified_timestamp), + .version = TimestampVersion::kV1}; +} + +absl::StatusOr<VersionedVerifiedTimestamp> VerifyV1Timestamp( + absl::string_view timestamp_resp, + const CryptoReadHandler& crypto_read_handler, + ValidationStatusSet* status_set) { + auto result = VerifyV1TimestampInternal(timestamp_resp, crypto_read_handler); + if (!result.ok()) { + return RecordInformationalAndReturnStatus(result.status(), status_set); + } + return result; +} + +absl::StatusOr<VersionedVerifiedTimestamp> VerifyV2Timestamp( + absl::string_view timestamp_token, + const CryptoReadHandler& crypto_read_handler, + ValidationStatusSet* status_set) { + absl::StatusOr<VerifiedTimestamp> default_attempt = + crypto_read_handler.VerifyTimestamp(timestamp_token); + + if (default_attempt.ok()) { + return VersionedVerifiedTimestamp{.timestamp = *std::move(default_attempt), + .version = TimestampVersion::kV2}; + } + + absl::StatusOr<VersionedVerifiedTimestamp> fallback_attempt = + VerifyV1TimestampInternal(timestamp_token, crypto_read_handler); + + if (fallback_attempt.ok()) { + RecordStatus(status_set, + InformationalStatusCode::kTimestampV1FormatInV2Header, + {.url = kClaimSignatureLabel}); + return fallback_attempt; + } + + return RecordInformationalAndReturnStatus(default_attempt.status(), + status_set); +} + +// Returns the oldest valid timestamp found in the `sigTst2` or `sigTst` +// header after building a trust chain from the certificate to an entry in the +// validator's trust anchors. +// +// Returns `std::nullopt` if the timestamp header is not present. +// Records a `timeStamp.untrusted` C2PA status to the tracker and returns +// an `std::nullopt` if a trust chain cannot be built from the TSA's +// certificate to one of the validator's trust anchors. +std::optional<VersionedVerifiedTimestamp> VerifyAndGetOldestTrustedTimestamp( + const CryptoReadHandler& crypto_read_handler, + const UnprotectedHeader& unprotected_header, + ValidationStatusSet* status_set) { + bool is_v2 = true; + auto timestamp_container = unprotected_header.sig_tst2; + if (!timestamp_container.has_value()) { + is_v2 = false; + timestamp_container = unprotected_header.sig_tst; + } + if (!timestamp_container.has_value()) { + return std::nullopt; + } + + auto timestamp_header_value = + ExtractTimestampHeaderValue(*timestamp_container); + if (!timestamp_header_value.ok()) { + DVLOG(1) << timestamp_header_value.status(); + RecordStatus(status_set, InformationalStatusCode::kTimestampMalformed, + {.url = kClaimSignatureLabel, + .explanation = timestamp_header_value.status().ToString()}); + return std::nullopt; + } + + absl::StatusOr<VersionedVerifiedTimestamp> verified_timestamp = + is_v2 ? VerifyV2Timestamp(*timestamp_header_value, crypto_read_handler, + status_set) + : VerifyV1Timestamp(*timestamp_header_value, crypto_read_handler, + status_set); + + if (!verified_timestamp.ok()) { + DVLOG(1) << verified_timestamp.status(); + // VerifyV[12]Timestamp would have already reported any issues to `tracker`. + return std::nullopt; + } + + return *verified_timestamp; +} + +absl::StatusOr<google::protobuf::Timestamp> EncodeGoogleApiProto(absl::Time t) { + const int64_t s = absl::ToUnixSeconds(t); + if (s < -62135596800 || s > 253402300799) { + return absl::OutOfRangeError("Timestamp is out of range"); + } + google::protobuf::Timestamp proto; + proto.set_seconds(s); + proto.set_nanos((t - absl::FromUnixSeconds(s)) / absl::Nanoseconds(1)); + return proto; +} + +} // namespace + +std::unique_ptr<CoseSign1Verifier> CoseSign1Verifier::Create( + const CryptoReadHandler* absl_nonnull crypto_read_handler, + const HashCheckerFactory* absl_nonnull hash_checker_factory, + absl::Clock* absl_nonnull clock, Options options) { + return absl::WrapUnique(new CoseSign1Verifier( + crypto_read_handler, hash_checker_factory, clock, options)); +} + +std::optional<SignatureInfo> CoseSign1Verifier::Verify( + const CoseSign1TaggedStructure& cose_sign1, + absl::string_view external_payload, ValidationStatusSet* status_set) const { + // STEP 0: Get the certificate chain and parse the certificates. + auto protected_headers = DecodeProtectedHeader(cose_sign1.protected_header); + if (!protected_headers.ok()) { + return RecordInvalidSigningCredential( + absl::StrCat("cannot decode protected header: ", + protected_headers.status().message()), + status_set); + } + auto certificate_chain = + GetCertificateChain(*protected_headers, cose_sign1.unprotected_header); + if (!certificate_chain.ok()) { + return RecordInvalidSigningCredential(certificate_chain.status().message(), + status_set); + } + if (certificate_chain->empty()) { + return RecordInvalidSigningCredential("no claim signer certificates", + status_set); + } + auto certs = crypto_read_handler_.ParseCertificatesDer( + StringViewVector(*certificate_chain)); + if (!certs.ok()) { + return RecordInvalidSigningCredential( + absl::StrCat("malformed claim signer certificates: ", + certs.status().message()), + status_set); + } + + // STEP 1: Verify the timestamp. + // Obtain and verify the oldest verified timestamp from sig_tst2 or sig_tst + // in the unprotected header. + std::optional<VersionedVerifiedTimestamp> oldest_trusted_timestamp = + VerifyAndGetOldestTrustedTimestamp( + crypto_read_handler_, cose_sign1.unprotected_header, status_set); + // Check that the timestamp matches the signature. + bool check_message_imprint = true; + if (oldest_trusted_timestamp.has_value()) { + if (check_message_imprint && + !MessageImprintMatch(external_payload, hash_checker_factory_, + status_set, cose_sign1.protected_header, + cose_sign1.signature_bstr(), cose_sign1.signature, + *oldest_trusted_timestamp)) { + oldest_trusted_timestamp = std::nullopt; + } else { + RecordStatus(status_set, SuccessStatusCode::kTimestampValidated, + {.url = kClaimSignatureLabel}); + RecordStatus(status_set, SuccessStatusCode::kTimestampTrusted, + {.url = kClaimSignatureLabel}); + } + } + + // STEP 2: Verify the signer's certificate chain. + absl::Time content_time = + oldest_trusted_timestamp.has_value() + ? oldest_trusted_timestamp->timestamp.asserted_time() + : clock_.TimeNow(); + absl::StatusOr<std::vector<std::string>> trust_chain = + (*certs)->VerifyClaimSignerTrust(content_time); + if (!trust_chain.ok()) { + switch (trust_chain.status().code()) { + case absl::StatusCode::kUnauthenticated: + RecordStatus(status_set, FailureStatusCode::kSigningCredentialUntrusted, + {.url = kClaimSignatureLabel, + .explanation = trust_chain.status().message()}); + return std::nullopt; + case absl::StatusCode::kOutOfRange: + RecordStatus(status_set, + FailureStatusCode::kClaimSignatureOutsideValidity, + {.url = kClaimSignatureLabel, + .explanation = trust_chain.status().message()}); + return std::nullopt; + case absl::StatusCode::kUnavailable: + // Trust list unavailable? + RecordStatus(status_set, FailureStatusCode::kGoogleInternalError, + {.url = kClaimSignatureLabel, + .explanation = trust_chain.status().message()}); + return std::nullopt; + default: + return RecordInvalidSigningCredential(trust_chain.status().message(), + status_set); + } + } + + // STEP 3: Verify the revocation information of the signer's cert chain. + // We first check the certificate revocation list, and then the stapled OCSP + // response as described in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_credential_revocation_information. + // Online OCSP checks are not supported yet. + if (!options_.check_ocsp_responses) { + RecordSkippedOcspCheck(status_set); + } else if (!oldest_trusted_timestamp.has_value()) { + // Trusted timestamp is required for verifying stapled OCSP responses + // (https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ocsp_stapled). + RecordSkippedOcspCheck(status_set); + } else { + OcspVerifier ocsp_verifier(&crypto_read_handler_); + if (!ocsp_verifier.VerifyOcspResponses( + cose_sign1.unprotected_header.ocsp_responses, *trust_chain, + oldest_trusted_timestamp->timestamp.asserted_time(), status_set)) { + // OCSP response indicates that a certificate in the chain is revoked. + return std::nullopt; + } + } + + RecordStatus(status_set, SuccessStatusCode::kSigningCredentialTrusted, + {.url = kClaimSignatureLabel}); + RecordStatus(status_set, SuccessStatusCode::kClaimSignatureInsideValidity, + {.url = kClaimSignatureLabel}); + + // STEP 4: Verify the signature. + if (options_.verify_signature) { + auto signing_algorithm = protected_headers->alg; + if (absl::Status status = (*certs)->VerifySignature( + cose_sign1.signature, + ConstructSigStructure(cose_sign1.protected_header, external_payload, + "Signature1"), + signing_algorithm); + !status.ok()) { + RecordStatus(status_set, FailureStatusCode::kClaimSignatureMismatch, + {.url = kClaimSignatureLabel, + .explanation = absl::Substitute( + "claim signature validation failed; alg=$0; status=$1", + signing_algorithm, status.ToString())}); + return std::nullopt; + } + RecordStatus(status_set, SuccessStatusCode::kClaimSignatureValidated, + {.url = kClaimSignatureLabel}); + } + + // Finally, construct and return the SignatureInfo. + SignatureInfo signature_info; + const ParsedCertificates& parsed_certs = **certs; + if (oldest_trusted_timestamp.has_value()) { + absl::StatusOr<google::protobuf::Timestamp> time_proto = + EncodeGoogleApiProto( + oldest_trusted_timestamp->timestamp.asserted_time()); + if (time_proto.ok()) { + *signature_info.mutable_timestamping_time() = *std::move(time_proto); + } else { + RecordStatus(status_set, InformationalStatusCode::kTimestampOutOfRange, + {.url = kClaimSignatureLabel}); + } + } + absl::StatusOr<std::string> subject = parsed_certs.GetSubject(0); + if (subject.ok()) { + signature_info.set_issuer(*subject); + } + absl::StatusOr<std::string> issuer = parsed_certs.GetIssuer(0); + if (issuer.ok()) { + signature_info.set_certificate_issuer(*issuer); + } + absl::StatusOr<std::string> assurance_level = + parsed_certs.GetAssuranceLevel(0); + if (assurance_level.ok()) { + if (*assurance_level == "1.3.6.1.4.1.62558.3.10") { + signature_info.set_assurance_level(ASSURANCE_LEVEL1); + } else if (*assurance_level == "1.3.6.1.4.1.62558.3.20") { + signature_info.set_assurance_level(ASSURANCE_LEVEL2); + } + } + absl::StatusOr<std::string> conforming_product_id = + parsed_certs.GetConformingProductId(0); + if (conforming_product_id.ok()) { + signature_info.set_conforming_product_id(*conforming_product_id); + } + return signature_info; +}; + +} // namespace credentio
diff --git a/cose/cose_sign1.h b/cose/cose_sign1.h new file mode 100644 index 0000000..3f1afc5 --- /dev/null +++ b/cose/cose_sign1.h
@@ -0,0 +1,79 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_COSE_SIGN1_H_ +#define THIRD_PARTY_CREDENTIO_COSE_COSE_SIGN1_H_ + +#include <memory> +#include <optional> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock_interface.h" +#include "cose/sig_structure.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/hash.h" +#include "google/protobuf/timestamp.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { + +// CoseSign1 verifier for C2PA use cases. +// +// This class does not support multiple signers. Do not use this for non-C2PA +// use cases. +class CoseSign1Verifier { + public: + struct Options { + // Whether to check OCSP responses. + bool check_ocsp_responses = true; + // Whether to verify the signature. + // NOTE: signer's certificate chain trust checks are always performed. + bool verify_signature = true; + }; + + // Creates a CoseSign1Verifier instance. + static std::unique_ptr<CoseSign1Verifier> Create( + const CryptoReadHandler* absl_nonnull crypto_read_handler, + const HashCheckerFactory* absl_nonnull hash_checker_factory, + absl::Clock* absl_nonnull clock, Options options); + + // Validates a COSE_Sign1 structure and returns a SignatureInfo on success, + // or std::nullopt if the signature is invalid. + std::optional<SignatureInfo> Verify( + const CoseSign1TaggedStructure& cose_sign1, + absl::string_view external_payload, + ValidationStatusSet* status_set) const; + + private: + CoseSign1Verifier(const CryptoReadHandler* absl_nonnull crypto_read_handler, + const HashCheckerFactory* absl_nonnull hash_checker_factory, + absl::Clock* absl_nonnull clock, Options options) + : crypto_read_handler_(*crypto_read_handler), + hash_checker_factory_(*hash_checker_factory), + clock_(*clock), + options_(std::move(options)) {} + + const CryptoReadHandler& crypto_read_handler_; + const HashCheckerFactory& hash_checker_factory_; + absl::Clock& clock_; + Options options_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_COSE_SIGN1_H_
diff --git a/cose/ocsp_verifier.cc b/cose/ocsp_verifier.cc new file mode 100644 index 0000000..73a45b4 --- /dev/null +++ b/cose/ocsp_verifier.cc
@@ -0,0 +1,106 @@ +// 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 "cose/ocsp_verifier.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "cose/validation_status_util.h" +#include "crypto/crypto_read_handler.h" +#include "proto/validation_status.pb.h" + +namespace credentio { + +void RecordSkippedOcspCheck(ValidationStatusSet* status_set) { + RecordStatus(status_set, + InformationalStatusCode::kSigningCredentialOcspSkipped, + {.url = kClaimSignatureLabel}); +} + +bool OcspVerifier::VerifyOcspResponses( + absl::Span<const std::string> ocsp_responses, + absl::Span<const std::string> trust_chain, absl::Time asserted_time, + ValidationStatusSet* status_set) const { + bool has_good_signing_cert = false; + // The manifest has an attested time provided by a valid signed + // time-stamp and `rVals` header contains at least one OCSP response. + for (int i = 0; i < ocsp_responses.size(); ++i) { + absl::string_view ocsp_value = ocsp_responses[i]; + for (int j = 1; j < trust_chain.size(); ++j) { + absl::string_view certificate = trust_chain[j - 1]; + // The issuer certificate is the next certificate in the chain. + absl::string_view issuer_certificate = trust_chain[j]; + auto revocation_status = crypto_read_handler_->VerifyOcspResponse( + ocsp_value, certificate, issuer_certificate, asserted_time); + if (revocation_status.ok()) { + if (*revocation_status == OCSPRevocationStatus::kGood) { + // According to the C2PA spec, if at least one OCSP response is + // `good`, then the credential is not revoked. + if (j == 1) { + has_good_signing_cert = true; + } + break; + } else if (*revocation_status == OCSPRevocationStatus::kRevoked) { + // According to the C2PA spec, if at least one OCSP response is + // `revoked`, then the credential is revoked. + if (j == 1) { + RecordStatus(status_set, + FailureStatusCode::kSigningCredentialOcspRevoked, + {.url = kClaimSignatureLabel}); + } else { + RecordStatus(status_set, + FailureStatusCode::kSigningCredentialUntrusted, + {.url = kClaimSignatureLabel}); + } + return false; + } + } else { + if (revocation_status.status().code() == + absl::StatusCode::kOutOfRange) { + // OCSP response is produced at a time outside of the + // corresponding certificate's validity period or the timestamp + // time (i.e., the verify time) falls outside of the validity + // window of the stapled OCSP response. + RecordStatus( + status_set, + InformationalStatusCode::kSigningCredentialOcspOutsideValidity, + {.url = kClaimSignatureLabel}); + } + } + } + } + if (has_good_signing_cert) { + RecordStatus(status_set, + SuccessStatusCode::kSigningCredentialOcspNotRevoked, + {.url = kClaimSignatureLabel}); + } else { + // None of the OCSP responses satisfied the conditions in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ocsp_stapled, + // or no OCSP responses were provided. The validator chooses not to perform + // online revocation checks so we record a `signingCredential.ocsp.skipped` + // informational code per + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ocsp_online. + RecordSkippedOcspCheck(status_set); + } + return true; +} + +} // namespace credentio
diff --git a/cose/ocsp_verifier.h b/cose/ocsp_verifier.h new file mode 100644 index 0000000..adc2906 --- /dev/null +++ b/cose/ocsp_verifier.h
@@ -0,0 +1,57 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_OCSP_VERIFIER_H_ +#define THIRD_PARTY_CREDENTIO_COSE_OCSP_VERIFIER_H_ + +#include <string> + +#include "absl/base/nullability.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/crypto_read_handler.h" +#include "proto/validation_status.pb.h" + +namespace credentio { + +// Validates the stapled OCSP responses for a certificate trust chain as +// described in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_credential_revocation_information. +class OcspVerifier { + public: + explicit OcspVerifier( + const CryptoReadHandler* absl_nonnull crypto_read_handler) + : crypto_read_handler_(crypto_read_handler) {} + + // Returns true and records appropriate success or informational status codes + // if the credential status is good or unknown. + // Returns false and records `signingCredential.ocsp.revoked` if the leaf + // certificate is verified as revoked, or `signingCredential.untrusted` if an + // intermediate certificate is verified as revoked. + bool VerifyOcspResponses(absl::Span<const std::string> ocsp_responses, + absl::Span<const std::string> trust_chain, + absl::Time asserted_time, + ValidationStatusSet* status_set) const; + + private: + const CryptoReadHandler* absl_nonnull crypto_read_handler_; +}; + +// Records that the online OCSP check was skipped. +void RecordSkippedOcspCheck(ValidationStatusSet* status_set); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_OCSP_VERIFIER_H_
diff --git a/cose/ocsp_verifier_test.cc b/cose/ocsp_verifier_test.cc new file mode 100644 index 0000000..91e6c1a --- /dev/null +++ b/cose/ocsp_verifier_test.cc
@@ -0,0 +1,366 @@ +// 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 "cose/ocsp_verifier.h" + +#include <memory> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "constants/status_codes.h" +#include "crypto/default/pem.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/validation_status.pb.h" +#include "testing/pem.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::IsEmpty; +using ::testing::IsFalse; +using ::testing::SizeIs; + +class MockCryptoReadHandler : public CryptoReadHandler { + public: + MOCK_METHOD(absl::StatusOr<std::unique_ptr<ParsedCertificates>>, + ParseCertificatesDer, + (absl::Span<const absl::string_view> certificates), + (const, override)); + MOCK_METHOD(absl::StatusOr<VerifiedTimestamp>, VerifyTimestamp, + (absl::string_view cms), (const, override)); + MOCK_METHOD(absl::StatusOr<OCSPRevocationStatus>, VerifyOcspResponse, + (absl::string_view ocsp_response_der, + absl::string_view certificate_der, + absl::string_view issuer_certificate_der, + absl::Time verify_time), + (const, override)); + MOCK_METHOD(TrustEnvironment, trust_environment, (), (const, override)); +}; + +// Matches validation status codes. +auto C2paStatusIs(auto status_code) { + return ::testing::Property(&ValidationStatus::code, + Eq(absl::StrCat(status_code))); +} + +absl::StatusOr<absl::Time> ParseTime(absl::string_view time_str) { + absl::Time time; + std::string err; + if (!absl::ParseTime(absl::RFC3339_full, time_str, &time, &err)) { + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse time: ", err)); + } + return time; +} + +TEST(OcspVerifierTest, RecordSkippedOcspCheck) { + ValidationStatusSet status_set; + RecordSkippedOcspCheck(&status_set); + + EXPECT_THAT(status_set.failures(), IsEmpty()); + EXPECT_THAT(status_set.successes(), IsEmpty()); + EXPECT_THAT(status_set.informationals(), + ElementsAre(C2paStatusIs( + InformationalStatusCode::kSigningCredentialOcspSkipped))); +} + +TEST(OcspVerifierTest, SkippedWhenResponsesEmpty) { + ValidationStatusSet status_set; + std::vector<std::string> ocsp_responses = {}; + std::vector<std::string> trust_chain = {"fake_cert_1", "fake_cert_2"}; + absl::Time asserted_time = absl::Now(); + + MockCryptoReadHandler mock_crypto_handler; + EXPECT_CALL(mock_crypto_handler, VerifyOcspResponse).Times(0); + OcspVerifier verifier(&mock_crypto_handler); + + EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain, + asserted_time, &status_set)); + + EXPECT_THAT(status_set.failures(), IsEmpty()); + EXPECT_THAT(status_set.successes(), IsEmpty()); + EXPECT_THAT(status_set.informationals(), + ElementsAre(C2paStatusIs( + InformationalStatusCode::kSigningCredentialOcspSkipped))); +} + +TEST(OcspVerifierTest, SkippedWhenNoValidResponses) { + ValidationStatusSet status_set; + // Valid check config but fake inputs that won't match/decode successfully. + std::vector<std::string> ocsp_responses = {"invalid_ocsp_data"}; + std::vector<std::string> trust_chain = {"invalid_cert_1", "invalid_cert_2"}; + absl::Time asserted_time = absl::Now(); + + MockCryptoReadHandler mock_crypto_handler; + EXPECT_CALL(mock_crypto_handler, VerifyOcspResponse) + .WillOnce(::testing::Return(absl::InvalidArgumentError("invalid ocsp"))); + OcspVerifier verifier(&mock_crypto_handler); + + EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain, + asserted_time, &status_set)); + + // Since CheckOCSP fails to identify revocation status, it will be neither + // good nor revoked, hence recorded as skipped. + EXPECT_THAT(status_set.failures(), IsEmpty()); + EXPECT_THAT(status_set.successes(), IsEmpty()); + EXPECT_THAT(status_set.informationals(), + ElementsAre(C2paStatusIs( + InformationalStatusCode::kSigningCredentialOcspSkipped))); +} + +// certs/claim_signer_ocsp_2010_2024.pem +static constexpr absl::string_view kSubjectCertPem = + R"(-----BEGIN CERTIFICATE----- +MIIBVjCB/qADAgECAhEAqY+Ga0uKJc71iAGCQ2xmSTAKBggqhkjOPQQDAjAUMRIw +EAYDVQQDEwlSb290IENBIDEwHhcNMTAwMTAxMDAwMDAwWhcNMjQxMjMxMjM1OTU5 +WjAcMRowGAYDVQQDExFDbGFpbSBTaWduZXIgT0NTUDBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABIekbtQdrGd0nLIoGkyxOxUHVDrdf4kCsoG1oy8hKUp0JX1nN6I5 +Uki0Rdnjjp0lkhRC7pS0ltq3RzYF2QLH9j2jKTAnMA4GA1UdDwEB/wQEAwIHgDAV +BgNVHSUEDjAMBgorBgEEAYPoXgIBMAoGCCqGSM49BAMCA0cAMEQCIEoOXctlLyTC +9ICtWt37D3UoLbtmGdPH/QKLuN5cEKmRAiABCe2qUdMzcrwR5rHJd2xQ8l4JZgv9 +t7/qA0tV/kHm6g== +-----END CERTIFICATE----- +)"; + +// certs/root_ca1_cert.pem +static constexpr absl::string_view kIssuerCertPem = + R"(-----BEGIN CERTIFICATE----- +MIIBaDCCAQ6gAwIBAgIQQjiBDyR9IFSiOk3YI9Vz6jAKBggqhkjOPQQDAjAUMRIw +EAYDVQQDEwlSb290IENBIDEwHhcNMDAwMTAxMDAwMDAwWhcNMjAwMTAxMDAwMDAw +WjAUMRIwEAYDVQQDEwlSb290IENBIDEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AATGlEZ2h/3CeOl6anwJPvk9rY0SbQAUnM7ogiSCh2Xh8A4MA/ZFvFxLokfUS+Le +Rew96qN9o6jC7EXGs5Dlcql2o0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUoD7enXLkSPQ4NFbWMsyPJw8CkREwCgYIKoZIzj0E +AwIDSAAwRQIgZHyZ6carq1CPrvQA87zDsfqVe1Hcy7/Hd2Pz8fTbn/YCIQC68wrO +LL8RURusZlo0c7KhG/TBTG368XayFcJbTq5piA== +-----END CERTIFICATE----- +)"; + +// ocsp/good_2012.pem +static constexpr absl::string_view kGoodOcspResponsePem = + R"(-----BEGIN OCSP RESPONSE----- +MIICeAoBAKCCAnEwggJtBgkrBgEFBQcwAQEEggJeMIICWjCBpKEbMBkxFzAVBgNV +BAMTDk9DU1AgUmVzcG9uZGVyGA8yMDEyMDEwMTAwMDAwMFowdDByMEowCQYFKw4D +AhoFAAQUH/QKbQfkANfLUhHO1/Ux3C+InIMEFLEaFwXj/kcm0icZbAOdlyD4YMUx +AhEAqY+Ga0uKJc71iAGCQ2xmSYAAGA8yMDEyMDEwMTAwMDAwMFqgERgPMjAxMjEy +MzEyMzU5NTlaMAoGCCqGSM49BAMCA0cAMEQCIFBWX+xMGwGlswHlzGoxUqNe4MQS +6M6rsH3WVhB6V1XqAiAH7ENBzOuqkiwuyn/Ll22OehLDOwhGkcPj4ngBH981X6CC +AVowggFWMIIBUjCB+aADAgECAhEAylWc524QSfWaV4Y1C8VtVTAKBggqhkjOPQQD +AjAUMRIwEAYDVQQDEwlSb290IENBIDEwHhcNMTAwMTAxMDAwMDAwWhcNMjQxMjMx +MjM1OTU5WjAZMRcwFQYDVQQDEw5PQ1NQIFJlc3BvbmRlcjBZMBMGByqGSM49AgEG +CCqGSM49AwEHA0IABBxOD6IfWwY2vJ/dQfo3iquqL7jqrliO6KquLxGJYOuhmGiX +mgKhnXHv9fAF3xnb09BHZMqVvkW/Vjqx3VvWRjajJzAlMA4GA1UdDwEB/wQEAwIH +gDATBgNVHSUEDDAKBggrBgEFBQcDCTAKBggqhkjOPQQDAgNIADBFAiAk4CANeeK7 +R5RQ+lgZkgiegSUaFu4pxHJzQ8yK2XnsqQIhAMevOsBvD9R6o5AmZJgn/dDsNwGQ +FuM38EKpIEjm+lH0 +-----END OCSP RESPONSE----- +)"; + +// ocsp/revoked_2012.pem +static constexpr absl::string_view kRevokedOcspResponsePem = + R"(-----BEGIN OCSP RESPONSE----- +MIICjQoBAKCCAoYwggKCBgkrBgEFBQcwAQEEggJzMIICbzCBt6EbMBkxFzAVBgNV +BAMTDk9DU1AgUmVzcG9uZGVyGA8yMDEyMDEwMTAwMDAwMFowgYYwgYMwSjAJBgUr +DgMCGgUABBQf9AptB+QA18tSEc7X9THcL4icgwQUsRoXBeP+RybSJxlsA52XIPhg +xTECEQCpj4ZrS4olzvWIAYJDbGZJoREYDzIwMTIwMTAxMDAwMDAwWhgPMjAxMjAx +MDEwMDAwMDBaoBEYDzIwMTIxMjMxMjM1OTU5WjAKBggqhkjOPQQDAgNJADBGAiEA +1N0hbCk0zfZUIWYqwwPmymq4l6/6379wQClNBvr+bWcCIQC8LPYY2wwrPGUa/LRu +hNmuv3wgUpapuqtrpRP9ool4S6CCAVowggFWMIIBUjCB+aADAgECAhEAylWc524Q +SfWaV4Y1C8VtVTAKBggqhkjOPQQDAjAUMRIwEAYDVQQDEwlSb290IENBIDEwHhcN +MTAwMTAxMDAwMDAwWhcNMjQxMjMxMjM1OTU5WjAZMRcwFQYDVQQDEw5PQ1NQIFJl +c3BvbmRlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBxOD6IfWwY2vJ/dQfo3 +iquqL7jqrliO6KquLxGJYOuhmGiXmgKhnXHv9fAF3xnb09BHZMqVvkW/Vjqx3VvW +RjajJzAlMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDCTAKBggq +hkjOPQQDAgNIADBFAiAk4CANeeK7R5RQ+lgZkgiegSUaFu4pxHJzQ8yK2XnsqQIh +AMevOsBvD9R6o5AmZJgn/dDsNwGQFuM38EKpIEjm+lH0 +-----END OCSP RESPONSE----- +)"; + +TEST(OcspVerifierTest, MatchingGoodResponse) { + auto subject_certs = LoadCertsFromPem(kSubjectCertPem); + ABSL_ASSERT_OK(subject_certs); + ASSERT_THAT(*subject_certs, SizeIs(1)); + + auto issuer_certs = LoadCertsFromPem(kIssuerCertPem); + ABSL_ASSERT_OK(issuer_certs); + ASSERT_THAT(*issuer_certs, SizeIs(1)); + + auto ocsp_response = LoadOcspResponseFromPem(kGoodOcspResponsePem); + ABSL_ASSERT_OK(ocsp_response); + + ValidationStatusSet status_set; + std::vector<std::string> ocsp_responses = {*ocsp_response}; + std::vector<std::string> trust_chain = {(*subject_certs)[0], + (*issuer_certs)[0]}; + + auto asserted_time = ParseTime("2012-06-01T00:00:00Z"); + ABSL_ASSERT_OK(asserted_time); + + MockCryptoReadHandler mock_crypto_handler; + EXPECT_CALL(mock_crypto_handler, + VerifyOcspResponse(*ocsp_response, (*subject_certs)[0], + (*issuer_certs)[0], *asserted_time)) + .WillOnce(::testing::Return(OCSPRevocationStatus::kGood)); + OcspVerifier verifier(&mock_crypto_handler); + + EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain, + *asserted_time, &status_set)); + + EXPECT_THAT(status_set.failures(), IsEmpty()); + EXPECT_THAT(status_set.informationals(), IsEmpty()); + EXPECT_THAT(status_set.successes(), + ElementsAre(C2paStatusIs( + SuccessStatusCode::kSigningCredentialOcspNotRevoked))); +} + +TEST(OcspVerifierTest, MatchingRevokedResponse) { + auto subject_certs = LoadCertsFromPem(kSubjectCertPem); + ABSL_ASSERT_OK(subject_certs); + ASSERT_THAT(*subject_certs, SizeIs(1)); + + auto issuer_certs = LoadCertsFromPem(kIssuerCertPem); + ABSL_ASSERT_OK(issuer_certs); + ASSERT_THAT(*issuer_certs, SizeIs(1)); + + auto ocsp_response = LoadOcspResponseFromPem(kRevokedOcspResponsePem); + ABSL_ASSERT_OK(ocsp_response); + + ValidationStatusSet status_set; + std::vector<std::string> ocsp_responses = {*ocsp_response}; + std::vector<std::string> trust_chain = {(*subject_certs)[0], + (*issuer_certs)[0]}; + + auto asserted_time = ParseTime("2012-06-01T00:00:00Z"); + ABSL_ASSERT_OK(asserted_time); + + MockCryptoReadHandler mock_crypto_handler; + EXPECT_CALL(mock_crypto_handler, + VerifyOcspResponse(*ocsp_response, (*subject_certs)[0], + (*issuer_certs)[0], *asserted_time)) + .WillOnce(::testing::Return(OCSPRevocationStatus::kRevoked)); + OcspVerifier verifier(&mock_crypto_handler); + + EXPECT_THAT(verifier.VerifyOcspResponses(ocsp_responses, trust_chain, + *asserted_time, &status_set), + IsFalse()); + + EXPECT_THAT(status_set.failures(), + ElementsAre(C2paStatusIs( + FailureStatusCode::kSigningCredentialOcspRevoked))); + EXPECT_THAT(status_set.informationals(), IsEmpty()); + EXPECT_THAT(status_set.successes(), IsEmpty()); +} + +TEST(OcspVerifierTest, MatchingRevokedResponseOnIntermediateCert) { + auto subject_certs = LoadCertsFromPem(kSubjectCertPem); + ABSL_ASSERT_OK(subject_certs); + ASSERT_THAT(*subject_certs, SizeIs(1)); + + auto issuer_certs = LoadCertsFromPem(kIssuerCertPem); + ABSL_ASSERT_OK(issuer_certs); + ASSERT_THAT(*issuer_certs, SizeIs(1)); + + auto ocsp_response = LoadOcspResponseFromPem(kRevokedOcspResponsePem); + ABSL_ASSERT_OK(ocsp_response); + + ValidationStatusSet status_set; + std::vector<std::string> ocsp_responses = {*ocsp_response}; + // Insert a fake leaf cert so that subject_certs[0] is considered an + // intermediate cert. + std::vector<std::string> trust_chain = {"fake_leaf_cert", (*subject_certs)[0], + (*issuer_certs)[0]}; + + auto asserted_time = ParseTime("2012-06-01T00:00:00Z"); + ABSL_ASSERT_OK(asserted_time); + + MockCryptoReadHandler mock_crypto_handler; + EXPECT_CALL(mock_crypto_handler, + VerifyOcspResponse(*ocsp_response, "fake_leaf_cert", + (*subject_certs)[0], *asserted_time)) + .WillOnce(::testing::Return(absl::InvalidArgumentError("no match"))); + EXPECT_CALL(mock_crypto_handler, + VerifyOcspResponse(*ocsp_response, (*subject_certs)[0], + (*issuer_certs)[0], *asserted_time)) + .WillOnce(::testing::Return(OCSPRevocationStatus::kRevoked)); + OcspVerifier verifier(&mock_crypto_handler); + + EXPECT_THAT(verifier.VerifyOcspResponses(ocsp_responses, trust_chain, + *asserted_time, &status_set), + IsFalse()); + + EXPECT_THAT(status_set.failures(), + ElementsAre(C2paStatusIs( + FailureStatusCode::kSigningCredentialUntrusted))); + EXPECT_THAT(status_set.informationals(), IsEmpty()); + EXPECT_THAT(status_set.successes(), IsEmpty()); +} + +TEST(OcspVerifierTest, MatchingGoodResponseOnIntermediateCert) { + auto subject_certs = LoadCertsFromPem(kSubjectCertPem); + ABSL_ASSERT_OK(subject_certs); + ASSERT_THAT(*subject_certs, SizeIs(1)); + + auto issuer_certs = LoadCertsFromPem(kIssuerCertPem); + ABSL_ASSERT_OK(issuer_certs); + ASSERT_THAT(*issuer_certs, SizeIs(1)); + + auto ocsp_response = LoadOcspResponseFromPem(kGoodOcspResponsePem); + ABSL_ASSERT_OK(ocsp_response); + + ValidationStatusSet status_set; + std::vector<std::string> ocsp_responses = {*ocsp_response}; + // Insert a fake leaf cert so that subject_certs[0] is considered an + // intermediate cert. + std::vector<std::string> trust_chain = {"fake_leaf_cert", (*subject_certs)[0], + (*issuer_certs)[0]}; + + auto asserted_time = ParseTime("2012-06-01T00:00:00Z"); + ABSL_ASSERT_OK(asserted_time); + + MockCryptoReadHandler mock_crypto_handler; + EXPECT_CALL(mock_crypto_handler, + VerifyOcspResponse(*ocsp_response, "fake_leaf_cert", + (*subject_certs)[0], *asserted_time)) + .WillOnce(::testing::Return(absl::InvalidArgumentError("no match"))); + EXPECT_CALL(mock_crypto_handler, + VerifyOcspResponse(*ocsp_response, (*subject_certs)[0], + (*issuer_certs)[0], *asserted_time)) + .WillOnce(::testing::Return(OCSPRevocationStatus::kGood)); + OcspVerifier verifier(&mock_crypto_handler); + + EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain, + *asserted_time, &status_set)); + + EXPECT_THAT(status_set.failures(), IsEmpty()); + EXPECT_THAT(status_set.informationals(), + ElementsAre(C2paStatusIs( + InformationalStatusCode::kSigningCredentialOcspSkipped))); + EXPECT_THAT(status_set.successes(), IsEmpty()); +} + +} // namespace +} // namespace credentio
diff --git a/cose/sig_structure.cc b/cose/sig_structure.cc new file mode 100644 index 0000000..055da02 --- /dev/null +++ b/cose/sig_structure.cc
@@ -0,0 +1,353 @@ +// 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 "cose/sig_structure.h" + +#include <cstdint> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_log.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "cbor/cbor.h" +#include "cbor/parse.h" +#include "cppbor/cppbor.h" +#include "crypto/algorithms.h" + +namespace credentio { +namespace { +// 'alg' in https://datatracker.ietf.org/doc/html/rfc8152#section-3.1 +constexpr int kSigningAlgorithmKey = 1; +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_obtaining_the_time_stamp +// for more details. +constexpr absl::string_view kTimestampV2HeaderKey = "sigTst2"; +// DEPRECATED. DO NOT GENERATE. Contents of 'sigTst' CBOR map. +constexpr absl::string_view kTimestampV1HeaderKey = "sigTst"; +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_create_a_temporary_claim_and_signature +// for more details about padding. +constexpr absl::string_view kPadHeaderKey = "pad"; +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_going_back_and_filling_in +// about using `pad2`. +constexpr absl::string_view kPad2HeaderKey = "pad2"; +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#x509_certificates +constexpr int kCertificateChainKey = 33; +constexpr absl::string_view kCertificateChainDeprecatedKey = "x5chain"; +constexpr absl::string_view kOcspValsKey = "ocspVals"; +constexpr absl::string_view kRvalsKey = "rVals"; + +// https://www.iana.org/assignments/cose/cose.xhtml#algorithms +absl::StatusOr<int64_t> GetAlgorithm(SigningAlgorithm e) { + switch (e) { + case SigningAlgorithm::kEs256: + return -7; + case SigningAlgorithm::kEdDsa: + return -8; + case SigningAlgorithm::kEs384: + return -35; + case SigningAlgorithm::kEs512: + return -36; + case SigningAlgorithm::kPs256: + return -37; + case SigningAlgorithm::kPs384: + return -38; + case SigningAlgorithm::kPs512: + return -39; + } + return absl::InvalidArgumentError("Unsupported algorithm"); +} + +absl::StatusOr<SigningAlgorithm> GetSigningAlgorithm(cbor::MapView headers) { + auto alg = headers.GetInt64(kSigningAlgorithmKey); + if (!alg.ok()) { + return absl::Status( + alg.status().code(), + absl::StrCat("no COSE algorithm specified in protected headers: ", + alg.status().message())); + } + // https://www.iana.org/assignments/cose/cose.xhtml#algorithms + ABSL_DVLOG(1) << ".cose_alg = " << *alg << ","; + switch (*alg) { + case -7: + return SigningAlgorithm::kEs256; + case -35: + return SigningAlgorithm::kEs384; + case -36: + return SigningAlgorithm::kEs512; + case -37: + return SigningAlgorithm::kPs256; + case -38: + return SigningAlgorithm::kPs384; + case -39: + return SigningAlgorithm::kPs512; + case -8: + return SigningAlgorithm::kEdDsa; + } + return absl::InvalidArgumentError( + absl::StrCat("unsupported COSE algorithm: ", *alg)); +} + +absl::StatusOr<std::vector<std::string>> GetCertificateChain( + cbor::MapView headers) { + // Check if there is a single certificate in the chain. In this case, label 33 + // of the header map (or "x5chain" key) is a bstr. + // Claim generators should only use 33 (integer) key but the validators should + // both accept 33 and "x5chain" keys. If both 33 (integer) and "x5chain" + // (string) keys are present, the label 33 should be used. + auto certificate_chain = headers.GetByteString(kCertificateChainKey); + if (!certificate_chain.ok()) { + certificate_chain = headers.GetByteString(kCertificateChainDeprecatedKey); + } + if (certificate_chain.ok()) { + return std::vector<std::string>({std::string(*certificate_chain)}); + } + // Check if there is a list of certificates in the chain. In this case, label + // 33 of the header map (or "x5chain" key) is an array of bstrs. + auto certificate_chain_array = headers.GetArray(kCertificateChainKey); + if (!certificate_chain_array.ok()) { + certificate_chain_array = headers.GetArray(kCertificateChainDeprecatedKey); + } + if (certificate_chain_array.ok()) { + std::vector<std::string> certificate_chain; + for (int i = 0; i < certificate_chain_array->size(); ++i) { + auto certificate_chain_element = + certificate_chain_array->GetByteString(i); + if (!certificate_chain_element.ok()) { + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse the certificate chain element: ", + certificate_chain_element.status())); + } + ABSL_DVLOG(1) << ".certs_b64[" << i << "] = R\"(" + << absl::Base64Escape(*certificate_chain_element) << ")\","; + certificate_chain.push_back(std::string(*certificate_chain_element)); + } + return certificate_chain; + } + return std::vector<std::string>(); +} + +std::vector<std::string> DecodeOcspResponses(cbor::MapView map) { + auto rvals = map.GetOptionalMap(kRvalsKey); + if (!rvals.has_value()) { + return {}; + } + auto ocsp_responses = rvals->GetArray(kOcspValsKey); + if (!ocsp_responses.ok()) { + return {}; + } + std::vector<std::string> v; + v.reserve(ocsp_responses->size()); + for (int i = 0; i < ocsp_responses->size(); ++i) { + auto ocsp_response = ocsp_responses->GetByteString(i); + if (ocsp_response.ok()) { + v.push_back(std::string(*ocsp_response)); + } + } + return v; +} + +} // namespace + +absl::StatusOr<std::string> EncodeProtectedHeader( + const ProtectedHeader& protected_header) { + cppbor::Map m; + ABSL_ASSIGN_OR_RETURN(auto alg, GetAlgorithm(protected_header.alg)); + m.add(cppbor::Uint(kSigningAlgorithmKey), cppbor::Nint(alg)); + if (protected_header.certificate_chain.size() == 1) { + m.add(cppbor::Uint(kCertificateChainKey), + cppbor::Bstr(protected_header.certificate_chain[0])); + } else { + cppbor::Array chain; + for (const auto& cert : protected_header.certificate_chain) { + chain.add(cppbor::Bstr(cert)); + } + m.add(cppbor::Uint(kCertificateChainKey), std::move(chain)); + } + return m.toString(); +} + +absl::StatusOr<ProtectedHeader> DecodeProtectedHeader(absl::string_view cbor) { + ProtectedHeader protected_header; + ABSL_ASSIGN_OR_RETURN(auto res, cbor::Parse(cbor)); + ABSL_ASSIGN_OR_RETURN(auto map, res->AsMap()); + ABSL_ASSIGN_OR_RETURN(auto alg, GetSigningAlgorithm(map)); + protected_header.alg = alg; + + ABSL_ASSIGN_OR_RETURN(auto certificate_chain, GetCertificateChain(map)); + protected_header.certificate_chain = std::move(certificate_chain); + return protected_header; +} + +void EncodeTstToken(const TstToken& tst_token, cppbor::Map& m) { + m.add(cppbor::Tstr("val"), cppbor::Bstr(tst_token.val)); +} + +absl::StatusOr<TstToken> DecodeTstToken(cbor::MapView map) { + ABSL_ASSIGN_OR_RETURN(auto val, map.GetByteString("val")); + return TstToken{.val = std::string(val)}; +} + +void EncodeTstContainer(const TstContainer& tst_container, cppbor::Map& m) { + cppbor::Array tstTokens; + for (const auto& tstToken : tst_container.tst_tokens) { + cppbor::Map tstTokenMap; + EncodeTstToken(tstToken, tstTokenMap); + tstTokens.add(std::move(tstTokenMap)); + } + m.add(cppbor::Tstr("tstTokens"), std::move(tstTokens)); +} + +absl::StatusOr<TstContainer> DecodeTstContainer(cbor::MapView map) { + ABSL_ASSIGN_OR_RETURN(auto tst_tokens, map.GetArray("tstTokens")); + TstContainer tst_container; + for (int i = 0; i < tst_tokens.size(); ++i) { + ABSL_ASSIGN_OR_RETURN(auto tst_token, tst_tokens.GetMap(i)); + ABSL_ASSIGN_OR_RETURN(auto tst_token_decoded, DecodeTstToken(tst_token)); + tst_container.tst_tokens.push_back(std::move(tst_token_decoded)); + } + return tst_container; +} + +void EncodeUnprotectedHeader(const UnprotectedHeader& unprotected_header, + cppbor::Map& m) { + if (unprotected_header.sig_tst2.has_value()) { + cppbor::Map tstContainer; + EncodeTstContainer(*unprotected_header.sig_tst2, tstContainer); + m.add(cppbor::Tstr(kTimestampV2HeaderKey), std::move(tstContainer)); + } + if (!unprotected_header.ocsp_responses.empty()) { + cppbor::Array ocsp_responses; + for (const auto& ocsp_response : unprotected_header.ocsp_responses) { + ocsp_responses.add(cppbor::Bstr(ocsp_response)); + } + cppbor::Map rvals; + rvals.add(cppbor::Tstr(kOcspValsKey), std::move(ocsp_responses)); + m.add(cppbor::Tstr(kRvalsKey), std::move(rvals)); + } + if (unprotected_header.pad.has_value()) { + m.add(cppbor::Tstr(kPadHeaderKey), cppbor::Bstr(*unprotected_header.pad)); + } + if (unprotected_header.pad2.has_value()) { + m.add(cppbor::Tstr(kPad2HeaderKey), cppbor::Bstr(*unprotected_header.pad2)); + } +} + +absl::StatusOr<UnprotectedHeader> DecodeUnprotectedHeader(cbor::MapView map) { + UnprotectedHeader unprotected_header; + auto sig_tst2 = map.GetOptionalMap(kTimestampV2HeaderKey); + if (sig_tst2.has_value()) { + ABSL_ASSIGN_OR_RETURN(unprotected_header.sig_tst2, + DecodeTstContainer(*sig_tst2)); + } + auto sig_tst = map.GetOptionalMap(kTimestampV1HeaderKey); + if (sig_tst.has_value()) { + ABSL_ASSIGN_OR_RETURN(unprotected_header.sig_tst, + DecodeTstContainer(*sig_tst)); + } + unprotected_header.ocsp_responses = DecodeOcspResponses(map); + auto pad = map.GetOptionalByteString(kPadHeaderKey); + if (pad.has_value()) { + unprotected_header.pad = std::string(*pad); + } + auto pad2 = map.GetOptionalByteString(kPad2HeaderKey); + if (pad2.has_value()) { + unprotected_header.pad2 = std::string(*pad2); + } + auto certificate_chain = GetCertificateChain(map); + if (certificate_chain.ok()) { + unprotected_header.certificate_chain = *std::move(certificate_chain); + } + return unprotected_header; +} + +std::string EncodeSig1Structure(const Sig1Structure& sig_structure) { + cppbor::Array s; + s.add(cppbor::Tstr(sig_structure.context)); + s.add(cppbor::Bstr(sig_structure.body_protected)); + s.add(cppbor::Bstr(sig_structure.external_aad)); + s.add(cppbor::Bstr(sig_structure.payload)); + return s.toString(); +} + +std::string EncodeCoseSign1TaggedStructure(const CoseSign1TaggedStructure& s) { + cppbor::Array array; + // Add protected_header. + array.add(cppbor::Bstr(s.protected_header)); + // Add unprotected_header. + cppbor::Map unprotected_header_map; + EncodeUnprotectedHeader(s.unprotected_header, unprotected_header_map); + array.add(std::move(unprotected_header_map)); + // Add payload. + if (s.payload.has_value()) { + array.add(cppbor::Bstr(*s.payload)); + } else { + array.add(cppbor::Null()); + } + // Add signature. + array.add(cppbor::Bstr(s.signature)); + // tag 18 see https://datatracker.ietf.org/doc/html/rfc8152#section-4.2 + cppbor::SemanticTag tagged_cbor(18, std::move(array)); + return tagged_cbor.toString(); +} + +absl::StatusOr<CoseSign1TaggedStructure> DecodeCoseSign1TaggedStructure( + absl::string_view cbor) { + // COSE_Sign1 structure is a CBOR array of size 4. + // CDDL schema for the case of a single signer is: + // + // COSE_Sign1 = [ + // protected: bstr, + // unprotected: map, + // payload: bstr / nil, + // signature: bstr + // ] + // + // See https://datatracker.ietf.org/doc/html/rfc9052 for more + // details. + ABSL_ASSIGN_OR_RETURN(auto res, cbor::Parse(cbor)); + ABSL_ASSIGN_OR_RETURN(auto array, res->AsArray()); + CoseSign1TaggedStructure s; + ABSL_ASSIGN_OR_RETURN(s.protected_header, array.GetByteString(0)); + ABSL_ASSIGN_OR_RETURN(auto unprotect_header_cbor, array.GetMap(1)); + ABSL_ASSIGN_OR_RETURN(s.unprotected_header, + DecodeUnprotectedHeader(unprotect_header_cbor)); + ABSL_ASSIGN_OR_RETURN(auto is_payload_null, array.IsNull(2)); + if (!is_payload_null) { + ABSL_ASSIGN_OR_RETURN(auto payload, array.GetByteString(2)); + s.payload.emplace(payload); + } + + // In COSE standard, there can also be an array of signatures instead of a + // single signature, if there are multiple signers. However, in C2PA we will + // always have a single signer. + ABSL_ASSIGN_OR_RETURN(auto signature, array.GetByteString(3)); + s.signature = std::string(signature); + return s; +} + +std::string CoseSign1TaggedStructure::signature_bstr() const { + return cppbor::Bstr(signature).toString(); +} + +} // namespace credentio
diff --git a/cose/sig_structure.h b/cose/sig_structure.h new file mode 100644 index 0000000..960ad43 --- /dev/null +++ b/cose/sig_structure.h
@@ -0,0 +1,137 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_SIG_STRUCTURE_H_ +#define THIRD_PARTY_CREDENTIO_COSE_SIG_STRUCTURE_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" + +namespace credentio { + +struct ProtectedHeader { + SigningAlgorithm alg; + std::vector<std::string> certificate_chain; + + bool operator==(const ProtectedHeader& other) const { + return alg == other.alg && certificate_chain == other.certificate_chain; + } +}; + +// Encodes ProtectedHeader as a serialized CBOR map. +absl::StatusOr<std::string> EncodeProtectedHeader( + const ProtectedHeader& protected_header); + +// Decodes ProtectedHeader serialized as a CBOR map. +absl::StatusOr<ProtectedHeader> DecodeProtectedHeader(absl::string_view cbor); + +// Sig_structure with sign_protected field omitted. +// Sig_structure = [ +// context : "Signature" / "Signature1" / "CounterSignature", +// body_protected : empty_or_serialized_map, +// external_aad : bstr, +// payload : bstr +// ] +struct Sig1Structure { + std::string context; + std::string body_protected; + std::string external_aad; + std::string payload; + + bool operator==(const Sig1Structure& other) const { + return context == other.context && body_protected == other.body_protected && + external_aad == other.external_aad && payload == other.payload; + } +}; + +// Encodes Sig1Structure to a CBOR byte string. +std::string EncodeSig1Structure(const Sig1Structure& sig_structure); + +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp +struct TstToken { + std::string val; // bstr in the CBOR. + + bool operator==(const TstToken& other) const { return val == other.val; } +}; + +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp +struct TstContainer { + std::vector<TstToken> tst_tokens; // contents of 'tstTokens' CBOR array. + + bool operator==(const TstContainer& other) const { + return tst_tokens == other.tst_tokens; + } +}; + +struct UnprotectedHeader { + // See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp. + std::optional<TstContainer> sig_tst2; // contents of 'sigTst2' CBOR map. + // DEPRECATED. DO NOT GENERATE. Contents of 'sigTst' CBOR map. + std::optional<TstContainer> sig_tst; + // DEPRECATED. DO NOT GENERATE. Contents of certificate chain, which should be + // in the protected header. + std::vector<std::string> certificate_chain; + // See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_certificate_revocation. + std::vector<std::string> ocsp_responses; // contents of `ocspVals` array. + // See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_going_back_and_filling_in + // about padding. + std::optional<std::string> pad; // contents of 'pad' CBOR bstr. + std::optional<std::string> pad2; // contents of 'pad2' CBOR bstr. + + bool operator==(const UnprotectedHeader& other) const { + return sig_tst2 == other.sig_tst2 && sig_tst == other.sig_tst && + certificate_chain == other.certificate_chain && + ocsp_responses == other.ocsp_responses && pad == other.pad && + pad2 == other.pad2; + } +}; + +// COSE_Sign1 = [ +// protected: bstr, +// unprotected: map, +// payload: bstr / nil, +// signature: bstr +// ] +struct CoseSign1TaggedStructure { + std::string protected_header; + UnprotectedHeader unprotected_header; + std::optional<std::string> payload = std::nullopt; + std::string signature; + // Returns the signature as a CBOR-encoded byte string. + std::string signature_bstr() const; + + bool operator==(const CoseSign1TaggedStructure& other) const { + return protected_header == other.protected_header && + unprotected_header == other.unprotected_header && + payload == other.payload && signature == other.signature; + } +}; + +std::string EncodeCoseSign1TaggedStructure(const CoseSign1TaggedStructure& s); +absl::StatusOr<CoseSign1TaggedStructure> DecodeCoseSign1TaggedStructure( + absl::string_view cbor); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_SIG_STRUCTURE_H_
diff --git a/cose/sig_structure_test.cc b/cose/sig_structure_test.cc new file mode 100644 index 0000000..f9d87f0 --- /dev/null +++ b/cose/sig_structure_test.cc
@@ -0,0 +1,340 @@ +// 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 "cose/sig_structure.h" + +#include <string> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "cbor/cbor.h" +#include "cbor/parse.h" +#include "crypto/algorithms.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "testing/cbor_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; + +absl::StatusOr<Sig1Structure> DecodeSig1Structure(absl::string_view cbor) { + ABSL_ASSIGN_OR_RETURN(auto res, cbor::Parse(cbor)); + ABSL_ASSIGN_OR_RETURN(auto array, res->AsArray()); + Sig1Structure s; + + auto context = array.GetString(0); + if (!context.ok()) { + return absl::Status( + context.status().code(), + absl::StrCat("could not get `context`: ", context.status().message())); + } + s.context = std::string(*context); + + auto body_protected = array.GetByteString(1); + if (!body_protected.ok()) { + return absl::Status(body_protected.status().code(), + absl::StrCat("could not get `body_protected`: ", + body_protected.status().message())); + } + s.body_protected = std::string(*body_protected); + + auto external_aad = array.GetByteString(2); + if (!external_aad.ok()) { + return absl::Status(external_aad.status().code(), + absl::StrCat("could not get `external_aad`: ", + external_aad.status().message())); + } + s.external_aad = std::string(*external_aad); + + auto payload = array.GetByteString(3); + if (!payload.ok()) { + return absl::Status( + payload.status().code(), + absl::StrCat("could not get `payload`: ", payload.status().message())); + } + s.payload = std::string(*payload); + + return s; +} + +std::string Base64Unescape(absl::string_view base64) { + std::string bytes; + CHECK(absl::Base64Unescape(base64, &bytes)); + return bytes; +} + +// Asserts the value is a byte string, and equals to the `hex` argument in hex +// representation. +MATCHER_P(IsBytesEqHexStr, hex, "") { + auto hex_arg = absl::BytesToHexString(arg); + *result_listener << absl::StrCat("Hex strings ", hex, " and ", hex_arg, + " are not equal"); + return hex == hex_arg; +} + +TEST(SigStructureTest, Sig1StructureTestVectorSuccess) { + const Sig1Structure payload = { + .context = "Signature1", + .body_protected = "body_protected", + .external_aad = "external_aad", + .payload = "payload", + }; + + const std::string cbor = EncodeSig1Structure(payload); + EXPECT_THAT( + cbor, IsBytesEqHexStr( + "846a5369676e6174757265314e626f64795f70726f7465637465644c657874" + "65726e616c5f616164477061796c6f6164")); + + EXPECT_THAT(DecodeSig1Structure(cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderEdDsa) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kEdDsa, + .certificate_chain = {"abcd"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderEs256) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kEs256, + .certificate_chain = {"abcd"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} +TEST(SigStructureTest, ProtectedHeaderEs384) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kEs384, + .certificate_chain = {"abcd"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderEs512) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kEs512, + .certificate_chain = {"abcd"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderPs256) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kPs256, + .certificate_chain = {"abcd"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderPs384) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kPs384, + .certificate_chain = {"abcd"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderPs512) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kPs512, + .certificate_chain = {"abcd"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderMultipleCerts) { + const ProtectedHeader payload = { + .alg = SigningAlgorithm::kPs512, + .certificate_chain = {"cert1", "cert2"}, + }; + + auto cbor = EncodeProtectedHeader(payload); + ASSERT_TRUE(cbor.ok()); + EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, ProtectedHeaderUnsupportedAlgorithm) { + // hex for {1: -100, 33: "abcd"} + auto cbor = absl::HexStringToBytes("a2013863214461626364"); + EXPECT_THAT(DecodeProtectedHeader(cbor), + StatusIs(absl::StatusCode::kInvalidArgument, + "unsupported COSE algorithm: -100")); +} + +TEST(SigStructureTest, CoseSign1TaggedStructureEncodeNullPayload) { + const CoseSign1TaggedStructure payload = { + .protected_header = "abc", + .signature = "def", + }; + const std::string cbor = EncodeCoseSign1TaggedStructure(payload); + EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload)); +} +TEST(SigStructureTest, CoseSign1TaggedStructureEncodeNonNullPayload) { + const CoseSign1TaggedStructure payload = { + .protected_header = "abc", + .payload = "xyz", + .signature = "def", + }; + const std::string cbor = EncodeCoseSign1TaggedStructure(payload); + EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload)); +} +TEST(SigStructureTest, CoseSign1TaggedStructureEncodeTstTokens) { + const CoseSign1TaggedStructure payload = { + .protected_header = "abc", + .unprotected_header = {.sig_tst2 = + TstContainer{.tst_tokens = {{.val = "val1"}, + {.val = "val2"}}}}, + .signature = "def", + }; + const std::string cbor = EncodeCoseSign1TaggedStructure(payload); + EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, CoseSign1TaggedStructureEncodeOcspResponses) { + const CoseSign1TaggedStructure payload = { + .protected_header = "abc", + .unprotected_header = {.ocsp_responses = {"ocsp1", "ocsp2"}}, + .signature = "def", + }; + const std::string cbor = EncodeCoseSign1TaggedStructure(payload); + EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, CoseSign1TaggedStructureEncodePad) { + const CoseSign1TaggedStructure payload = { + .protected_header = "abc", + .unprotected_header = {.pad = "padding"}, + .signature = "def", + }; + const std::string cbor = EncodeCoseSign1TaggedStructure(payload); + EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload)); +} + +TEST(SigStructureTest, CoseSign1TaggedStructureDecodeSignature) { + auto protected_headers_b64 = + R"(cHJvdGVjdGVkX2hlYWRlcg==)"; // "protected_header" + auto timestamp_cms_b64 = R"(dGltZXN0YW1w)"; // "timestamp" + auto signature_b64 = R"(c2lnbmF0dXJl)"; // "signature" + auto signature = cbor::FromJson(absl::Substitute( + R"json(["b64'$0'", {"sigTst2": {"tstTokens": [{"val": "b64'$1'"}]}}, null, "b64'$2'"])json", + protected_headers_b64, timestamp_cms_b64, signature_b64)); + // Prepend the COSE_Sign1 tag (Major type 6 addl value 18). + signature = "\xD2" + signature; + CoseSign1TaggedStructure expected{ + .protected_header = Base64Unescape(protected_headers_b64), + .unprotected_header = + { + .sig_tst2 = + TstContainer{ + .tst_tokens = {{.val = + Base64Unescape(timestamp_cms_b64)}}, + }, + }, + .signature = Base64Unescape(signature_b64), + }; + EXPECT_THAT(DecodeCoseSign1TaggedStructure(signature), + IsOkAndHolds(expected)); + EXPECT_EQ(EncodeCoseSign1TaggedStructure(expected), signature); +}; + +TEST(SigStructureTest, DecodeUnprotectedHeaderContainingSigTst) { + auto protected_headers_b64 = + R"(cHJvdGVjdGVkX2hlYWRlcg==)"; // "protected_header" + auto timestamp_cms_b64 = R"(dGltZXN0YW1w)"; // "timestamp" + auto signature_b64 = R"(c2lnbmF0dXJl)"; // "signature" + auto signature = cbor::FromJson(absl::Substitute( + R"json(["b64'$0'", {"sigTst": {"tstTokens": [{"val": "b64'$1'"}]}}, null, "b64'$2'"])json", + protected_headers_b64, timestamp_cms_b64, signature_b64)); + // Prepend the COSE_Sign1 tag (Major type 6 addl value 18). + signature = "\xD2" + signature; + CoseSign1TaggedStructure expected{ + .protected_header = Base64Unescape(protected_headers_b64), + .unprotected_header = + { + .sig_tst = + TstContainer{ + .tst_tokens = {{.val = + Base64Unescape(timestamp_cms_b64)}}, + }, + }, + .signature = Base64Unescape(signature_b64), + }; + EXPECT_THAT(DecodeCoseSign1TaggedStructure(signature), + IsOkAndHolds(expected)); +} + +TEST(SigStructureTest, DecodeUnprotectedHeaderContainingCertificateChain) { + auto protected_headers_b64 = + R"(cHJvdGVjdGVkX2hlYWRlcg==)"; // "protected_header" + auto timestamp_cms_b64 = R"(dGltZXN0YW1w)"; // "timestamp" + auto signature_b64 = R"(c2lnbmF0dXJl)"; // "signature" + auto certificate_64 = R"(Y2VydGlmaWNhdGU=)"; // "certificate" + auto signature = cbor::FromJson(absl::Substitute( + R"json(["b64'$0'", {"sigTst": {"tstTokens": [{"val": "b64'$1'"}]}, "x5chain": ["b64'$2'"]}, null, "b64'$3'"])json", + protected_headers_b64, timestamp_cms_b64, certificate_64, signature_b64)); + // Prepend the COSE_Sign1 tag (Major type 6 addl value 18). + signature = "\xD2" + signature; + CoseSign1TaggedStructure expected{ + .protected_header = Base64Unescape(protected_headers_b64), + .unprotected_header = + { + .sig_tst = + TstContainer{ + .tst_tokens = {{.val = + Base64Unescape(timestamp_cms_b64)}}, + }, + .certificate_chain = {Base64Unescape(certificate_64)}, + }, + .signature = Base64Unescape(signature_b64), + }; + EXPECT_THAT(DecodeCoseSign1TaggedStructure(signature), + IsOkAndHolds(expected)); +} + +} // namespace +} // namespace credentio
diff --git a/cose/simple_cms_parser.cc b/cose/simple_cms_parser.cc new file mode 100644 index 0000000..fc2cc18 --- /dev/null +++ b/cose/simple_cms_parser.cc
@@ -0,0 +1,225 @@ +// 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 "cose/simple_cms_parser.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/algorithm/container.h" +#include "absl/memory/memory.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/default/cms/certificates.h" +#include "crypto/default/cms/cms_error_code.h" +#include "crypto/default/cms/cms_parser.h" +#include "crypto/default/cms/oids.h" +#include "crypto/default/cms/verify_signature.h" +#include "openssl/asn1.h" +#include "openssl/bytestring.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/stack.h" +#include "openssl/x509.h" +#include "tsp/constants.h" +#include "tsp/parsed_timestamp_token.h" + +namespace credentio { + +namespace { + +absl::StatusOr<std::string> GetDer(X509* x509) { + uint8_t* der_ptr = nullptr; + int der_len = i2d_X509(x509, &der_ptr); + bssl::UniquePtr<uint8_t> der(der_ptr); + + if (der_len <= 0) { + return absl::InvalidArgumentError( + "cannot convert certificate within timestamp token to DER"); + } + return std::string(reinterpret_cast<char*>(der_ptr), der_len); +} + +} // namespace + +SimpleParsedTimestampToken::SimpleParsedTimestampToken(absl::string_view data) + : data_(data) {} + +absl::StatusOr<std::unique_ptr<SimpleParsedTimestampToken>> +SimpleParsedTimestampToken::Create(absl::string_view timestamp_token) { + auto token = + absl::WrapUnique(new SimpleParsedTimestampToken(timestamp_token)); + std::string error_message; + credentio_cms::ErrorCode err = credentio_cms::ParseCms( + token->data_.data(), token->data_.size(), &token->cms_, &error_message); + + if (err != credentio_cms::ErrorCode::OK) { + return absl::InvalidArgumentError( + absl::StrCat("could not parse TimeStampToken cms: ", error_message)); + } + + if (!credentio_cms::CompareOid(token->cms_.content_type.cbs(), + credentio_cms::kCtTstInfoOid, + sizeof(credentio_cms::kCtTstInfoOid))) { + return absl::InvalidArgumentError( + "TimeStampToken eContentType is not id-ct-TSTInfo"); + } + return token; +} + +absl::StatusOr<const credentio_cms::SignerInfo*> +SimpleParsedTimestampToken::GetSingleSignerInfo() const { + const std::vector<credentio_cms::SignerInfo>& signer_infos = cms_.signers; + if (signer_infos.size() != 1) { + return absl::InvalidArgumentError( + absl::StrFormat("wrong number of signer infos: timestamp has %d " + "signers; exactly 1 is required", + signer_infos.size())); + } + return &signer_infos[0]; +} + +absl::StatusOr<X509*> SimpleParsedTimestampToken::GetSignerCert( + const STACK_OF(X509) * certs, + const credentio_cms::SignerInfo& signer_info) const { + std::vector<X509*> tsa_certs = + credentio_cms::GetSignerCertificates(certs, signer_info); + if (tsa_certs.empty() || tsa_certs[0] == nullptr) { + return absl::UnauthenticatedError( + "timestamp does not contain TSA certificate"); + } + return tsa_certs[0]; +} + +absl::Status SimpleParsedTimestampToken::VerifySignature() const { + ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo()); + + bssl::UniquePtr<STACK_OF(X509)> certs( + credentio_cms::GetAllCertificates(cms_)); + if (!certs) { + return absl::UnauthenticatedError("failed to parse certificates"); + } + + ABSL_ASSIGN_OR_RETURN(auto tsa_cert, + GetSignerCert(certs.get(), *signer_info)); + + return credentio_cms::VerifySignature(cms_, *signer_info, cms_.content, + *tsa_cert) + .status(); +} + +absl::StatusOr<HashAlgorithm> +SimpleParsedTimestampToken::GetMessageImprintHashAlgorithm() const { + ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo()); + return NidToHashAlgorithm( + OBJ_cbs2nid(signer_info->digest_algorithm.algorithm_oid.cbs_ptr())); +} + +absl::StatusOr<std::vector<std::string>> +SimpleParsedTimestampToken::GetCertificateChain() const { + ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo()); + + bssl::UniquePtr<STACK_OF(X509)> certs( + credentio_cms::GetAllCertificates(cms_)); + if (!certs) { + return absl::UnauthenticatedError("failed to parse certificates"); + } + + ABSL_ASSIGN_OR_RETURN(auto tsa_cert, + GetSignerCert(certs.get(), *signer_info)); + + std::vector<std::string> chain; + chain.reserve(sk_X509_num(certs.get()) + 1); + + ABSL_ASSIGN_OR_RETURN(auto tsa_der, GetDer(tsa_cert)); + chain.push_back(std::move(tsa_der)); + + for (size_t i = 0; i < sk_X509_num(certs.get()); ++i) { + X509* x509 = sk_X509_value(certs.get(), i); + if (X509_cmp(x509, tsa_cert) != 0) { + ABSL_ASSIGN_OR_RETURN(auto der, GetDer(x509)); + chain.push_back(std::move(der)); + } + } + return chain; +} + +absl::StatusOr<absl::string_view> +SimpleParsedTimestampToken::GetSigningCertificateV2Bytes() const { + ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo()); + + const auto& attributes = signer_info->signed_attributes; + auto v2_attribute = absl::c_find_if( + attributes, [](const credentio_cms::Attribute& attribute) -> bool { + const CBS* cbs = &attribute.type.cbs(); + bssl::UniquePtr<ASN1_OBJECT> oid( + ASN1_OBJECT_create(NID_undef, CBS_data(cbs), CBS_len(cbs), + /*sn=*/nullptr, /*ln=*/nullptr)); + if (!oid) { + return false; + } + return OBJ_cmp(IdAaSigningCertificateV2(), oid.get()) == 0; + }); + + if (v2_attribute == attributes.end()) { + return absl::InvalidArgumentError( + "invalid signed attributes; TSA did not set the " + "\"SigningCertificateV2\" signed attribute"); + } + + if (v2_attribute->values.size() != 1) { + return absl::InvalidArgumentError( + absl::StrFormat("SigningCertificateV2 signed attribute contains the " + "wrong number of entries; got %d entries, expect 1", + v2_attribute->values.size())); + } + + CBS signing_certificate_cbs = v2_attribute->values[0].cbs(); + const char* data = + reinterpret_cast<const char*>(CBS_data(&signing_certificate_cbs)); + size_t len = CBS_len(&signing_certificate_cbs); + return absl::string_view(data, len); +} + +absl::StatusOr<absl::string_view> SimpleParsedTimestampToken::GetTstInfoBytes() + const { + if (cms_.content.size() != 1) { + return absl::InvalidArgumentError( + absl::StrFormat("TSTInfo content contains unexpected number of chunks; " + "got %d chunks, expect 1", + cms_.content.size())); + } + const CBS& cbs = cms_.content[0].cbs(); + const char* data = reinterpret_cast<const char*>(CBS_data(&cbs)); + size_t len = CBS_len(&cbs); + return absl::string_view(data, len); +} + +absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> +SimpleCmsParser::ParseTimestampToken(absl::string_view timestamp_token) const { + return SimpleParsedTimestampToken::Create(timestamp_token); +} + +} // namespace credentio
diff --git a/cose/simple_cms_parser.h b/cose/simple_cms_parser.h new file mode 100644 index 0000000..8a966ee --- /dev/null +++ b/cose/simple_cms_parser.h
@@ -0,0 +1,77 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_SIMPLE_CMS_PARSER_H_ +#define THIRD_PARTY_CREDENTIO_COSE_SIMPLE_CMS_PARSER_H_ + +#include <memory> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/default/cms/cms_parser.h" +#include "openssl/stack.h" +#include "openssl/x509.h" +#include "tsp/cms_parser.h" +#include "tsp/parsed_timestamp_token.h" + +namespace credentio { + +// ParsedTimestampToken implementation backed by the SimpleCMS library +class SimpleParsedTimestampToken : public ParsedTimestampToken { + public: + static absl::StatusOr<std::unique_ptr<SimpleParsedTimestampToken>> Create( + absl::string_view timestamp_token); + + // SimpleParsedTimestampToken is self-referential (cms_ contains pointers into + // data_). Copying or moving this object would invalidate those pointers. + SimpleParsedTimestampToken(const SimpleParsedTimestampToken&) = delete; + SimpleParsedTimestampToken& operator=(const SimpleParsedTimestampToken&) = + delete; + + ~SimpleParsedTimestampToken() override = default; + + absl::Status VerifySignature() const override; + absl::StatusOr<HashAlgorithm> GetMessageImprintHashAlgorithm() const override; + absl::StatusOr<std::vector<std::string>> GetCertificateChain() const override; + absl::StatusOr<absl::string_view> GetSigningCertificateV2Bytes() + const override; + absl::StatusOr<absl::string_view> GetTstInfoBytes() const override; + + private: + explicit SimpleParsedTimestampToken(absl::string_view data); + absl::StatusOr<const credentio_cms::SignerInfo*> GetSingleSignerInfo() const; + absl::StatusOr<X509*> GetSignerCert( + const STACK_OF(X509) * certs, + const credentio_cms::SignerInfo& signer_info) const; + + const std::string data_; + credentio_cms::Content cms_; +}; + +// CmsParser implementation backed by the SimpleCMS library +// (//third_party/simple_cms). +class SimpleCmsParser : public CmsParser { + public: + absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> ParseTimestampToken( + absl::string_view timestamp_token) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_SIMPLE_CMS_PARSER_H_
diff --git a/cose/simple_cms_parser_test.cc b/cose/simple_cms_parser_test.cc new file mode 100644 index 0000000..62a0bae --- /dev/null +++ b/cose/simple_cms_parser_test.cc
@@ -0,0 +1,87 @@ +// 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 "cose/simple_cms_parser.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/escaping.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "tsp/parsed_timestamp_token.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::IsEmpty; +using ::testing::Not; + +constexpr absl::string_view kTestTimestampBase64 = + "MIIELgYJKoZIhvcNAQcCoIIEHzCCBBsCAQMxDzANBglghkgBZQMEAgEFADBtBgsqhkiG9w0B" + "CRABBKBeBFwwWgIBAQYLKoZIhvcSBAGEtwkwLzALBglghkgBZQMEAgEEIEvMhz4zDmm90cO7" + "V4KzzQtZsdcDbHNnB3pLBuM40dwzAgEBGA8yMDI1MDMwODAzMDYwN1owAAEB/6CCAdswggHX" + "MIIBXKADAgECAhRpQze1ZW5xPO9VZQ1jQeMUoEASTDAKBggqhkjOPQQDAjAmMSQwIgYDVQQD" + "DBtHb29nbGUgQzJQQSBUZXN0aW5nIFJvb3QgQ0EwIBcNMjUwMjA0MjE0MjE0WhgPMjEyNTAx" + "MTEyMTQyMTRaMCIxIDAeBgNVBAMMF0dvb2dsZSBDMlBBIFRlc3RpbmcgVFNBMFkwEwYHKoZI" + "zj0CAQYIKoZIzj0DAQcDQgAEuUuyy7wwl0MJLdRXgI9wjWXiWdDtjpXuMBoBdO3vBtGc3yVu" + "NwyZ/NjiaqFzocy0VipiPhR47u22OS/MjY98aaNqMGgwDgYDVR0PAQH/BAQDAgeAMBYGA1Ud" + "JQEB/wQMMAoGCCsGAQUFBwMIMB0GA1UdDgQWBBR5bJKsXm0pzYLB7sDOTEAtdVfIjDAfBgNV" + "HSMEGDAWgBTTMOrQr2ny9gYyywPGUZGx/KENtzAKBggqhkjOPQQDAgNpADBmAjEA/AIooYzK" + "ZhXynaBcMPIlIxscmd8e78W995pZgXprq3AIK3sgV6ElZvKD9/Wn/dDtAjEArTIsjov8VNA9" + "FoHedkGbg5zD1BnXvWqKwMp3XBbzeaOYUCn1o0WUW/bJC30DU14RMYIBtTCCAbECAQEwPjAm" + "MSQwIgYDVQQDDBtHb29nbGUgQzJQQSBUZXN0aW5nIFJvb3QgQ0ECFGlDN7VlbnE871VlDWNB" + "4xSgQBJMMA0GCWCGSAFlAwQCAQUAoIIBAjAvBgkqhkiG9w0BCQQxIgQgN8dNxTh6LWv1EaL+" + "ofr4okM1B8iSO/Gl00H5EaCJS+YwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqG" + "SIb3DQEJBTEPFw0xNTAzMzAxODAxMjNaMFwGCSqGSIb3DQEJDzFPME0wCwYJYIZIAWUDBAEq" + "MAsGCWCGSAFlAwQBFjALBglghkgBZQMEAQIwCgYIKoZIhvcNAwcwCwYJKoZIhvcNAQEHMAsG" + "CWCGSAFlAwQCATA3BgsqhkiG9w0BCRACLzEoMCYwJDAiBCDJQ1abcB8vef+N3PQLpUzfC7V8" + "WkIdUyUW/U2T9Y4EqDANBgkqhkiG9w0BAQEFAARIMEYCIQC4cdlBBcD7HlrGTj+8Kcfjj2+i" + "/xnE9/c61emmfJfTlQIhAKTiTMV71p0tHgFHyxsCIyIPCc96VfgYQ1qBMBolMeHf"; + +TEST(SimpleCmsParserTest, ParseValidTimestampToken) { + std::string timestamp; + ASSERT_TRUE(absl::Base64Unescape(kTestTimestampBase64, ×tamp)); + SimpleCmsParser parser; + auto parsed_token = parser.ParseTimestampToken(timestamp); + ASSERT_TRUE(parsed_token.ok()); + std::unique_ptr<ParsedTimestampToken> token = std::move(*parsed_token); + ASSERT_NE(token, nullptr); + + EXPECT_THAT(token->VerifySignature(), IsOk()); + EXPECT_THAT(token->GetMessageImprintHashAlgorithm(), + IsOkAndHolds(HashAlgorithm::kSha256)); + EXPECT_THAT(token->GetCertificateChain(), IsOkAndHolds(Not(IsEmpty()))); + EXPECT_THAT(token->GetSigningCertificateV2Bytes(), + IsOkAndHolds(Not(IsEmpty()))); + EXPECT_THAT(token->GetTstInfoBytes(), IsOkAndHolds(Not(IsEmpty()))); +} + +TEST(SimpleCmsParserTest, ParseInvalidCmsBytes) { + SimpleCmsParser parser; + EXPECT_THAT(parser.ParseTimestampToken("invalid binary data"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +} // namespace +} // namespace credentio
diff --git a/cose/validation_status_util.h b/cose/validation_status_util.h new file mode 100644 index 0000000..1d2edae --- /dev/null +++ b/cose/validation_status_util.h
@@ -0,0 +1,55 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_VALIDATION_STATUS_UTIL_H_ +#define THIRD_PARTY_CREDENTIO_COSE_VALIDATION_STATUS_UTIL_H_ + +#include <type_traits> + +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "proto/validation_status.pb.h" + +namespace credentio { + +struct RecordOptions { + absl::string_view url = ""; + absl::string_view explanation = ""; +}; + +template <typename StatusCode> +void RecordStatus(ValidationStatusSet* status_set, StatusCode code, + RecordOptions options = {}) { + ValidationStatus* status = nullptr; + if constexpr (std::is_same_v<StatusCode, SuccessStatusCode>) { + status = status_set->add_successes(); + } else if constexpr (std::is_same_v<StatusCode, InformationalStatusCode>) { + status = status_set->add_informationals(); + } else if constexpr (std::is_same_v<StatusCode, FailureStatusCode>) { + status = status_set->add_failures(); + } + status->set_code(absl::StrCat(code)); + if (!options.url.empty()) { + status->set_url(options.url); + } + if (!options.explanation.empty()) { + status->set_explanation(options.explanation); + } +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_VALIDATION_STATUS_UTIL_H_
diff --git a/cose/verifier.cc b/cose/verifier.cc new file mode 100644 index 0000000..9e27c89 --- /dev/null +++ b/cose/verifier.cc
@@ -0,0 +1,89 @@ +// 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 "cose/verifier.h" + +#include <memory> +#include <optional> +#include <utility> + +#include "absl/log/check.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "cose/cose_sign1.h" +#include "cose/sig_structure.h" +#include "cose/validation_status_util.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "google/protobuf/timestamp.pb.h" +#include "proto/cose_verification_result.pb.h" +#include "proto/signature_info.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { +namespace { + +class CoseVerifierImpl : public CoseVerifier { + public: + explicit CoseVerifierImpl(CoseVerifierOptions options) + : options_(std::move(options)) {} + CoseVerificationResult Verify(absl::string_view cose_signature, + absl::string_view data) const override { + CoseVerificationResult result; + ValidationStatusSet* status_set = result.mutable_validation_status(); + absl::StatusOr<CoseSign1TaggedStructure> cose_sign1_struct = + DecodeCoseSign1TaggedStructure(cose_signature); + if (!cose_sign1_struct.ok()) { + RecordStatus(status_set, FailureStatusCode::kSigningCredentialInvalid, + {.url = kClaimSignatureLabel, + .explanation = cose_sign1_struct.status().ToString()}); + return result; + } + auto cose_sign1_verifier = CoseSign1Verifier::Create( + options_.crypto_read_handler.get(), &DefaultHashCheckerFactory(), + options_.clock, + CoseSign1Verifier::Options{ + .check_ocsp_responses = options_.revocation_checks == + CoseVerifierRevocationChecks::kEnabled, + .verify_signature = options_.signature_checks == + CoseVerifierSignatureChecks::kEnabled, + }); + std::optional<SignatureInfo> signature_info = cose_sign1_verifier->Verify( + *std::move(cose_sign1_struct), data, status_set); + if (!signature_info.has_value()) { + // Failure has already been recorded to the tracker by CoseSign1::Verify. + return result; + } + *result.mutable_signature_info() = *std::move(signature_info); + + return result; + } + + private: + const CoseVerifierOptions options_; +}; + +} // namespace + +std::unique_ptr<CoseVerifier> CreateCoseVerifier(CoseVerifierOptions options) { + CHECK(options.crypto_read_handler); + return std::make_unique<CoseVerifierImpl>(std::move(options)); +} + +} // namespace credentio
diff --git a/cose/verifier.h b/cose/verifier.h new file mode 100644 index 0000000..ba7b04e --- /dev/null +++ b/cose/verifier.h
@@ -0,0 +1,69 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_COSE_VERIFIER_H_ +#define THIRD_PARTY_CREDENTIO_COSE_VERIFIER_H_ + +#include <memory> + +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock_interface.h" +#include "crypto/crypto_read_handler.h" +#include "proto/cose_verification_result.pb.h" +#include "proto/signature_info.pb.h" + +namespace credentio { + +class CoseVerifier { + public: + virtual ~CoseVerifier() = default; + + // Validates the provided COSE_SIGN1 signature (`signature`) against the + // provided expected data (`data`). + // Returns a CoseVerificationResult, which contains the SignatureInfo + // if the signature is valid, and the validation statuses encountered + // during verification. + virtual CoseVerificationResult Verify(absl::string_view signature, + absl::string_view data) const = 0; +}; + +// Whether claim signatures (by the leaf certificate holder) are checked. +// This does not affect trust chain validation. +enum class CoseVerifierSignatureChecks { kDisabledForTest, kEnabled }; + +// Whether OCSP revocation checks are performed. +// This does not affect trust chain validation. +enum class CoseVerifierRevocationChecks { kDisabledForTest, kEnabled }; + +struct CoseVerifierOptions { + std::unique_ptr<CryptoReadHandler> absl_nonnull crypto_read_handler; + // Clock to use by the verifier. + absl::Clock* clock = &absl::Clock::GetRealClock(); + // Option to enable/disable signature checks. + CoseVerifierSignatureChecks signature_checks = + CoseVerifierSignatureChecks::kEnabled; + // Option to enable/disable OCSP revocation checks. + CoseVerifierRevocationChecks revocation_checks = + CoseVerifierRevocationChecks::kEnabled; +}; + +// Creates a COSE verifier. +std::unique_ptr<CoseVerifier> CreateCoseVerifier( + CoseVerifierOptions options = {}); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_COSE_VERIFIER_H_
diff --git a/crypto/BUILD b/crypto/BUILD new file mode 100644 index 0000000..37ee2ca --- /dev/null +++ b/crypto/BUILD
@@ -0,0 +1,113 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "algorithms", + srcs = ["algorithms.cc"], + hdrs = ["algorithms.h"], + deps = [ + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "algorithms_test", + srcs = ["algorithms_test.cc"], + deps = [ + ":algorithms", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "hash", + hdrs = ["hash.h"], + deps = [ + ":algorithms", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "cbs_utils", + hdrs = ["cbs_utils.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + ], +) + +cc_library( + name = "mock_hash", + testonly = True, + hdrs = ["mock_hash.h"], + deps = [ + ":algorithms", + ":hash", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest", + ], +) + +cc_library( + name = "timestamp_read_handler", + hdrs = ["timestamp_read_handler.h"], + deps = [ + "//tsp:verified_timestamp", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "crypto_read_handler", + srcs = ["crypto_read_handler.cc"], + hdrs = ["crypto_read_handler.h"], + deps = [ + ":algorithms", + ":timestamp_read_handler", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + ], +) + +cc_test( + name = "crypto_read_handler_test", + srcs = ["crypto_read_handler_test.cc"], + deps = [ + ":crypto_read_handler", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +)
diff --git a/crypto/algorithms.cc b/crypto/algorithms.cc new file mode 100644 index 0000000..89029d0 --- /dev/null +++ b/crypto/algorithms.cc
@@ -0,0 +1,49 @@ +// 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 "crypto/algorithms.h" + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +absl::StatusOr<HashAlgorithm> NidToHashAlgorithm(int nid) { + HashAlgorithm algorithm = static_cast<HashAlgorithm>(nid); + switch (algorithm) { + case HashAlgorithm::kSha256: + case HashAlgorithm::kSha384: + case HashAlgorithm::kSha512: + return algorithm; + } + return absl::InvalidArgumentError( + absl::StrCat("hash algorithm NID is not on C2PA allowlist: ", nid)); +} + +absl::StatusOr<HashAlgorithm> ParseHashAlgorithm(absl::string_view txt) { + if (txt == "sha256") { + return HashAlgorithm::kSha256; + } else if (txt == "sha384") { + return HashAlgorithm::kSha384; + } else if (txt == "sha512") { + return HashAlgorithm::kSha512; + } else { + return absl::InvalidArgumentError("unrecognized hash algorithm name"); + } +} + +} // namespace credentio
diff --git a/crypto/algorithms.h b/crypto/algorithms.h new file mode 100644 index 0000000..b37bb9b --- /dev/null +++ b/crypto/algorithms.h
@@ -0,0 +1,117 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_ALGORITHMS_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_ALGORITHMS_H_ + +#include <cstdint> + +#include "absl/log/log.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +// Hashing algorithms allowed by the C2PA spec, see +// https://c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html#_hashing +// All values must correspond to an OpenSSL NID. +enum class HashAlgorithm { + kSha256 = 672, // NID_sha256 + kSha384 = 673, // NID_sha384 + kSha512 = 674, // NID_sha512 +}; + +inline uint64_t HashSizeBytes(HashAlgorithm algorithm) { + switch (algorithm) { + case credentio::HashAlgorithm::kSha256: + return 32; + case credentio::HashAlgorithm::kSha512: + return 64; + case credentio::HashAlgorithm::kSha384: + return 48; + } + LOG(DFATAL) << "unknown HashAlgorithm enum: " << static_cast<int>(algorithm); + return 0; +} + +absl::StatusOr<HashAlgorithm> NidToHashAlgorithm(int nid); +absl::StatusOr<HashAlgorithm> ParseHashAlgorithm(absl::string_view txt); + +template <typename Sink> +void AbslStringify(Sink& sink, HashAlgorithm algorithm) { + switch (algorithm) { + case HashAlgorithm::kSha256: + sink.Append("sha256"); + return; + case HashAlgorithm::kSha384: + sink.Append("sha384"); + return; + case HashAlgorithm::kSha512: + sink.Append("sha512"); + return; + } + LOG(DFATAL) << "unknown HashAlgorithm enum: " << static_cast<int>(algorithm); + sink.Append( + absl::StrCat("UNKNOWN_HASH_ALGORITHM_", static_cast<int>(algorithm))); +} + +// IANA registry: https://www.iana.org/assignments/cose/cose.xhtml#algorithms +// C2PA accepted list: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signature_algorithms +enum class SigningAlgorithm { + kEs256, // ECDSA with SHA-256 + kEs384, // ECDSA with SHA-384 + kEs512, // ECDSA with SHA-512 + kPs256, // RSASSA-PSS using SHA-256 and MGF1 with SHA-256 + kPs384, // RSASSA-PSS using SHA-384 and MGF1 with SHA-384 + kPs512, // RSASSA-PSS using SHA-512 and MGF1 with SHA-512 + kEdDsa, // Edwards-Curve DSA +}; + +template <typename Sink> +inline void AbslStringify(Sink& sink, const SigningAlgorithm& algorithm) { + switch (algorithm) { + case SigningAlgorithm::kEs256: + sink.Append("ES256"); + return; + case SigningAlgorithm::kEs384: + sink.Append("ES384"); + return; + case SigningAlgorithm::kEs512: + sink.Append("ES512"); + return; + case SigningAlgorithm::kPs256: + sink.Append("PS256"); + return; + case SigningAlgorithm::kPs384: + sink.Append("PS384"); + return; + case SigningAlgorithm::kPs512: + sink.Append("PS512"); + return; + case SigningAlgorithm::kEdDsa: + sink.Append("EdDSA"); + return; + } + LOG(DFATAL) << "unknown SigningAlgorithm enum: " + << static_cast<int>(algorithm); + sink.Append( + absl::StrCat("UNKNOWN_SIGNING_ALGORITHM_", static_cast<int>(algorithm))); +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_ALGORITHMS_H_
diff --git a/crypto/algorithms_test.cc b/crypto/algorithms_test.cc new file mode 100644 index 0000000..c8f6aec --- /dev/null +++ b/crypto/algorithms_test.cc
@@ -0,0 +1,118 @@ +// 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 "crypto/algorithms.h" + +#include <sys/stat.h> + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::Eq; +using ::testing::HasSubstr; + +TEST(SigningAlgorithmTest, StringifyOk) { + EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEs256), Eq("ES256")); + EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEs384), Eq("ES384")); + EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEs512), Eq("ES512")); + EXPECT_THAT(absl::StrCat(SigningAlgorithm::kPs256), Eq("PS256")); + EXPECT_THAT(absl::StrCat(SigningAlgorithm::kPs384), Eq("PS384")); + EXPECT_THAT(absl::StrCat(SigningAlgorithm::kPs512), Eq("PS512")); + EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEdDsa), Eq("EdDSA")); +} + +TEST(SigningAlgorithmTest, StringifyUnrecognized) { + std::string s; + EXPECT_DEBUG_DEATH( + { + s = absl::StrCat(static_cast<SigningAlgorithm>(-999)); + EXPECT_THAT(s, Eq("UNKNOWN_SIGNING_ALGORITHM_-999")); + }, + "unknown SigningAlgorithm enum: -999"); +} + +TEST(HashAlgorithmTest, StringifyOk) { + EXPECT_THAT(absl::StrCat(HashAlgorithm::kSha256), Eq("sha256")); + EXPECT_THAT(absl::StrCat(HashAlgorithm::kSha384), Eq("sha384")); + EXPECT_THAT(absl::StrCat(HashAlgorithm::kSha512), Eq("sha512")); +} + +TEST(HashAlgorithmTest, StringifyUnrecognized) { + std::string s; + EXPECT_DEBUG_DEATH( + { + s = absl::StrCat(static_cast<HashAlgorithm>(-999)); + EXPECT_THAT(s, Eq("UNKNOWN_HASH_ALGORITHM_-999")); + }, + "unknown HashAlgorithm enum: -999"); +} + +TEST(HashSizeTest, GetSizeOk) { + EXPECT_THAT(HashSizeBytes(HashAlgorithm::kSha256), Eq(32)); +} + +TEST(HashSizeTest, GetSizeUnrecognized) { + uint64_t size; + EXPECT_DEBUG_DEATH( + { + size = HashSizeBytes(static_cast<HashAlgorithm>(-999)); + EXPECT_THAT(size, Eq(0)); + }, + "unknown HashAlgorithm enum: -999"); +} + +TEST(NidToHashAlgorithmTest, Ok) { + EXPECT_THAT(NidToHashAlgorithm(672), + IsOkAndHolds(Eq(HashAlgorithm::kSha256))); + EXPECT_THAT(NidToHashAlgorithm(673), + IsOkAndHolds(Eq(HashAlgorithm::kSha384))); + EXPECT_THAT(NidToHashAlgorithm(674), + IsOkAndHolds(Eq(HashAlgorithm::kSha512))); +} + +TEST(NidToHashAlgorithmTest, Invalid) { + EXPECT_THAT(NidToHashAlgorithm(1), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("algorithm NID is not on C2PA allowlist"))); +} + +TEST(ParseHashAlgorithmTest, Ok) { + EXPECT_THAT(ParseHashAlgorithm("sha256"), + IsOkAndHolds(Eq(HashAlgorithm::kSha256))); + EXPECT_THAT(ParseHashAlgorithm("sha384"), + IsOkAndHolds(Eq(HashAlgorithm::kSha384))); + EXPECT_THAT(ParseHashAlgorithm("sha512"), + IsOkAndHolds(Eq(HashAlgorithm::kSha512))); +} + +TEST(ParseHashAlgorithmTest, Invalid) { + EXPECT_THAT(ParseHashAlgorithm("sha1"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("unrecognized hash algorithm name"))); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/cbs_utils.h b/crypto/cbs_utils.h new file mode 100644 index 0000000..b572e71 --- /dev/null +++ b/crypto/cbs_utils.h
@@ -0,0 +1,58 @@ +// 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. +// + +// Common conversions for working with OpenSSL CBS structs +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_CBS_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_CBS_UTILS_H_ + +#include <cstddef> +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" // IWYU pragma: keep, CBS in base.h is incomplete +#include "openssl/mem.h" // IWYU pragma: keep, `bssl::UniquePtr` in base.h is incomplete + +namespace credentio { + +inline absl::string_view ToStringView(const CBS& cbs) { + return absl::string_view(reinterpret_cast<const char*>(cbs.data), cbs.len); +} + +// Returns a CBS that points to the same underlying array as `sv`, Caller must +// ensure that the backing array outlives the returned CBS. +inline CBS FromStringView(absl::string_view sv) { + CBS cbs; + CBS_init(&cbs, reinterpret_cast<const uint8_t*>(sv.data()), sv.length()); + return cbs; +} + +// Finishes `cbb` and copies the final content to a string. +inline absl::StatusOr<std::string> FinishToString(CBB* cbb) { + uint8_t* data; + size_t len; + if (!CBB_finish(cbb, &data, &len)) { + return absl::InternalError("CBB_finish(...) failed"); + } + bssl::UniquePtr<uint8_t> data_uniq(data); + return std::string(reinterpret_cast<char*>(data), len); +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_CBS_UTILS_H_
diff --git a/crypto/crypto_read_handler.cc b/crypto/crypto_read_handler.cc new file mode 100644 index 0000000..b3409b6 --- /dev/null +++ b/crypto/crypto_read_handler.cc
@@ -0,0 +1,69 @@ +// 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 "crypto/crypto_read_handler.h" + +#include <string> + +#include "absl/strings/string_view.h" + +namespace credentio { + +bool AbslParseFlag(absl::string_view text, + CryptoReadHandler::TrustEnvironment* trust, + std::string* error) { + if (text == "prod") { + *trust = CryptoReadHandler::TrustEnvironment::kProd; + return true; + } + if (text == "qual") { + *trust = CryptoReadHandler::TrustEnvironment::kQualOnly; + return true; + } + if (text == "qual_and_prod") { + *trust = CryptoReadHandler::TrustEnvironment::kQualAndProd; + return true; + } + if (text == "dev") { + *trust = CryptoReadHandler::TrustEnvironment::kDevOnly; + return true; + } + if (text == "any") { + *trust = CryptoReadHandler::TrustEnvironment::kAny; + return true; + } + *error = "unknown trust environment"; + return false; +} + +std::string AbslUnparseFlag(CryptoReadHandler::TrustEnvironment trust) { + switch (trust) { + case CryptoReadHandler::TrustEnvironment::kProd: + return "prod"; + case CryptoReadHandler::TrustEnvironment::kQualOnly: + return "qual"; + case CryptoReadHandler::TrustEnvironment::kQualAndProd: + return "qual_and_prod"; + case CryptoReadHandler::TrustEnvironment::kDevOnly: + return "dev"; + case CryptoReadHandler::TrustEnvironment::kAny: + return "any"; + case CryptoReadHandler::TrustEnvironment::kUnspecified: + break; + } + return "unknown"; +} + +} // namespace credentio
diff --git a/crypto/crypto_read_handler.h b/crypto/crypto_read_handler.h new file mode 100644 index 0000000..d2ab5ea --- /dev/null +++ b/crypto/crypto_read_handler.h
@@ -0,0 +1,181 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_READ_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_READ_HANDLER_H_ + +#include <cstddef> +#include <memory> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "crypto/timestamp_read_handler.h" + +namespace credentio { + +// A parsed public-key certificate with optional supporting intermediate certs. +class ParsedCertificates { + public: + virtual ~ParsedCertificates() = default; + + // Verifies that `signature` applies to `data`, using the public key from the + // leaf certificate. This function can be used for both claim and OCSP + // response signatures. Performs no trust checks. + // + // Returns: + // - INVALID_ARGUMENT if the signature is invalid. + virtual absl::Status VerifySignature(absl::string_view signature, + absl::string_view data, + SigningAlgorithm algorithm) const = 0; + + // Verifies the trustworthiness of the leaf certificate for claim signing, by + // finding an ordered chain of valid certificates to a trusted root, + // making use of the provided intermediate certificates as needed. All + // certificate checks are performed in accordance with + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements, + // and validity period checks are performed based on the provided time. + // + // Returns: + // - UNAUTHENTICATED if the certificate cannot be trusted. + // - OUT_OF_RANGE if `content_time` is outside the validity period of a + // certificate. + // - INVALID_ARGUMENT if a certificate is invalid. + // - UNAVAILABLE if the root trust list cannot be accessed (only possible on + // platforms where it is not built into the binary). + // - The verified trust chain from leaf to root (including the relevant + // trust anchor's cert) if the verification is successful. + virtual absl::StatusOr<std::vector<std::string>> VerifyClaimSignerTrust( + absl::Time content_time) const = 0; + + // Returns the number of certificates in the chain. Index 0 is the leaf + // certificate, but the order of the other certificates is not guaranteed + // and not all of them are necessarily part of the chain to a trust anchor + // that a successful call to VerifyClaimSignerTrust finds. There is always at + // least 1 certificate. + virtual size_t GetCertificateCount() const = 0; + + // Returns the distinguished name of the subject of the certificate + // (RFC 2253 format). Index 0 represents the leaf certificate. + virtual absl::StatusOr<std::string> GetSubject(size_t index) const = 0; + // Returns the distinguished name of the issuer of the certificate + // (RFC 2253 format). + virtual absl::StatusOr<std::string> GetIssuer(size_t index) const = 0; + + // Returns the certificates's start time (notBefore). + virtual absl::StatusOr<absl::Time> GetStartTime(size_t index) const = 0; + + // Returns the certificate's end time (notAfter) + virtual absl::StatusOr<absl::Time> GetEndTime(size_t index) const = 0; + // Returns the certificate's serial number as a lowercase hex string. + virtual absl::StatusOr<std::string> GetSerialNumberHex( + size_t index) const = 0; + + // Returns the certificate's C2PA assurance level as a string (e.g., + // "1.3.6.1.4.1.62558.3.10"), if it is present and can be parsed. + // This is only relevant for the leaf certificate (index 0). + virtual absl::StatusOr<std::string> GetAssuranceLevel(size_t index) const = 0; + + // Returns the certificate's C2PA conforming product ID. + // This is only relevant for the leaf certificate (index 0). + virtual absl::StatusOr<std::string> GetConformingProductId( + size_t index) const = 0; +}; + +// Represents the certificate revocation status returned by the OCSP +// responder, as defined in RFC 6960 (Section 2.2). +enum class OCSPRevocationStatus { + kUnknown, // The responder does not know the status of the certificate. + kGood, // The certificate is not revoked (valid). + kRevoked, // The certificate has been revoked. +}; + +// Encapsulates the crypto operation involved in claim verification. +class CryptoReadHandler : public TimestampReadHandler { + public: + enum class TrustEnvironment { + kUnspecified, + // Trust any certificates, i.e., skip claim signer and timestamp trust + // checks. + kAny, + // Trust dev certificates only. + kDevOnly, + // Trust qual certificates for signers and prod certificates for timestamps. + kQualOnly, + // Trust qual and prod certificates. + kQualAndProd, + // Trust prod certificates only. + kProd, + }; + + ~CryptoReadHandler() override = default; + + // Parses a set of DER-encoded certificates, the first of which is the + // leaf certificate. + virtual absl::StatusOr<std::unique_ptr<ParsedCertificates>> + ParseCertificatesDer( + absl::Span<const absl::string_view> certificates) const = 0; + + // Verifies the signature and validity of the DER-encoded OCSP response + // against the provided issuer certificate (`issuer_certificate_der`) or + // an authorized responder, and returns the revocation status of the target + // certificate (`certificate_der`) at the specified verification time + // (`verify_time`). + // + // The verification process ensures that the OCSP response is correctly signed + // by the issuer or a delegated responder, conforms to RFC 6960 requirements, + // matches the target certificate, and is valid at `verify_time`. + // + // Arguments: + // ocsp_response_der: The DER-encoded OCSP response. + // certificate_der: The DER-encoded X.509 certificate whose revocation + // status is being checked. + // issuer_certificate_der: The DER-encoded X.509 certificate of the issuer + // that signed `certificate_der`. + // verify_time: The time at which the OCSP response validity and revocation + // status are evaluated. + // + // Returns: + // - INVALID_ARGUMENT if `certificate_der`, `issuer_certificate_der`, or the + // OCSP response is malformed or invalid. + // - UNAUTHENTICATED if the OCSP response signature fails verification or + // if no matching response is found for `certificate_der`. + // - OUT_OF_RANGE if `verify_time` is outside the valid time window of the + // OCSP response (e.g., based on `thisUpdate` and `nextUpdate`). + // - The verified `OCSPRevocationStatus` (kGood, kRevoked, or kUnknown) if + // verification is successful. + virtual absl::StatusOr<OCSPRevocationStatus> VerifyOcspResponse( + absl::string_view ocsp_response_der, absl::string_view certificate_der, + absl::string_view issuer_certificate_der, + absl::Time verify_time) const = 0; + + // Returns the trust environment of this handler. + virtual TrustEnvironment trust_environment() const = 0; +}; + +bool AbslParseFlag(absl::string_view text, + CryptoReadHandler::TrustEnvironment* trust, + std::string* error); + +std::string AbslUnparseFlag(CryptoReadHandler::TrustEnvironment trust); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_READ_HANDLER_H_
diff --git a/crypto/crypto_read_handler_test.cc b/crypto/crypto_read_handler_test.cc new file mode 100644 index 0000000..0a3d8b5 --- /dev/null +++ b/crypto/crypto_read_handler_test.cc
@@ -0,0 +1,71 @@ +// 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 "crypto/crypto_read_handler.h" + +#include <string> + +#include "absl/strings/string_view.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +TEST(CryptoReadHandlerTest, AbslParseFlagValid) { + CryptoReadHandler::TrustEnvironment trust; + std::string error; + + EXPECT_TRUE(AbslParseFlag("prod", &trust, &error)); + EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kProd); + + EXPECT_TRUE(AbslParseFlag("qual", &trust, &error)); + EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kQualOnly); + + EXPECT_TRUE(AbslParseFlag("qual_and_prod", &trust, &error)); + EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kQualAndProd); + + EXPECT_TRUE(AbslParseFlag("dev", &trust, &error)); + EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kDevOnly); + + EXPECT_TRUE(AbslParseFlag("any", &trust, &error)); + EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kAny); + + EXPECT_TRUE(error.empty()); +} + +TEST(CryptoReadHandlerTest, AbslParseFlagInvalid) { + CryptoReadHandler::TrustEnvironment trust; + std::string error; + + EXPECT_FALSE(AbslParseFlag("invalid", &trust, &error)); + EXPECT_EQ(error, "unknown trust environment"); +} + +TEST(CryptoReadHandlerTest, AbslUnparseFlag) { + EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kProd), + "prod"); + EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kQualOnly), + "qual"); + EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kQualAndProd), + "qual_and_prod"); + EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kDevOnly), + "dev"); + EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kAny), "any"); + EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kUnspecified), + "unknown"); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/default/BUILD b/crypto/default/BUILD new file mode 100644 index 0000000..85a1f98 --- /dev/null +++ b/crypto/default/BUILD
@@ -0,0 +1,285 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "hasher", + srcs = ["hasher.cc"], + hdrs = ["hasher.h"], + deps = [ + "//crypto:algorithms", + "//crypto:hash", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:absl_log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "hasher_test", + srcs = ["hasher_test.cc"], + deps = [ + ":hasher", + "//crypto:algorithms", + "//crypto:hash", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "eku_verifier", + srcs = ["eku_verifier.cc"], + hdrs = ["eku_verifier.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "eku_verifier_test", + srcs = ["eku_verifier_test.cc"], + deps = [ + ":eku_verifier", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "compliance_checker", + srcs = ["compliance_checker.cc"], + hdrs = ["compliance_checker.h"], + deps = [ + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "compliance_checker_test", + srcs = ["compliance_checker_test.cc"], + deps = [ + ":compliance_checker", + "//testing:crypto_testing", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@boringssl//:crypto", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "x509_certificate", + srcs = ["x509_certificate.cc"], + hdrs = ["x509_certificate.h"], + deps = [ + ":compliance_checker", + "//crypto:algorithms", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@boringssl//:crypto", + "@tink_cc//proto:common_cc_proto", + "@tink_cc//tink:cleartext_keyset_handle", + "@tink_cc//tink:public_key_verify", + "@tink_cc//tink/signature:config_v0", + "@tink_cc//tink/signature:signature_pem_keyset_reader", + ], +) + +cc_test( + name = "x509_certificate_test", + srcs = ["x509_certificate_test.cc"], + deps = [ + ":x509_certificate", + "//crypto:algorithms", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@boringssl//:crypto", + "@googletest//:gtest_main", + "@tink_cc//proto:common_cc_proto", + "@tink_cc//tink/signature:signature_pem_keyset_reader", + ], +) + +cc_library( + name = "pem", + srcs = ["pem.cc"], + hdrs = ["pem.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "pem_test", + srcs = ["pem_test.cc"], + deps = [ + ":pem", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "trust_store", + srcs = ["trust_store.cc"], + hdrs = ["trust_store.h"], + deps = [ + ":pem", + "//proto:signature_info_cc_proto", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:pki", + "@protobuf//:timestamp_cc_proto", + ], +) + +cc_library( + name = "timestamp_verifier", + srcs = ["timestamp_verifier.cc"], + hdrs = ["timestamp_verifier.h"], + deps = [ + ":hasher", + "//crypto:algorithms", + "//crypto:hash", + "//tsp:parsed_timestamp_token", + "//tsp:timestamp_parsing", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:absl_log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@boringssl//:pki", + ], +) + +cc_test( + name = "trust_store_test", + srcs = ["trust_store_test.cc"], + data = ["//testing/testdata/keys"], + deps = [ + ":trust_store", + "//testing:test_file_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "timestamp_verifier_test", + srcs = ["timestamp_verifier_test.cc"], + deps = [ + ":timestamp_verifier", + ":trust_store", + "//crypto:algorithms", + "//crypto:cbs_utils", + "//proto:validation_result_cc_proto", + "//tsp:constants", + "//tsp:parsed_timestamp_token", + "//tsp:test_helpers", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@boringssl//:pki", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "default_crypto_read_handler", + srcs = ["default_crypto_read_handler.cc"], + hdrs = ["default_crypto_read_handler.h"], + deps = [ + ":eku_verifier", + ":trust_store", + ":x509_certificate", + "//cose:simple_cms_parser", + "//crypto:algorithms", + "//crypto:crypto_read_handler", + "//tsp:timestamp_verifier", + "//tsp:verified_timestamp", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/log:absl_log", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@boringssl//:pki", + ], +)
diff --git a/crypto/default/cms/BUILD b/crypto/default/cms/BUILD new file mode 100644 index 0000000..3ab85a1 --- /dev/null +++ b/crypto/default/cms/BUILD
@@ -0,0 +1,135 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "cms_parser", + srcs = [ + "ber_parser.cc", + "cms_parser.cc", + "oids.cc", + ], + hdrs = [ + "ber_parser.h", + "cms_error_code.h", + "cms_parser.h", + "oids.h", + ], + deps = [ + "@abseil-cpp//absl/strings", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "cms_parser_test", + srcs = ["cms_parser_test.cc"], + data = glob([ + "testdata/**", + ]), + deps = [ + ":cms_parser", + ":cms_test_helper", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@boringssl//:crypto", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "cms_test_helper", + testonly = True, + srcs = ["cms_test_helper.cc"], + hdrs = ["cms_test_helper.h"], + deps = [ + ":cms_parser", + "//testing:test_file_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@boringssl//:crypto", + ], +) + +cc_library( + name = "verify_signature", + srcs = ["verify_signature.cc"], + hdrs = ["verify_signature.h"], + deps = [ + ":cms_parser", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "ber_parser_test", + srcs = ["ber_parser_test.cc"], + deps = [ + ":cms_parser", + "@boringssl//:crypto", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "verify_signature_test", + srcs = ["verify_signature_test.cc"], + data = glob(["testdata/**"]), + deps = [ + ":certificates", + ":cms_parser", + ":cms_test_helper", + ":verify_signature", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@boringssl//:crypto", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "certificates", + srcs = ["certificates.cc"], + hdrs = ["certificates.h"], + deps = [ + ":cms_parser", + "@boringssl//:crypto", + ], +) + +cc_test( + name = "certificates_test", + srcs = ["certificates_test.cc"], + data = glob(["testdata/**"]), + deps = [ + ":certificates", + ":cms_parser", + ":cms_test_helper", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/strings", + "@boringssl//:crypto", + "@googletest//:gtest_main", + ], +)
diff --git a/crypto/default/cms/ber_parser.cc b/crypto/default/cms/ber_parser.cc new file mode 100644 index 0000000..221b543 --- /dev/null +++ b/crypto/default/cms/ber_parser.cc
@@ -0,0 +1,266 @@ +// 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 "crypto/default/cms/ber_parser.h" + +#include <cstring> +#include <limits> +#include <string> +#include <vector> + +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/default/cms/cms_error_code.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" + +namespace credentio_cms { + +BerParser::BerParser(const uint8_t* data, size_t length) { + CBS cbs; + CBS_init(&cbs, data, length); + scopes_.push_back(Scope(cbs, false)); + start_ = data; +} + +void BerParser::SetElementName(absl::string_view text) { + scopes_.back().element_name = text; +} + +bool BerParser::ok() const { return status_ == ErrorCode::OK; } + +ErrorCode BerParser::status() const { return status_; } + +std::string BerParser::error_message() const { return error_message_; } + +// Checks that the next element has the expected tag. If the element is +// constructed the parser will read from it until CloseTag is called. +void BerParser::GetTag(unsigned expected_tag) { + unsigned tag = 0; + GetTagInternal(&tag, nullptr, nullptr, false /* raw */); + CompareTag(tag, expected_tag, nullptr /* out */); +} + +bool BerParser::GetOptionalTag(unsigned expected_tag) { + if (!Peek(expected_tag)) { + return false; + } + GetTag(expected_tag); + return ok(); +} + +void BerParser::GetElementData(unsigned expected_tag, CBS* out) { + if (!IsValid(out)) { + return; + } + unsigned tag = 0; + GetTagInternal(&tag, out, nullptr /* headers */, false /* raw tag */); + CompareTag(tag, expected_tag, out); +} + +bool BerParser::GetOptionalElementData(unsigned expected_tag, CBS* out) { + if (!IsValid(out) || !Peek(expected_tag)) { + return false; + } + GetElementData(expected_tag, out); + return ok(); +} + +void BerParser::GetAnyElementAndSkipChildren(CBS* out) { + if (!IsValid(out)) { + return; + } + unsigned tag; + GetTagInternal(&tag, nullptr, out, true /* raw */); +} + +bool BerParser::Peek(unsigned expected_tag) const { + if (!ok()) { + return false; + } + const Scope& scope = scopes_.back(); + return CBS_peek_asn1_tag(&scope.data, expected_tag) == 1; +} + +bool BerParser::IsNullOrEmpty() { + if (!ok()) { + return true; + } + Scope& scope = scopes_.back(); + if (CBS_len(&scope.data) == 0) { + return true; + } + if (CBS_peek_asn1_tag(&scope.data, CBS_ASN1_NULL) == 1) { + if (CBS_get_asn1(&scope.data, nullptr, CBS_ASN1_NULL) != 1) { + SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to read NULL element "); + } + return true; + } + return scope.indefinite && IsEndOfContent(); +} + +int BerParser::GetInt() { + if (!ok()) { + // No-op if there was already an error. + return 0; + } + Scope& scope = scopes_.back(); + uint64_t v; + if (CBS_get_asn1_uint64(&scope.data, &v) != 1 || + v > std::numeric_limits<int>::max()) { + SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to get an int "); + return 0; + } + return static_cast<int>(v); +} + +void BerParser::EndConstructed() { + if (!ok()) { + return; + } + if (scopes_.size() < 2) { + // There should always be at least one element in the scope stack, this + // would pop the last one. + SetError(ErrorCode::ASN1_PARSING_ERROR, "Unexpected EndConstructed call "); + return; + } + + Scope& scope = scopes_.back(); + if (!scope.indefinite) { + if (CBS_len(&scope.data) > 0) { + SetError(ErrorCode::ASN1_PARSING_ERROR, + "Found data remaining while expecting the end of the current " + "element "); + } + scopes_.pop_back(); + return; + } + if (!IsEndOfContent() || CBS_skip(&scope.data, 2) != 1) { + SetError(ErrorCode::ASN1_PARSING_ERROR, + "Error while expecting the end of an indefinite length element "); + return; + } + const auto end = CBS_data(&scope.data); + scopes_.pop_back(); + const auto previous = CBS_data(&scopes_.back().data); + if (end < previous || CBS_skip(&scopes_.back().data, end - previous) != 1) { + SetError( + ErrorCode::ASN1_PARSING_ERROR, + "Failed to seek past the end of the current indefinite length tag "); + } +} + +bool BerParser::IsValid(CBS* out) { + if (out == nullptr) { + SetError(ErrorCode::INVALID_PARAMETER, + "Caller error, null pointer passed to IsValid "); + return false; + } + CBS_init(out, nullptr, 0); + return status_ == ErrorCode::OK; +} + +bool BerParser::IsEndOfContent() { + Scope& scope = scopes_.back(); + return CBS_len(&scope.data) >= 2 && + memcmp(CBS_data(&scope.data), "\0\0", 2) == 0; +} + +void BerParser::GetTagInternal(unsigned* tag, CBS* value, CBS* header_and_value, + bool raw_tag) { + if (!ok()) { + return; + } + Scope& scope = scopes_.back(); + size_t header_length; + CBS out; + int indefinite_length; + if (CBS_get_any_ber_asn1_element(&scope.data, &out, tag, &header_length, + /*out_ber_found=*/nullptr, + &indefinite_length) != 1) { + SetError(ErrorCode::ASN1_PARSING_ERROR, "Parsing error "); + return; + } + + if (indefinite_length && (value != nullptr || header_and_value != nullptr)) { + SetError( + ErrorCode::ASN1_PARSING_ERROR, + "Indefinite length not supported when the element data is needed "); + return; + } + + if (header_and_value != nullptr) { + *header_and_value = out; + } + if (value != nullptr) { + *value = out; + if (CBS_skip(value, header_length) != 1) { + SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to skip the header "); + return; + } + } + + if (raw_tag || (*tag & CBS_ASN1_CONSTRUCTED) == 0) { + return; + } + // If the tag is constructed, start a new scope containing either the + // element we just read (definite length) or all the data remaining if the + // length is unknown. + if (indefinite_length) { + scopes_.push_back(Scope(scope.data, indefinite_length)); + } else { + if (CBS_skip(&out, header_length) != 1) { + SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to read the header "); + return; + } + scopes_.push_back(Scope(out, indefinite_length)); + } +} + +void BerParser::CompareTag(unsigned tag, unsigned expected_tag, CBS* out) { + if (ok() && tag != expected_tag) { + std::string message = + absl::StrCat("Got tag ", tag, " instead of tag ", expected_tag); + + SetError(ErrorCode::ASN1_PARSING_ERROR, message); + if (out != nullptr) { + CBS_init(out, nullptr, 0); + } + } +} + +void BerParser::SetError(ErrorCode status, absl::string_view text) { + if (!ok()) { + // Don't clear existing errors. + return; + } + status_ = status; + error_message_ = + absl::StrCat(text, " at offset: ", GetCurrentOffset(), " in scope: "); + for (const auto& scope : scopes_) { + if (!scope.element_name.empty()) { + absl::StrAppend(&error_message_, scope.element_name, " > "); + } + } +} + +size_t BerParser::GetCurrentOffset() { + if (scopes_.empty()) { + return 0; + } + return CBS_data(&scopes_.back().data) - start_; +} + +} // namespace credentio_cms
diff --git a/crypto/default/cms/ber_parser.h b/crypto/default/cms/ber_parser.h new file mode 100644 index 0000000..d21669b --- /dev/null +++ b/crypto/default/cms/ber_parser.h
@@ -0,0 +1,188 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_BER_PARSER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_BER_PARSER_H_ + +#include <cstddef> +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/strings/string_view.h" +#include "crypto/default/cms/cms_error_code.h" +#include "openssl/bytestring.h" + +namespace credentio_cms { + +// Zero copy ASN.1 parser supporting the minimum amount of BER encoding needed +// to work with the S/MIME clients targeted. +// +// This parser reads the elements in the order they appear, when a constructed +// element is read, the next element will be read from its value. +// +// Example if the data contains this: +// SEQUENCE +// INTEGER 1 +// OCTECT_STRING 'foo' +// SET +// ... +// The elements read will be in order: SEQUENCE, INTEGER, OCTET_STRING, +// SET. +// +// Error handling: the parser keeps track of the first error. Once an error +// is encountered all the functions will effectively be no-op. +// This allows the caller to postpone error checking until the end of the +// parsing and makes the code easier to read. +class BerParser { + public: + // The data must be valid for the life of the BerParser object. + BerParser(const uint8_t* data, size_t length); + + // Returns true if the parsing is successful, or false if there was an error. + // Errors are sticky and once the parser encounters an error (or SetError is + // called). + bool ok() const; + + // Returns the current status. + ErrorCode status() const; + + // Returns the text for the error, if any. + std::string error_message() const; + + // Checks that the next element has the expected tag and read it. + // If the element is constructed the parser will read from its value until + // EndConstructedElement is called. + // If the next element does not match an error will be set and ok() will + // always return false. + void GetTag(unsigned expected_tag); + + // If the next element has a tag equals to expected_tag this function behaves + // like GetTag, otherwise it is a no-op. + // Returns true if the expected_tag was found and read. + bool GetOptionalTag(unsigned expected_tag); + + // If the next element has a tag equal to expected_tag, read it and make + // 'out' point to the element value, otherwise set an error condition and + // clear the 'out' parameter. + // If the element is constructed the parser will read from its value until + // EndConstructedElement is called. + // + // Restriction: the element must have a definite length. + void GetElementData(unsigned expected_tag, CBS* out); + + // If the next element has a tag equal to 'expected_tag' this function behaves + // like GetElementData, otherwise it is a no-op + bool GetOptionalElementData(unsigned expected_tag, CBS* out); + + // Indicate that parsing is done for the current constructed element, check + // that there is no data left in it and keep reading from its siblings. + // If the current constructed element had an indefinite length, also check for + // the end of content marker. + void EndConstructed(); + + // Read the next element regardless of its tag and put the header and data in + // the 'out' parameter. Its contents will be skipped even if it is a + // constructed element (so no need to call EndConstructedElement). + // Restriction: the element must have a definite length. + void GetAnyElementAndSkipChildren(CBS* out); + + // Returns true if there is no error and the next element matches the + // expected tag. + bool Peek(unsigned expected_tag) const; + + // Returns the offset of the current tag byte relative to the start of the + // 'data' passed in the constructor. + size_t GetCurrentOffset(); + + // Returns true if: + // - The next element has the NULL type (the null will be consumed). + // - The current parsing scope is empty (reached the end of the data). + // - The next element is the End Of Content marker and the scope was an + // indefinite length element. + // - There was an earlier parsing error (to indicate that nothing more + // will be read). + bool IsNullOrEmpty(); + + // Checks that the next element has an INTEGER tag and its value fits in an + // int. The element is consumed. + // If there is an error return zero. + int GetInt(); + + // The text passed in will be added to the error message if an error happens + // in the current parsing context. + // This has no effect on parsing but allows error messages to be more + // informative. + void SetElementName(absl::string_view text); + + // If the parser is not already in an error state, this will set the internal + // status and build an error message using 'text' and appending the byte + // offset and scope chain. + // If 'status' is OK this function is no-op (i.e. it doesn't clear existing + // errors). + void SetError(ErrorCode status, absl::string_view text); + + private: + struct Scope { + Scope(const CBS& data, bool indefinite) + : data(data), indefinite(indefinite) {} + // If this is the first stack level it points to the complete data. + // If we are currently parsing a constructed element value, if indefinite_ + // is false it contains the element value, otherwise it contains all the + // data that is left to parse. + CBS data; + // Indicates if 'data' belongs to a definite or indefinite length + // constructed element. + bool indefinite = false; + // Used to provide more useful error messages. + std::string element_name; + }; + + // Sets out data to nullptr and its length to 0. + // Returns false if an error occurred. + bool IsValid(CBS* out); + + // Returns true if the next two bytes are 0,0 (ASN.1 End Of Content), does not + // consume the EOC marker. + bool IsEndOfContent(); + + // Reads the next element. + // - out_tag will contain the tag value read. + // - raw_tag: if it is false and the element is constructed, a new scope will + // be pushed and the next tags will be read from its value. If it is true + // the element value will be skipped by the parser. + // - out_value will be set to the element data if it is not null. + // - out_value will be set to the element header and data if it is not null. + void GetTagInternal(unsigned* out_tag, CBS* out_value, + CBS* out_header_and_value, bool raw_tag); + + // Calls SetError if tag != expected_tag and clears out if it is not null. + void CompareTag(unsigned tag, unsigned expected_tag, CBS* out); + + std::string error_message_; + ErrorCode status_ = ErrorCode::OK; + // Contains the start of the data being parsed, only used to show offsets in + // error messages. + const uint8_t* start_; + // Each time a constructed tag is entered a new scope is pushed. If the length + // is indefinite the data contains all the remaining data. + // Each time CloseTag is called, a scope is popped, if the length was + // indefinite the next read will start right after the scope just popped. + std::vector<Scope> scopes_; +}; + +} // namespace credentio_cms + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_BER_PARSER_H_
diff --git a/crypto/default/cms/ber_parser_test.cc b/crypto/default/cms/ber_parser_test.cc new file mode 100644 index 0000000..d1f1afd --- /dev/null +++ b/crypto/default/cms/ber_parser_test.cc
@@ -0,0 +1,828 @@ +// 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 "crypto/default/cms/ber_parser.h" + +#include <cstring> +#include <limits> +#include <string> + +#include "crypto/default/cms/cms_error_code.h" +#include "gtest/gtest.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" + +namespace credentio_cms { +namespace { + +TEST(BerParser, NoData) { + BerParser parser(nullptr, 0); + EXPECT_TRUE(parser.ok()); + EXPECT_TRUE(parser.IsNullOrEmpty()); + EXPECT_EQ(ErrorCode::OK, parser.status()); + EXPECT_EQ("", parser.error_message()); + EXPECT_FALSE(parser.GetOptionalTag(0)); + EXPECT_TRUE(parser.ok()); + // Trying to read a tag should set an error. + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); +} + +TEST(BerParser, GetTagNonConstructedOk) { + uint8_t data[] = {// BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_TRUE(parser.ok()); + EXPECT_EQ(ErrorCode::OK, parser.status()); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_TRUE(parser.ok()); + EXPECT_EQ(ErrorCode::OK, parser.status()); + // Reading past the end + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); + + // Reading the wrong tag. + BerParser parser2(data, sizeof(data)); + parser2.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); +} + +TEST(BerParser, GetTagDefiniteLengthConstructedOk) { + uint8_t data[] = {// SEQUENCE { + 0x30, 8, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SEQUENCE); + EXPECT_TRUE(parser.ok()); + // We should now read inside the sequence. + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_TRUE(parser.ok()); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_TRUE(parser.ok()); + EXPECT_TRUE(parser.IsNullOrEmpty()); + // Trying to read past the end of the sequence without calling CloseTag should + // fail. + parser.GetTag(CBS_ASN1_INTEGER); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); +} + +TEST(BerParser, GetTagIndefiniteLengthConstructedOk) { + uint8_t data[] = {// SEQUENCE (indefinite) { + 0x30, 0x80, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + 0, 0, + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SEQUENCE); + EXPECT_TRUE(parser.ok()); + // We should now read inside the sequence. + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_TRUE(parser.ok()); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_TRUE(parser.ok()); + EXPECT_TRUE(parser.IsNullOrEmpty()); + // Trying to read past the end of the sequence without calling CloseTag should + // fail. + parser.GetTag(CBS_ASN1_INTEGER); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); +} + +TEST(BerParser, GetTagNoDataInElement) { + // Making sure that an element with with no data is not seen as having + // indefinite length. + uint8_t data[] = {// NULL {} + 5, 0, + // INTEGER { 0x55 } + 2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + parser.GetTag(5); + EXPECT_TRUE(parser.ok()); + parser.GetTag(CBS_ASN1_INTEGER); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, GetTagBrokenEncoding) { + uint8_t data[] = {1, 2, 0xFF}; // BOOLEAN { TRUE } + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SEQUENCE); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); +} + +TEST(BerParser, GetTagMalformedData) { + uint8_t data[] = {1}; // BOOLEAN { missing value / error } + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); +} + +TEST(BerParser, GetTagReadAfterError) { + uint8_t data[] = {// BOOLEAN { TRUE } + 1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33}; + BerParser parser(data, sizeof(data)); + // Expecting the wrong tag. + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); + // Now that the parser is in an error state reading the correct tag + // should leave it in the error state. + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status()); +} + +TEST(BerParser, GetOptionalTag) { + uint8_t data[] = {// BOOLEAN { TRUE } + 1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33}; + BerParser parser(data, sizeof(data)); + EXPECT_FALSE(parser.GetOptionalTag(CBS_ASN1_OCTETSTRING)); + EXPECT_TRUE(parser.ok()); + EXPECT_TRUE(parser.GetOptionalTag(CBS_ASN1_BOOLEAN)); + EXPECT_TRUE(parser.ok()); + EXPECT_FALSE(parser.GetOptionalTag(CBS_ASN1_BOOLEAN)); + EXPECT_TRUE(parser.GetOptionalTag(CBS_ASN1_OCTETSTRING)); + // Reading and optional tag at the end doesn't set an error. + EXPECT_FALSE(parser.GetOptionalTag(CBS_ASN1_OCTETSTRING)); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, GetElementDataOk) { + uint8_t data[] = {// BOOLEAN { TRUE } + 1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); // harmless dummy values. + + parser.GetElementData(CBS_ASN1_BOOLEAN, &out); + EXPECT_TRUE(parser.ok()); + EXPECT_EQ(1, CBS_len(&out)); + EXPECT_EQ(data + 2, CBS_data(&out)); + parser.GetElementData(CBS_ASN1_OCTETSTRING, &out); + EXPECT_TRUE(parser.ok()); + EXPECT_EQ(3, CBS_len(&out)); + EXPECT_EQ(data + 5, CBS_data(&out)); + // Read past the end, it should set an error and clear 'out'. + parser.GetElementData(CBS_ASN1_OCTETSTRING, &out); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParser, GetElementDataReadAfterError) { + uint8_t data[] = {// BOOLEAN { TRUE } + 1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); // harmless dummy values. + + // Expect the wrong tag. + parser.GetElementData(CBS_ASN1_OCTETSTRING, &out); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); + // Now all reads will fail and 'out' will be empty. + parser.GetElementData(CBS_ASN1_BOOLEAN, &out); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParser, GetElementDataConstructedDefiniteTag) { + uint8_t data[] = {// SEQUENCE { + 0x30, 8, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + + parser.GetElementData(CBS_ASN1_SEQUENCE, &out); + EXPECT_TRUE(parser.ok()); + EXPECT_EQ(8, CBS_len(&out)); + EXPECT_EQ(data + 2, CBS_data(&out)); + + parser.GetElementData(CBS_ASN1_BOOLEAN, &out); + EXPECT_TRUE(parser.ok()); + EXPECT_EQ(1, CBS_len(&out)); + EXPECT_EQ(data + 4, CBS_data(&out)); + EXPECT_EQ(0, memcmp(CBS_data(&out), "\xFF", 1)); + + parser.GetElementData(CBS_ASN1_OCTETSTRING, &out); + EXPECT_TRUE(parser.ok()); + EXPECT_EQ(3, CBS_len(&out)); + EXPECT_EQ(data + 7, CBS_data(&out)); + EXPECT_EQ(0, memcmp(CBS_data(&out), "\x11\x22\x33", 3)); + + // Reading past the end of the sequence. + EXPECT_TRUE(parser.IsNullOrEmpty()); + parser.GetElementData(CBS_ASN1_INTEGER, &out); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParser, GetElementDataConstructedIndefiniteLengthTag) { + uint8_t data[] = {// SEQUENCE (indefinite) { + 0x30, 0x80, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + 0, 0, + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + // We don't support reading element data for indefinite length elements. + parser.GetElementData(CBS_ASN1_SEQUENCE, &out); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParse, GetElementDataNullPointer) { + uint8_t data[] = {1, 1, 0xFF}; // BOOLEAN { TRUE } + BerParser parser(data, sizeof(data)); + parser.GetElementData(CBS_ASN1_BOOLEAN, nullptr); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::INVALID_PARAMETER, parser.status()); +} + +TEST(BerParser, GetElementDataMalformedData) { + uint8_t data[] = {1}; // BOOLEAN { missing value / error } + BerParser parser(data, sizeof(data)); + parser.GetElementData(CBS_ASN1_BOOLEAN, nullptr); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::INVALID_PARAMETER, parser.status()); +} + +TEST(BerParser, GetOptionalElementData) { + uint8_t data[] = {1, 1, 0xFF}; // BOOLEAN { TRUE } + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + EXPECT_FALSE(parser.GetOptionalElementData(CBS_ASN1_OCTETSTRING, &out)); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); + // Not finding the optional tag should not set an error. + EXPECT_TRUE(parser.ok()); + + EXPECT_TRUE(parser.GetOptionalElementData(CBS_ASN1_BOOLEAN, &out)); + EXPECT_EQ(1, CBS_len(&out)); + EXPECT_EQ(0, memcmp(CBS_data(&out), "\xff", 1)); + EXPECT_TRUE(parser.ok()); + // Make sure that reading after an error clears 'out' + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_FALSE(parser.ok()); + CBS_init(&out, data, 5); + EXPECT_FALSE(parser.GetOptionalElementData(CBS_ASN1_BOOLEAN, &out)); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParse, GetOptionalElementDataNullPointer) { + uint8_t data[] = {1, 1, 0xFF}; // BOOLEAN { TRUE } + BerParser parser(data, sizeof(data)); + parser.GetOptionalElementData(CBS_ASN1_BOOLEAN, nullptr); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(ErrorCode::INVALID_PARAMETER, parser.status()); +} + +TEST(BerParser, GetAnyElementAndSkipChildrenConstructed) { + uint8_t data[] = {// SET { + 0x31, 8, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + parser.GetAnyElementAndSkipChildren(&out); + EXPECT_EQ(10, CBS_len(&out)); + EXPECT_EQ(data, CBS_data(&out)); + EXPECT_TRUE(parser.ok()); + // The next read should be the INTEGER since the contents of the set are not + // parsed. + parser.GetAnyElementAndSkipChildren(&out); + EXPECT_EQ(3, CBS_len(&out)); + EXPECT_EQ(0, memcmp(CBS_data(&out), "\x02\x01\x55", 1)); + EXPECT_TRUE(parser.ok()); + // And it should fail when reading past the end. + parser.GetAnyElementAndSkipChildren(&out); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParser, GetAnyElementAndSkipChildrenConstructedIndefiniteLength) { + uint8_t data[] = {// SEQUENCE (indefinite) { + 0x30, 0x80, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + 0, 0, + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + parser.GetAnyElementAndSkipChildren(&out); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParser, GetAnyElementAndSkipChildrenReadAfterError) { + uint8_t data[] = {4, 3, 0x11, 0x22, 0x33}; // OCTET STRING { "\x11\x22\x33" } + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + parser.GetAnyElementAndSkipChildren(&out); + EXPECT_EQ(sizeof(data), CBS_len(&out)); + EXPECT_EQ(data, CBS_data(&out)); + EXPECT_TRUE(parser.ok()); + + BerParser parser2(data, sizeof(data)); + parser2.GetTag(CBS_ASN1_SEQUENCE); + EXPECT_FALSE(parser2.ok()); + // The read should fail and out be initialized. + parser2.GetAnyElementAndSkipChildren(&out); + EXPECT_FALSE(parser2.ok()); + EXPECT_EQ(0, CBS_len(&out)); + EXPECT_EQ(nullptr, CBS_data(&out)); +} + +TEST(BerParser, Peek) { + uint8_t data[] = {4, 3, 0x11, 0x22, 0x33}; // OCTET STRING { "\x11\x22\x33" } + BerParser parser(data, sizeof(data)); + EXPECT_TRUE(parser.Peek(CBS_ASN1_OCTETSTRING)); + // Peek doesn't read so it can be repeated. + EXPECT_TRUE(parser.Peek(CBS_ASN1_OCTETSTRING)); + EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER)); + // Create an error condition. + parser.GetTag(CBS_ASN1_SEQUENCE); + EXPECT_FALSE(parser.ok()); + EXPECT_FALSE(parser.Peek(CBS_ASN1_OCTETSTRING)); +} + +TEST(BerParser, IsNullOrEmpty) { + uint8_t data[] = {4, 3, 0x11, 0x22, 0x33}; // OCTET STRING { "\x11\x22\x33" } + BerParser parser(data, sizeof(data)); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_TRUE(parser.IsNullOrEmpty()); + + BerParser parser2(nullptr, 0); + EXPECT_TRUE(parser2.IsNullOrEmpty()); +} + +TEST(BerParser, IsNullOrEmptyAfterError) { + uint8_t data[] = {4, 3, 0x11, 0x22, 0x33}; // OCTET STRING { "\x11\x22\x33" } + BerParser parser(data, sizeof(data)); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_INTEGER); + EXPECT_FALSE(parser.ok()); + EXPECT_TRUE(parser.IsNullOrEmpty()); +} + +TEST(BerParser, IsNullOrEmptyDefiniteLengthConstructed) { + uint8_t data[] = {// SEQUENCE { + 0x30, 8, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + parser.GetTag(CBS_ASN1_SEQUENCE); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_TRUE(parser.IsNullOrEmpty()); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, IsNullOrEmptyConstructedWithNull) { + uint8_t data[] = {// SET { + 0x31, 5, + // BOOLEAN { TRUE } + 1, 1, 0xFF, + // NULL {} + 5, 0, + // } + // INTEGER { 0x55 } + 2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + parser.GetTag(CBS_ASN1_SET); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_TRUE(parser.Peek(5 /* NULL */)); + EXPECT_TRUE(parser.IsNullOrEmpty()); + // Make sure that IsNullOrEmpty consumed the NULL. + EXPECT_FALSE(parser.Peek(5 /* NULL */)); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, IsNullOrEmptyConstructedWithBrokenNull) { + uint8_t data[] = {// SET { + 0x31, 5, + // BOOLEAN { TRUE } + 1, 1, 0xFF, + // NULL with invalid length + 5, 1, + // } + // INTEGER { 0x55 } + 2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + parser.GetTag(CBS_ASN1_SET); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_TRUE(parser.Peek(5 /* NULL */)); + EXPECT_TRUE(parser.IsNullOrEmpty()); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, IsNullOrEmptyIndefiniteLengthConstructed) { + uint8_t data[] = {// SEQUENCE (indefinite) { + 0x30, 0x80, + // BOOLEAN { TRUE } + 0x1, 1, 0xFF, + // OCTET STRING { "\x11\x22\x33" } + 0x4, 3, 0x11, 0x22, 0x33, + // } + 0, 0, + // INTEGER { 0x55 } + 0x2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + CBS out; + CBS_init(&out, data, 5); + parser.GetTag(CBS_ASN1_SEQUENCE); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.IsNullOrEmpty()); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_TRUE(parser.IsNullOrEmpty()); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, GetInt) { + uint8_t data[] = {2, 1, 0x55}; // INTEGER { 0x55 } + BerParser parser(data, sizeof(data)); + EXPECT_EQ(0x55, parser.GetInt()); + EXPECT_TRUE(parser.ok()); + // Reading after an error should just clear the value passed in. + EXPECT_EQ(0, parser.GetInt()); + EXPECT_FALSE(parser.ok()); + EXPECT_EQ(0, parser.GetInt()); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, GetIntWrongTag) { + uint8_t data[] = {1, 1, 0x55}; // BOOLEAN { TRUE } + BerParser parser(data, sizeof(data)); + EXPECT_EQ(0, parser.GetInt()); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, GetIntNegative) { + uint8_t data[100] = {2 /* INTEGER */}; + static_assert(sizeof(int) + 2 < sizeof(data), "The int size is too large"); + data[1] = sizeof(int); + for (int i = 0; i < sizeof(int); ++i) { + data[i + 2] = 0xFF; + } + BerParser parser(data, sizeof(data)); + EXPECT_EQ(0, parser.GetInt()); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, GetIntMaxValue) { + uint8_t data[100] = {2 /* INTEGER */}; + static_assert(sizeof(int) + 2 < sizeof(data), "The int size is too large"); + data[1] = sizeof(int); + for (int i = 0; i < sizeof(int); ++i) { + data[i + 2] = 0xFF; + } + data[2] = 0x7F; + BerParser parser(data, sizeof(data)); + EXPECT_EQ(std::numeric_limits<int>::max(), parser.GetInt()); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, GetIntValueTooLarge) { + // The value doesn't fit in an uint64_t. + uint8_t data[100] = {2 /* INTEGER */, 9, 1}; + static_assert(sizeof(uint64_t) == 8, "Unexpected uint64_t size."); + BerParser parser(data, sizeof(data)); + EXPECT_EQ(0, parser.GetInt()); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, GetIntMaxValuePlusOne) { + uint8_t data[100] = {2 /* INTEGER */}; + static_assert(sizeof(int) + 2 < sizeof(data), "The int size is too large"); + data[1] = sizeof(int); + data[2] = 0x80; + BerParser parser(data, sizeof(data)); + EXPECT_EQ(0, parser.GetInt()); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, EndConstructed) { + uint8_t data[] = { + // SET { + 0x31, 5, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33, + // INTEGER { 0x55 } + 2, 1, 0x55 + // } + }; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER)); + parser.EndConstructed(); + EXPECT_TRUE(parser.ok()); + EXPECT_TRUE(parser.Peek(CBS_ASN1_INTEGER)); +} + +TEST(BerParser, EndConstructedTwoLevels) { + uint8_t data[] = {// SEQUENCE { + 0x30, 10, + // SET { + 0x31, 5, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33, + // } + // BOOLEAN { TRUE } + 1, 1, 0xFF, + // } + // INTEGER { 0x55 } + 2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SEQUENCE); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_FALSE(parser.Peek(CBS_ASN1_BOOLEAN)); + parser.EndConstructed(); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER)); + parser.EndConstructed(); + parser.GetTag(CBS_ASN1_INTEGER); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, EndConstructedUnexpectedData) { + uint8_t data[] = {// SET { + 0x31, 5, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33, + // } + // INTEGER { 0x55 } + 2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SET); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, EndConstructedIndefiniteLength) { + uint8_t data[] = {// SEQUENCE (indefinite) { + 0x30, 0x80, + // SET (indefinite) { + 0x31, 0x80, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33, + // } + 0, 0, + // BOOLEAN { TRUE } + 1, 1, 0xFF, + // } + 0, 0, + // INTEGER { 0x55 } + 2, 1, 0x55}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SEQUENCE); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_FALSE(parser.Peek(CBS_ASN1_BOOLEAN)); + parser.EndConstructed(); + parser.GetTag(CBS_ASN1_BOOLEAN); + EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER)); + parser.EndConstructed(); + parser.GetTag(CBS_ASN1_INTEGER); + EXPECT_TRUE(parser.ok()); +} + +TEST(BerParser, EndConstructedIndefiniteLengthUnexpectedData) { + uint8_t data[] = { + // SET (indefinite) { + 0x31, + 0x80, + // OCTET STRING { "\x11\x22\x33" } + 4, + 3, + 0x11, + 0x22, + 0x33, + // INTEGER { 0x55 } + 2, + 1, + 0x55, + // The SET is missing the EOC. + }; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SET); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, EndConstructedMissingEOC) { + uint8_t data[] = { + // SET (indefinite) { + 0x31, + 0x80, + // OCTET STRING { "\x11\x22\x33" } + 4, + 3, + 0x11, + 0x22, + 0x33, + // The SET is missing the EOC. + }; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_OCTETSTRING); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, EndConstructedIncompleteEOC) { + uint8_t data[] = {// SET (indefinite) { + 0x31, 0x80, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33, + // Incomplete EOC for the SET. + 0}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_OCTETSTRING); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, EndConstructedIndefiniteLengthEOCInNextTag) { + uint8_t data[] = { + // SET { + 0x31, 7, + // SET (indefinite) { + 0x31, 0x80, + // OCTET STRING { "\x11\x22\x33" } + 4, 3, 0x11, 0x22, 0x33, + // This is the EOC for the inner set, but it is past the end of the outer + // one, which is invalid. + 0, 0}; + BerParser parser(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_OCTETSTRING); + EXPECT_TRUE(parser.ok()); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, EndConstructedTooManyCalls) { + BerParser parser(nullptr, 0); + EXPECT_TRUE(parser.ok()); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); + + // This is the following structure, missing the SET's EOC: + // + // SET (indefinite) { OCTET STRING { "\x11" } + uint8_t data[] = {0x31, 0x80, 4, 1, 0x11}; + BerParser parser2(data, sizeof(data)); + parser.GetTag(CBS_ASN1_SET); + parser.GetTag(CBS_ASN1_OCTETSTRING); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); + parser.EndConstructed(); + EXPECT_FALSE(parser.ok()); +} + +TEST(BerParser, SetError) { + BerParser parser(nullptr, 0); + EXPECT_TRUE(parser.ok()); + parser.SetError(ErrorCode::UNSUPPORTED, "foo"); + EXPECT_EQ(ErrorCode::UNSUPPORTED, parser.status()); + EXPECT_FALSE(parser.ok()); + EXPECT_GE(parser.error_message().find("foo"), 0); + // Make sure that the error can't be reset. + parser.SetError(ErrorCode::OK, "bar"); + EXPECT_EQ(ErrorCode::UNSUPPORTED, parser.status()); + EXPECT_FALSE(parser.ok()); + EXPECT_GE(parser.error_message().find("foo"), 0); +} + +TEST(BerParser, SetElementName) { + uint8_t data[] = {// SET (indefinite { + 0x31, 0x80, + // OCTET STRING { \x11 } + 4, 1, 0x11, + // } + 0, 0}; + BerParser parser(data, sizeof(data)); + parser.SetElementName("@1@"); + parser.GetInt(); + EXPECT_FALSE(parser.ok()); + EXPECT_GE(parser.error_message().find("@1@"), 0); + + BerParser parser2(data, sizeof(data)); + parser2.SetElementName("@1@"); + parser2.GetTag(CBS_ASN1_SET); + parser2.SetElementName("@2@"); + parser2.GetInt(); + EXPECT_FALSE(parser2.ok()); + EXPECT_GE(parser2.error_message().find("@1@"), 0); + EXPECT_GE(parser2.error_message().find("@2@"), 0); + + // Check that the names are not in the error string once their context is + // poped. + BerParser parser3(data, sizeof(data)); + parser3.SetElementName("@1@"); + parser3.GetTag(CBS_ASN1_SET); + parser3.SetElementName("@2@"); + parser3.GetTag(CBS_ASN1_OCTETSTRING); + parser3.EndConstructed(); + EXPECT_TRUE(parser3.ok()); + parser3.GetInt(); + EXPECT_FALSE(parser2.ok()); + EXPECT_GE(parser3.error_message().find("@1@"), 0); + EXPECT_EQ(std::string::npos, parser3.error_message().find("@2@")); +} + +} // namespace +} // namespace credentio_cms
diff --git a/crypto/default/cms/certificates.cc b/crypto/default/cms/certificates.cc new file mode 100644 index 0000000..ad47c62 --- /dev/null +++ b/crypto/default/cms/certificates.cc
@@ -0,0 +1,134 @@ +// 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 "crypto/default/cms/certificates.h" + +#include <cstring> +#include <vector> + +#include "crypto/default/cms/cms_parser.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/obj_mac.h" +#include "openssl/safestack.h" +#include "openssl/stack.h" +#include "openssl/x509.h" + +namespace credentio_cms { +namespace { + +std::vector<X509*> MatchByIssuerNameAndSerialNumber( + const STACK_OF(X509) * certificates, const ByteString& issuer_name, + const ByteString& serial_number) { + std::vector<X509*> matches; + if (CBS_data(issuer_name.cbs_ptr()) == nullptr || + CBS_data(serial_number.cbs_ptr()) == nullptr || + CBS_len(issuer_name.cbs_ptr()) == 0 || + CBS_len(serial_number.cbs_ptr()) == 0) { + return matches; + } + const unsigned char* p = CBS_data(issuer_name.cbs_ptr()); + bssl::UniquePtr<X509_NAME> x509_issuer_name( + d2i_X509_NAME(nullptr, &p, CBS_len(issuer_name.cbs_ptr()))); + p = CBS_data(serial_number.cbs_ptr()); + bssl::UniquePtr<ASN1_INTEGER> x509_serial_number( + c2i_ASN1_INTEGER(nullptr, &p, CBS_len(serial_number.cbs_ptr()))); + if (x509_issuer_name == nullptr || x509_serial_number == nullptr) { + return matches; + } + for (int i = 0; i < sk_X509_num(certificates); ++i) { + X509* cert = sk_X509_value(certificates, i); + if (cert == nullptr) { + continue; + } + const ASN1_INTEGER* certificate_serial = X509_get0_serialNumber(cert); + if (certificate_serial == nullptr || + ASN1_INTEGER_cmp(x509_serial_number.get(), certificate_serial) != 0) { + continue; + } + if (X509_name_cmp(x509_issuer_name.get(), X509_get_issuer_name(cert)) == + 0) { + matches.push_back(cert); + } + } + return matches; +} + +std::vector<X509*> MatchBySubjectKeyId( + const STACK_OF(X509) * certificates, + const ByteString& subject_key_identifier) { + std::vector<X509*> matches; + for (int i = 0; i < sk_X509_num(certificates); ++i) { + X509* cert = sk_X509_value(certificates, i); + if (cert == nullptr) { + continue; + } + int index = X509_get_ext_by_NID(cert, NID_subject_key_identifier, -1); + if (index < 0) { + continue; + } + const X509_EXTENSION* ext = X509_get_ext(cert, index); + if (ext == nullptr) { + continue; + } + const ASN1_OCTET_STRING* value = X509_EXTENSION_get_data(ext); + // https://tools.ietf.org/html/rfc5280#section-4.2.1.2 + CBS extension; + CBS_init(&extension, ASN1_STRING_get0_data(value), + ASN1_STRING_length(value)); + CBS ext_ski; + if (CBS_get_asn1(&extension, &ext_ski, CBS_ASN1_OCTETSTRING) == 1 && + CBS_len(&ext_ski) == CBS_len(subject_key_identifier.cbs_ptr()) && + memcmp(CBS_data(subject_key_identifier.cbs_ptr()), CBS_data(&ext_ski), + CBS_len(&ext_ski)) == 0) { + matches.push_back(cert); + } + } + return matches; +} + +} // namespace + +STACK_OF(X509) * GetAllCertificates(const Content& cms_content) { + bssl::UniquePtr<STACK_OF(X509)> stack(sk_X509_new_null()); + for (const auto& certificate : cms_content.certificates) { + // Return null if there is an error parsing any certificate instead of + // returning partial results. + const unsigned char* udata = CBS_data(certificate.cbs_ptr()); + if (udata == nullptr || CBS_len(certificate.cbs_ptr()) == 0) { + return nullptr; + } + X509* cert = + d2i_X509(nullptr /* unused */, &udata, CBS_len(certificate.cbs_ptr())); + if (cert == nullptr) { + return nullptr; + } + sk_X509_push(stack.get(), cert); + } + return stack.release(); +} + +std::vector<X509*> GetSignerCertificates(const STACK_OF(X509) * certificates, + const SignerInfo& signer) { + if (CBS_len(signer.subject_key_identifier.cbs_ptr()) > 0) { + return MatchBySubjectKeyId(certificates, signer.subject_key_identifier); + } else { + return MatchByIssuerNameAndSerialNumber(certificates, signer.issuer_name, + signer.serial_number); + } +} + +} // namespace credentio_cms
diff --git a/crypto/default/cms/certificates.h b/crypto/default/cms/certificates.h new file mode 100644 index 0000000..0c0a440 --- /dev/null +++ b/crypto/default/cms/certificates.h
@@ -0,0 +1,46 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CERTIFICATES_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CERTIFICATES_H_ + +#include <vector> + +#include "crypto/default/cms/cms_parser.h" +#include "openssl/ossl_typ.h" +#include "openssl/safestack.h" +#include "openssl/x509.h" + +namespace credentio_cms { +// Parses the certificates returned in a CmsContent. +// The caller is responsible for deleting the stack returned using +// sk_X509_pop_free. +// Returns null if there is an error when parsing the certificates. +STACK_OF(X509) * GetAllCertificates(const Content& cms_content); + +// Return all the embedded certificates matching the signer issuer name and +// serial number. +// The certificates are not validated so it is up to the caller to check their +// trustworthiness, if they match the sender's email address, etc. +// - Returns the certificates matching the issuer and serial number or subject +// key id mentioned in 'signer'. +// - The X509 pointers returned are only valid as long as the certificates +// stack is valid. The caller should not free them. +std::vector<X509*> GetSignerCertificates(const STACK_OF(X509) * certificates, + const SignerInfo& signer); + +} // namespace credentio_cms + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CERTIFICATES_H_
diff --git a/crypto/default/cms/certificates_test.cc b/crypto/default/cms/certificates_test.cc new file mode 100644 index 0000000..9332f8d --- /dev/null +++ b/crypto/default/cms/certificates_test.cc
@@ -0,0 +1,178 @@ +// 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 "crypto/default/cms/certificates.h" + +#include <memory> +#include <string> +#include <vector> + +#include "absl/log/check.h" +#include "absl/log/die_if_null.h" +#include "absl/strings/escaping.h" +#include "crypto/default/cms/cms_error_code.h" +#include "crypto/default/cms/cms_parser.h" +#include "crypto/default/cms/cms_test_helper.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/bytestring.h" +#include "openssl/safestack.h" +#include "openssl/stack.h" +#include "openssl/x509.h" + +using testing::ElementsAre; + +namespace credentio_cms { +namespace { + +std::string X509ToDer(X509* x509) { + const int len = i2d_X509(x509, nullptr /* buffer */); + CHECK_GT(len, 0); + std::unique_ptr<char[]> data(new char[len]); + unsigned char* dataptr = reinterpret_cast<unsigned char*>(data.get()); + const int len2 = i2d_X509(x509, &dataptr); + CHECK_EQ(len, len2); + std::string der_output; + der_output.assign(data.get(), len); + return der_output; +} + +ByteString ByteStringFromString(const std::string& value) { + ByteString bs; + CBS_init(bs.cbs_ptr(), reinterpret_cast<const unsigned char*>(value.data()), + value.length()); + return bs; +} + +class CertificateTest : public testing::Test { + protected: + void SetUp() override { + user1_rsa_.reset( + ABSL_DIE_IF_NULL(PemToCertificate(GetFileContents("user1_both.pem")))); + user1_ecc_.reset( + ABSL_DIE_IF_NULL(PemToCertificate(GetFileContents("user1_ecc.pem")))); + user2_rsa_.reset( + ABSL_DIE_IF_NULL(PemToCertificate(GetFileContents("user2_both.pem")))); + user1_rsa_der_ = X509ToDer(user1_rsa_.get()); + user1_ecc_der_ = X509ToDer(user1_ecc_.get()); + user2_rsa_der_ = X509ToDer(user2_rsa_.get()); + content_.certificates.push_back(ByteStringFromString(user1_rsa_der_)); + content_.certificates.push_back(ByteStringFromString(user1_ecc_der_)); + content_.certificates.push_back(ByteStringFromString(user2_rsa_der_)); + + test_stack_ = sk_X509_new_null(); + sk_X509_push(test_stack_, user1_rsa_.get()); + sk_X509_push(test_stack_, user1_ecc_.get()); + sk_X509_push(test_stack_, user2_rsa_.get()); + } + + void TearDown() override { + if (test_stack_ != nullptr) { + sk_X509_free(test_stack_); + } + } + + STACK_OF(X509) * test_stack_ = nullptr; + Content content_; + bssl::UniquePtr<X509> user1_rsa_; + bssl::UniquePtr<X509> user1_ecc_; + bssl::UniquePtr<X509> user2_rsa_; + std::string user1_rsa_der_; + std::string user1_ecc_der_; + std::string user2_rsa_der_; +}; + +TEST_F(CertificateTest, GetAllCertificates) { + bssl::UniquePtr<STACK_OF(X509)> stack(GetAllCertificates(content_)); + ASSERT_EQ(3, sk_X509_num(stack.get())); + EXPECT_EQ(0, X509_cmp(sk_X509_value(stack.get(), 0), user1_rsa_.get())); + EXPECT_EQ(0, X509_cmp(sk_X509_value(stack.get(), 1), user1_ecc_.get())); + EXPECT_EQ(0, X509_cmp(sk_X509_value(stack.get(), 2), user2_rsa_.get())); +} + +TEST_F(CertificateTest, GetByName) { + X509_NAME* issuer = X509_get_issuer_name(user1_rsa_.get()); + const int len = i2d_X509_NAME(issuer, nullptr /* buffer */); + CHECK_GT(len, 0); + std::unique_ptr<char[]> data(new char[len]); + unsigned char* dataptr = reinterpret_cast<unsigned char*>(data.get()); + const int len2 = i2d_X509_NAME(issuer, &dataptr); + CHECK_EQ(len, len2); + std::string name_der; + name_der.assign(data.get(), len); + SignerInfo signer_info; + signer_info.issuer_name = ByteStringFromString(name_der); + std::string serial = "\x12\x3b"; + signer_info.serial_number = ByteStringFromString(serial); + + std::vector<X509*> matching = GetSignerCertificates(test_stack_, signer_info); + EXPECT_THAT(matching, ElementsAre(user1_ecc_.get())); + + CHECK(absl::HexStringToBytes("123f", &serial)); + signer_info.serial_number = ByteStringFromString(serial); + matching = GetSignerCertificates(test_stack_, signer_info); + EXPECT_THAT(matching, ElementsAre()); +} + +TEST_F(CertificateTest, GetBySki) { + SignerInfo signer_info; + std::string ski; + CHECK( + absl::HexStringToBytes("9B6EAE67945D61FB736371448CE0F2ECBA43CBA0", &ski)); + signer_info.subject_key_identifier = ByteStringFromString(ski); + std::vector<X509*> matching = GetSignerCertificates(test_stack_, signer_info); + EXPECT_THAT(matching, ElementsAre(user1_ecc_.get())); +} + +TEST_F(CertificateTest, RFCSkiExample) { + // The RFC 4134 4.7 example uses the Subject Key Id to match the signer's + // certificate. + std::string cms = GetCms(GetFileContents("rfc4134-4.7.msg")); + Content contents; + std::string error_message; + ASSERT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &contents, &error_message)) + << error_message; + bssl::UniquePtr<STACK_OF(X509)> certs(GetAllCertificates(contents)); + ASSERT_EQ(1, sk_X509_num(certs.get())); + ASSERT_EQ(1, contents.signers.size()); + + std::vector<X509*> matching = + GetSignerCertificates(certs.get(), contents.signers[0]); + EXPECT_THAT(matching, ElementsAre(sk_X509_value(certs.get(), 0))); +} + +TEST_F(CertificateTest, GetAllCertificatesWithEmptyCert) { + // Zero out the 2nd certificate in the stack. + CBS_init(content_.certificates[1].cbs_ptr(), nullptr, 0); + bssl::UniquePtr<STACK_OF(X509)> stack(GetAllCertificates(content_)); + ASSERT_TRUE(stack == nullptr); +} + +TEST_F(CertificateTest, GetAllCertificatesWithBrokenCert) { + // A valid X509 certificate should start with a 0x30 (Constructed Sequence) + // tag. + uint8_t kBrokenCert[] = {0xFF}; + // Make the second certificate in the stack broken. + CBS_init(content_.certificates[1].cbs_ptr(), kBrokenCert, + sizeof(kBrokenCert)); + bssl::UniquePtr<STACK_OF(X509)> stack(GetAllCertificates(content_)); + ASSERT_TRUE(stack == nullptr); +} + +} // namespace +} // namespace credentio_cms
diff --git a/crypto/default/cms/cms_error_code.h b/crypto/default/cms/cms_error_code.h new file mode 100644 index 0000000..b975d3e --- /dev/null +++ b/crypto/default/cms/cms_error_code.h
@@ -0,0 +1,35 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_ERROR_CODE_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_ERROR_CODE_H_ + +namespace credentio_cms { + +enum class ErrorCode { + OK = 0, + // The ASN.1 encoding is not DER or the limited BER subset supported. + ASN1_PARSING_ERROR = 1, + // This means that the S/MIME part contains an unsupported encoding or + // feature. + UNSUPPORTED = 2, + // Set when invalid parameters are passed to the API, for example an + // unexpected null pointer. + INVALID_PARAMETER = 3, +}; + +} // namespace credentio_cms + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_ERROR_CODE_H_
diff --git a/crypto/default/cms/cms_parser.cc b/crypto/default/cms/cms_parser.cc new file mode 100644 index 0000000..0c6ee9f --- /dev/null +++ b/crypto/default/cms/cms_parser.cc
@@ -0,0 +1,538 @@ +// 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 "crypto/default/cms/cms_parser.h" + +#include <stdint.h> + +#include <string> +#include <vector> + +#include "crypto/default/cms/ber_parser.h" +#include "crypto/default/cms/cms_error_code.h" +#include "crypto/default/cms/oids.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" + +namespace credentio_cms { + +namespace { + +constexpr unsigned ContextSpecificConstructed(int n) { + return CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC | n; +} +constexpr unsigned ContextSpecific(int n) { + return CBS_ASN1_CONTEXT_SPECIFIC | n; +} + +// Scoped class making it easier to keep track of the constructed tag by +// calling EndConstructed on destruction. +class ScopedConstructedElement { + public: + // Only calls EndConstructed when destroyed, use it when the constructed tag + // was already read (usually with GetOptionalTag). + explicit ScopedConstructedElement(BerParser* parser, const char* hint) + : parser_(parser) { + parser->SetElementName(hint); + } + // Reads expected_tag, provides a scope name for the error messages and calls + // EndConstructed when destroyed. + ScopedConstructedElement(unsigned expected_tag, const char* hint, + BerParser* parser) + : parser_(parser) { + parser->GetTag(expected_tag); + parser->SetElementName(hint); + } + ~ScopedConstructedElement() { parser_->EndConstructed(); } + + private: + BerParser* parser_; // Not owned. +}; + +// Some applications encode OCTET STRING as a constructed element composed +// of octet strings. +void ParseBerOctetString(int tag, BerParser* parser, + std::vector<ByteString>* data) { + data->clear(); + if (parser->Peek(tag)) { + data->push_back(ByteString()); + parser->GetElementData(tag, data->back().cbs_ptr()); + return; + } + ScopedConstructedElement constructed_string(tag | CBS_ASN1_CONSTRUCTED, + "BerOctetString", parser); + for (; !parser->IsNullOrEmpty();) { + data->push_back(ByteString()); + parser->GetElementData(CBS_ASN1_OCTETSTRING, data->back().cbs_ptr()); + } +} + +// Expect a sequence with a single OID in it. +void ParseAlgorithmIdentifier(const char* hint, BerParser* parser, + AlgorithmIdentifier* id) { + parser->GetAnyElementAndSkipChildren(id->raw_value.cbs_ptr()); + // Extract the algorithm identifier parts. + // https://tools.ietf.org/html/rfc5280#section-4.1.1.2 + BerParser id_parser(CBS_data(id->raw_value.cbs_ptr()), + CBS_len(id->raw_value.cbs_ptr())); + id_parser.GetTag(CBS_ASN1_SEQUENCE); + id_parser.GetElementData(CBS_ASN1_OBJECT, id->algorithm_oid.cbs_ptr()); + if (!id_parser.IsNullOrEmpty()) { + id_parser.GetAnyElementAndSkipChildren(id->parameter.cbs_ptr()); + } + id_parser.EndConstructed(); + if (!id_parser.ok()) { + parser->SetError(id_parser.status(), hint); + } +} + +template <typename T> +void GetIssuerAndSerialNumber(BerParser* parser, T* ids) { + ScopedConstructedElement issuer_and_serial_number( + CBS_ASN1_SEQUENCE, "Issuer and serial number", parser); + parser->GetAnyElementAndSkipChildren(ids->issuer_name.cbs_ptr()); + parser->GetElementData(CBS_ASN1_INTEGER, ids->serial_number.cbs_ptr()); +} + +// Sn = Serial Number +// Ski = Subject Key Identifier +template <typename T> +void GetIssuerAndSnOrSki(BerParser* parser, T* ids) { + if (parser->Peek(CBS_ASN1_SEQUENCE)) { + GetIssuerAndSerialNumber(parser, ids); + } else if (parser->Peek(ContextSpecific(0))) { + parser->GetElementData(ContextSpecific(0), + ids->subject_key_identifier.cbs_ptr()); + } +} + +// http://tools.ietf.org/html/rfc5652#section-6.2.1 +void ParseKeyTransRecipientInfo(BerParser* parser, RecipientInfo* recipient) { + ScopedConstructedElement key_agree(parser, "KeyTransRecipientInfo"); + parser->SetElementName("RecipientInfo"); + recipient->version = parser->GetInt(); + if (recipient->version != 0 && recipient->version != 2) { + parser->SetError(static_cast<ErrorCode>(ErrorCode::UNSUPPORTED), + "Unsupported recipient version"); + return; + } + recipient->encrypted_keys.push_back(RecipientEncryptedKey()); + GetIssuerAndSnOrSki(parser, &recipient->encrypted_keys.back()); + ParseAlgorithmIdentifier("Key Encryption Algorithm", parser, + &recipient->key_encryption_algorithm); + parser->GetElementData( + CBS_ASN1_OCTETSTRING, + recipient->encrypted_keys.back().encrypted_key.cbs_ptr()); +} + +void ParseRecipientEncryptedKey(BerParser* parser, RecipientEncryptedKey* key) { + ScopedConstructedElement recipient_key(CBS_ASN1_SEQUENCE, + "RecicientEncryptedKey", parser); + if (parser->Peek(CBS_ASN1_SEQUENCE)) { + GetIssuerAndSerialNumber(parser, key); + } else { + ScopedConstructedElement rid(ContextSpecificConstructed(0), + "RecipientKeyIdentifier", parser); + GetIssuerAndSerialNumber(parser, key); + parser->GetOptionalElementData(CBS_ASN1_GENERALIZEDTIME, + key->date.cbs_ptr()); + if (!parser->IsNullOrEmpty()) { + parser->GetAnyElementAndSkipChildren(key->other.cbs_ptr()); + } + } + parser->GetElementData(CBS_ASN1_OCTETSTRING, key->encrypted_key.cbs_ptr()); +} + +// http://tools.ietf.org/html/rfc5652#section-6.2.2 +void ParseOriginatorPublicKey(BerParser* parser, + OriginatorIdentifierOrKey* oik) { + ScopedConstructedElement opk(parser, "OriginatorPublicKey"); + ParseAlgorithmIdentifier("Public Key Algorithm", parser, + &oik->public_key_algorithm); + parser->GetElementData(CBS_ASN1_BITSTRING, oik->public_key_value.cbs_ptr()); +} + +// http://tools.ietf.org/html/rfc5652#section-6.2.2 +void ParseOriginatorIdentifierOrKey(BerParser* parser, + OriginatorIdentifierOrKey* oik) { + ScopedConstructedElement originator(ContextSpecificConstructed(0), + "OriginatorIdentifierOrKey", parser); + if (parser->Peek(CBS_ASN1_SEQUENCE)) { + GetIssuerAndSerialNumber(parser, oik); + } else if (parser->GetOptionalTag(ContextSpecificConstructed(0))) { + parser->GetElementData(CBS_ASN1_OCTETSTRING, + oik->subject_key_identifier.cbs_ptr()); + parser->EndConstructed(); + } else if (parser->GetOptionalTag(ContextSpecificConstructed(1))) { + ParseOriginatorPublicKey(parser, oik); + } else { + parser->SetError(static_cast<ErrorCode>(ErrorCode::UNSUPPORTED), + "Unsupported originator tag"); + } +} + +// http://tools.ietf.org/html/rfc5652#section-6.2.2 +void ParseKeyAgreeRecipientInfo(BerParser* parser, RecipientInfo* recipient) { + ScopedConstructedElement key_agree(parser, "KeyAgreeRecipientInfo"); + recipient->version = parser->GetInt(); + if (recipient->version != 3) { + parser->SetError(static_cast<ErrorCode>(ErrorCode::UNSUPPORTED), + "Unsupported key agree recipient version"); + return; + } + ParseOriginatorIdentifierOrKey(parser, + &recipient->originator_identifier_or_key); + // User Key Material (UKM) + if (parser->GetOptionalTag(ContextSpecificConstructed(1))) { + parser->GetElementData(CBS_ASN1_OCTETSTRING, + recipient->user_keying_material.cbs_ptr()); + parser->EndConstructed(); + } + ParseAlgorithmIdentifier("Key Encryption Algorithm", parser, + &recipient->key_encryption_algorithm); + + ScopedConstructedElement recipient_keys(CBS_ASN1_SEQUENCE, + "RecipientEncryptedKeys", parser); + for (; !parser->IsNullOrEmpty();) { + RecipientEncryptedKey key; + ParseRecipientEncryptedKey(parser, &key); + recipient->encrypted_keys.push_back(key); + } +} + +// https://tools.ietf.org/html/rfc5751#section-2.5.3 +bool ExtractSmimeEncryptionKeyPreference(SignerInfo* signer, + ByteString key_preference) { + SmimeEncryptionKeyPreference& preference = signer->encryption_key_preference; + BerParser parser(CBS_data(key_preference.cbs_ptr()), + CBS_len(key_preference.cbs_ptr())); + if (parser.Peek(ContextSpecificConstructed(0))) { + ScopedConstructedElement issuer_sn(ContextSpecificConstructed(0), + "IssuerAndSerialNumber", &parser); + parser.GetAnyElementAndSkipChildren(preference.issuer_name.cbs_ptr()); + parser.GetElementData(CBS_ASN1_INTEGER, preference.serial_number.cbs_ptr()); + } else if (parser.Peek(ContextSpecificConstructed(1))) { + ScopedConstructedElement issuer_sn(ContextSpecificConstructed(1), + "RecipientKeyIdentifier", &parser); + parser.GetElementData(CBS_ASN1_OCTETSTRING, + preference.subject_key_identifier.cbs_ptr()); + parser.GetOptionalElementData(CBS_ASN1_GENERALIZEDTIME, + preference.date.cbs_ptr()); + parser.GetAnyElementAndSkipChildren(preference.other.cbs_ptr()); + } else if (parser.Peek(ContextSpecificConstructed(2))) { + parser.GetElementData(ContextSpecific(2), + preference.subject_key_identifier.cbs_ptr()); + } else { + return false; + } + return parser.ok(); +} + +bool ExtractSingleValue(const Attribute& attribute, unsigned expected_tag, + CBS* out) { + if (attribute.values.size() != 1) { + return false; + } + CBS copy; + CBS_init(©, CBS_data(attribute.values[0].cbs_ptr()), + CBS_len(attribute.values[0].cbs_ptr())); + return CBS_get_asn1(©, out, expected_tag) == 1; +} + +bool ExtractCommonAttributes(SignerInfo* signer) { + bool status = true; + for (const auto& attribute : signer->signed_attributes) { + if (CompareOid(attribute.type.cbs(), kContentAttributeType, + sizeof(kContentAttributeType))) { + status &= ExtractSingleValue(attribute, CBS_ASN1_OBJECT, + signer->content_type_signed.cbs_ptr()); + } else if (CompareOid(attribute.type.cbs(), kContentAttributeMessageDigest, + sizeof(kContentAttributeMessageDigest))) { + status &= ExtractSingleValue(attribute, CBS_ASN1_OCTETSTRING, + signer->message_digest.cbs_ptr()); + } else if (CompareOid(attribute.type.cbs(), + kSmimeEncryptionKeyPreferenceOid, + sizeof(kSmimeEncryptionKeyPreferenceOid))) { + status &= (attribute.values.size() == 1); + if (status) { + status &= + ExtractSmimeEncryptionKeyPreference(signer, attribute.values[0]); + } + } + } + return status; +} + +// Extracts the message digest and content type from the signed attributes. +// http://tools.ietf.org/html/rfc5652#section-5.3 +void ParseSignedAttributes(BerParser* parser, SignerInfo* signer) { + // Keep the raw signed attributes to make the signature check easier. + parser->GetAnyElementAndSkipChildren(signer->raw_signed_attributes.cbs_ptr()); + // Extract the content type and content digest. + BerParser att_parser(CBS_data(signer->raw_signed_attributes.cbs_ptr()), + CBS_len(signer->raw_signed_attributes.cbs_ptr())); + att_parser.GetTag(ContextSpecificConstructed(0)); + for (; !att_parser.IsNullOrEmpty();) { + ScopedConstructedElement seq(CBS_ASN1_SEQUENCE, "Authenticated Attributes", + &att_parser); + signer->signed_attributes.push_back(Attribute()); + Attribute& attribute = signer->signed_attributes.back(); + att_parser.GetElementData(CBS_ASN1_OBJECT, attribute.type.cbs_ptr()); + ScopedConstructedElement s(CBS_ASN1_SET, "Signed attribute values", + &att_parser); + for (; !att_parser.IsNullOrEmpty();) { + attribute.values.push_back(ByteString()); + att_parser.GetAnyElementAndSkipChildren( + attribute.values.back().cbs_ptr()); + } + } + att_parser.EndConstructed(); + if (!att_parser.ok()) { + parser->SetError(att_parser.status(), + "Failed to parse the signed attributes"); + return; + } + if (!ExtractCommonAttributes(signer)) { + parser->SetError(att_parser.status(), + "Invalid content type or digest attributes"); + } +} + +// Extract the unsigned attributes for anybody who needs to look at them. +// http://tools.ietf.org/html/rfc5652#section-5.3 +void ParseUnsignedAttributes(BerParser* parser, SignerInfo* signer) { + parser->GetAnyElementAndSkipChildren( + signer->raw_unsigned_attributes.cbs_ptr()); + BerParser att_parser(CBS_data(signer->raw_unsigned_attributes.cbs_ptr()), + CBS_len(signer->raw_unsigned_attributes.cbs_ptr())); + att_parser.GetTag(ContextSpecificConstructed(1)); + for (; !att_parser.IsNullOrEmpty();) { + ScopedConstructedElement seq(CBS_ASN1_SEQUENCE, "Unsigned Attributes", + &att_parser); + signer->unsigned_attributes.push_back(Attribute()); + Attribute& attribute = signer->unsigned_attributes.back(); + att_parser.GetElementData(CBS_ASN1_OBJECT, attribute.type.cbs_ptr()); + ScopedConstructedElement s(CBS_ASN1_SET, "Unsigned attribute values", + &att_parser); + for (; !att_parser.IsNullOrEmpty();) { + attribute.values.push_back(ByteString()); + att_parser.GetAnyElementAndSkipChildren( + attribute.values.back().cbs_ptr()); + } + } + att_parser.EndConstructed(); + if (!att_parser.ok()) { + parser->SetError(att_parser.status(), + "Failed to parse the unsigned attributes"); + } +} + +// http://tools.ietf.org/html/rfc5652#section-5.3 +static void ParseSignerInfoInternal(BerParser* parser, SignerInfo* signer) { + ScopedConstructedElement signer_info(CBS_ASN1_SEQUENCE, "SignerInfo", parser); + signer->version = parser->GetInt(); + if (signer->version != 1 && signer->version != 3) { + parser->SetError(ErrorCode::UNSUPPORTED, "Unsupported signer version"); + return; + } + GetIssuerAndSnOrSki(parser, signer); + ParseAlgorithmIdentifier("Digest Algorithm", parser, + &signer->digest_algorithm); + // Authenticated attributes + if (parser->Peek(ContextSpecificConstructed(0))) { + ParseSignedAttributes(parser, signer); + } + ParseAlgorithmIdentifier("Signature Algorithm", parser, + &signer->signature_algorithm); + parser->GetElementData(CBS_ASN1_OCTETSTRING, + signer->signature_value.cbs_ptr()); + // Implicit tag 1: Unauthenticated attributes + if (parser->Peek(ContextSpecificConstructed(1))) { + ParseUnsignedAttributes(parser, signer); + } +} + +void ParseCertificates(BerParser* parser, + std::vector<ByteString>* certificates) { + ScopedConstructedElement s(ContextSpecificConstructed(0), "Certificates", + parser); + for (; !parser->IsNullOrEmpty();) { + ByteString out; + parser->GetAnyElementAndSkipChildren(out.cbs_ptr()); + certificates->push_back(out); + } +} + +// Certificates Revocation Lists are ignored. +void ParseCrls(BerParser* parser) { + ScopedConstructedElement s(ContextSpecificConstructed(1), "CRL", parser); + for (; !parser->IsNullOrEmpty();) { + ByteString out; + parser->GetAnyElementAndSkipChildren(out.cbs_ptr()); + } +} + +// https://tools.ietf.org/html/rfc5652#section-5.1 +void ParseSignedData(BerParser* parser, Content* content) { + content->type = ContentType::SIGNED_DATA; + ScopedConstructedElement signed_data(CBS_ASN1_SEQUENCE, "SignedData", parser); + content->version = parser->GetInt(); + { + ScopedConstructedElement algorigthm_set(CBS_ASN1_SET, "AlgorithmSet", + parser); + for (; !parser->IsNullOrEmpty();) { + content->digest_algorithms.push_back(AlgorithmIdentifier()); + ParseAlgorithmIdentifier("DigestAlgorithm", parser, + &content->digest_algorithms.back()); + } + } + { + ScopedConstructedElement contentInfo(CBS_ASN1_SEQUENCE, "Content", parser); + parser->GetElementData(CBS_ASN1_OBJECT, content->content_type.cbs_ptr()); + content->content_tag = 0; + if (parser->GetOptionalTag(ContextSpecificConstructed(0))) { + if (parser->Peek(CBS_ASN1_OCTETSTRING) || + parser->Peek(CBS_ASN1_OCTETSTRING | CBS_ASN1_CONSTRUCTED)) { + content->content_offsets.first = parser->GetCurrentOffset(); + ParseBerOctetString(CBS_ASN1_OCTETSTRING, parser, &content->content); + content->content_offsets.second = parser->GetCurrentOffset(); + content->content_tag = CBS_ASN1_OCTETSTRING; + } else if (parser->Peek(CBS_ASN1_SEQUENCE)) { + // PKCS#7 compatibility. + // See https://tools.ietf.org/html/rfc5652#section-5.2.1 + content->content_offsets.first = parser->GetCurrentOffset(); + ByteString bytes; + parser->GetAnyElementAndSkipChildren(bytes.cbs_ptr()); + content->content_offsets.second = parser->GetCurrentOffset(); + content->content.push_back(bytes); + content->content_tag = CBS_ASN1_SEQUENCE; + } else { + parser->SetError(ErrorCode::UNSUPPORTED, "Unexpected type"); + } + parser->EndConstructed(); // Content + } + } + // Certificates + if (parser->Peek(ContextSpecificConstructed(0))) { + ParseCertificates(parser, &content->certificates); + } + // Certificate Revocation List are ignored. + if (parser->Peek(ContextSpecificConstructed(1))) { + ParseCrls(parser); + } + // Signers + { + ScopedConstructedElement signers(CBS_ASN1_SET, "Signers", parser); + for (; !parser->IsNullOrEmpty();) { + content->signers.push_back(SignerInfo()); + ParseSignerInfoInternal(parser, &content->signers.back()); + } + } +} + +// http://tools.ietf.org/html/rfc5652#section-6.1 +void ParseEncryptedContentInfo(BerParser* parser, Content* content) { + ScopedConstructedElement s(CBS_ASN1_SEQUENCE, "EncryptedContentInfo", parser); + CBS oid; + parser->GetElementData(CBS_ASN1_OBJECT, &oid); + if (!CompareOid(oid, kDataOid, sizeof(kDataOid))) { + parser->SetError(ErrorCode::UNSUPPORTED, "Unexpected content type."); + return; + } + ParseAlgorithmIdentifier("content encryption algorithm", parser, + &content->encryption_algorithm); + if (parser->Peek(CBS_ASN1_CONTEXT_SPECIFIC | CBS_ASN1_CONSTRUCTED) || + parser->Peek(CBS_ASN1_CONTEXT_SPECIFIC)) { + content->content_offsets.first = parser->GetCurrentOffset(); + ParseBerOctetString(CBS_ASN1_CONTEXT_SPECIFIC, parser, &content->content); + content->content_offsets.second = parser->GetCurrentOffset(); + } else { + content->content.clear(); + } +} + +// http://tools.ietf.org/html/rfc5652#section-6.2 +void ParseRecipientsInfo(BerParser* parser, Content* content) { + ScopedConstructedElement recipients(CBS_ASN1_SET, "Recipients", parser); + for (; !parser->IsNullOrEmpty();) { + content->recipients.push_back(RecipientInfo()); + if (parser->GetOptionalTag(CBS_ASN1_SEQUENCE)) { + // http://tools.ietf.org/html/rfc2630#section-6.2.1 + ParseKeyTransRecipientInfo(parser, &content->recipients.back()); + } else if (parser->GetOptionalTag(ContextSpecificConstructed(1))) { + // http://tools.ietf.org/html/rfc2630#section-6.2.2 + ParseKeyAgreeRecipientInfo(parser, &content->recipients.back()); + } else { + // Unsupported: + // KEKRecipientInfo, PasswordRecipientInfo, OtherRecipientInfo + parser->SetError(ErrorCode::UNSUPPORTED, "Unsupported recipient type."); + return; + } + } +} + +// https://tools.ietf.org/html/rfc5652#section-6.1 +void ParseEnvelopedData(BerParser* parser, Content* content) { + content->type = ContentType::ENVELOPED_DATA; + ScopedConstructedElement enveloped_data(CBS_ASN1_SEQUENCE, "EnvelopedData", + parser); + content->version = parser->GetInt(); + if (content->version != 0 && content->version != 2) { + parser->SetError(ErrorCode::UNSUPPORTED, "Unsupported envelope version."); + return; + } + if (parser->Peek(ContextSpecificConstructed(0))) { + ScopedConstructedElement s(ContextSpecificConstructed(0), "OriginatorInfo", + parser); + if (parser->Peek(ContextSpecificConstructed(0))) { + ParseCertificates(parser, &content->certificates); + } + if (parser->Peek(ContextSpecificConstructed(1))) { + ParseCrls(parser); + } + } + ParseRecipientsInfo(parser, content); + ParseEncryptedContentInfo(parser, content); + // UnprotectedAttributes are not used in this implementation. + if (parser->Peek(ContextSpecificConstructed(1))) { + CBS dummy; + parser->GetAnyElementAndSkipChildren(&dummy); + } +} + +} // namespace + +// RFC 5652 (partial support), RFC 2633 SMIME and RFC 2315 PKCS #7 +ErrorCode ParseCms(const char* data, size_t size, Content* contents, + std::string* error_message) { + BerParser parser(reinterpret_cast<const uint8_t*>(data), size); + parser.SetElementName("ContentInfo"); + parser.GetTag(CBS_ASN1_SEQUENCE); + CBS oid; + parser.GetElementData(CBS_ASN1_OBJECT, &oid); + parser.GetTag(ContextSpecificConstructed(0)); + if (CompareOid(oid, kSignedDataOid, sizeof(kSignedDataOid))) { + ParseSignedData(&parser, contents); + } else if (CompareOid(oid, kEnvelopedDataOid, sizeof(kEnvelopedDataOid))) { + ParseEnvelopedData(&parser, contents); + } else { + parser.SetError(ErrorCode::UNSUPPORTED, "Unsupported data type."); + } + parser.EndConstructed(); + *error_message = parser.error_message(); + return parser.status(); +} + +} // namespace credentio_cms
diff --git a/crypto/default/cms/cms_parser.h b/crypto/default/cms/cms_parser.h new file mode 100644 index 0000000..a34528f --- /dev/null +++ b/crypto/default/cms/cms_parser.h
@@ -0,0 +1,174 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_PARSER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_PARSER_H_ + +#include <cstddef> +#include <string> +#include <utility> +#include <vector> + +#include "crypto/default/cms/cms_error_code.h" +#include "openssl/bytestring.h" + +namespace credentio_cms { + +// Class making sure that the CRYPTO ByteString used are always initialized. +class ByteString { + public: + ByteString() { CBS_init(&cbs_, nullptr, 0); } + CBS* cbs_ptr() { return &cbs_; } + const CBS* cbs_ptr() const { return &cbs_; } + const CBS& cbs() const { return cbs_; } + + private: + CBS cbs_; +}; + +// https://tools.ietf.org/html/rfc5280#section-4.1.1.2 +struct AlgorithmIdentifier { + ByteString algorithm_oid; + ByteString parameter; + // The un-parsed bytes so that d2i_X509_ALGOR can be used. + ByteString raw_value; +}; + +// https://tools.ietf.org/html/rfc5652#section-6.2.2 +struct OriginatorIdentifierOrKey { + // The issuer name and serial numbers are used if the subject_key_identifier + // is not set. + ByteString issuer_name; + ByteString serial_number; + ByteString subject_key_identifier; + AlgorithmIdentifier public_key_algorithm; + ByteString public_key_value; +}; + +// https://tools.ietf.org/html/rfc5652#section-6.2.2 +struct RecipientEncryptedKey { + // The issuer name and serial numbers are used if the subject_key_identifier + // is not set. + ByteString issuer_name; + ByteString serial_number; + ByteString subject_key_identifier; + // Optional + ByteString date; + // Optional OtherKeyAttribute from the RecipientKeyIdentifier. + ByteString other; + ByteString encrypted_key; +}; + +// https://tools.ietf.org/html/rfc5652#section-5.3 +// Signed or unsigned attribute. +struct Attribute { + ByteString type; + std::vector<ByteString> values; +}; + +// https://tools.ietf.org/html/rfc5751#section-2.5.3 +struct SmimeEncryptionKeyPreference { + // If the SMIMEEncryptionKeyPreference is not present all the fields will be + // empty. + ByteString issuer_name; + ByteString serial_number; + ByteString subject_key_identifier; + // Optional + ByteString date; + // Optional OtherKeyAttribute from the RecipientKeyIdentifier. + ByteString other; +}; + +struct SignerInfo { + int version; + // The issuer name and serial numbers are used if the subject_key_identifier + // is not set. + ByteString issuer_name; + ByteString serial_number; + ByteString subject_key_identifier; + AlgorithmIdentifier digest_algorithm; + ByteString raw_signed_attributes; + ByteString raw_unsigned_attributes; + // The 'message_digest' and 'content_type_signed' fields are extracted from + // the signed attributes (if present). + ByteString message_digest; + ByteString content_type_signed; + // All the signed attributes other than message digest and content type. + std::vector<Attribute> signed_attributes; + std::vector<Attribute> unsigned_attributes; + + AlgorithmIdentifier signature_algorithm; + ByteString signature_value; + SmimeEncryptionKeyPreference encryption_key_preference; +}; + +// https://tools.ietf.org/html/rfc5652#section-6.2.1 +struct RecipientInfo { + int version; + OriginatorIdentifierOrKey originator_identifier_or_key; + // Used for some key agreement algorithms. + ByteString user_keying_material; + AlgorithmIdentifier key_encryption_algorithm; + std::vector<RecipientEncryptedKey> encrypted_keys; +}; + +// https://tools.ietf.org/html/rfc5652#section-11.1 +enum class ContentType { + // Using the last digit of the OID defined in RFC 2315 section 14 as the enum + // value. + DATA = 1, + SIGNED_DATA = 2, + ENVELOPED_DATA = 3, + SIGNED_AND_ENVELOPED_DATA = 4, + DIGESTED_DATA = 5, + ENCRYPTED_DATA = 6 +}; + +struct Content { + enum ContentType type; + int version; + std::vector<AlgorithmIdentifier> digest_algorithms; + // The encapsulated_content_info.type for signed data, + // encrypted_content_info.type for enveloped data. + ByteString content_type; + // The content can be absent, for example in detached signatures, or it + // can be in one or more chunks. + std::vector<ByteString> content; + // ASN.1 tag of the content (CBS_ASN1_OCTETSTRING, etc.) + unsigned content_tag; + // The byte stream offsets of the first content tag byte and the first byte + // after the content. + std::pair<size_t, size_t> content_offsets; + // The encryption_algorithm_identifier and encryption_algorithm_iv are used + // for enveloped data. + AlgorithmIdentifier encryption_algorithm; + std::vector<ByteString> certificates; + std::vector<SignerInfo> signers; + std::vector<RecipientInfo> recipients; +}; + +// Zero copy CMS parser. +// +// - 'data' and 'size' should point to the binary data. This data must remain +// valid as long +// as 'contents' is used. +// - 'contents' is a view into the data and is only valid if the function +// returns ErrorCode::OK. +ErrorCode ParseCms(const char* data, size_t size, Content* contents, + std::string* error_message); + +} // namespace credentio_cms + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_PARSER_H_
diff --git a/crypto/default/cms/cms_parser_test.cc b/crypto/default/cms/cms_parser_test.cc new file mode 100644 index 0000000..eda7005 --- /dev/null +++ b/crypto/default/cms/cms_parser_test.cc
@@ -0,0 +1,747 @@ +// 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 "crypto/default/cms/cms_parser.h" + +#include <string> +#include <vector> + +#include "absl/log/check.h" +#include "absl/strings/escaping.h" +#include "crypto/default/cms/cms_error_code.h" +#include "crypto/default/cms/cms_test_helper.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/bytestring.h" +#include "openssl/obj_mac.h" + +namespace credentio_cms { +namespace { + +using testing::HasSubstr; + +// DER encoded Oids. +const char kSha1Oid[] = "2b0e03021a"; +const char kSha256Oid[] = "608648016503040201"; +const char kAes256CbcOid[] = "60864801650304012a"; +const char kRsaEncryptionOid[] = "2a864886f70d010101"; +const char kCmsDataOid[] = "2a864886f70d010701"; +const char kDesEde3Cbc[] = "2a864886f70d0307"; +const char kEcdhStdSha1KdfOid[] = "2b81051086483f0002"; +// The one line issuer name for User1 and User2 certificates. +const char kTestIssuer[] = + "C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA"; +const char kUser1NoCnSerialNumber[] = "1235"; +const char kUser2NoCnSerialNumber[] = "1236"; +const char kUser2BothSerialNumber[] = "1237"; +const char kUser1EccSerialNumber[] = "123b"; +const char kUser1EcdsaSerialNumber[] = + "6bef0b67723662c54e991de365cddf84e2a402cd"; + +std::string PemToDer(const std::string& cert) { + const char kStart[] = "-----BEGIN CERTIFICATE-----"; + int start = cert.find(kStart) + sizeof(kStart); + CHECK_GE(start, 0); + int end = cert.find("-----END CERTIFICATE-----", start); + CHECK_GT(end, 0); + std::string decoded; + CHECK(absl::Base64Unescape(cert.substr(start, end - start), &decoded)); + return decoded; +} + +std::string CBSToString(const ByteString& b) { + std::string s; + s.assign(reinterpret_cast<const char*>(CBS_data(b.cbs_ptr())), + CBS_len(b.cbs_ptr())); + return s; +} + +std::string CBSChunksToString(const std::vector<ByteString>& data) { + std::string s; + for (const auto& chunk : data) { + s.append(reinterpret_cast<const char*>(CBS_data(chunk.cbs_ptr())), + CBS_len(chunk.cbs_ptr())); + } + return s; +} + +std::string CBSToHex(const ByteString& b) { + return absl::BytesToHexString(CBSToString(b)); +} + +TEST(ParseCms, OpenSSLDetached) { + std::string cms = GetCms(GetFileContents("signed-openssl-detached.msg")); + std::string ca_cert = PemToDer(GetFileContents("ca.pem")); + std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)) + << error_message; + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(2, signed_data.certificates.size()); + EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[0])); + EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[1])); + EXPECT_EQ(0, signed_data.content.size()); + // The message is not encrypted. + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter)); + // No recipient info in signed data. + EXPECT_EQ(0, signed_data.recipients.size()); + + // One signer. + ASSERT_EQ(1, signed_data.signers.size()); + const auto& signer = signed_data.signers[0]; + EXPECT_EQ(1, signer.version); + EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number)); + EXPECT_EQ(kTestIssuer, CBSToName(signer.issuer_name)); + EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1", + CBSToHex(signer.message_digest)); + + EXPECT_EQ( + "6d6a93c0e9faeeb01ad176087975f8ac27218074afe7def711520b3ac99159aa627e00ad" + "07cfd0c5eca749950c896b51dea87b706f5ca85b1f8f5da8e2144befe9d396d5fbf9dc2b" + "fa7acd2f0db6f57ac8dc3daca2aea85263fe7d56c544bce58b3c49b1f78150ac6ba06d62" + "075be7c9165a41d5ab525b7cfc2631c0b70339bc84c2de9aa54c08393f362e2ea16f2e1a" + "e6a585da4daeb269495fb676b451bd9ed10669be940b2b40a952a4cabc775dc08182d304" + "a5b718d5562133a469302c50d09f6176d539bf300dee6c3014b5fe3dad1c2ea39149d9db" + "77bc5c64fdeb611a5ae4d7ab073532ffa4a439736fbd0525c742ebd93a2d234c8acac942" + "f80dd2ea", + CBSToHex(signer.signature_value)); + EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed)); + EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid)); + EXPECT_EQ(kRsaEncryptionOid, + CBSToHex(signer.signature_algorithm.algorithm_oid)); + EXPECT_EQ("300d06092a864886f70d0101010500", + CBSToHex(signer.signature_algorithm.raw_value)); +} + +TEST(ParseCms, OpenSSLOpaque) { + std::string cms = GetCms(GetFileContents("signed-openssl-opaque.msg")); + std::string ca_cert = PemToDer(GetFileContents("ca.pem")); + std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)); + + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(2, signed_data.certificates.size()); + EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[0])); + EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[1])); + EXPECT_EQ( + "Content-Type: text/plain\r\n\r\nThis is a message.\r\n" + "There are many like it, but this one is mine.\r\n", + CBSChunksToString(signed_data.content)); + // The message is not encrypted. + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter)); + // No recipient info in signed data. + EXPECT_EQ(0, signed_data.recipients.size()); + + // One signer. + ASSERT_EQ(1, signed_data.signers.size()); + const auto& signer = signed_data.signers[0]; + EXPECT_EQ(1, signer.version); + EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number)); + EXPECT_EQ(kTestIssuer, CBSToName(signer.issuer_name)); + EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1", + CBSToHex(signer.message_digest)); + EXPECT_EQ( + "323ef3ee26aa0bf4ccfc147a73536dabfeda7ee84f6c32ff96b4669a790e69f4f07e80cd" + "ff7357dd5ede286a33dd0985bd8a55aa531f8f93fb33ac336a0fddbcb5a14ad4337fd15c" + "fb63a16e72a3f2b7ef34a8d868767fd0b5d6316c44402ff2993a969c08c41496048e08be" + "18eedf1289fd9242a6b7b9a1f242cea7d2c86f097ddf8b241797856a82c3a8ccb0376a3c" + "82b79feba37886c96a6f00192ac243b310a99665d6653dcde777c22861828a6aa39867b5" + "a600ac6e5ab4eb1460dd0c2355c402ef8db6631e818d09dd1c66e81d87efd02fadf1b063" + "7fadcf930d531c8519518e33890b274bdf0a51fe51c68a4b14fc30fc01ec9a8d83b0c637" + "d3434565", + CBSToHex(signer.signature_value)); + + EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed)); + EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid)); + EXPECT_EQ(kRsaEncryptionOid, + CBSToHex(signer.signature_algorithm.algorithm_oid)); +} + +TEST(ParseCms, OpenSSLEcdsaOpaque) { + std::string cms = GetCms(GetFileContents("signed-openssl-ecdsa.msg")); + std::string user_cert = PemToDer(GetFileContents("user1_ecdsa.pem")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)); + + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(1, signed_data.certificates.size()); + EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[0])); + EXPECT_EQ( + "Content-Type: text/plain\r\n\r\nThis is a message.\r\n" + "There are many like it, but this one is mine.\r\n", + CBSChunksToString(signed_data.content)); + // The message is not encrypted. + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter)); + // No recipient info in signed data. + EXPECT_EQ(0, signed_data.recipients.size()); + + // One signer. + ASSERT_EQ(1, signed_data.signers.size()); + const auto& signer = signed_data.signers[0]; + EXPECT_EQ(1, signer.version); + EXPECT_EQ(kUser1EcdsaSerialNumber, CBSToHex(signer.serial_number)); + // This particular certificate is self-signed. + EXPECT_EQ("emailAddress=user1@foo.bar", CBSToName(signer.issuer_name)); + EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1", + CBSToHex(signer.message_digest)); + EXPECT_EQ( + "304402200c42ce53ff672e2adf9d87da9c3c2d6346894d68dff9cc23ddaf1bd96cf42cd1" + "02206d6b318cbea8a477aa540c4ec9eebdc63281e3a16258540eb8cd5f010c91dd59", + CBSToHex(signer.signature_value)); + + EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed)); + EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid)); + EXPECT_EQ(absl::BytesToHexString(NidToDerString(NID_ecdsa_with_SHA256)), + CBSToHex(signer.signature_algorithm.algorithm_oid)); +} + +TEST(ParseCms, OpenSSLTwoSignaturesDetached) { + std::string cms = + GetCms(GetFileContents("two-signatures-openssl-detached.msg")); + std::string ca_cert = PemToDer(GetFileContents("ca.pem")); + std::string user1_cert = PemToDer(GetFileContents("user1_no_cn.pem")); + std::string user2_cert = PemToDer(GetFileContents("user2_no_cn.pem")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)); + + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(3, signed_data.certificates.size()); + EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[0])); + EXPECT_EQ(user1_cert, CBSToString(signed_data.certificates[1])); + EXPECT_EQ(user2_cert, CBSToString(signed_data.certificates[2])); + EXPECT_EQ(0, signed_data.content.size()); + // The message is not encrypted. + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter)); + // No recipient info in signed data. + EXPECT_EQ(0, signed_data.recipients.size()); + + // Two signers. + ASSERT_EQ(2, signed_data.signers.size()); + // First signer. + const auto& signer1 = signed_data.signers[0]; + EXPECT_EQ(1, signer1.version); + EXPECT_EQ("1236", CBSToHex(signer1.serial_number)); + EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1", + CBSToHex(signer1.message_digest)); + + EXPECT_EQ( + "b3ae16888b424fa19484cd823ada53856c4d79065d47a33393b23372da06afb9c38d610e" + "851ef25b0fee4fd17df179c8476fb02dc1cc4ed347a9ac4b6b1ec1508d31980264ea1b03" + "7d87d9cb3d03c462871f96f5feb3be843f356a8d167feddebbe3891e9d45a1161b44b116" + "cc39cf1454988e027b309f2f29a4c0efb89b41f9e2ab63b6e446c69d6fc0e0b67729921b" + "7c1da6aa026f3e6245d8de327f7e55a247b65f800fb0907f2c48cc6d2ef640895a01816a" + "370d5447ef5d13d360386b342ebac7350f046e2d609b3c76188645e694b681589445a79c" + "f142b2ec85cbd1ce7e35bb89040809998540dd1a6fa0eff9f0688589f7e886b1c4608a1f" + "8095a2eb", + CBSToHex(signer1.signature_value)); + // PKCS-7.1 Data + EXPECT_EQ("2a864886f70d010701", CBSToHex(signer1.content_type_signed)); + EXPECT_EQ(kSha256Oid, CBSToHex(signer1.digest_algorithm.algorithm_oid)); + EXPECT_EQ(kRsaEncryptionOid, + CBSToHex(signer1.signature_algorithm.algorithm_oid)); + EXPECT_EQ(kTestIssuer, CBSToName(signer1.issuer_name)); + + // Second signer. + const auto& signer2 = signed_data.signers[1]; + EXPECT_EQ(1, signer2.version); + EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer2.serial_number)); + EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1", + CBSToHex(signer2.message_digest)); + EXPECT_EQ(kTestIssuer, CBSToName(signer2.issuer_name)); + + EXPECT_EQ( + "6d6a93c0e9faeeb01ad176087975f8ac27218074afe7def711520b3ac99159aa627e00ad" + "07cfd0c5eca749950c896b51dea87b706f5ca85b1f8f5da8e2144befe9d396d5fbf9dc2b" + "fa7acd2f0db6f57ac8dc3daca2aea85263fe7d56c544bce58b3c49b1f78150ac6ba06d62" + "075be7c9165a41d5ab525b7cfc2631c0b70339bc84c2de9aa54c08393f362e2ea16f2e1a" + "e6a585da4daeb269495fb676b451bd9ed10669be940b2b40a952a4cabc775dc08182d304" + "a5b718d5562133a469302c50d09f6176d539bf300dee6c3014b5fe3dad1c2ea39149d9db" + "77bc5c64fdeb611a5ae4d7ab073532ffa4a439736fbd0525c742ebd93a2d234c8acac942" + "f80dd2ea", + CBSToHex(signer2.signature_value)); + // PKCS-7.1 Data + EXPECT_EQ(kCmsDataOid, CBSToHex(signer2.content_type_signed)); + EXPECT_EQ(kSha256Oid, CBSToHex(signer2.digest_algorithm.algorithm_oid)); + EXPECT_EQ(kRsaEncryptionOid, + CBSToHex(signer2.signature_algorithm.algorithm_oid)); +} + +TEST(ParseCms, ThunderbirdSigned) { + std::string cms = GetCms(GetFileContents("signed-thunderbird.msg")); + std::string ca_cert = PemToDer(GetFileContents("ca.pem")); + std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)); + + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(1, signed_data.certificates.size()); + EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[0])); + EXPECT_EQ(0, signed_data.content.size()); + // The message is not encrypted. + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter)); + // No recipient info in signed data. + EXPECT_EQ(0, signed_data.recipients.size()); + + // One signer. + ASSERT_EQ(1, signed_data.signers.size()); + const auto& signer = signed_data.signers[0]; + EXPECT_EQ(1, signer.version); + EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number)); + EXPECT_EQ(kTestIssuer, CBSToName(signer.issuer_name)); + EXPECT_EQ("fb749fdbc8f3e5d6d11f02e2272048f90ad0035e", + CBSToHex(signer.message_digest)); + + EXPECT_EQ( + "0333cefa73f44054bd78f55f6a6af9aeac546e09d52bf069a471afb0a7facc9e048e8bec" + "29a32a88eecc6bda7bc3abeeb9d2e8632971dde6f24387ae59dd9aeeca3f99dbb955fbf8" + "24ebba7a7d160af8ca650d53373af61a12da042adbd3b0ec432aa6578e49ece7ce1fe5af" + "8897b26a30e23f3f09cfb66997f07ec5b4b2a7b762f8bae38cf80ec056f788c4ae3c28b8" + "71e8fa5e7109807552ed36bb00fd65abb71e48e5af9aea9c7d888004a413e8212bb9f1b2" + "f3162a9f37e3664ffa6c4e500cbac0f09e362023f54cd1c02cfe05ef3bed677d9cfae8c8" + "9f682a531491fa8b9cb6980c4755b5c4de7b1b46c31ba5336db260538a1b109d0bf48540" + "709f795e", + CBSToHex(signer.signature_value)); + + EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed)); + EXPECT_EQ(kSha1Oid, CBSToHex(signer.digest_algorithm.algorithm_oid)); + EXPECT_EQ(kRsaEncryptionOid, + CBSToHex(signer.signature_algorithm.algorithm_oid)); + EXPECT_EQ(CBSToHex(signer.encryption_key_preference.serial_number), "1235"); + EXPECT_EQ(CBSToName(signer.encryption_key_preference.issuer_name), + kTestIssuer); +} + +TEST(ParseCms, OutlookMac2011Signed) { + std::string cms = GetCms(GetFileContents("signed-outlook-mac-2011.msg")); + std::string ca_cert = PemToDer(GetFileContents("ca.pem")); + std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)); + + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(2, signed_data.certificates.size()); + EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[0])); + EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[1])); + EXPECT_EQ(0, signed_data.content.size()); + // The message is not encrypted. + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter)); + // No recipient info in signed data. + EXPECT_EQ(0, signed_data.recipients.size()); + + // One signer. + ASSERT_EQ(1, signed_data.signers.size()); + const auto& signer = signed_data.signers[0]; + EXPECT_EQ(1, signer.version); + EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number)); + EXPECT_EQ("33fafcff3de4de6ca57218fc6c6befd038956ab4dd7346c1deebecc86a22fb86", + CBSToHex(signer.message_digest)); + + EXPECT_EQ( + "b50d1583b44d9f96efaced02496976cec63df0b40026d19198d513e5d6ed4010b7a8fe26" + "95df5ab31bbb69faf59a91fe081a59676f0dffee406fcfa1dafb6191dec6b76a089df449" + "e41f4005e56433581b5630034edded3cefb4deb151d85a6a2e2a60dce8a24432a8838116" + "b69aff8126ae7005329727fd5764830f5bbab6e6f372e60bbf14ee772d6f4a46761bbe84" + "05958eb594515bc285954270c8e16e575b42a7b1dfdd3d26a65362bccda7130c36064893" + "9664713d88b943bf379fe94b180f6bf0fd3d3b3cae96ef43787722430542ada19162a85f" + "2f167695ff77f646d7c5d42202656245ff24d2ed1b6c95ad2c3e530c3733195c5ba7d831" + "431ec31d", + CBSToHex(signer.signature_value)); + + EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed)); + EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid)); + EXPECT_EQ(kRsaEncryptionOid, + CBSToHex(signer.signature_algorithm.algorithm_oid)); +} + +// Testing signed message produced by DoD that previously triggers a parsing +// error due to the BER encoding of a element length <127 in the 2-byte long +// form. +TEST(ParseCms, DoDCustomSingerSigned) { + // a signed message produced by DoD + std::string cms = GetCms(GetFileContents("dod_signed.msg")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)) + << error_message; + + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(4, signed_data.certificates.size()); + ASSERT_EQ(1, signed_data.signers.size()); + ASSERT_EQ(6, signed_data.signers[0].signed_attributes.size()); +} + +TEST(ParseCms, DoDCustomSingerSignedLong) { + // a signed message produced by DoD + std::string cms = GetCms(GetFileContents("dod_signed_long.msg")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)) + << error_message; + + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(5, signed_data.certificates.size()); + ASSERT_EQ(1, signed_data.signers.size()); + ASSERT_EQ(6, signed_data.signers[0].signed_attributes.size()); +} + +TEST(ParseCms, OutlookMac2011Encrypted) { + std::string cms = GetCms(GetFileContents("encrypted-outlook-mac-2011.msg")); + std::string ca_cert = PemToDer(GetFileContents("ca.pem")); + std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem")); + Content enveloped_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message)) + << error_message; + + EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type); + EXPECT_EQ(0, enveloped_data.version); + ASSERT_EQ(0, enveloped_data.certificates.size()); + + // Two recipients: the actual recipient and the sender so they can read + // what was sent. + ASSERT_EQ(2, enveloped_data.recipients.size()); + const auto& signer = enveloped_data.recipients[0]; + ASSERT_EQ(0, signer.version); + EXPECT_EQ(kTestIssuer, CBSToName(signer.encrypted_keys[0].issuer_name)); + EXPECT_EQ(kUser2BothSerialNumber, + CBSToHex(signer.encrypted_keys[0].serial_number)); + ASSERT_EQ(kRsaEncryptionOid, + CBSToHex(signer.key_encryption_algorithm.algorithm_oid)); + ASSERT_EQ( + "928a924ca3f4777ac22271e6ec89a773d909ba6d79ac4961ee3692c87c2654fdcbc1b3dd" + "355a7ac0e2d231b8b3fc6de5a89a9d1a9408339bf711e2e8a91054e6681d8668c28f1e0f" + "73ec68db6a63beab6873058fa7d180510fa109be99a21b33ea014185df3b13a15adfa19d" + "7872805037d171cc04f663233b0d350fecd890347d59f7ba445d13ecf51154dd2252f0e7" + "b1ed0ae663cabc3c4d77bc6f081302adfceafc29da768d9e1e29bb8e81e909ec93e65f99" + "c8a1224b977693c0e62dc92155abdbd38bd198b1a99167e39534817cb5dac41b4f893bf9" + "52c0be07f122210399b6e3ebdd3fe56d0d4dbb27d048ec11fef3580da91470e5bd026d55" + "6aeac0ae", + CBSToHex(signer.encrypted_keys[0].encrypted_key)); + + const auto& recipient2 = enveloped_data.recipients[1]; + ASSERT_EQ(0, recipient2.version); + EXPECT_EQ(kTestIssuer, CBSToName(recipient2.encrypted_keys[0].issuer_name)); + EXPECT_EQ(kUser1NoCnSerialNumber, + CBSToHex(recipient2.encrypted_keys[0].serial_number)); + ASSERT_EQ(kRsaEncryptionOid, + CBSToHex(recipient2.key_encryption_algorithm.algorithm_oid)); + ASSERT_EQ( + "8deec1a3c6ce33deaff7560b6f4a1844a34aada30459af5357ffb85cce07c284a189af18" + "ffa693553c8629b39b019e27fee0573c73f6cf2f100c7af28402d8d81d83028d45141d40" + "4be04b62e83d2fc4f1893f7f51068d330e1755a66618d604df63e58069c5eaf11de7b5c2" + "590481973cecbb2725707f5c03da360832573ebbf6d180f20d3ac475648d7b67456eae6b" + "264b72e75c390116bf8ad09335529b9f11cb61bb52a32c0d6b65fffe218dc429e038b5ab" + "0f1d102b6277da27b1bc2e708707299605d7ce3e1941e364c35568b6c1eff7c7d488b496" + "5b3d53fa038d82904327c4669dfccdded733ac887e00857600be875302bbce72b67020f1" + "9276f06e", + CBSToHex(recipient2.encrypted_keys[0].encrypted_key)); + + std::string content = CBSChunksToString(enveloped_data.content); + EXPECT_EQ(4576, content.size()); + // Check the first 16 and last 16 bytes for the encrypted payload. + std::string hex_content = absl::BytesToHexString(content); + EXPECT_EQ(0, hex_content.find("75d46c500858ac2476c0b36916d49f3b")); + EXPECT_EQ(4560 * 2, hex_content.find("966d5b525e9a81f737e439537b74cd21")); + + EXPECT_EQ(kAes256CbcOid, + CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("0410d86dc5375a13ba12c48d3a0da6100316", + CBSToHex(enveloped_data.encryption_algorithm.parameter)); + // No signer on the envelope. + ASSERT_EQ(0, enveloped_data.signers.size()); +} + +TEST(ParseCms, ThunderbirdEncrypted) { + std::string cms = GetCms(GetFileContents("encrypted-thunderbird.msg")); + Content enveloped_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message)) + << error_message; + + EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type); + EXPECT_EQ(0, enveloped_data.version); + ASSERT_EQ(0, enveloped_data.certificates.size()); + + // Two recipients: the actual recipient and the sender so they can read + // what was sent. + ASSERT_EQ(2, enveloped_data.recipients.size()); + const auto& recipient1 = enveloped_data.recipients[0]; + ASSERT_EQ(0, recipient1.version); + EXPECT_EQ(kTestIssuer, CBSToName(recipient1.encrypted_keys[0].issuer_name)); + EXPECT_EQ(kUser1NoCnSerialNumber, + CBSToHex(recipient1.encrypted_keys[0].serial_number)); + ASSERT_EQ(kRsaEncryptionOid, + CBSToHex(recipient1.key_encryption_algorithm.algorithm_oid)); + ASSERT_EQ( + "c7e9154370787a7980e60e7f7ea4152be93d2247518d62091df2206d0ab12125c35dc078" + "74e75b9b365fc50f71f3fce8e75caac42eda81517a9a3844fdc270d99ff77468bac370dc" + "bbccd4f3efa859a87064b79ecfd7ed857091e150e364853942ac5f9644df0278a5984f98" + "12948c4e22ab00f931473cc8f2a087d22d40f5c8c188171d6829113a76f0b98cf9f7d67a" + "96bd5f567c28ae3eb6e7b689ce4ce5e878d6a9808b3f23cb1929e2291d63c3ac0dfac69a" + "d85225930b694f4023a63adda4b5be3cc8554e506d973ccf945011052cfe04e21ed8a949" + "b9ad0810a6f3c394a9bf34a43923f08d077c7b8c4d1e726b90d0e673fbcca8e47f1fce68" + "2c1df1e5", + CBSToHex(recipient1.encrypted_keys[0].encrypted_key)); + + const auto& recipient2 = enveloped_data.recipients[1]; + ASSERT_EQ(0, recipient2.version); + EXPECT_EQ(kTestIssuer, CBSToName(recipient2.encrypted_keys[0].issuer_name)); + EXPECT_EQ(kUser2NoCnSerialNumber, + CBSToHex(recipient2.encrypted_keys[0].serial_number)); + ASSERT_EQ(kRsaEncryptionOid, + CBSToHex(recipient2.key_encryption_algorithm.algorithm_oid)); + ASSERT_EQ( + "835cdd7a9e5a92541d0ed18e6c66dc9528a3ad0b6d34193da464606798c8a875b24afda6" + "e5dfcf181a6f4b6160f983cbd6f2964bccd66bca7d1a345c37ef5beeef573888877e5a0c" + "09669666bec111da66e46f3c363d6cbcc69cf67ad6ce6e2f35f1e855d4a7eb692bbe3cfa" + "a4d2a50872d2ff5305cb8ad60bb8947761ba7255289acdc43fb688aabaaaad658f7cb5aa" + "58251217608450f547a94259f1ff154d57fdc53464189aba1691cc0d8772bf2fd0775abd" + "4160db52ade680cf1472f87140ab90efe6be48b0179acc2f475daad745afa9fb4ea09b80" + "8b2947624659bf6708852c55e5354c9e2ec162f2eeb8d20e7abe64c0496c095f02e3ec95" + "c60e1d79", + CBSToHex(recipient2.encrypted_keys[0].encrypted_key)); + + EXPECT_EQ( + "107a12c35ea7c2c7ffa427256869243f5e61693d6a4b0c3bc2168c97d2dfa64d78ae09a4" + "acb014b99c97f3a6169336420ff25e6c0ccec1dfc147b5ba7517c786e7106a910147334b" + "6b8e9873624c839051a27cc6242efb8a5c6773d74b7b8748189f98bf1e124aba7df35cb2" + "c1cf73bf8b1267b57c673e3119405e29530e44a76c5353540ed8829c5dc553cb6f17a97f" + "2f1d44b538ca4ddd0f7d34c8cdb3bc7b23fdd2b380dfaa134fa9432ef8e13cb6", + absl::BytesToHexString(CBSChunksToString(enveloped_data.content))); + + EXPECT_EQ(kDesEde3Cbc, + CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("04085a0d5623a1e7a4a0", + CBSToHex(enveloped_data.encryption_algorithm.parameter)); + // No signer on the envelope. + ASSERT_EQ(0, enveloped_data.signers.size()); +} + +TEST(ParseCms, OpenSslEncrypted) { + std::string cms = GetCms(GetFileContents("encrypted-openssl.msg")); + std::string ca_cert = PemToDer(GetFileContents("ca.pem")); + std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem")); + Content enveloped_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message)) + << error_message; + + EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type); + EXPECT_EQ(0, enveloped_data.version); + ASSERT_EQ(0, enveloped_data.certificates.size()); + + // Two recipients: the actual recipient and the sender so they can read + // what was sent. + ASSERT_EQ(1, enveloped_data.recipients.size()); + const auto& recipient = enveloped_data.recipients[0]; + ASSERT_EQ(0, recipient.version); + EXPECT_EQ(kTestIssuer, CBSToName(recipient.encrypted_keys[0].issuer_name)); + EXPECT_EQ(kUser2NoCnSerialNumber, + CBSToHex(recipient.encrypted_keys[0].serial_number)); + ASSERT_EQ(kRsaEncryptionOid, + CBSToHex(recipient.key_encryption_algorithm.algorithm_oid)); + ASSERT_EQ( + "a873be9a23303b91769f8dcf641eb8ee95164710401d76db97f444fe73df1197110b1b7d" + "1d5c6bec413793d4c86371e9a96bd9a7313e02eb7bf6f94fcc9249237bc037df63ed4681" + "d7dd2ae27da31e5da4c42737516ecde4e7c61691508b4f9544348f7a5df646bf619466a1" + "4730d37d452bf98058b522ecd02138f2746f4b4745157753ed8d3998c88682424d19016d" + "11aaa0c20b8b3c2865deb2bf0f7b436bb82df10908ba8f7dc719ec11b7bae892a6e86d59" + "16895017b302e2cdcf9504e1271ac0b23bcf270c4d3d75c41ed24f308149f118a0e8ac29" + "4910ce67ff43fc379959e25f5ead1fb5354bb24ee5287d1a6d7327424fa11f0509f0f374" + "ebf9ecd3", + CBSToHex(recipient.encrypted_keys[0].encrypted_key)); + + EXPECT_EQ( + "0ad0fb4894c4c73c6d255e1c59f24b331000b0ce058cc1d2367d73a7a870eea5cfd46740" + "ad3fcf65f4e9acb1982937237b067692b9aa7731d39f28b0e5ad58682a3614ba3caf794e" + "1a71025642cb01cf401be16761d8cfbdcb41c998ad5d3704", + absl::BytesToHexString(CBSChunksToString(enveloped_data.content))); + + EXPECT_EQ(kAes256CbcOid, + CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("0410b0b6a6406c1a6a006c567400d664bfe9", + CBSToHex(enveloped_data.encryption_algorithm.parameter)); + // No signer on the envelope. + ASSERT_EQ(0, enveloped_data.signers.size()); +} + +TEST(ParseCms, OpensslEncryptedEcc) { + std::string cms = GetCms(GetFileContents("encrypted-openssl-ecdh.msg")); + Content enveloped_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message)) + << error_message; + EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type); + EXPECT_EQ(2, enveloped_data.version); + ASSERT_EQ(0, enveloped_data.certificates.size()); + + ASSERT_EQ(1, enveloped_data.recipients.size()); + const auto& recipient = enveloped_data.recipients[0]; + EXPECT_EQ(3, recipient.version); + EXPECT_EQ(kTestIssuer, CBSToName(recipient.encrypted_keys[0].issuer_name)); + EXPECT_EQ(kUser1EccSerialNumber, + CBSToHex(recipient.encrypted_keys[0].serial_number)); + EXPECT_EQ(kEcdhStdSha1KdfOid, + CBSToHex(recipient.key_encryption_algorithm.algorithm_oid)); + EXPECT_EQ("300f060b2a864886f70d01091003060500", + CBSToHex(recipient.key_encryption_algorithm.parameter)); + EXPECT_EQ( + "a9ad0552a61f549516be729006670369449181fc845fd1647445d40c967062f2bc2c0a58" + "edb12c65", + CBSToHex(recipient.encrypted_keys[0].encrypted_key)); + EXPECT_EQ( + "0004df6ea880dc4db5952082d0f1a19c61019f1d34b6dfde586d4515e7d43622949b4cc0" + "d63c6725615b5ecd0a421a8f144b53bbd53bc249abef0c2af1adc11c5e41", + CBSToHex(recipient.originator_identifier_or_key.public_key_value)); + EXPECT_EQ("", CBSToHex(recipient.user_keying_material)); + EXPECT_EQ("6854c40ae91385c43d593cb8576341f322ecb3691e5a219ee911aa28d4512a86", + absl::BytesToHexString(CBSChunksToString(enveloped_data.content)) + .substr(0, 64)); + + EXPECT_EQ(kDesEde3Cbc, + CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid)); + EXPECT_EQ("301406082a864886f70d03070408a74c3bb3a5d47c6e", + CBSToHex(enveloped_data.encryption_algorithm.raw_value)); + // No signer on the envelope. + ASSERT_EQ(0, enveloped_data.signers.size()); +} + +TEST(ParseCms, SimpleChecks) { + std::vector<std::string> files = {"encrypted-openssl-oaep.msg", + "signed-openssl-pss.msg"}; + for (const auto& filename : files) { + std::string cms = GetCms(GetFileContents(filename)); + Content contents; + std::string error_message; + auto status = ParseCms(cms.data(), cms.length(), &contents, &error_message); + EXPECT_EQ(ErrorCode::OK, status) + << "File tested: " << filename << " error: " << error_message; + } +} + +TEST(ParseCms, RFC4134_47) { + std::string cms = GetCms(GetFileContents("rfc4134-4.7.msg")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)) + << error_message; + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(3, signed_data.version); + ASSERT_EQ(1, signed_data.certificates.size()); + + ASSERT_EQ(1, signed_data.signers.size()); + const auto& signer = signed_data.signers[0]; + EXPECT_EQ(3, signer.version); + EXPECT_EQ("be6ca1b3e3c1f7ed4370a4ce1301e2fde397fecd", + CBSToHex(signer.subject_key_identifier)); +} + +TEST(ParseCms, SignedOffice365Owa) { + std::string cms = GetCms(GetFileContents("signed-office365-owa.msg")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)) + << error_message; + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(3, signed_data.certificates.size()); + + ASSERT_EQ(1, signed_data.signers.size()); + const auto& signer = signed_data.signers[0]; + EXPECT_EQ(1, signer.version); +} + +TEST(ParseCms, ParseCertificateOnly) { + // This is a certificates only file (degenerate signature). + std::string cms = GetCms(GetFileContents("rfc4134-4.11.msg")); + Content signed_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &signed_data, &error_message)) + << error_message; + EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type); + EXPECT_EQ(1, signed_data.version); + ASSERT_EQ(2, signed_data.certificates.size()); + ASSERT_EQ(0, signed_data.signers.size()); +} + +TEST(ParseCms, ParseEnvelopedDataWithOriginatorInfo) { + std::string b64 = GetFileContents("encrypted-with-originator-info.msg"); + std::string cms; + CHECK(absl::Base64Unescape(b64, &cms)); + Content enveloped_data; + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message)) + << error_message; + ASSERT_EQ(4, enveloped_data.certificates.size()); + // Spot check the certificates. + EXPECT_THAT(CBSToString(enveloped_data.certificates[0]), + HasSubstr("AddTrust External CA Root")); + EXPECT_THAT(CBSToString(enveloped_data.certificates[1]), + HasSubstr("COMODO SHA-256 Client Authentication")); + EXPECT_THAT(CBSToString(enveloped_data.certificates[2]), + HasSubstr("tererqa3")); + EXPECT_THAT(CBSToString(enveloped_data.certificates[3]), + HasSubstr("ns-ex13.gtestexchange.com")); + ASSERT_EQ(2, enveloped_data.recipients.size()); + // Check the first recipient. + const auto& recipient = enveloped_data.recipients[0]; + ASSERT_EQ(0, recipient.version); + EXPECT_EQ( + "C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO " + "SHA-256 Client Authentication and Secure Email CA", + CBSToName(recipient.encrypted_keys[0].issuer_name)); + EXPECT_EQ("4dc1e7496c5320405df99a567dde046f", + CBSToHex(recipient.encrypted_keys[0].serial_number)); +} + +} // namespace +} // namespace credentio_cms
diff --git a/crypto/default/cms/cms_test_helper.cc b/crypto/default/cms/cms_test_helper.cc new file mode 100644 index 0000000..d224183 --- /dev/null +++ b/crypto/default/cms/cms_test_helper.cc
@@ -0,0 +1,181 @@ +// 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 "crypto/default/cms/cms_test_helper.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <string> + +#include "absl/log/check.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/default/cms/cms_parser.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/bytestring.h" +#include "openssl/obj.h" +#include "openssl/pem.h" +#include "openssl/x509.h" +#include "testing/test_file_utils.h" + +namespace credentio_cms { + +constexpr absl::string_view kTestRepoDir = "c2pa/"; + +std::string GetRawFileContents(const std::string& filename) { + std::string path = + absl::StrCat(kTestRepoDir, "crypto/default/cms/testdata/", filename); + auto contents = credentio_testing::GetContents(path); + CHECK(contents.ok()) << "Failed to load: " << path << ", " + << contents.status(); + return *contents; +} + +std::string GetFileContents(const std::string& filename) { + std::string contents = GetRawFileContents(filename); + // Normalize line endings by replacing \n with \r\n when needed. + std::string normalized_contents; + bool has_cr = false; + for (char c : contents) { + if (c == '\r') { + has_cr = true; + } else { + if (c == '\n' && !has_cr) { + normalized_contents += '\r'; + } + has_cr = false; + } + normalized_contents += c; + } + return normalized_contents; +} + +// Quick and dirty functions to extract the CMS object. It probably only works +// on the test cases. +std::string GetCms(const std::string& message) { + auto pos = message.find("name=\"smime.p7s\""); + if (pos == std::string::npos) { + pos = message.find("name=\"smime.p7m\""); + } + if (pos == std::string::npos) { + pos = message.find("name=smime.p7s"); + } + if (pos == std::string::npos) { + pos = message.find("name=smime.p7m"); + } + if (pos == std::string::npos) { + pos = message.find("name=smime.p7c"); + } + CHECK(pos != std::string::npos); + auto start = message.find("\x0d\x0a\x0d\x0a", pos); + CHECK(start != std::string::npos); + start += 2; + auto end = message.find('-', start); + if (end == std::string::npos) { + end = message.find("\x0d\x0a\x0d\x0a", start); + } + if (end == std::string::npos) { + end = message.size(); + } + std::string decoded; + CHECK(absl::Base64Unescape(message.substr(start, end - start), &decoded)); + return decoded; +} + +std::string GetFirstMimePart(const std::string& contents) { + const char kBoundary[] = "boundary=\""; + const char kUnquotedBoundary[] = "boundary="; + int boundary_start = contents.find(kBoundary); + int boundary_end; + if (boundary_start == std::string::npos) { + boundary_start = contents.find(kUnquotedBoundary); + CHECK_NE(std::string::npos, boundary_start); + boundary_start += sizeof(kUnquotedBoundary) - 1; + boundary_end = contents.find_first_of(";\r", boundary_start); + } else { + boundary_start += sizeof(kBoundary) - 1; + boundary_end = contents.find('\"', boundary_start); + } + CHECK_NE(std::string::npos, boundary_end); + std::string boundary = + "--" + contents.substr(boundary_start, boundary_end - boundary_start); + + int part_start = contents.find(boundary, boundary_end); + CHECK_NE(std::string::npos, part_start); + part_start = contents.find("\r\n", part_start + boundary.size()); + CHECK_NE(std::string::npos, part_start); + part_start += 2; + int part_end = contents.find(boundary, part_start); + CHECK_NE(std::string::npos, part_end); + CHECK_GT(part_end, part_start + 2); + // String the CRLF that is right before the boundary end. + return contents.substr(part_start, part_end - part_start - 2); +} + +// Convert an ASN.1 X509 name to a single line string. +std::string CBSToName(const ByteString& raw_name) { + X509_NAME* name = nullptr; + const unsigned char* p = CBS_data(raw_name.cbs_ptr()); + d2i_X509_NAME(&name, &p, CBS_len(raw_name.cbs_ptr())); + std::string line; + BIO* bio = BIO_new(BIO_s_mem()); + X509_NAME_print_ex(bio, name, 0 /* indent */, 0 /* flags */); + int size = BIO_pending(bio); + std::unique_ptr<char[]> buffer(new char[size]); + CHECK_EQ(size, BIO_read(bio, buffer.get(), size)); + line.assign(&buffer[0], size); + X509_NAME_free(name); + BIO_free_all(bio); + return line; +} + +X509* PemToCertificate(const std::string& pem) { + bssl::UniquePtr<BIO> bio( + BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size())); + return PEM_read_bio_X509(bio.get(), nullptr /* existing key */, + nullptr /* password callback */, + nullptr /* password */); +} + +EVP_PKEY* PemToKey(const std::string& pem) { + bssl::UniquePtr<BIO> bio( + BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size())); + return PEM_read_bio_PrivateKey(bio.get(), nullptr /* existing key */, + nullptr /* password callback */, + nullptr /* password */); +} + +std::string NidToDerString(int nid) { + // Encode the OID as an ASN.1 OBJECT. + CBB cbb; + CHECK(CBB_init(&cbb, 0)); + CHECK(OBJ_nid2cbb(&cbb, nid)); + CBS asn1_cbs; + CBS_init(&asn1_cbs, CBB_data(&cbb), CBB_len(&cbb)); + // Copy the raw OID into the return value and discard the ASN.1 headers. + CBS raw_oid_cbs; + CHECK(CBS_get_asn1(&asn1_cbs, &raw_oid_cbs, CBS_ASN1_OBJECT)); + size_t raw_oid_len = CBS_len(&raw_oid_cbs); + std::string ret(raw_oid_len, '\0'); + CHECK(CBS_copy_bytes(&raw_oid_cbs, reinterpret_cast<uint8_t*>(ret.data()), + raw_oid_len)); + CBB_cleanup(&cbb); + return ret; +} + +} // namespace credentio_cms
diff --git a/crypto/default/cms/cms_test_helper.h b/crypto/default/cms/cms_test_helper.h new file mode 100644 index 0000000..8b71b3e --- /dev/null +++ b/crypto/default/cms/cms_test_helper.h
@@ -0,0 +1,59 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_TEST_HELPER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_TEST_HELPER_H_ + +#include <string> + +#include "crypto/default/cms/cms_parser.h" +#include "openssl/base.h" +#include "openssl/ossl_typ.h" + +namespace credentio_cms { + +// Read a file from the test data folder and return its contents. +std::string GetRawFileContents(const std::string& filename); + +// Read a file from the test data folder, normalizes the line endings and return +// its contents. +std::string GetFileContents(const std::string& filename); + +// Quick and dirty functions to extract the CMS object. It only works on the +// test cases. +std::string GetCms(const std::string& message); + +// Extract the first Mime part. Only suitable for tests. +// Returns the first mime part on success or check fails on failure. +std::string GetFirstMimePart(const std::string& contents); + +// Convert a byte string representing an X509 Name object to a single line +// string. +std::string CBSToName(const ByteString& raw_name); + +// Convert a PEM string to an X509 certificate. The caller needs to call +// X509_free on the returned certificate. +X509* PemToCertificate(const std::string& pem); + +// Convert a PEM string to an EVP_PKEY private key. The caller needs to call +// EVP_PKEY_free on the returned key. +EVP_PKEY* PemToKey(const std::string& pem); + +// Get the raw DER encoding of the OID with the given NID. +std::string NidToDerString(int nid); + +} // namespace credentio_cms + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_TEST_HELPER_H_
diff --git a/crypto/default/cms/oids.cc b/crypto/default/cms/oids.cc new file mode 100644 index 0000000..8d41055 --- /dev/null +++ b/crypto/default/cms/oids.cc
@@ -0,0 +1,38 @@ +// 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 "crypto/default/cms/oids.h" + +#include <cstddef> +#include <cstdint> +#include <cstring> + +#include "openssl/bytestring.h" + +namespace credentio_cms { + +// Returns true if the contents of data match expected. +bool CompareOid(const CBS& data, const uint8_t expected[], + size_t expected_length) { + return CBS_len(&data) == expected_length && + memcmp(CBS_data(&data), expected, expected_length) == 0; +} + +bool CompareOid(const CBS& a, const CBS& b) { + return CBS_len(&a) == CBS_len(&b) && + memcmp(CBS_data(&a), CBS_data(&b), CBS_len(&a)) == 0; +} + +} // namespace credentio_cms
diff --git a/crypto/default/cms/oids.h b/crypto/default/cms/oids.h new file mode 100644 index 0000000..4e1efcd --- /dev/null +++ b/crypto/default/cms/oids.h
@@ -0,0 +1,121 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_OIDS_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_OIDS_H_ + +#include <cstdint> + +#include "openssl/bytestring.h" +#include "openssl/x509.h" + +namespace credentio_cms { + +// Abbreviations: +// rsadsi = RSA Data Security Inc +// pkcs = Public Key Cryptographic Standards + +// https://tools.ietf.org/html/rfc5652#section-4 +// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-7 1 } +constexpr uint8_t kDataOid[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7, + 0x0d, 1, 7, 1}; +// https://tools.ietf.org/html/rfc5652#section-5.1 +// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-7 2 } +constexpr uint8_t kSignedDataOid[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7, + 0x0d, 1, 7, 2}; +// https://tools.ietf.org/html/rfc5652#section-5.3 +// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-7 3 } +constexpr uint8_t kEnvelopedDataOid[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7, + 0x0d, 1, 7, 3}; +// https://tools.ietf.org/html/rfc5652#section-11.1 +// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-9 3 } +constexpr uint8_t kContentAttributeType[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7, + 0x0d, 1, 9, 3}; +// https://tools.ietf.org/html/rfc5652#section-11.2 +// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-9 4 } +constexpr uint8_t kContentAttributeMessageDigest[] = { + 1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7, 0x0d, 1, 9, 4}; + +// https://tools.ietf.org/html/rfc5751 +// iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1) pkcs-9(9) 5 +constexpr uint8_t kSigningTimeOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 0x01, 0x09, 0x05}; +// https://tools.ietf.org/html/rfc5751 +constexpr uint8_t kSmimeCapabilitiesOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 0x01, 0x09, 0x0F}; +// https://tools.ietf.org/html/rfc7508#section-4.1 +// iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1) pkcs-9(9) smime(16) +// attributes(2) 55 +constexpr uint8_t kSecureHeaderFieldsIdentifierOid[] = { + 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x10, 0x2, 55}; +// SMIMEEncryptionKeyPreference +// https://tools.ietf.org/html/rfc5751#section-2.5.3 +// {iso(1) member-body(2) usa(840) rsadsi(113549) pkcs(1) pkcs-9(9) smime(16) +// attributes(2)} 11 +constexpr uint8_t kSmimeEncryptionKeyPreferenceOid[] = { + 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x10, 0x2, 11}; + +// http://csrc.nist.gov/groups/ST/crypto_apps_infra/csor/algorithms.html +constexpr uint8_t kSha256Oid[] = {0x60, 0x86, 0x48, 0x01, 0x65, + 0x03, 0x04, 0x02, 0x01}; + +constexpr uint8_t kSha1WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 1, 1, 1}; + +// https://tools.ietf.org/html/rfc5754#section-3.2 +constexpr uint8_t kSha256WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 1, 1, 11}; +// https://tools.ietf.org/html/rfc5754#section-3.2 +constexpr uint8_t kSha384WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 1, 1, 12}; +// https://tools.ietf.org/html/rfc5754#section-3.2 +constexpr uint8_t kSha512WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 1, 1, 13}; + +// https://tools.ietf.org/html/rfc4056 +constexpr uint8_t kRsaSsaPssOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 0x01, 0x01, 0x0a}; +// https://tools.ietf.org/html/rfc4056 +constexpr uint8_t kRsaEsOaepOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 0x01, 0x01, 0x07}; +// https://tools.ietf.org/html/rfc4056 +constexpr uint8_t kRsaPkcs1Oid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 0x01, 0x01, 0x01}; + +// https://tools.ietf.org/html/rfc3565 +constexpr uint8_t kAes128CbcOid[] = {0x60, 0x86, 0x48, 0x01, 0x65, 3, 4, 1, 2}; +// https://tools.ietf.org/html/rfc3565 +constexpr uint8_t kAes192CbcOid[] = {0x60, 0x86, 0x48, 0x01, 0x65, 3, 4, 1, 22}; +// https://tools.ietf.org/html/rfc3565 +constexpr uint8_t kAes256CbcOid[] = {0x60, 0x86, 0x48, 0x01, 0x65, 3, 4, 1, 42}; + +// https://tools.ietf.org/html/rfc3370#section-5.1 +constexpr uint8_t kDesEde3CbcOid[] = {0x2A, 0x86, 0x48, 0x86, + 0xF7, 0x0D, 0x03, 0x07}; + +// https://datatracker.ietf.org/doc/html/rfc3161#section-2.4.2 +// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-9 1 smime(16) +// ct(1) 4 } +constexpr uint8_t kCtTstInfoOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, + 0x01, 0x09, 0x10, 0x01, 0x04}; + +// Returns true if the contents of data match expected. +bool CompareOid(const CBS& data, const uint8_t expected[], + size_t expected_length); +bool CompareOid(const CBS& a, const CBS& b); + +} // namespace credentio_cms + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_OIDS_H_
diff --git a/crypto/default/cms/testdata/ca.pem b/crypto/default/cms/testdata/ca.pem new file mode 100644 index 0000000..11be3cf --- /dev/null +++ b/crypto/default/cms/testdata/ca.pem
@@ -0,0 +1,80 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + a3:87:4b:2c:9c:e8:a1:90 + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 6 18:04:21 2015 GMT + Not After : Nov 21 18:04:21 2288 GMT + Subject: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:cf:1e:92:80:13:8f:29:d4:b2:74:49:3a:03:d0: + 22:60:81:6a:ac:e3:eb:41:3b:8e:1d:2d:ec:89:92: + 7e:2a:e6:9f:9f:fd:bb:a3:87:d1:4f:30:37:92:f6: + 65:76:c5:3d:45:34:4a:83:42:74:e7:39:96:64:e6: + d4:6e:24:4b:1f:56:e8:31:d8:f4:19:84:93:c2:4f: + c9:bf:8f:71:b1:4c:61:26:34:aa:fd:d3:94:24:7b: + 69:a3:f9:0d:02:a0:90:06:3e:ad:66:9f:a5:eb:3c: + f9:c8:7d:4c:19:7a:96:5e:ce:01:02:12:06:13:61: + e9:dd:f8:49:8f:2d:fc:e1:6a:52:cd:53:2a:f3:dc: + ac:2f:35:56:f5:cc:1d:1e:4d:3f:6e:72:29:fe:a8: + a0:01:02:80:f1:e2:8e:e9:97:17:de:ac:ef:df:51: + 17:0d:f0:66:e2:c7:dc:6d:9c:19:01:00:85:44:9a: + 2f:1e:3c:ef:1f:d4:ca:c6:fd:8a:2f:87:55:74:61: + ca:74:0f:bb:dd:31:af:eb:36:f5:68:9b:bd:7a:d3: + be:a6:98:e8:b8:76:39:75:52:ba:12:79:31:f9:d9: + 3c:e0:24:da:00:13:aa:17:31:93:d2:ae:ab:f9:cd: + e2:b1:15:22:bc:c1:34:b7:e6:8a:37:21:8c:84:8f: + 14:47 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Key Identifier: + 01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + X509v3 Authority Key Identifier: + 01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + X509v3 Basic Constraints: + CA:TRUE + Signature Algorithm: sha256WithRSAEncryption + Signature Value: + 19:a5:65:96:6a:4a:7c:12:e8:fd:14:4e:c7:8e:45:df:7b:4f: + 5e:22:53:b2:5f:15:0b:e7:70:45:95:62:2b:dd:53:bd:27:5f: + bd:5b:d5:27:15:11:10:b7:25:9f:0e:df:b6:82:21:76:6e:69: + 45:aa:c4:60:25:bd:f9:6b:57:cc:aa:c0:80:89:a2:d1:f3:e1: + 67:d0:06:11:41:7e:f8:af:f4:27:40:e0:0c:a8:09:98:f1:7e: + ae:86:ae:96:e2:d7:1d:b7:0d:5d:ef:06:6c:0a:5e:06:57:06: + 30:1b:37:07:f7:e3:3b:91:71:42:73:3e:bb:58:e5:89:63:84: + c2:b3:1b:c5:51:ea:5d:83:98:09:16:7c:28:00:70:56:65:db: + b9:ca:83:d7:1a:ea:54:9b:d2:4b:c2:c0:bf:15:c3:87:1f:1e: + af:a6:f4:d0:ac:7a:c3:cb:b8:7c:60:ec:a6:6c:77:4a:b2:b6: + dd:21:42:92:9c:b9:d9:fe:0f:98:51:0f:fe:ae:86:27:3b:c1: + c3:8d:c3:83:b2:a2:20:c8:27:1d:33:13:8e:11:a8:04:72:ad: + 6a:87:4a:46:7b:06:05:9e:26:b8:d1:08:f3:8c:6c:55:80:8b: + dd:e1:26:82:38:1b:9c:f8:21:85:d5:ae:c8:0d:ed:73:f5:ac: + cb:a6:4d:93 +-----BEGIN CERTIFICATE----- +MIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQTAgFw0x +NTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjELMAkGA1UEBhMCVVMxCzAJ +BgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWlt +ZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9AiYIFqrOPrQTuOHS3siZJ+ +Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRLH1boMdj0GYSTwk/Jv49x +sUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqWXs4BAhIGE2Hp3fhJjy38 +4WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX3qzv31EXDfBm4sfcbZwZ +AQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9etO+ppjouHY5dVK6Enkx ++dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8URwIDAQABo1AwTjAdBgNV +HQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0jBBgwFoAUAfqHCjtrUZbc +tqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGaVl +lmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdfvVvVJxURELclnw7ftoIh +dm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0DgDKgJmPF+roauluLXHbcN +Xe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHqXYOYCRZ8KABwVmXbucqD +1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK23SFCkpy52f4PmFEP/q6G +JzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI84xsVYCL3eEmgjgbnPgh +hdWuyA3tc/Wsy6ZNkw== +-----END CERTIFICATE----- \ No newline at end of file
diff --git a/crypto/default/cms/testdata/dbx.bin b/crypto/default/cms/testdata/dbx.bin new file mode 100644 index 0000000..e87e0e3 --- /dev/null +++ b/crypto/default/cms/testdata/dbx.bin Binary files differ
diff --git a/crypto/default/cms/testdata/dod_signed.msg b/crypto/default/cms/testdata/dod_signed.msg new file mode 100644 index 0000000..53f43a8 --- /dev/null +++ b/crypto/default/cms/testdata/dod_signed.msg
@@ -0,0 +1,102 @@ +Content-Disposition: attachment; filename="smime.p7m" +Content-Transfer-Encoding: base64 +MIME-Version: 1.0 + +MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAaCAJIAEgaxDb250 +ZW50LVR5cGU6IHRleHQvcGxhaW47DQoJY2hhcnNldD0idXMtYXNjaWkiDQpDb250ZW50LVRyYW5z +ZmVyLUVuY29kaW5nOiA3Yml0DQoNCkhpIC0NCkkgaGF2ZSBhIHJlc2VydmF0aW9uIGF0IEphY2tz +b24ncyBmb3Igbm9vbiwgaWYgdGhhdCB3b3JrcyBmb3IgeW91Pw0KDQp2L3IsDQpTdGV2ZQ0KAAAA +AAAAoIIR1DCCA3MwggJboAMCAQICAQEwDQYJKoZIhvcNAQELBQAwWzELMAkGA1UEBhMCVVMxGDAW +BgNVBAoTD1UuUy4gR292ZXJubWVudDEMMAoGA1UECxMDRG9EMQwwCgYDVQQLEwNQS0kxFjAUBgNV +BAMTDURvRCBSb290IENBIDMwHhcNMTIwMzIwMTg0NjQxWhcNMjkxMjMwMTg0NjQxWjBbMQswCQYD +VQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsT +A1BLSTEWMBQGA1UEAxMNRG9EIFJvb3QgQ0EgMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKnsFHKK6Etwo9oQA4Sm+6c2DSo6Uha/MBVShgVHIM+qps11xGRu7/FgI8sKZkCutMhoKgBR +aEk36VkyTZW8QyfpQI06EM4UvEMYofnezOeFdnNeGBojW70/H/LtjRnMA9FApI+nIAJMJ1p5Nvaj +NyGOAFoGFsrTVZZvMSm7cg7L4khR8tQ3pDXWb+4Xs7EGqwsZhugjbTEbKHhlxd5iUrzBfevuoF1U +BPuyyyuyI1SRgkzwv7p0QDsMBEWAZ1zF66JXwxp/Ci29f7ncwZmwyAfkDIY2lDolL/J95pc8G5S0 +l1kGyTrkC9nq6fw7czRv/eeY5POhwpBfHPU/LtcZ038CAwEAAaNCMEAwHQYDVR0OBBYEFGyKlKJ3 +sYByHYF6Fqry3M5m7kXAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQCfcaTAtpbSgEOgSOkfdgT5xTytZhhYY5vDtuhoioVaQmYStNLmi4h/h/SY9ajG +Cckf8Cwf7IK49KVHOMEzK99Mfpq+Cwuxyw98UCgQz4qNoum6rIbX1LGTXyKPlgW0Tgx1kX3T8ueU +wpQUdk+PDKsQh1gyhQd1hhILXupTtArITISSH+voQYY8uvROQUrRbFhHQcOGWvLu6fKYJ4LqLjbW ++AZegvGgUpNECbrSqRlaWKOoXSBtT2T4MIcbkBNIgc3KkMcNwdSYP47yDldoMxKOmQmx8OT2EPQ2 +8km96qM4yFZBI4Oa36EbNXzrP0Gz9W9LOl6ub5N2mNLxmZ1FxI5yMIIEpTCCA42gAwIBAgIDAVdz +MA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQx +DDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNTEwHhcN +MTgwODMwMDAwMDAwWhcNMTkwOTMwMjM1OTU5WjB9MQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5T +LiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTETMBEGA1UECxMKQ09OVFJB +Q1RPUjEjMCEGA1UEAxMaSk9ORVMuU1RFUEhFTi5ELjEwMzI5ODczNjkwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDCDbwtZmSFHPzLARrPvWJgVMt5hdLk8uC0voMxlRJh+HNkfaFq4Hu8 +uPjye2RowlNOctwsci5p5M8LfzPCMf0CHpChcPw23ltPgGfV+GndDNnXNTNARoRDLmIA70OT/HkA +Rn77P6pLcvxtxfeS3Hj9eGsRDROp1W5Qsb1rpjoS1t8NQTxe7ORUSk4MpA06BK5Eb8jgdW7zPajt +7aBbmImug4F8nC72SPIC6BxypiSrJQnRzla/Y4qkOcMw5ZRvVR68ylLMJ79q59xxoOZN1zHApIbl +3xunA5sMpihowkalaICUeqCMhBWm83OYFnD+TIns7OCd83ehHgf9ZplWwMobAgMBAAGjggFMMIIB +SDAfBgNVHSMEGDAWgBQ17xAibLoSD73CTRnkzn2PBVdcNDA6BgNVHR8EMzAxMC+gLaArhilodHRw +Oi8vY3JsLmRpc2EubWlsL2NybC9ET0RFTUFJTENBXzUxLmNybDAOBgNVHQ8BAf8EBAMCBSAwFgYD +VR0gBA8wDTALBglghkgBZQIBCycwHQYDVR0OBBYEFB6Dsdk0XZ4fx36rAZFvPMe7DsyXMGgGCCsG +AQUFBwEBBFwwWjA2BggrBgEFBQcwAoYqaHR0cDovL2NybC5kaXNhLm1pbC9zaWduL0RPREVNQUlM +Q0FfNTEuY2VyMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5kaXNhLm1pbDAbBgNVHREEFDASgRBq +b25lc3N0ZUBucm8ubWlsMBsGA1UdCQQUMBIwEAYIKwYBBQUHCQQxBBMCVVMwDQYJKoZIhvcNAQEL +BQADggEBAIofdK3Ru3Ec4JeJHaLn1aanUjU3HvtrGaW1noO+faeSkHSJ9ecIE2ndMX4K/CBiFqGG +NnrGts0Q7gB9omnGqbxJko8WkNFY7wm9TozIg/8dM8wXr9cdxLFzXnY2RvLHz6Bq4fHsg5VCqOvR +BvaKw4Yeqfl2dhW4fKJiHvj2AZvi23sZ8DDFjTsXBjXDO5CP6f1GP4ARNigyOjGO9Uz/QotdZDIw +03JLJ4G/4DQoajx1hjLn3tWOBx7Iqkc7g4e0bGRRvPLJRSXU9AGEnuor0oPUnzVXb9SSYxTZJNWM +mlLcABgvp5FLG8PD1Hm//NSDbzUdjZXIfWppzEaKVrmkOoUwggS8MIIDpKADAgECAgIBJTANBgkq +hkiG9w0BAQsFADBbMQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYD +VQQLEwNEb0QxDDAKBgNVBAsTA1BLSTEWMBQGA1UEAxMNRG9EIFJvb3QgQ0EgMzAeFw0xNjExMjIx +MzQ2NDlaFw0yMjExMjMxMzQ2NDlaMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVy +bm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0Et +NTEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfDs9NqPlQZIyLzCHEak+ygKEYyNVB +GTUXOn0n+Y+PH+hdzYzAxqErfGkTdgHzqr9J4fUK/mFz09r30e1IX9FocCjd+sWg+dE2g8MuPqKE +8aYRbAwrLBSA4KdNBwZKU2pHR6y1isxPG2L1soTe5zSGBaJtliW6SIZiDRTKBg9Lj5p8y3NoGWOJ +RZ5AWuYJoSB4bcFS9f/OA11Kq0b0pfUuOq01kH3WEgOtFQ2iFfnFIvE8ymiMLl3f3W7/QZ8ARd4r +IDJs83wF1WOonsWXK55UpSAgAH+3+To07eUoSIL5CyuGvBoUjdiZ70l9GxlTz9iG/QvAp4mGjPfn +nahPR7g/AgMBAAGjggGGMIIBgjAfBgNVHSMEGDAWgBRsipSid7GAch2Behaq8tzOZu5FwDAdBgNV +HQ4EFgQUNe8QImy6Eg+9wk0Z5M59jwVXXDQwDgYDVR0PAQH/BAQDAgGGMGcGA1UdIARgMF4wCwYJ +YIZIAWUCAQskMAsGCWCGSAFlAgELJzALBglghkgBZQIBCyowCwYJYIZIAWUCAQs7MAwGCmCGSAFl +AwIBAw0wDAYKYIZIAWUDAgEDETAMBgpghkgBZQMCAQMnMBIGA1UdEwEB/wQIMAYBAf8CAQAwDAYD +VR0kBAUwA4ABADA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmRpc2EubWlsL2NybC9ET0RS +T09UQ0EzLmNybDBsBggrBgEFBQcBAQRgMF4wOgYIKwYBBQUHMAKGLmh0dHA6Ly9jcmwuZGlzYS5t +aWwvaXNzdWVkdG8vRE9EUk9PVENBM19JVC5wN2MwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmRp +c2EubWlsMA0GCSqGSIb3DQEBCwUAA4IBAQBjZQocyL3yayU0n2pQFPxk0YqKb6diR8t8GB0/Xfqy +YlhgNzBQ5fAe3dvVo7LixcjQGQ01Lq07l929ntbj/NgWi4hWciZpryGMOIk2gCLl1goPGQHyI5Zi +UA/xNnCko3KxsaHgRAdZZDzAO4Vx0Dn/0SWfGlD8knNKBQ4eG0aby+sRCaYkpbQW9sZeEbJ0fHYj +ka0O8YeqWkXwYUgADkc0BrDWzkN7ZK0RgJNL/L3mdqplp8LFgbrZwZseKvx2nRQpA5RC3zy1OHRM +CrNwsrPU4kigiRt+nNXpeJe4vRVKhv5bSl5l6pNWr7DudY/Vw2vjiWKDDBWN4XTgvexkoDnjMIIE +8DCCA9igAwIBAgIDAVdyMA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9V +LlMuIEdvdmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0Qg +RU1BSUwgQ0EtNTEwHhcNMTgwODMwMDAwMDAwWhcNMTkwOTMwMjM1OTU5WjB9MQswCQYDVQQGEwJV +UzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTET +MBEGA1UECxMKQ09OVFJBQ1RPUjEjMCEGA1UEAxMaSk9ORVMuU1RFUEhFTi5ELjEwMzI5ODczNjkw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDQjrZkoQP6s3N1YE9epfzobpQJ5R4kAxlh +5CrHLErReT8PRbVi+rts7yAN089MyHoYZkRBJhwvE/02TgOoB5ggdPE5rfLz0v8q/rh42cQQgWpU +HEBNTFY/09AVyaS6kzSyQCdElU+enb92uctTOiHEQX/9DsAcoH3ClQPkmmD5kQa2WJXMvSNIXt7d +ptkSzV0yL/i90Dm5qyesgbHfqFD3gHysCKxPDgvneYvY6AaOQW5PPgNEjdUZE+etNragAOMzhysx +VSi9qbzL4k44dH92LptNOXDtxCgh1h9w9cFSg/IvNyozHEXxRh+1h0Vv44u9kKBlvJlriw5ob6q2 +C3nvAgMBAAGjggGXMIIBkzAfBgNVHSMEGDAWgBQ17xAibLoSD73CTRnkzn2PBVdcNDA6BgNVHR8E +MzAxMC+gLaArhilodHRwOi8vY3JsLmRpc2EubWlsL2NybC9ET0RFTUFJTENBXzUxLmNybDAOBgNV +HQ8BAf8EBAMCBsAwFgYDVR0gBA8wDTALBglghkgBZQIBCyowHQYDVR0OBBYEFMnAEmniFAJzoika ++5iI+4Tv9/CVMGgGCCsGAQUFBwEBBFwwWjA2BggrBgEFBQcwAoYqaHR0cDovL2NybC5kaXNhLm1p +bC9zaWduL0RPREVNQUlMQ0FfNTEuY2VyMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5kaXNhLm1p +bDA7BgNVHREENDAygRBqb25lc3N0ZUBucm8ubWlsoB4GCisGAQQBgjcUAgOgEAwOMTAzMjk4NzM2 +OUBtaWwwGwYDVR0JBBQwEjAQBggrBgEFBQcJBDEEEwJVUzApBgNVHSUEIjAgBgorBgEEAYI3FAIC +BggrBgEFBQcDAgYIKwYBBQUHAwQwDQYJKoZIhvcNAQELBQADggEBAA11J3/Y1WW7dKClfouuSLGd +VdwdldHpUErezAhZbqGkJ8BBDtcDMbaEFrRIzTm1kOrL73X5aY1GE3cimTCMv+aUKKpnhV/6WCm5 ++Foxs3Eha07TyCM4txitSHfXK0Gi2c5CqznOxM6FsTF6Ci+8Eg10pxO36DI+6rOmBJuFJJlDLu9R +ciCCqQVAp0xAtTpGOnS9YOdWhB1eWx7sjsrIv2zwT8B+RrDvLqDjgKvgvSJOLR2Th700X4ZiPgCJ +bLji5Tv1I/dfm6kpWnUQAqTpMR5kdpBG+4mRJErmUN/ZVHiFxZDZi6a3f7jGpMYET8LofD8zg9d2 +74fh6dSpuNd4q4MxggL/MIIC+wIBATBkMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdv +dmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwg +Q0EtNTECAwFXcjAJBgUrDgMCGgUAoIIBcDAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqG +SIb3DQEJBTEPFw0xOTA4MjgxMzA4NDJaMCMGCSqGSIb3DQEJBDEWBBSduDE4kGE2D+GZUBs2XXRg +SY3auTAlBgkqhkiG9w0BCQ8xGDCBFTAHBgUrDgMCGjAKBggqhkiG9w0DBzBzBgkrBgEEAYI3EAQx +ZjBkMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQxDDAKBgNVBAsMA0Rv +RDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNTECAwFXczB1BgsqhkiG9w0B +CRACCzFmoGQwXTELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVudDEMMAoGA1UE +CwwDRG9EMQwwCgYDVQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS01MQIDAVdzMA0GCSqG +SIb3DQEBAQUABIIBAJ3vab3/uSG5kSgHQF9senXwKUyNowgFfVuq8Sr396P8qIvtw3qyn6rj5bq7 +UScglfnIev/zbgI7inVBJARRjR+OYpUyqh9pMSEC7Sr3gWGbuG26K9b7T+Yxqmr6Ad6E1ilFsRaV +nQiVbA7iQ1joVKZB2oZaz/Ku+ZNX9BmdlNo1YSPOvYMc3I9lHSDTwQs7ff0rK4wTMJcXK5H3acG3 +HvcrwGAxeITgQikwKliGUNr1vfo5nOsz20f3IgJsVOnprkXn6b/BrSX9Zuev34zGKnpvwZBkbBHd +2Ge1UZutXZf9A2r9XGtcQyzmBiPsWa6Q73ldZ/Wj0NoNFcBJOTU7yrsAAAAAAAA= \ No newline at end of file
diff --git a/crypto/default/cms/testdata/dod_signed_long.msg b/crypto/default/cms/testdata/dod_signed_long.msg new file mode 100644 index 0000000..8615354 --- /dev/null +++ b/crypto/default/cms/testdata/dod_signed_long.msg
@@ -0,0 +1,360 @@ +Content-Disposition: attachment; filename="smime.p7m" +Content-Transfer-Encoding: base64 +MIME-Version: 1.0 + +MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAaCAJIAEggj1Q29u +dGVudC1UeXBlOiBtdWx0aXBhcnQvYWx0ZXJuYXRpdmU7DQoJYm91bmRhcnk9Ii0tLS09X05leHRQ +YXJ0XzAwMF8wMDAwXzAxRDZDMzQzLjMzQUJDQTkwIg0KDQpUaGlzIGlzIGEgbXVsdGlwYXJ0IG1l +c3NhZ2UgaW4gTUlNRSBmb3JtYXQuDQoNCi0tLS0tLT1fTmV4dFBhcnRfMDAwXzAwMDBfMDFENkMz +NDMuMzNBQkNBOTANCkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbjsNCgljaGFyc2V0PSJ1dGYtOCIN +CkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IDhiaXQNCg0KS291c2hhLA0KDQoNCg0KSGFwcHkg +VHVya2V5IERheeKAmXMgRXZlIHRvIHlvdSBhbmQgdGhlIGZhbWlseSENCg0KDQoNClRoYW5rcyDi +gJMgSSBjbG9zZWQgdGhlIGxvb3Agd2l0aCBCaWxsIE1haWRlbiB0byBjbGVhciB0aGF0IHVwLg0K +DQpGb3IgbmV4dCB3ZWVrIGlmIHlvdSBhcmUgc3RpbGwgZ29vZCB3aXRoIGJyaW5naW5nIGRvd24g +dGhlIHZpZGVvIGZpbGVzIGZvciANClNwbHVuayAxMDEgYW5kIDEwMiDigJMgQmlsbCBNYWlkZW4g +d2hvIHNpdHMgYXQgdGhlIGxhYiBhbmQgd2lsbCBiZSBpbiBuZXh0IHdlZWsgDQpoaWdoIHNpZGUg +d2lsbCBiZSB5b3VyIFBPQy4gIE1wNCBmb3JtYXQgc2hvdWxkIHdvcmsgZmluZS4NCg0KDQoNCkZZ +SS4gU3RhcnRpbmcgMzAgTm92IHdlIGFyZSBiYWNrIHRvIGEgQmx1ZSBUZWFtL0dvbGQgVGVhbSBy +b3RhdGlvbi4gIEJpbGwgDQpNYWlkZW4gaXMgYXNzaWduZWQgdG8gQmx1ZSBUZWFtIHNvIGhlIHdp +bGwgaW4gbmV4dCB3ZWVrIOKAkyBteXNlbGYgYW5kIFJ5YW4gDQpLZW5zZXkgYXJlIG9uIEdvbGQg +dGVhbSBzbyB3ZSB3aWxsIGJlIG9uIHRoZSBsb3cgc2lkZSBhbGwgbmV4dCB3ZWVrIOKAkyB3aWxs +IA0KcmVhY2ggb3V0IHRvIHlvdSBuZXh0IHdlZWsgdG8gY2xvc2Ugb24gc29tZSBvZiB0aGUgYWN0 +aW9uIGl0ZW1zIGZyb20gbGFzdCB3ZWVr4oCZcyANCnByZXNlbnRhdGlvbi4NCg0KDQoNCi0gICAg +ICAgICAgRGFuIFQuDQoNCg0KDQoNCg0KRnJvbTogS291c2hhIFJhdGVnaCA8a3JhdGVnaEBzcGx1 +bmsuY29tPg0KU2VudDogVHVlc2RheSwgTm92ZW1iZXIgMjQsIDIwMjAgOTo0OSBBTQ0KVG86IFRv +YmF0IERhbmllbCBMIDx0b2JhZGFuaUBucm8ubWlsPg0KQ2M6IERhbiBUb2JhdCA8cS1kYW4tdEBo +b3RtYWlsLmNvbT47IGRzdGVwaGVuc0BwbGNpbmMudXMNClN1YmplY3Q6IFJlOiBbTm9uLURvRCBT +b3VyY2VdIEludGVybmFsIFNwbHVuayBTaXRlDQoNCg0KDQpIaSBEYW4sDQoNCg0KDQpCaWxsIHdh +cyByZWZlcnJpbmcgdG8gdGhlIEVJVEEgd2VicGFnZSwgYnV0IGlmIHlvdSByZWFjaCBvdXQgdG8g +aGltIHJlOiB0aGUgDQpTcGx1bmsgdHJhaW5pbmcgY29udmVyc2F0aW9uIHdlIGhhZCBoZSBzaG91 +bGQgYmUgYXdhcmUuICBIZSBhc2tlZCB0aGF0IEkgbGV0IA0KeW91IGtub3cgYWJvdXQgdGhlIEVJ +VEEgd2VicGFnZSBiZWluZyBhbiBvcHRpb24uIFl1cmkgd291bGQgYWxzbyBoYXZlIHRoZSBVUkwg +DQppZiBuZWVkZWQuDQoNCg0KDQpUaGFua3MsDQoNCg0KDQpLb3VzaGEgUmF0ZWdoDQoNCkN1c3Rv +bWVyIFN1Y2Nlc3MgTWFuYWdlciwgUHVibGljIFNlY3Rvcg0KDQpTcGx1bmsgSW5jLg0KDQorMSA0 +NDMuNTQwLjI3MjQNCg0KIDxtYWlsdG86a3JhdGVnaEBzcGx1bmsuY29tPiBrb3VzaGFAc3BsdW5r +LmNvbQ0KDQpXYXNoaW5ndG9uIEQuQy4gfCBBTUVSIHwgQVBBQyB8IEVNRUENCg0KDQoNCiA8aHR0 +cHM6Ly93d3cuc3BsdW5rLmNvbS9lbl91cy9lbnRlcnByaXNlLWRhdGEtcGxhdGZvcm0uaHRtbD4N +Cg0KVGhpcyBtZXNzYWdlIGlzIGludGVuZGVkIG9ubHkgZm9yIHRoZSBwZXJzb25hbCwgY29uZmlk +ZW50aWFsLCBhbmQgYXV0aG9yaXplZCANCnVzZSBvZiB0aGUgcmVjaXBpZW50KHMpIG5hbWVkIGFi +b3ZlLiBJZiB5b3UgYXJlIG5vdCB0aGF0IHBlcnNvbiwgeW91IGFyZSBub3QgDQphdXRob3JpemVk +IHRvIHJldmlldywgdXNlLCBjb3B5LCBmb3J3YXJkLCBkaXN0cmlidXRlIG9yIG90aGVyd2lzZSBk +aXNjbG9zZSB0aGUgDQppbmZvcm1hdGlvbiBjb250YWluZWQgaW4gdGhlIG1lc3NhZ2UuDQoNCg0K +DQoNCg0KDQoNCk9uIE1vbiwgTm92IDIzLCAyMDIwIGF0IDM6NDAgUE0gPHRvYmFkYW5pQG5yby5t +aWwgPG1haWx0bzp0b2JhZGFuaUBucm8ubWlsPiA+IA0Kd3JvdGU6DQoNCg0KLS0tLS0tPV9OZXh0 +UGFydF8wMDBfMDAwMF8wMUQ2QzM0My4zM0FCQ0E5MA0KQ29udGVudC1UeXBlOiB0ZXh0L2h0bWw7 +DQoJY2hhcnNldD0idXRmLTgiDQpDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBxdW90ZWQtcHJp +bnRhYmxlDQoNCgSCEAA8aHRtbCB4bWxuczp2PTNEInVybjpzY2hlbWFzLW1pY3Jvc29mdC1jb206 +dm1sIiA9DQp4bWxuczpvPTNEInVybjpzY2hlbWFzLW1pY3Jvc29mdC1jb206b2ZmaWNlOm9mZmlj +ZSIgPQ0KeG1sbnM6dz0zRCJ1cm46c2NoZW1hcy1taWNyb3NvZnQtY29tOm9mZmljZTp3b3JkIiA9 +DQp4bWxuczptPTNEImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vb2ZmaWNlLzIwMDQvMTIv +b21tbCIgPQ0KeG1sbnM9M0QiaHR0cDovL3d3dy53My5vcmcvVFIvUkVDLWh0bWw0MCI+PGhlYWQ+ +PG1ldGEgPQ0KaHR0cC1lcXVpdj0zRENvbnRlbnQtVHlwZSBjb250ZW50PTNEInRleHQvaHRtbDsg +Y2hhcnNldD0zRHV0Zi04Ij48bWV0YSA9DQpuYW1lPTNER2VuZXJhdG9yIGNvbnRlbnQ9M0QiTWlj +cm9zb2Z0IFdvcmQgMTUgKGZpbHRlcmVkIG1lZGl1bSkiPjwhLS1baWYgPQ0KIW1zb10+PHN0eWxl +PnZcOioge2JlaGF2aW9yOnVybCgjZGVmYXVsdCNWTUwpO30NCm9cOioge2JlaGF2aW9yOnVybCgj +ZGVmYXVsdCNWTUwpO30NCndcOioge2JlaGF2aW9yOnVybCgjZGVmYXVsdCNWTUwpO30NCi5zaGFw +ZSB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0KPC9zdHlsZT48IVtlbmRpZl0tLT48c3R5 +bGU+PCEtLQ0KLyogRm9udCBEZWZpbml0aW9ucyAqLw0KQGZvbnQtZmFjZQ0KCXtmb250LWZhbWls +eTpXaW5nZGluZ3M7DQoJcGFub3NlLTE6NSAwIDAgMCAwIDAgMCAwIDAgMDt9DQpAZm9udC1mYWNl +DQoJe2ZvbnQtZmFtaWx5OiJDYW1icmlhIE1hdGgiOw0KCXBhbm9zZS0xOjIgNCA1IDMgNSA0IDYg +MyAyIDQ7fQ0KQGZvbnQtZmFjZQ0KCXtmb250LWZhbWlseTpDYWxpYnJpOw0KCXBhbm9zZS0xOjIg +MTUgNSAyIDIgMiA0IDMgMiA0O30NCi8qIFN0eWxlIERlZmluaXRpb25zICovDQpwLk1zb05vcm1h +bCwgbGkuTXNvTm9ybWFsLCBkaXYuTXNvTm9ybWFsDQoJe21hcmdpbjowaW47DQoJbWFyZ2luLWJv +dHRvbTouMDAwMXB0Ow0KCWZvbnQtc2l6ZToxMi4wcHQ7DQoJZm9udC1mYW1pbHk6IlRpbWVzIE5l +dyBSb21hbiIsc2VyaWY7fQ0KYTpsaW5rLCBzcGFuLk1zb0h5cGVybGluaw0KCXttc28tc3R5bGUt +cHJpb3JpdHk6OTk7DQoJY29sb3I6Ymx1ZTsNCgl0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lO30N +CmE6dmlzaXRlZCwgc3Bhbi5Nc29IeXBlcmxpbmtGb2xsb3dlZA0KCXttc28tc3R5bGUtcHJpb3Jp +dHk6OTk7DQoJY29sb3I6cHVycGxlOw0KCXRleHQtZGVjb3JhdGlvbjp1bmRlcmxpbmU7fQ0KcA0K +CXttc28tc3R5bGUtcHJpb3JpdHk6OTk7DQoJbXNvLW1hcmdpbi10b3AtYWx0OmF1dG87DQoJbWFy +Z2luLXJpZ2h0OjBpbjsNCgltc28tbWFyZ2luLWJvdHRvbS1hbHQ6YXV0bzsNCgltYXJnaW4tbGVm +dDowaW47DQoJZm9udC1zaXplOjEyLjBwdDsNCglmb250LWZhbWlseToiVGltZXMgTmV3IFJvbWFu +IixzZXJpZjt9DQpwLk1zb0xpc3RQYXJhZ3JhcGgsIGxpLk1zb0xpc3RQYXJhZ3JhcGgsIGRpdi5N +c29MaXN0UGFyYWdyYXBoDQoJe21zby1zdHlsZS1wcmlvcml0eTozNDsNCgltYXJnaW4tdG9wOjBp +bjsNCgltYXJnaW4tcmlnaHQ6MGluOw0KCW1hcmdpbi1ib3R0b206MGluOw0KCW1hcmdpbi1sZWZ0 +Oi41aW47DQoJbWFyZ2luLWJvdHRvbTouMDAwMXB0Ow0KCWZvbnQtc2l6ZToxMi4wcHQ7DQoJZm9u +dC1mYW1pbHk6IlRpbWVzIE5ldyBSb21hbiIsc2VyaWY7fQ0KcC5tc29ub3JtYWwwLCBsaS5tc29u +b3JtYWwwLCBkaXYubXNvbm9ybWFsMA0KCXttc28tc3R5bGUtbmFtZTptc29ub3JtYWw7DQoJbXNv +LW1hcmdpbi10b3AtYWx0OmF1dG87DQoJbWFyZ2luLXJpZ2h0OjBpbjsNCgltc28tbWFyZ2luLWJv +dHRvbS1hbHQ6YXV0bzsNCgltYXJnaW4tbGVmdDowaW47DQoJZm9udC1zaXplOjEyLjBwdDsNCglm +b250LWZhbWlseToiVGltZXMgTmV3IFJvbWFuIixzZXJpZjt9DQpzcGFuLkVtYWlsU3R5bGUxOQ0K +CXttc28tc3R5bGUtdHlwZTpwZXJzb25hbC1yZXBseTsNCglmb250LWZhbWlseToiQ2FsaWJyaSIs +c2Fucy1zZXJpZjsNCgljb2xvcjojMUY0OTdEO30NCi5Nc29DaHBEZWZhdWx0DQoJe21zby1zdHls +ZS10eXBlOmV4cG9ydC1vbmx5Ow0KCWZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO30N +CkBwYWdlIFdvcmRTZWN0aW9uMQ0KCXtzaXplOjguNWluIDExLjBpbjsNCgltYXJnaW46MS4waW4g +MS4waW4gMS4waW4gMS4waW47fQ0KZGl2LldvcmRTZWN0aW9uMQ0KCXtwYWdlOldvcmRTZWN0aW9u +MTt9DQovKiBMaXN0IERlZmluaXRpb25zICovDQpAbGlzdCBsMA0KCXttc28tbGlzdC1pZDoxMjU4 +NTY1MDU1Ow0KCW1zby1saXN0LXR5cGU6aHlicmlkOw0KCW1zby1saXN0LXRlbXBsYXRlLWlkczot +MTM5MzAxNjUzNCAtMTE3MzQ2NDMyNiA2NzY5ODY5MSA2NzY5ODY5MyA9DQo2NzY5ODY4OSA2NzY5 +ODY5MSA2NzY5ODY5MyA2NzY5ODY4OSA2NzY5ODY5MSA2NzY5ODY5Mzt9DQpAbGlzdCBsMDpsZXZl +bDENCgl7bXNvLWxldmVsLXN0YXJ0LWF0OjA7DQoJbXNvLWxldmVsLW51bWJlci1mb3JtYXQ6YnVs +bGV0Ow0KCW1zby1sZXZlbC10ZXh0Oi07DQoJbXNvLWxldmVsLXRhYi1zdG9wOm5vbmU7DQoJbXNv +LWxldmVsLW51bWJlci1wb3NpdGlvbjpsZWZ0Ow0KCXRleHQtaW5kZW50Oi0uMjVpbjsNCglmb250 +LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjsNCgltc28tZmFyZWFzdC1mb250LWZhbWlseTpD +YWxpYnJpO30NCkBsaXN0IGwwOmxldmVsMg0KCXttc28tbGV2ZWwtbnVtYmVyLWZvcm1hdDpidWxs +ZXQ7DQoJbXNvLWxldmVsLXRleHQ6bzsNCgltc28tbGV2ZWwtdGFiLXN0b3A6bm9uZTsNCgltc28t +bGV2ZWwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7DQoJdGV4dC1pbmRlbnQ6LS4yNWluOw0KCWZvbnQt +ZmFtaWx5OiJDb3VyaWVyIE5ldyI7fQ0KQGxpc3QgbDA6bGV2ZWwzDQoJe21zby1sZXZlbC1udW1i +ZXItZm9ybWF0OmJ1bGxldDsNCgltc28tbGV2ZWwtdGV4dDo9RUY9ODI9QTc7DQoJbXNvLWxldmVs +LXRhYi1zdG9wOm5vbmU7DQoJbXNvLWxldmVsLW51bWJlci1wb3NpdGlvbjpsZWZ0Ow0KCXRleHQt +aW5kZW50Oi0uMjVpbjsNCglmb250LWZhbWlseTpXaW5nZGluZ3M7fQ0KQGxpc3QgbDA6bGV2ZWw0 +DQoJe21zby1sZXZlbC1udW1iZXItZm9ybWF0OmJ1bGxldDsNCgltc28tbGV2ZWwtdGV4dDo9RUY9 +ODI9Qjc7DQoJbXNvLWxldmVsLXRhYi1zdG9wOm5vbmU7DQoJbXNvLWxldmVsLW51bWJlci1wb3Np +dGlvbjpsZWZ0Ow0KCXRleHQtaW5kZW50Oi0uMjVpbjsNCglmb250LWZhbWlseTpTeW1ib2w7fQ0K +QGxpc3QgbDA6bGV2ZWw1DQoJe21zby1sZXZlbC1udW1iZXItZm9ybWF0OmJ1bGxldDsNCgltc28t +bGV2ZWwtdGV4dDpvOw0KCW1zby1sZXZlbC10YWItc3RvcDpub25lOw0KCW1zby1sZXZlbC1udW1i +ZXItcG9zaXRpb246bGVmdDsNCgl0ZXh0LWluZGVudDotLjI1aW47DQoJZm9udC1mYW1pbHk6IkNv +dXJpZXIgTmV3Ijt9DQpAbGlzdCBsMDpsZXZlbDYNCgl7bXNvLWxldmVsLW51bWJlci1mb3JtYXQ6 +YnVsbGV0Ow0KCW1zby1sZXZlbC10ZXh0Oj1FRj04Mj1BNzsNCgltc28tbGV2ZWwtdGFiLXN0b3A6 +bm9uZTsNCgltc28tbGV2ZWwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7DQoJdGV4dC1pbmRlbnQ6LS4y +NWluOw0KCWZvbnQtZmFtaWx5OldpbmdkaW5nczt9DQpAbGlzdCBsMDpsZXZlbDcNCgl7bXNvLWxl +dmVsLW51bWJlci1mb3JtYXQ6YnVsbGV0Ow0KCW1zby1sZXZlbC10ZXh0Oj1FRj04Mj1CNzsNCglt +c28tbGV2ZWwtdGFiLXN0b3A6bm9uZTsNCgltc28tbGV2ZWwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7 +DQoJdGV4dC1pbmRlbnQ6LS4yNWluOw0KCWZvbnQtZmFtaWx5OlN5bWJvbDt9DQpAbGlzdCBsMDps +ZXZlbDgNCgl7bXNvLWxldmVsLW51bWJlci1mb3JtYXQ6YnVsbGV0Ow0KCW1zby1sZXZlbC10ZXh0 +Om87DQoJbXNvLWxldmVsLXRhYi1zdG9wOm5vbmU7DQoJbXNvLWxldmVsLW51bWJlci1wb3NpdGlv +bjpsZWZ0Ow0KCXRleHQtaW5kZW50Oi0uMjVpbjsNCglmb250LWZhbWlseToiQ291cmllciBOZXci +O30NCkBsaXN0IGwwOmxldmVsOQ0KCXttc28tbGV2ZWwtbnVtYmVyLWZvcm1hdDpidWxsZXQ7DQoJ +bXNvLWxldmVsLXRleHQ6PUVGPTgyPUE3Ow0KCW1zby1sZXZlbC10YWItc3RvcDpub25lOw0KCW1z +by1sZXZlBIIQAGwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7DQoJdGV4dC1pbmRlbnQ6LS4yNWluOw0K +CWZvbnQtZmFtaWx5OldpbmdkaW5nczt9DQpvbA0KCXttYXJnaW4tYm90dG9tOjBpbjt9DQp1bA0K +CXttYXJnaW4tYm90dG9tOjBpbjt9DQotLT48L3N0eWxlPjwhLS1baWYgZ3RlIG1zbyA5XT48eG1s +Pg0KPG86c2hhcGVkZWZhdWx0cyB2OmV4dD0zRCJlZGl0IiBzcGlkbWF4PTNEIjEwMjYiIC8+DQo8 +L3htbD48IVtlbmRpZl0tLT48IS0tW2lmIGd0ZSBtc28gOV0+PHhtbD4NCjxvOnNoYXBlbGF5b3V0 +IHY6ZXh0PTNEImVkaXQiPg0KPG86aWRtYXAgdjpleHQ9M0QiZWRpdCIgZGF0YT0zRCIxIiAvPg0K +PC9vOnNoYXBlbGF5b3V0PjwveG1sPjwhW2VuZGlmXS0tPjwvaGVhZD48Ym9keSBsYW5nPTNERU4t +VVMgbGluaz0zRGJsdWUgPQ0Kdmxpbms9M0RwdXJwbGU+PGRpdiBjbGFzcz0zRFdvcmRTZWN0aW9u +MT48cCBjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0 +O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPktvdXNo +YSw8bzpwPjwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5 +bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtj +b2xvcjojMUY0OTdEJz0NCj48bzpwPiZuYnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RN +c29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToi +Q2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjojMUY0OTdEJz0NCj5IYXBweSBUdXJrZXkgRGF5PUUy +PTgwPTk5cyBFdmUgdG8geW91IGFuZCB0aGUgPQ0KZmFtaWx5ITxvOnA+PC9vOnA+PC9zcGFuPjwv +cD48cCBjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0 +O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPjxvOnA+ +Jm5ic3A7PC9vOnA+PC9zcGFuPjwvcD48cCBjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHls +ZT0zRCdmb250LXNpemU6MTEuMHB0O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2Nv +bG9yOiMxRjQ5N0QnPQ0KPlRoYW5rcyA9RTI9ODA9OTMgSSBjbG9zZWQgdGhlIGxvb3Agd2l0aCBC +aWxsIE1haWRlbiB0byBjbGVhciB0aGF0ID0NCnVwLjxvOnA+PC9vOnA+PC9zcGFuPjwvcD48cCBj +bGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0O2ZvbnQt +ZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPkZvciBuZXh0IHdl +ZWsgaWYgeW91IGFyZSBzdGlsbCBnb29kIHdpdGggYnJpbmdpbmcgZG93biB0aGUgdmlkZW8gZmls +ZXMgPQ0KZm9yIFNwbHVuayAxMDEgYW5kIDEwMiA9RTI9ODA9OTMgQmlsbCBNYWlkZW4gd2hvIHNp +dHMgYXQgdGhlIGxhYiBhbmQgPQ0Kd2lsbCBiZSBpbiBuZXh0IHdlZWsgaGlnaCBzaWRlIHdpbGwg +YmUgeW91ciBQT0MuPUMyPUEwIE1wNCBmb3JtYXQgc2hvdWxkID0NCndvcmsgZmluZS48bzpwPjwv +bzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9u +dC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjojMUY0 +OTdEJz0NCj48bzpwPiZuYnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+ +PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIs +c2Fucy1zZXJpZjtjb2xvcjojMUY0OTdEJz0NCj5GWUkuIFN0YXJ0aW5nIDMwIE5vdiB3ZSBhcmUg +YmFjayB0byBhIEJsdWUgVGVhbS9Hb2xkIFRlYW0gPQ0Kcm90YXRpb24uPUMyPUEwIEJpbGwgTWFp +ZGVuIGlzIGFzc2lnbmVkIHRvIEJsdWUgVGVhbSBzbyBoZSB3aWxsIGluIG5leHQgPQ0Kd2VlayA9 +RTI9ODA9OTMgbXlzZWxmIGFuZCBSeWFuIEtlbnNleSBhcmUgb24gR29sZCB0ZWFtIHNvIHdlIHdp +bGwgYmUgb24gPQ0KdGhlIGxvdyBzaWRlIGFsbCBuZXh0IHdlZWsgPUUyPTgwPTkzIHdpbGwgcmVh +Y2ggb3V0IHRvIHlvdSBuZXh0IHdlZWsgdG8gPQ0KY2xvc2Ugb24gc29tZSBvZiB0aGUgYWN0aW9u +IGl0ZW1zIGZyb20gbGFzdCB3ZWVrPUUyPTgwPTk5cyA9DQpwcmVzZW50YXRpb24uPG86cD48L286 +cD48L3NwYW4+PC9wPjxwIGNsYXNzPTNETXNvTm9ybWFsPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQt +c2l6ZToxMS4wcHQ7Zm9udC1mYW1pbHk6IkNhbGlicmkiLHNhbnMtc2VyaWY7Y29sb3I6IzFGNDk3 +RCc9DQo+PG86cD4mbmJzcDs8L286cD48L3NwYW4+PC9wPjxwIGNsYXNzPTNETXNvTGlzdFBhcmFn +cmFwaCA9DQpzdHlsZT0zRCd0ZXh0LWluZGVudDotLjI1aW47bXNvLWxpc3Q6bDAgbGV2ZWwxIGxm +bzEnPjwhW2lmID0NCiFzdXBwb3J0TGlzdHNdPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQtc2l6ZTox +MS4wcHQ7Zm9udC1mYW1pbHk6IkNhbGlicmkiLHNhbnMtc2VyaWY7Y29sb3I6IzFGNDk3RCc9DQo+ +PHNwYW4gc3R5bGU9M0QnbXNvLWxpc3Q6SWdub3JlJz4tPHNwYW4gc3R5bGU9M0QnZm9udDo3LjBw +dCAiVGltZXMgTmV3ID0NClJvbWFuIic+Jm5ic3A7Jm5ic3A7Jm5ic3A7Jm5ic3A7Jm5ic3A7Jm5i +c3A7Jm5ic3A7Jm5ic3A7Jm5ic3A7ID0NCjwvc3Bhbj48L3NwYW4+PC9zcGFuPjwhW2VuZGlmXT48 +c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixz +YW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPkRhbiBULiA8bzpwPjwvbzpwPjwvc3Bhbj48L3A+ +PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtm +b250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjojMUY0OTdEJz0NCj48bzpwPiZu +YnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9 +M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xv +cjojMUY0OTdEJz0NCj48bzpwPiZuYnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29O +b3JtYWw+PGI+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToi +Q2FsaWJyaSIsc2Fucy1zZXJpZic+RnJvbTo8L3NwYW4+PD0NCi9iPjxzcGFuIHN0eWxlPTNEJ2Zv +bnQtc2l6ZToxMS4wcHQ7Zm9udC1mYW1pbHk6IkNhbGlicmkiLHNhbnMtc2VyaWYnPiA9DQpLb3Vz +aGEgUmF0ZWdoICZsdDtrcmF0ZWdoQHNwbHVuay5jb20mZ3Q7IDxicj48Yj5TZW50OjwvYj4gVHVl +c2RheSwgPQ0KTm92ZW1iZXIgMjQsIDIwMjAgOTo0OSBBTTxicj48Yj5Ubzo8L2I+IFRvYmF0IERh +bmllbCBMID0NCiZsdDt0b2JhZGFuaUBucm8ubWlsJmd0Ozxicj48Yj5DYzo8L2I+IERhbiBUb2Jh +dCA9DQombHQ7cS1kYW4tdEBob3RtYWlsLmNvbSZndDs7IGRzdGVwaGVuc0BwbGNpbmMudXM8YnI+ +PGI+U3ViamVjdDo8L2I+IFJlOiA9DQpbTm9uLURvRCBTb3VyY2VdIEludGVybmFsIFNwbHVuayBT +aXRlPG86cD48L286cD48L3NwYW4+PC9wPjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxvOnA+Jm5i +c3A7PC9vOnA+PC9wPjxkaXY+PGRpdj48cCBjbGFzcz0zRE1zb05vcm1hbD5IaSA9DQpEYW4sPG86 +cD48L286cD48L3A+PC9kaXY+PGRpdj48cCA9DQpjbGFzcz0zRE1zb05vcm1hbD48bzpwPiZuYnNw +OzwvbzpwPjwvcD48L2Rpdj48ZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPkJpbGwgd2FzIHJl +ZmVycmluZyB0byB0aGUgRUlUQSB3ZWJwYWdlLCBidXQgaWYgeW91ID0NCnJlYWNoIG91dCB0byBo +aW0gcmU6IHRoZSZuYnNwO1NwbHVuayB0cmFpbmluZyBjb252ZXJzYXRpb24gd2UgaGFkIGhlID0N +CnNob3VsZCBiZSBhd2FyZS4mbmJzcDsgSGUgYXNrZWQgdGhhdCBJIGxldCB5b3Uga25vdyBhYm91 +dCB0aGUgRUlUQSA9DQp3ZWJwYWdlIGJlaW5nIGFuIG9wdGlvbi4gWXVyaSB3b3VsZCBhbHNvIGhh +dmUgdGhlIFVSTCBpZiA9DQpuZWVkZWQuPG86cD48L286cD48L3A+PC9kaXY+PGRpdj48cCA9DQpj +bGEEggxXc3M9M0RNc29Ob3JtYWw+PG86cD4mbmJzcDs8L286cD48L3A+PC9kaXY+PGRpdj48cCA9 +DQpjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LWZhbWlseToiQXJpYWwi +LHNhbnMtc2VyaWY7Y29sb3I6IzNCM0IzQic+VGhhbmtzLDwvc3Bhbj48bzpwPQ0KPjwvbzpwPjwv +cD48L2Rpdj48ZGl2PjxkaXY+PGRpdj48cCA9DQpjbGFzcz0zRE1zb05vcm1hbD48bzpwPiZuYnNw +OzwvbzpwPjwvcD48cCBjbGFzcz0zRE1zb05vcm1hbD48Yj48c3BhbiA9DQpzdHlsZT0zRCdmb250 +LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzNCM0IzQic+S291c2hhID0NClJhdGVn +aDwvc3Bhbj48L2I+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWls +eToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjpibGFjayc+PD0NCm86cD48L286cD48L3NwYW4+ +PC9wPjxwIHN0eWxlPTNEJ21hcmdpbjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48c3BhbiA9 +DQpzdHlsZT0zRCdmb250LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzMxMzEzMSc+ +Q3VzdG9tZXIgU3VjY2VzcyA9DQpNYW5hZ2VyLCBQdWJsaWMgU2VjdG9yPC9zcGFuPjxzcGFuID0N +CnN0eWxlPTNEJ2NvbG9yOmJsYWNrJz48bzpwPjwvbzpwPjwvc3Bhbj48L3A+PHAgPQ0Kc3R5bGU9 +M0QnbWFyZ2luOjBpbjttYXJnaW4tYm90dG9tOi4wMDAxcHQnPjxzcGFuID0NCnN0eWxlPTNEJ2Zv +bnQtc2l6ZTo5LjVwdDtmb250LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6YmxhY2sn +PlNwbHU9DQpuayBJbmMuPC9zcGFuPjxvOnA+PC9vOnA+PC9wPjxwID0NCnN0eWxlPTNEJ21hcmdp +bjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48c3BhbiA9DQpzdHlsZT0zRCdmb250LWZhbWls +eToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzMxMzEzMSc+KzEgPQ0KNDQzLjU0MC4yNzI0Jm5i +c3A7PC9zcGFuPjxzcGFuID0NCnN0eWxlPTNEJ2NvbG9yOmJsYWNrJz48bzpwPjwvbzpwPjwvc3Bh +bj48L3A+PHAgPQ0Kc3R5bGU9M0QnbWFyZ2luOjBpbjttYXJnaW4tYm90dG9tOi4wMDAxcHQnPjxz +cGFuID0NCnN0eWxlPTNEJ2NvbG9yOmJsYWNrJz48YSBocmVmPTNEIm1haWx0bzprcmF0ZWdoQHNw +bHVuay5jb20iID0NCnRhcmdldD0zRCJfYmxhbmsiPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQtZmFt +aWx5OiJBcmlhbCIsc2Fucy1zZXJpZjtjb2xvcjojMDU2M0MxJz5rb3VzaGFAc3BsdW5rLmNvbTw9 +DQovc3Bhbj48L2E+PG86cD48L286cD48L3NwYW4+PC9wPjxwID0NCnN0eWxlPTNEJ21hcmdpbjow +aW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48Yj48c3BhbiA9DQpzdHlsZT0zRCdmb250LWZhbWls +eToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6YmxhY2snPldhc2hpbmd0b24gPQ0KRC5DLjwvc3Bh +bj48L2I+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1mYW1pbHk6IkFyaWFsIixzYW5zLXNlcmlmO2Nv +bG9yOmJsYWNrJz4mbmJzcDs8L3NwYW4+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1mYW1pbHk6IkFy +aWFsIixzYW5zLXNlcmlmO2NvbG9yOiNBNUE1QTUnPnwmbmJzcDtBTUVSIHwgPQ0KQVBBQyB8IEVN +RUE8L3NwYW4+PHNwYW4gPQ0Kc3R5bGU9M0QnY29sb3I6YmxhY2snPjxvOnA+PC9vOnA+PC9zcGFu +PjwvcD48ZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxvOnA+Jm5ic3A7PC9vOnA+PC9wPjwv +ZGl2PjxwID0NCnN0eWxlPTNEJ21hcmdpbjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48YSA9 +DQpocmVmPTNEImh0dHBzOi8vd3d3LnNwbHVuay5jb20vZW5fdXMvZW50ZXJwcmlzZS1kYXRhLXBs +YXRmb3JtLmh0bWwiID0NCnRhcmdldD0zRCJfYmxhbmsiPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQt +c2l6ZTo3LjBwdDtmb250LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzExNTVDQzti +b3I9DQpkZXI6bm9uZSB3aW5kb3d0ZXh0IDEuMHB0O3BhZGRpbmc6MGluO3RleHQtZGVjb3JhdGlv +bjpub25lJz48aW1nID0NCmJvcmRlcj0zRDAgd2lkdGg9M0Q0MzIgaGVpZ2h0PTNEOTMgc3R5bGU9 +M0Qnd2lkdGg6NC41aW47aGVpZ2h0Oi45Njg3aW4nID0NCmlkPTNEIl94MDAwMF9pMTAyNSIgPQ0K +c3JjPTNEImh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9qV0pES0lkREpWeVN4RTVB +emRnRFE1eEdEamd4Rk0tUD0NCnE4dm4xWlYwcFdwMGtCR00zQThqVndNd00tVGtLbGNqUy1nRDFp +M2dtX0FsYWdia0lDbEg1aTU4dEdKOHVhM0JJMGRTU0ZRTWg9DQpITnAzUjl2UXF5LV90d0h6RDBO +MVlmdHhDTkNMZG5KIj48L3NwYW4+PC9hPjxvOnA+PC9vOnA+PC9wPjxwID0NCnN0eWxlPTNEJ21h +cmdpbjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNp +emU6Ny4wcHQ7Zm9udC1mYW1pbHk6IkFyaWFsIixzYW5zLXNlcmlmO2NvbG9yOiNBNUE1QTUnPlRo +PQ0KaXMgbWVzc2FnZSBpcyBpbnRlbmRlZCBvbmx5IGZvciB0aGUgcGVyc29uYWwsIGNvbmZpZGVu +dGlhbCwgYW5kID0NCmF1dGhvcml6ZWQgdXNlIG9mIHRoZSByZWNpcGllbnQocykgbmFtZWQgYWJv +dmUuIElmIHlvdSBhcmUgbm90IHRoYXQgPQ0KcGVyc29uLCB5b3UgYXJlIG5vdCBhdXRob3JpemVk +IHRvIHJldmlldywgdXNlLCBjb3B5LCBmb3J3YXJkLCBkaXN0cmlidXRlID0NCm9yIG90aGVyd2lz +ZSBkaXNjbG9zZSB0aGUgaW5mb3JtYXRpb24gY29udGFpbmVkIGluIHRoZSA9DQptZXNzYWdlLjwv +c3Bhbj48bzpwPjwvbzpwPjwvcD48ZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxvOnA+Jm5i +c3A7PC9vOnA+PC9wPjwvZGl2PjwvZGl2PjwvZGl2PjwvZGl2PjxwID0NCmNsYXNzPTNETXNvTm9y +bWFsPjxvOnA+Jm5ic3A7PC9vOnA+PC9wPjwvZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxv +OnA+Jm5ic3A7PC9vOnA+PC9wPjxkaXY+PGRpdj48cCBjbGFzcz0zRE1zb05vcm1hbD5PbiA9DQpN +b24sIE5vdiAyMywgMjAyMCBhdCAzOjQwIFBNICZsdDs8YSA9DQpocmVmPTNEIm1haWx0bzp0b2Jh +ZGFuaUBucm8ubWlsIj50b2JhZGFuaUBucm8ubWlsPC9hPiZndDsgPQ0Kd3JvdGU6PG86cD48L286 +cD48L3A+PC9kaXY+PC9kaXY+PC9kaXY+PC9ib2R5PjwvaHRtbD4NCi0tLS0tLT1fTmV4dFBhcnRf +MDAwXzAwMDBfMDFENkMzNDMuMzNBQkNBOTAtLQ0KAAAAAAAAoIIWkjCCA3MwggJboAMCAQICAQEw +DQYJKoZIhvcNAQELBQAwWzELMAkGA1UEBhMCVVMxGDAWBgNVBAoTD1UuUy4gR292ZXJubWVudDEM +MAoGA1UECxMDRG9EMQwwCgYDVQQLEwNQS0kxFjAUBgNVBAMTDURvRCBSb290IENBIDMwHhcNMTIw +MzIwMTg0NjQxWhcNMjkxMjMwMTg0NjQxWjBbMQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBH +b3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTEWMBQGA1UEAxMNRG9EIFJvb3Qg +Q0EgMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKnsFHKK6Etwo9oQA4Sm+6c2DSo6 +Uha/MBVShgVHIM+qps11xGRu7/FgI8sKZkCutMhoKgBRaEk36VkyTZW8QyfpQI06EM4UvEMYofne +zOeFdnNeGBojW70/H/LtjRnMA9FApI+nIAJMJ1p5NvajNyGOAFoGFsrTVZZvMSm7cg7L4khR8tQ3 +pDXWb+4Xs7EGqwsZhugjbTEbKHhlxd5iUrzBfevuoF1UBPuyyyuyI1SRgkzwv7p0QDsMBEWAZ1zF +66JXwxp/Ci29f7ncwZmwyAfkDIY2lDolL/J95pc8G5S0l1kGyTrkC9nq6fw7czRv/eeY5POhwpBf +HPU/LtcZ038CAwEAAaNCMEAwHQYDVR0OBBYEFGyKlKJ3sYByHYF6Fqry3M5m7kXAMA4GA1UdDwEB +/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCfcaTAtpbSgEOgSOkf +dgT5xTytZhhYY5vDtuhoioVaQmYStNLmi4h/h/SY9ajGCckf8Cwf7IK49KVHOMEzK99Mfpq+Cwux +yw98UCgQz4qNoum6rIbX1LGTXyKPlgW0Tgx1kX3T8ueUwpQUdk+PDKsQh1gyhQd1hhILXupTtArI +TISSH+voQYY8uvROQUrRbFhHQcOGWvLu6fKYJ4LqLjbW+AZegvGgUpNECbrSqRlaWKOoXSBtT2T4 +MIcbkBNIgc3KkMcNwdSYP47yDldoMxKOmQmx8OT2EPQ28km96qM4yFZBI4Oa36EbNXzrP0Gz9W9L +Ol6ub5N2mNLxmZ1FxI5yMIIEpDCCA4ygAwIBAgIDHh1EMA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNV +BAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwD +UEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNDkwHhcNMTkxMjExMDAwMDAwWhcNMjIxMTExMjM1 +OTU5WjB8MQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNE +b0QxDDAKBgNVBAsTA1BLSTETMBEGA1UECxMKQ09OVFJBQ1RPUjEiMCAGA1UEAxMZVE9CQVQuREFO +SUVMLkwuMTA0NzU3NDUxNjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKWAxVnCZxLl +Cuf5epS+D3BqK5iVFjmRcB+SNVIreNsvq8qEn699p+zKyQHcjs668YocaVDS0q1sHVWHJEjaY0zh +C+aemyER5ErOFfk643fPE9l7+98EnYXqAwhGZT4JBcWPL9/lCnF2wMbWkXPPAOVLStxjKGW/24cj +owhGnNlFOo7++jb1OtZzzr2XxRE3dy6BMo1jgGK1cQZaCEDe5NpyBN6QYocVoaAxW4pyTHRM/rck ++/zjmvI8tsxgylvRUAUWbohrcS3qv2X9prblvaaTU4h7Z7R3ngTouMxRUyDXtT66JV3A7/NTDikr +5KLZibIui2xUTXH4gb4hNsRxCaUCAwEAAaOCAUwwggFIMB8GA1UdIwQYMBaAFGlEHxqVTqUaf4g6 +zHsSOSwfzxoxMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwuZGlzYS5taWwvY3JsL0RPREVN +QUlMQ0FfNDkuY3JsMA4GA1UdDwEB/wQEAwIFIDAWBgNVHSAEDzANMAsGCWCGSAFlAgELJzAdBgNV +HQ4EFgQUYRFS5Ebn6PC0KadICbTIhjZwIV0waAYIKwYBBQUHAQEEXDBaMDYGCCsGAQUFBzAChipo +dHRwOi8vY3JsLmRpc2EubWlsL3NpZ24vRE9ERU1BSUxDQV80OS5jZXIwIAYIKwYBBQUHMAGGFGh0 +dHA6Ly9vY3NwLmRpc2EubWlsMBsGA1UdEQQUMBKBEHRvYmFkYW5pQG5yby5taWwwGwYDVR0JBBQw +EjAQBggrBgEFBQcJBDEEEwJVUzANBgkqhkiG9w0BAQsFAAOCAQEARPxs6JdWoXUiLU7Q5XW8vwF3 +GBCpOxkUaEpmMmrWyPKMhsnhzZZcJVTcf+W8kHD4og4Q5T4biGHh/NuJz4IVzyEXT0rAiZ+hSNL+ +bGO7pJQ/kF6NvQYNUJVA7oDsrcNUmX5yZ0STwsjcuxC2sPjbpU5TxKx3Z4WYd6jatOHk7Hdord7b +k/CmrAdpaq5mSZFswqJ5Y4OHdD1tQ6R9zC4H4dLappPGgEbMynWpmEFzRKkHJlkjvnCL1TXr1qgL +JFVpU62RUqylG2bKVVkGm5dGBNL7616mvnz3qDx85hwcV3mBXjO8+793+cFREAokDyuCT/YD1OTK +DsBwJR8/0p/EcTCCBLwwggOkoAMCAQICAgEjMA0GCSqGSIb3DQEBCwUAMFsxCzAJBgNVBAYTAlVT +MRgwFgYDVQQKEw9VLlMuIEdvdmVybm1lbnQxDDAKBgNVBAsTA0RvRDEMMAoGA1UECxMDUEtJMRYw +FAYDVQQDEw1Eb0QgUm9vdCBDQSAzMB4XDTE2MTEyMjEzNDMxNFoXDTIyMTEyMzEzNDMxNFowXTEL +MAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVudDEMMAoGA1UECwwDRG9EMQwwCgYD +VQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS00OTCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALrNt8bTgG3y8T4fjkvFNzf3O2bJ0zWRpIuio5xIOBFR51CsAQdaVi3kG2U1adNX +R1pcUSsrwDZ5PvKMzPLbnKMMdKun6OSf2LXg/ahxQKFoyUKUQeh759r7x71G7u4gGOT3pdAegNDT +f5SlturU5YpujZFdTLgnL6Qhq8B9H9kzgs5A3+yD6kA7y1joz0D/Z5jTBp0vqgrxeRdIhhV0x9TQ +3iPBD7yjAUBPi4CeDwBfHhXQ6NJgxOoi3zwCFwc32FHRZDEt3laSTlj8zhGHkS3FkJDkb3M38TdK +G8Uh90xqASwd7mxCzqIqoLExe6yyhNSss1q2qNebqGH9vTvY2rUCAwEAAaOCAYYwggGCMB8GA1Ud +IwQYMBaAFGyKlKJ3sYByHYF6Fqry3M5m7kXAMB0GA1UdDgQWBBRpRB8alU6lGn+IOsx7EjksH88a +MTAOBgNVHQ8BAf8EBAMCAYYwZwYDVR0gBGAwXjALBglghkgBZQIBCyQwCwYJYIZIAWUCAQsnMAsG +CWCGSAFlAgELKjALBglghkgBZQIBCzswDAYKYIZIAWUDAgEDDTAMBgpghkgBZQMCAQMRMAwGCmCG +SAFlAwIBAycwEgYDVR0TAQH/BAgwBgEB/wIBADAMBgNVHSQEBTADgAEAMDcGA1UdHwQwMC4wLKAq +oCiGJmh0dHA6Ly9jcmwuZGlzYS5taWwvY3JsL0RPRFJPT1RDQTMuY3JsMGwGCCsGAQUFBwEBBGAw +XjA6BggrBgEFBQcwAoYuaHR0cDovL2NybC5kaXNhLm1pbC9pc3N1ZWR0by9ET0RST09UQ0EzX0lU +LnA3YzAgBggrBgEFBQcwAYYUaHR0cDovL29jc3AuZGlzYS5taWwwDQYJKoZIhvcNAQELBQADggEB +AFwzZGg9hsHuGaFpJcLwL73qDmCubPHyW42ToyNldbWYyD+XncPGErywxk6rGg5WqNvIQlYaDlNP +ldt+KdAgBijSiA+lgkeOrO7gAC2mln3eGhw839tKmLJzK5dHigr+hbFLSgZCGCQY8aaGiKbnUQp0 +pkJMcHiswhgrY1gk9jBkia6NkNHkFkw8ZyV/f7/M+3WfHXWTtc4KFNcZwMfS8Xa1Zeh9lXAde8+r +dneU6kizPzp7kh0+y1qmG1SREX9/A1NFkkO1TqdsdUpCJyaqXsL4vtK5f8ofe1KA2bkvxlJFSDBW +6SZG3GnYhBgSDRK0ZUeb0GQ3rAaRqlMTWLiCCWwwggS8MIIDpKADAgECAgIBJTANBgkqhkiG9w0B +AQsFADBbMQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNE +b0QxDDAKBgNVBAsTA1BLSTEWMBQGA1UEAxMNRG9EIFJvb3QgQ0EgMzAeFw0xNjExMjIxMzQ2NDla +Fw0yMjExMjMxMzQ2NDlaMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQx +DDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNTEwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfDs9NqPlQZIyLzCHEak+ygKEYyNVBGTUXOn0n ++Y+PH+hdzYzAxqErfGkTdgHzqr9J4fUK/mFz09r30e1IX9FocCjd+sWg+dE2g8MuPqKE8aYRbAwr +LBSA4KdNBwZKU2pHR6y1isxPG2L1soTe5zSGBaJtliW6SIZiDRTKBg9Lj5p8y3NoGWOJRZ5AWuYJ +oSB4bcFS9f/OA11Kq0b0pfUuOq01kH3WEgOtFQ2iFfnFIvE8ymiMLl3f3W7/QZ8ARd4rIDJs83wF +1WOonsWXK55UpSAgAH+3+To07eUoSIL5CyuGvBoUjdiZ70l9GxlTz9iG/QvAp4mGjPfnnahPR7g/ +AgMBAAGjggGGMIIBgjAfBgNVHSMEGDAWgBRsipSid7GAch2Behaq8tzOZu5FwDAdBgNVHQ4EFgQU +Ne8QImy6Eg+9wk0Z5M59jwVXXDQwDgYDVR0PAQH/BAQDAgGGMGcGA1UdIARgMF4wCwYJYIZIAWUC +AQskMAsGCWCGSAFlAgELJzALBglghkgBZQIBCyowCwYJYIZIAWUCAQs7MAwGCmCGSAFlAwIBAw0w +DAYKYIZIAWUDAgEDETAMBgpghkgBZQMCAQMnMBIGA1UdEwEB/wQIMAYBAf8CAQAwDAYDVR0kBAUw +A4ABADA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmRpc2EubWlsL2NybC9ET0RST09UQ0Ez +LmNybDBsBggrBgEFBQcBAQRgMF4wOgYIKwYBBQUHMAKGLmh0dHA6Ly9jcmwuZGlzYS5taWwvaXNz +dWVkdG8vRE9EUk9PVENBM19JVC5wN2MwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmRpc2EubWls +MA0GCSqGSIb3DQEBCwUAA4IBAQBjZQocyL3yayU0n2pQFPxk0YqKb6diR8t8GB0/XfqyYlhgNzBQ +5fAe3dvVo7LixcjQGQ01Lq07l929ntbj/NgWi4hWciZpryGMOIk2gCLl1goPGQHyI5ZiUA/xNnCk +o3KxsaHgRAdZZDzAO4Vx0Dn/0SWfGlD8knNKBQ4eG0aby+sRCaYkpbQW9sZeEbJ0fHYjka0O8Yeq +WkXwYUgADkc0BrDWzkN7ZK0RgJNL/L3mdqplp8LFgbrZwZseKvx2nRQpA5RC3zy1OHRMCrNwsrPU +4kigiRt+nNXpeJe4vRVKhv5bSl5l6pNWr7DudY/Vw2vjiWKDDBWN4XTgvexkoDnjMIIE7zCCA9eg +AwIBAgIDG+g3MA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdv +dmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwg +Q0EtNTEwHhcNMTkxMjExMDAwMDAwWhcNMjIxMTExMjM1OTU5WjB8MQswCQYDVQQGEwJVUzEYMBYG +A1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTETMBEGA1UE +CxMKQ09OVFJBQ1RPUjEiMCAGA1UEAxMZVE9CQVQuREFOSUVMLkwuMTA0NzU3NDUxNjCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAI4i8CP3MZv76KFIMiqly0bU8kHpFef8EF/xyMcmacYR +pVs0aLfwMDPMIsvEnJNRu3kIaMNkXJI8gKujrXI8n8woVmtFJc8kbrc96twfP3MYJcw9LEs8Q5fU +4Sf8x/Y6eftTWo7sP8TrxoZjIihxQGQ7HcrHD78qklLENZWllVabEEHDJBDTwK3zI7b6nnLOBtVX +Z418tXMU89iVz8NK8xzQnLymjbFjWnyg8sJ7MgejiJ9Z8T0j5qIrat1IfFITzgLKMFw/BTkEyMsK +Jzh4DoQ5XjXgf7BPZvmsWrFPIAbDJ0m1zGN81p37gvvSKpOK6+yn7IIl5tEa7XqrUOHJ3p0CAwEA +AaOCAZcwggGTMB8GA1UdIwQYMBaAFDXvECJsuhIPvcJNGeTOfY8FV1w0MDoGA1UdHwQzMDEwL6At +oCuGKWh0dHA6Ly9jcmwuZGlzYS5taWwvY3JsL0RPREVNQUlMQ0FfNTEuY3JsMA4GA1UdDwEB/wQE +AwIGwDAWBgNVHSAEDzANMAsGCWCGSAFlAgELKjAdBgNVHQ4EFgQUXK3htFNLKN0dEIJBVGOG0gwU +zoswaAYIKwYBBQUHAQEEXDBaMDYGCCsGAQUFBzAChipodHRwOi8vY3JsLmRpc2EubWlsL3NpZ24v +RE9ERU1BSUxDQV81MS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmRpc2EubWlsMDsGA1Ud +EQQ0MDKBEHRvYmFkYW5pQG5yby5taWygHgYKKwYBBAGCNxQCA6AQDA4xMDQ3NTc0NTE2QG1pbDAb +BgNVHQkEFDASMBAGCCsGAQUFBwkEMQQTAlVTMCkGA1UdJQQiMCAGCisGAQQBgjcUAgIGCCsGAQUF +BwMCBggrBgEFBQcDBDANBgkqhkiG9w0BAQsFAAOCAQEAHn8EjP1Iijs6XwM2ENQrCK97qbL4YaGG +LW5ir3H8N+nV55qEMyPKltKdBT15erEQ07UkfY64ESl0Y2t60AMxMZwDd4AT8IxmJtfeWgGOjncI +OO7v5anLPhKecQ7fXrTziOf5byewcdEgWFkAm3XimefHtXNL+avpbxqKMn1cIBPiyj/OZlmcsDI8 +0y/m6Hzt7ZVkk15K7kaoJziYNnX+JmHOq1zNSujhozR+i6k3hGp1FAaECYwtzxkHVEWS1LYb31Ua +26Zuy7A0iv5KldGPqghFzj2F8CB3rCqSc45MYRhnbtq6XSEtFur2QYtLBIrm0iFnstRe9KE9sjy3 +B7UsQTGCAv8wggL7AgEBMGQwXTELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVu +dDEMMAoGA1UECwwDRG9EMQwwCgYDVQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS01MQID +G+g3MAkGBSsOAwIaBQCgggFwMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkF +MQ8XDTIwMTEyNTIwNTQwM1owIwYJKoZIhvcNAQkEMRYEFObjEsj6iAKkGYHlXiO6xhwsr4cSMCUG +CSqGSIb3DQEJDzEYMIEVMAcGBSsOAwIaMAoGCCqGSIb3DQMHMHMGCSsGAQQBgjcQBDFmMGQwXTEL +MAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVudDEMMAoGA1UECwwDRG9EMQwwCgYD +VQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS00OQIDHh1EMHUGCyqGSIb3DQEJEAILMWag +ZDBdMQswCQYDVQQGEwJVUzEYMBYGA1UECgwPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLDANEb0Qx +DDAKBgNVBAsMA1BLSTEYMBYGA1UEAwwPRE9EIEVNQUlMIENBLTQ5AgMeHUQwDQYJKoZIhvcNAQEB +BQAEggEAOPK1FRTU6nMjAjZVc14qrq6poU7FLjqh5l+DVO16VYerobEakQiSlOG8ewji9FuORlqF +fZ/SK8FnYQeqPp/281X3XDiXs2rUu+0PHnyeEUS/KQt0M/a4Ezy6ZBGjb+xjyVDY63+Pw1/Ua72r +LvFTVCkUgzqSHIJtBhf2Dkr4L/DRPt9v1dYJayDPHV82AO5hfUV4uQl0YBNduSKpnh6Pz8JS6bj+ +JQnIG38xd+9BX03wtCt9wewxlp5ticU9+rQxzQGnmGPrRKGfmd5lqGA4W3jNzM9gdj5/HlczsYtm +U/SgNdtWdcaZ9nyDkEZxmcjzOg6hPAdH8O40niYsPE1XhwAAAAAAAA== \ No newline at end of file
diff --git a/crypto/default/cms/testdata/encrypted-aes-128-cbc.msg b/crypto/default/cms/testdata/encrypted-aes-128-cbc.msg new file mode 100644 index 0000000..c4f73c1 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-aes-128-cbc.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICHAYJKoZIhvcNAQcDoIICDTCCAgkCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEAChuX9o6tBsTgR2Lqq+n6ZN25Cxa1u50enJKvlXXs +5BqiQGDTTHpcRV+xN2DP0py4Hw8iyKyeP5HNzPk316td8AZ59PeJkVPI992K6PHn +qg0IT4NqRFMf7RZXtnRNt/FTqejXgI9kDsJScCobuz/paHsuiHiVMSy3/OazY+og +5EGV6I2BDD+LtU8TgAhrDkNOICyIUsdcA9j/KBBtbZVFk63bZZDeAjFa6EY6CNW1 +wM1BSBPVmthARoORFO+0liey7Gf+UUwpPZWBLnpOnCSYuLW0EeE35hipYjG4BgTR +lFarQ9ANwiRMus/z0O+aRF2UoOxaZE9nn8q9gNmE/zi8IzB8BgkqhkiG9w0BBwEw +HQYJYIZIAWUDBAECBBBWHb/n+iCAACPByNQyvDyagFAOI/Or8NCR566M2NqxqRXe +AcQROk8Bjicot0Lbp54z8XtCTjaRrbid87LIk+rItAWYUaQhbq93KU6TD49QNZuc +WuvqYsDtiMqG5GHEVIM2JA== +
diff --git a/crypto/default/cms/testdata/encrypted-aes-192-cbc.msg b/crypto/default/cms/testdata/encrypted-aes-192-cbc.msg new file mode 100644 index 0000000..51b3f27 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-aes-192-cbc.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICHAYJKoZIhvcNAQcDoIICDTCCAgkCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEAZpk721XN00FQKVPo46sm/yXTKjMs23r+VcPc2qZk +087XHg3YbsjbwCj62kAJdFbmBtPXHQBg9Fikbrds9Y1Pub8EmZg4ZbXSpkabzBLj +73DyGo4HuAz5Lw/+uAxtthGl8UePHsY7m2d/FyTgQw7dRk0AoCSpge3Ad9lD5nms +xOkgJPpnYJ+of9hPfkIfJTbPUaJ+XzUAQ+0KLzeCsxLu1bjEAeHU/ZWL82vc11L3 +xBmlg/5+ScCMkoKnTZBQfpH5JSRGg2gwbcjVF0CCbyNYwXGTuUuw1ztGXEDP/wBK +QFhxVgIAW7AdK8A4Rw8tEmo8rNJH3LAMusmbfmaMJLioxDB8BgkqhkiG9w0BBwEw +HQYJYIZIAWUDBAEWBBAQd5RkUFRU3UCbu2amDoXcgFBETNV71rk0JW+ypBUeeQLT +JlpRh5NMYvkkG0uxQ8Q6RJFNByhUC63ZXhlHUWsnP0H5mmcoEInToJz6mCakfqsN +Fa2rGuF+ng6YWeADcfsXNw== +
diff --git a/crypto/default/cms/testdata/encrypted-aes-256-cbc.msg b/crypto/default/cms/testdata/encrypted-aes-256-cbc.msg new file mode 100644 index 0000000..d64a766 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-aes-256-cbc.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICHAYJKoZIhvcNAQcDoIICDTCCAgkCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEAd+Rs73vpsjpBufLIbJFt29t6ZhWYtxfl7e/X1nLk +obRu5BISSAd1ljMDMTSvrGcFDYz95HtOQFdKUdrY899bPJ4vUzjbdiylABULvg+1 +wWUzPCcAqgQOZipqKIxiBBAfSkW+2uaxBPFPqYcHBUet+6/8V3cEPe1ecLI40p3l +3r9cTJ3MeSeDQcefAZLnqKyPVQlwvf9OuBbM/0qtymUaZymhkh4pDPwil+lt+dak +ZByzwwZypBsIyzV7ih7cBFROk6tng4X1jSIQydlDlzulLFp6ZA/RjqX2vYtiBxiW +E59uVyJoy6soqk4fpINh4R9oNXyKiL56jJ2zRZqLxinikTB8BgkqhkiG9w0BBwEw +HQYJYIZIAWUDBAEqBBCuGbDkYCP62O68MK7GgZ61gFD9Ub1c6iuvQZPWDPP5Piua +YVxJnMVpdASITGxKRfN9Pqjm3XYSwrnP4Qwb7PEusD7gLO2/dg3u2S9NEjyWB7J9 +XI0BN+4uRTyjGsVBI1fXZA== +
diff --git a/crypto/default/cms/testdata/encrypted-apple-ecc.msg b/crypto/default/cms/testdata/encrypted-apple-ecc.msg new file mode 100644 index 0000000..d1a594d --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-apple-ecc.msg
@@ -0,0 +1,70 @@ +From: Test <user1@foo.bar> +Content-Type: application/pkcs7-mime; + name=smime.p7m; + smime-type=enveloped-data +Content-Transfer-Encoding: base64 +X-Smtp-Server: 127.0.0.1 +Subject: Test with ECC +Message-Id: <CEEC8370-88EC-4031-8158-CFB80F76E3CB@foo.bar> +X-Universally-Unique-Identifier: 1AC166F1-C772-49E3-8918-BDA7687D6593 +Date: Thu, 5 Mar 2015 11:11:33 -0800 +To: user2@foo.bar +Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\)) +Content-Disposition: attachment; + filename=smime.p7m + +MIAGCSqGSIb3DQEHA6CAMIACAQIxggISoYIBBTCCAQECAQOgVaFTMFEwCwYHKoZIzj0CAQUAA0IA +BNPT1E5XAZTOFa/IUH3KWWlxa79kK6FUZ2CuQtoTNbk4Nuw0L5wjO1ERzBOkTqJjJaEdxjmxwhD3 +rxOin30eViShCgQIbW2UCcKHoTYwIQYJK4EFEIZIPwACMBQGCCqGSIb3DQMHBAibxTVubvK5lDB2 +MHQwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx +FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7BAih/SSY +X1obMaGCAQUwggEBAgEDoFWhUzBRMAsGByqGSM49AgEFAANCAATZA9kLSz3GhA73mWtqnXuDIofx +tDYFK1ToUet/jI35TPI3CcNlL00jdCdSjBwYWr3u1Clv0modfCvKMs9tCXftoQoECFtjHTZW5H6X +MCEGCSuBBRCGSD8AAjAUBggqhkiG9w0DBwQIEoCEirtZno4wdjB0MGgwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWltZSBUZXN0 +IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBAgISPAQI1YJqkSxroa0wgAYJKoZIhvcNAQcBMBoG +CCqGSIb3DQMCMA4CAgCgBAgxHl5JQlXGYaCABBhwHvugL81nVUsy9mP3hkQ6nHLLSWIzscMECC8p +HaJgOJTYBDjZ5lCIMfnmnoYM8btELNdp3eUY4cZGr/cjChOz1tBWFKvCLzBVQP0KvhnjkVwpwA3g +gro9nB2DiwQICrW2pBhtCl0EKNqs5xz66JVO2XooDB17SffKyuHpSgC9zSfTUzLaTI40Ky7fGpFg +gMcECDD0Vnr2IpdJBAjRjgcAwenesQQw6WSl2IQ83aG2fvteYO8fKLlOeQC5hw9shacJlwh+JtVb +8igwxXX5YlhzkcMKtT86BAhDPTuQVHlSjQQYAblXqRSzDEi3ryMAh0gBkjyXZEU/NP5DBAgkomut +TN1N4wQYZDycW2/AdpHuFrJs9ozFKsmF+iWvUwICBBDPUr8Cz9FAVDpAubN4FNCIBAiNsB7bnfL2 +IgQQ0c4C0FT6qArhM6Mm9kWkgwQ4ceIZ+kQOz34t4YsPXJoiR2D91ExV/ksDMlAAxbJiYw02CTJ9 +M/HL7ePWhK43Wi7VUxTPGE8DfUsEIPiyqiHMQXy0YVL49Z9k2ywJc55T999Z2MNJWNuzeOqqBBAA +Vw/BKRM+PRjb9/gGSimwBAjm7ueUe8VRpAQoEn6VCEkn1SwBJFmQxaOv8oZEIXgsKqpFNA2PEuAL +41/onENdfeBdrwQQmHxp1vFsFhkpQbHwoIirrAQgjcGUGSt3ZKrUTxqPFXU9R82GCSQs6xb4x6o5 +Mog+suAECOxfKL8DvzX0BEgnWY0daRqyB0xU0rzqeeseWgfSSE4RB28wFccxqkTTpslGGiqKiBgH +T90HwkSO60I9UlGrsEPlAq2oCDujQ6ms47Y7UPrmul0ECLNR3BNo23uZBEhQJaLNzXTYAcWteqlU +Uzjv8QvGs21I6reNyZlXWgtHc7zoPh4M8A+0A/nd+R6H2fHRUOcgvMU73XyRVgRtdNJhIMJ/ys3q +2MoEUNdXNR7YfSVIyvnp2VAd8D6AAPcrd8Wq9+4hgpTDB60cIXjgcBLZV6/EV+HaV+qqirRouX8O +ySgT5ZeCnqnTU1OxlqcRC4/lPQlaYe+ySHE2BFCJWfzk1vdrvW3Xbk8MjvK+BS9rBZ9Q8vilk75X +KyDyYKmopdpIGMOjJXkoO3+8iyB24d6gUun5yrjpbX34XtluNijKglQtLKfmIqPiiiOHhQRI8nyL +V74+qYfh1XXOFAbRvMmqtovELb1LVguKb2/u1Ss+3JVip03DFk9KfbEPyxTthstrtHasaKv6+icC +TetM8DNOI6rJhZ6cBAiKUbwJYMW+yQRI2Za1BGwPOYrl9MI+dx3rKl996+tbx0aPxnRTWMOhMPbM +28pXftHZCl/fRsn7E3MrrE0EVC+Fk2Ij2c9T3NkhxaGmansNtnDvBFBm0BkY96V6UppNyFQBntEf +cIIgsPL9VTOICmjrZCjFUmSRW6JpTYoVmy8StmSWvP1K95HOLeI+XWv+/bFroyyLhUQHiiOSQCmV +oasnFFjlUARQuZ9v/YB+7laHoCle67Vt7idJ6qWxi3jVXC6mMc89e2TSqdi6CvYuPjGA3ezgHQUX +b4l74lf5nYWGjCh+z7/eqSsL0sIOUsGa0itLLH33UPoESIt5TnrcDZX6VsgV7umd8pBGWoAAmcmx +SbQ9QPNZ5HkfwBTd/e6mdEkIzhSHlIU5ZvBRIPqaIJ+SZ8q1P6KXS5qPedaU8bG8VQQIVt0K4+aw +6roESNR1vm1jv1sbgAvTplDODldq7jPGZUmR2c8RsYu2WsEd8U7DvindLDmUbPjGCEi9fpMF/2GQ +RV14sQ99JpIc3RnAlmOWwjJGOARQt/7H5Z7EAX+aCXxsTgaLbaOTB55n6q0S7bRHn4wGOpOSKMs/ +RHUaO5bC+WzsrdVVP4yyU3u4f77o50BAB341ZQcQMJqpzGf6Sq9jQn8+fqMEUBnIwozNyVb9ssG7 +uvSFX4YoDqHvxxzcO3iihxempHwnvEwbDJuGXucjGRRf96+G1RzWMUtgoPKj2JAaGKRxLortgIgU +pe3sBF3OgBqMyexxBEh8T6B+fWszvUjvTxefA8XzLe47TljLW/SDxyu8Gkcr8EygV0tIuHTMPAtd +EaYw/6b6d3i3Rxpp+sGZFo797VdD/HRdn6D2DPMECFSddINkaFokBEjUFuXEo98EQPt1KF7ZsLpS +LkcSLrxPNKorgQXrb4HiCA/hsTK3oE2fC81kUDOF8+1jOPvIdGBNF3pIvKi6AqmQ5HQG40wOr98E +UFOdQuuUhnXd2nJ/tYldJz85dNIYtuNgWEYG1zLzoIRBj7rDHpmDfg0I5Su3NgdrMRxN9QoETAZ8 +e9em9LsRzstU+Annq1+HmoBUd/lN3hJ2BFBFap9q+v+5Id+i3nNof5bZgr/CbEPgaj5MP+4C5+jc +mTXutLiShl14O/X1/hzIAElX00wcpU4ppzGzqSwB4XBoystNPbNK/wYQojaC1M4KOQRIIQcW/LSM +T/T5ztfC4KtTx4NVsZFRRjLh9QIMu5hwGY+g6uQWHd3+x/AZrCEHt4OJEShFmeAlzRtY4HhIFMFs +gda/Y1lUEIJ/BAgBxGFCFJl/SgRIGsjPoRhsaY3HYa5WtEevLF+9vrDnf7VOWskPYqK2/DN45O4f +ZyRGzm93cnS2LWzaTcok2sxwY+vISuxIPM4xcEkWhD5MqlxhBFAM8tlQhapRp+u1EKlSZ0rc0Xqd +EV0v0IRgByT8u3b5/uzGDa+AfPYw64ecjKIrs/1RBwk2KqauEb/ExZKWCwFsVRK6xgW36W2rUM5B +GkOVCQRQVNaQrafAQYUS1eYbgaMvLbtyV/qdbYnzyovrwXFh05ak27dWaEiRIEVzo0nCFPTpE7NS +vpMWqhdSI4G5nezrED1x/+4ZXdvHqyYCCoLDvyEESHqU4KAztlgPq57DjauzhSLVnmqXd5XbljwY +uGNp3UriAwI9ci0EyM9UqHFNUEEaqe3gavKqgcwTK47RMo6knwmapCUJfjhJiQQIaI83xPpYISAE +SDt+CJUMxTIR93d56BxaXh1dmno2DyShELsvyDBl4Q0m7YALIE6MsOHJAr7JiyaZdza4Vz5Kz8gy +8kG0r1TyPqr6WO4zrnJofwRQW5NvnGAmvSV/W94QDzH7ncGx+7JOXQBeJoy6w60xA0ab0z66YwUG +xVABdZnS9H5o1vvxi36C7btlnpw3QHheVbOBe3SZBdbtPBPZkr05+TsEMDof2z+QIp2uV9hzuEC/ +Ul8//W1MeL8TMds9vW+CRgeYa1V0Vs8e+db7slztgRHc/QQI6RfZ76MHK6EEMI4vtJkUAN5entPG +4mm85gbiYapsApV5OLXKJxM85faZYGJtm7fb72dbJcr5x2WZCQQIDrAEpZ4bKCoAAAAAAAAAAAAA
diff --git a/crypto/default/cms/testdata/encrypted-des-ede3-cbc.msg b/crypto/default/cms/testdata/encrypted-des-ede3-cbc.msg new file mode 100644 index 0000000..b0e3cd6 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-des-ede3-cbc.msg
@@ -0,0 +1,17 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICCwYJKoZIhvcNAQcDoIIB/DCCAfgCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEAoyE6/1kCKQRwOYJl4jaCHwGXW0DTpbUJx5sWsS7W +h1j7BpsdGaAlvl0Tq7gvdyEfGm+uPpJ42nqelce/SchU1vqy1TqntgcIk9IxqQ46 +2KC67KbfjehtpsRIEpv35HdIh6wcLRG15abHpR62rVKbVVGca62AzwnYCaxuqlfi +FwMCdlbpNnpZejkKbi/bCVsM7zZiDRm6Kk0ENn30N+6sFRrZNvijbkWQrh6cxXm7 +V8PKke487LLgETBdGODUI3qn15fMAhAGJ/0uDkn11nXKpzVS/3QOHCo6/JN4DVtz +kLSRi11eXjE0wygAPTfD5blmuapEXJxQi2UCM8xtFUqIGzBrBgkqhkiG9w0BBwEw +FAYIKoZIhvcNAwcECC1Jhj/M4ZzugEgUlfNnY/EuNPWGnsgJE4Iqtb6I5+T0RBEl +GGC6vLQOM2CVCrKORrSuSPvftJbgbxpEAndcvI1Fv7HhCroO6XD5IYVsdDSrSh8= +
diff --git a/crypto/default/cms/testdata/encrypted-openssl-ecdh.msg b/crypto/default/cms/testdata/encrypted-openssl-ecdh.msg new file mode 100644 index 0000000..28da78c --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-openssl-ecdh.msg
@@ -0,0 +1,15 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQIxggESoYIBDgIBA6BRoU8wCQYHKoZI +zj0CAQNCAATfbqiA3E21lSCC0PGhnGEBnx00tt/eWG1FFefUNiKUm0zA1jxnJWFb +Xs0KQhqPFEtTu9U7wkmr7wwq8a3BHF5BMBwGCSuBBRCGSD8AAjAPBgsqhkiG9w0B +CRADBgUAMIGXMIGUMGgwYjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYD +VQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYD +VQQDDA1TbWltZSBUZXN0IENBAgISOwQoqa0FUqYfVJUWvnKQBmcDaUSRgfyEX9Fk +dEXUDJZwYvK8LApY7bEsZTBrBgkqhkiG9w0BBwEwFAYIKoZIhvcNAwcECKdMO7Ol +1HxugEhoVMQK6ROFxD1ZPLhXY0HzIuyzaR5aIZ7pEaoo1FEqhgq5VLiqko+5IREa +1Go2ta1JmnGbeG6kn2qvSZBqZ0EZALFXsWy4RuQ= +
diff --git a/crypto/default/cms/testdata/encrypted-openssl-oaep.msg b/crypto/default/cms/testdata/encrypted-openssl-oaep.msg new file mode 100644 index 0000000..28503c4 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-openssl-oaep.msg
@@ -0,0 +1,17 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICCwYJKoZIhvcNAQcDoIIB/DCCAfgCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjQw +DQYJKoZIhvcNAQEHMAAEggEAALuoPd9oa/XVWPUCmm1e4t9a0WvP4raKUgqroqfc +1rvZgMqB8hRO5BnqJTwQxvlN0MRII91P4K28dCXuCNXNgGZQOLzGKfQ29VjQwuAT +ktYWlwT6gs5hQeSGdg63pc4BxMx2TKpoqA70wnX6q55jwF9ncTtyOmQS3cgGOC6B +0XI5rj2GSAPGeDnMmPYm0N5GWzG6mbpyGmm0zlHKh4JAepUVvNTj+Cvdc+mGefqu +90kq1nOUSESdmkRj04Whb8UgqSpTOy9XsfHjMI50rxNT3sf4K981SCZ07QRLKEyM +0W8YlxyohG7wJllk7PpxCEChc5rDv9iS/2Yd3uhis8iQbzBrBgkqhkiG9w0BBwEw +FAYIKoZIhvcNAwcECN+m5nmRI+FIgEjBLNmFLyha4/i73t+cUeByWbH/uMExvaNR +mDQCqeBt5+ap6CGiN7eAZiwzjS/Y6nKqm04fpyCyexgUbvAd5jVfrtOVA1PH8wo= +
diff --git a/crypto/default/cms/testdata/encrypted-openssl-rc2-128.msg b/crypto/default/cms/testdata/encrypted-openssl-rc2-128.msg new file mode 100644 index 0000000..d44f878 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-openssl-rc2-128.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICEAYJKoZIhvcNAQcDoIICATCCAf0CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEAjutrCfZ61QiKlyK9H7AfXa7Lr60tyrU/y+Yq7SXX +z1gO5LB5bFASBGKSgkjQJdbRKGV5XtwzFzOY4ltfWrWKxbLy9BQdA366+R31+RM3 +qy/AWMl9WlylyPgKQJj1j4B1EjheJQgvsOIBBGrNGnlxsIblIl38ht7StICTIEpF +2pw5owIRIpldW3hdkFiMO2uytewkB3yJMBHevzn7mGPVoUaqdDxgjvDUQG4DpK6R +yEcCSckedQSF9XdaSORtdbTKTvL5Q/A47AW93N2SneNy/VsNN5VHLsm77fsZVqfW +Lm0kFoBAp7Gf17g6RiDomnT5d9n+u8NTLYmOpoLovC9/EjBwBgkqhkiG9w0BBwEw +GQYIKoZIhvcNAwIwDQIBOgQI/ewlufGbYJ2ASCL+RnVVq2oFtjObvJTijUtHU3Db +1pwKIW7wR4/hPEUX727kyowut8UV0ppHcd2NKKT5aXYSthQJ8EuGvCF8iDXLI35F +MjJN9g== +
diff --git a/crypto/default/cms/testdata/encrypted-openssl.msg b/crypto/default/cms/testdata/encrypted-openssl.msg new file mode 100644 index 0000000..78fdb51 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-openssl.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/x-pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICLQYJKoZIhvcNAQcDoIICHjCCAhoCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjYw +DQYJKoZIhvcNAQEBBQAEggEAqHO+miMwO5F2n43PZB647pUWRxBAHXbbl/RE/nPf +EZcRCxt9HVxr7EE3k9TIY3HpqWvZpzE+Aut79vlPzJJJI3vAN99j7UaB190q4n2j +Hl2kxCc3UW7N5OfGFpFQi0+VRDSPel32Rr9hlGahRzDTfUUr+YBYtSLs0CE48nRv +S0dFFXdT7Y05mMiGgkJNGQFtEaqgwguLPChl3rK/D3tDa7gt8QkIuo99xxnsEbe6 +6JKm6G1ZFolQF7MC4s3PlQThJxrAsjvPJwxNPXXEHtJPMIFJ8Rig6KwpSRDOZ/9D +/DeZWeJfXq0ftTVLsk7lKH0abXMnQk+hHwUJ8PN06/ns0zCBjAYJKoZIhvcNAQcB +MB0GCWCGSAFlAwQBKgQQsLamQGwaagBsVnQA1mS/6YBgCtD7SJTExzxtJV4cWfJL +MxAAsM4FjMHSNn1zp6hw7qXP1GdArT/PZfTprLGYKTcjewZ2krmqdzHTnyiw5a1Y +aCo2FLo8r3lOGnECVkLLAc9AG+FnYdjPvctByZitXTcE +
diff --git a/crypto/default/cms/testdata/encrypted-outlook-mac-2011.msg b/crypto/default/cms/testdata/encrypted-outlook-mac-2011.msg new file mode 100644 index 0000000..5f0dac2 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-outlook-mac-2011.msg
@@ -0,0 +1,117 @@ +User-Agent: Microsoft-MacOutlook/14.3.8.130913 +Date: Wed, 18 Feb 2015 23:25:31 -0800 +Subject: Encrypted +From: Laetitia <user1@foo.bar> +To: <user2@foo.bar> +Message-ID: <D10ACFEB.2%user1@foo.bar> +Thread-Topic: Encrypted +Mime-version: 1.0 +Content-Type: application/pkcs7-mime; + smime-type=enveloped-data; + name=smime.p7m +Content-disposition: attachment; + filename="smime.p7m" +Content-transfer-encoding: base64 + +MIIVNAYJKoZIhvcNAQcDoIIVJTCCFSECAQAxggMIMIIBgAIBADBoMGIxCzAJBgNVBAYTAlVT +MQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUg +VGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjcwDQYJKoZIhvcNAQEBBQAEggEA +koqSTKP0d3rCInHm7Imnc9kJum15rElh7jaSyHwmVP3LwbPdNVp6wOLSMbiz/G3lqJqdGpQI +M5v3EeLoqRBU5mgdhmjCjx4Pc+xo22pjvqtocwWPp9GAUQ+hCb6Zohsz6gFBhd87E6Fa36Gd +eHKAUDfRccwE9mMjOw01D+zYkDR9Wfe6RF0T7PURVN0iUvDnse0K5mPKvDxNd7xvCBMCrfzq +/Cnado2eHim7joHpCeyT5l+ZyKEiS5d2k8DmLckhVavb04vRmLGpkWfjlTSBfLXaxBtPiTv5 +UsC+B/EiIQOZtuPr3T/lbQ1NuyfQSOwR/vNYDakUcOW9Am1VaurArjCCAYACAQAwaDBiMQsw +CQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNV +BAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCSqGSIb3 +DQEBAQUABIIBAI3uwaPGzjPer/dWC29KGESjSq2jBFmvU1f/uFzOB8KEoYmvGP+mk1U8himz +mwGeJ/7gVzxz9s8vEAx68oQC2NgdgwKNRRQdQEvgS2LoPS/E8Yk/f1EGjTMOF1WmZhjWBN9j +5YBpxerxHee1wlkEgZc87LsnJXB/XAPaNggyVz679tGA8g06xHVkjXtnRW6uayZLcudcOQEW +v4rQkzVSm58Ry2G7UqMsDWtl//4hjcQp4Di1qw8dECtid9onsbwucIcHKZYF184+GUHjZMNV +aLbB7/fH1Ii0lls9U/oDjYKQQyfEZp38zd7XM6yIfgCFdgC+h1MCu85ytnAg8ZJ28G4wghIO +BgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBDYbcU3WhO6EsSNOg2mEAMWgIIR4HXUbFAIWKwk +dsCzaRbUnzs1NnTR3WfwzIysvueNkBtEDmJQz0ppMIl5tS1+4chL63U3ChQn/64otNfs1HZR +qeLq+0/TAHx++mXwSqxdnZkE+GNPNZFh5JxUdky0Y/M6jzj0lMyR0V1ahlhnoiuuZM26A6MH +Vg1rkuSqdXyKySTVIX4Ydu4lzfhyjCySbr5t0WSOqef11tEf7fMs5LEBo5T5NIVGiJRJ2NfX +fOJPFgMtHBg1H8hCtgbZzaiA42CLRT04yy9aSa5pq/rdGaTo/KG6c/EiZRJxUGXaS57GUQzb +yo+CLgi6P5jkxCQ3lt5vug93S2/N5IkEJMk9/kjvWGCaZnFv8hYAuH0MWmmJPSMEBzoewb/7 +IF+jCiwFAVj63m6YA74wgYTVPJ6iyKQI2T6FWmx2js935ORXypQUS4sWSRRmmtL+GkNs+FrP +ybWdjtafxYnBtqvlGCm5cpXlLmxav9c1lkV5UkQ2nO1T8RMEQUNJ1nDpB12nuMgdP6hjM70s +xyTlbIEDAp21QgQ2UpUpgI9rtjHNV5vty2INcN9hWjbuXs0LbTC4i0UifONoOp08ZLlH7JWn +mDPn0RwqaIdTmy3a+HZq3nRLYkL8hemsiz0tEdwW8Rzee6gZyx/ziuO95tplSPDjN1snKhIv +VM3oKJCXMDb/+BMftH8Niip1563oqNZWeLMm7NQEQtQFZQGlFfzpePqNI/gEm41cph+mx6VI +Jw45bnDKXhKoGDiLA0TC/lVJYmN5bWwzKW6UdMgJe9Z64pGJr1QQq0D2PIWCBoOaUiF/lVwH +zZOe1o7DhanWjNYl7QF9zqQ1hmXVnTVV/IDWHif+oilc5YYvHH/S/nisbfykqkGlqrXH7gl7 +wqQ45Lo/6SVvChyHv3Lmqz6gr9e0hwmhvT3NJQmtyuHcVOL4GeC+RyxSzfYCvsvwpMmlPC94 +g2qJ5PNVzBGvwzJlMpvrxmPfUpOTaHOVGM+9uxitbMl9hlQoTGJqw/KKx8UIRGa376WC9Zmv +4AHUEfvGIXtMSmuC88l4D2+V5bqyHk2qkMtcIS557jk5fu7xP2y9MqmEFR58VF/TtQFxwrSy +l9iVvGNrFwPqWw7wGPmKd4NzVM1OPEJbM1l+V0Fl22j88fbay1AOtkMlyuN74rDPZhh1+OFK +/RSOKo2kWiaM79dvtHxVVl77KFrHV92looCBd0hPWsR70T4RrCmNeX6lFPhTUftxdFMy3qGo +pDUpsbzXKxMvo4qLRs29LtWbYCKM5sP4BOpYMXhDNt5iOySRwHIJhVKPdW3jsfmCsSIfGUPV +6xbGYBtjtllBOx0k74pr5lhClOapodVHkGBkGS2i0zmI89YLuQQl2thewpjTCzZVbxVBzEvD +Xx9n3h/j16C9bW3Q0wG8Ivc5i7B5aUlrCg+2P4TDNSEtjhksruEWzAPOJlww5Whiwx2LkYH8 +T7YiTcdR6I71tMDJNCHaib26U3Nf4tJiZAnXnBh8vGch6JGoNE431w0FYviHtDu2uQOylnFs +1GK6367rEr+UMFFbMyCk0K52z0GIBNtrfczToGPZUnwbFL8EUgXcyywtWDx4qe0j487rztD+ +feTPiUW2Xag52WXuki5A4zwJo68NExovqI/dwgiRJ8oPvx6snUqkJR2FBWJxa6hAz/0OVnmM +0jOgWfrQtUIASwHmoEPFt1GShdNObDU3P3aYtafqTkQmOD/ScHMXN3+jZFv+34dSGr905C9D +aUYqmbH1hB2S123D6GbexXuOgQDET1i07qr1JttR8Id8a1/RfkYKdJq11qJ6jSBFOuSfLuSe +heBE2yWRWWGI3MjrTU0UbhS5nSYu+FObO9ijeDe/S+1RuGthPemc2M630pFeyuKPo0GHOO+p +CRl73X7J0YWh+Y3phfne2wF8MoYa2XIGGqctnxM0rKyG/WaaPkG2/VTXcexiREpWSOlwtYVb +ZCCGjf722ZtB6w8f5EAqhNrWIv6kxDylx50SBpmfNQcDFMbY40hUQPjuozlc+qsQEv+MK9iR +wJ0WERTrgWyKPXQ9LazFBJL5m67GMsx49qigqIYF5Br0jjDJ/pGXqNHkdBQyZg3BZKRjQJ2P +8teTJMs7l+p+v3qM340i5bjJ6MQJoZo9MPZvBu2d0nOZGhZNKOQ6SgrotRRwZL2NfucPlpS4 +YtswAjVobp6KsGJWR85MYEW0BCJDJFqlDVn8W1O68zqnCGGWd7Bk4yRWcS8Z4A/0GEjjJOP2 +s9LG6iIaF9m1euHseBObHCTB2Z4yBuiPAI4n7erZmDmc8hCL058MpfjsiEOs30V6H9XVuON0 +FcK4hYZ3TeNRANZA4jzariBzKcNiVh7VVTHGGSIE4hjQIoOZlxXhomdG+ugE467Op27+geVG +BR20gt1DK9N+NdW2wBY35/dbvN/8cJeyCJo5DTrP+X6t+/86LrNdkk/D5CoeI6c0Nqdr7hB6 +QPsiJlCsbC8RgWnPYmpMLkWMDySRrR/sBoVTmlBb5TrmriVO6umD/W4eOweqB+AZhzhgLenH +a2aGM0LScYS9Pc2Y5p2sznYygEApWrEWXfB08TWDcq2DfU495G/YCPLLULdN2liA7NNy2oGD +cwINKbI63S16f1WhoBkMdVCNUDWo61J+mg45cEy/32+6YZHjUPM2E5yBqzMqr64F6gI9IoUG +72YqY59P+GhPeSblDqRdBj6LoQPNm53PksZKJLqBxJwrlg4hQevc+6d+L3Gi8kNN+EVxstMS +HYeBavoFmYENzGivWAEQDNLE4VdMlyKCkaUo0BxokJJhGtD3P+ecDxhlC6DzbtaU0/5xQ6HM +lGsZaRL0HvDtUyzQ0G9TzGT9E+8oMjOBn+KaAGZT7446u9FhtU5105jXD3u8vybpaEDtZygg +f816izrA4r/k/Mxcx9OsEzaJYKVE48pub3c6mXeRT/Q7WGmAzXRh4bm4lVkjDXd1z855QVfl +wE+ShwMr/vn1ml0bd3KaMcigYbNDX3ym5bMjU1+UAEyRJ5/87b/j+4wu4RPtVYK6Nqa/raa8 +iNY1z7POdA5JhTqr0CbKbetLEkADDksarKO2/7sFKZDIstwT2+TdDPpYFV0m4Xfthfq8+mVM +6qXaxPi26ku0WVt9xYgaliiQ3T+TPb71XTfR0euxzPQdEA8ibzoApSajzvw06mH1hwtW/RIs +Upt/40IKn6/Sx6rZ7AJQ5WfdS+KgDQwV8x2HtLmjMfnO6aNGvh6T8JAC7OhmUfzV4y8TrQaA +YEemw6rscQpH+Np5xgXYayJzieaegbjX45a7rdeElnDz5aekhjgbSR636ZeNbv2EfbPB+emK +sLlfD4/+r9kRk8SUKeesa62ulKCjoJM+9IqQpjD4AEHQp2fJLIEOzWJOZzChN+MkkPlFkbKd +bX9MUfKGX1qPGKFQzr/vSMxr7K2IpG5/tqXflbcNkW/APC7qo8L7riXM6BySVVVpKYvBhBY8 +8u9CHK3bs0+DZ/479XusYhXWXY8y4MixXuUCvhLBGYqH5nKGGsOvFVq/EVdsg76OryGW82T0 +vaIlTu3acnKakQ7PGZYySwj4dzHG5u+ESsnvIL5QyPsxzXJ/yhjGxMorcuAbBpkVP8gfsq2h +dSPmVT4WFYS+BXTCDE1jAl1nKD7a7NXOx5V4n3w8bOS1uGxDBvOlbqEcJysFftsP2ypB+y8z +Rj/bcEiBH9rraKsQjdHDxSfQJKMEjO6j1bB8nwtFfyAFI9bxrqP+iBZkRjwTgsd80FNXRAv/ +BtdNyqbN/5lnp20aDLioVbrdSIMG9aaHFm6VPKQoihfI3rtv6BZb5v34xpLV/ljktG3jvGMY +a8VHHgcK/CDLLuZwF3ZFKsmRdiVeHy5Gw2ADQGmMBONiYDa4GsCsbfCidiUt+8Zmp6eosO1s +ghXFU0pztM8oVEh6zNwpk9CB0GX8vprQHCpJvbtI1EcmIdc7gZ+Qq0OaqcR6delcyFl+t+1U +9C/2VofCGB1nXBtGdfO1wmNCC5yuYtiS2RqX/ibLIc398aRrnWXXXLGs3TC9M7obnSOih2xE +I0/ytk5rz6CIut+2yHknSYtc0+wikDLfno+m9RAwfBRraAYq8Zquz6GnEmFdesIW/zE+W3uo +OzeaZpIjToonMVjSsWZ2C7nEwdQtvCIDKUOR09UDCUFWIqXyXWNsrM7fBEnzpddTtaDrCESU +4nlmPPxrl1/JpNlp2TLYfEu+wOoceZKavd9JHM4yE0687cxM4Z6dasGeW6pV3uWG9BREvq1N +F2n8ANN9OIpLWAQeOONhVqonFKGGuL7elFom7PdSFthMcUr6S5NIBNYAETt7fbRDK4nQbwu7 +VdjbFEodYTsBbbALaYxA9Ek7CRKA7vRXS+LRS51e0w8821eYLOUAWDqAZ8S+4obC/Wter7Gz +76xtj/O/1z5idq1mtBoOEeN0g2yEuhlmCH6eS3K83nzDiyBTfiEguxeOOvsCnhy+ZpJI2wck +Et3e9oqRGsdXPpFvlNEUDlEIHRTJ/MYiKjbhc4KRZKUgIRBaM/MebcIDCUrsBilX8Iz9Iw6k +13NCPdrMTYV+j0N62XiakGhfLq74zq76cdpSmBRZyC5Xb1baa0OO6KarZ3hCO/4CrDLOfVQj +2aEvffECSDSY2PEWPTvxrtEFrVc4s92CHy3E9UfwbkWAlCO65BC80ZfOGv9JmHDoLnNB90oC +TlsDYkIpG7a1WTaPxwPfcr2ODTZsHB0C3r3Syuvry/c0v7MRCRscGNKAGfKbhiprfod79n4q +BoOP/I5roQM4X5Ep+3Xb3qM9TTFq02uxD7D3SqM6w/1AAHByeyx5ET0ETkp5wBrWOkWMSo1P +uMmtKJGC/pi+uX1YFvrO6UmT+hli4aK1DTngit3PQB5nNxQkff8a7bB3mtEYnzgR5oChy79J +wclJCwKHS1Oe2gzrpITLQ+uOMyOd4Rn3bpITtBwXhI9PtLJahZ/NgCOMJVqToIzwChWwFBW7 +TFPot2PbDR8AB+A9GUUzj0fjs98zLbw6MP7FcBd0GwDi1u4w9ENUcHdFXDO4aZ2xud2aHA3Z +yKfY5r+9IcKYlya2H+u7u3GnGEj0JO9fd8ETYiNzThKmSVTKQtWULLODt/S//HgKz/gXts7P +dp885DzYMj3DlYmHxYGKZ1DX+/jr7HgOFzaWXLHKyCtMIlHStWy4+4VMmqg0W2KPWc/i5ZWa +IgnBWvwoy9AKlzuc+hmallv5XJ+mIEZ6/Q4IPz9KZ8fHzAP9s3WcNxRjx0//OYeN2faZtcRV +aTkgdjvzHvFgH69KXwE3YoP8evAzC4Ay+m5LYmz10+PRw3E4MzxoWKCdxigo+0pUQJfjTY3V +5QGfdC1/yn3HvzvLFMXyM4+ejZ7ZABAFxGza4qLCJCqVoWH1dm7DJz4OdVFeYbT6SEB5b/pN +DO7J0eFlQZ9ltwa8Qq3/AWEzziJNOW8efzQaLzUTF7Nz3CNRrcWTZlrLyOvlmBTbjg9aWsgz +HEyF3JFhHecZdtDM9bTLHnu8nXsC1dek0isT1Z/bRTkCr2QML28Gj5/c5933iDzObZeRQ5jK +5DSz8Q8HXIH/cu1BIfrGIBMWNtfBl8iUb/sgDrBNcsMHX1i+vIWgcZh/n3rv3a6Rm2WlhVQx +6iWLw5xzoGs6RdZMayw4vERU+RVXrxQo38ZUUxYUzbX41rfy054IqP4V/2Ms1t3DvWCsTjVM +q2/FJ+rM9pRNO1t+DpcZ/9CRkT+BslJYE6KJyNEeUGqMiwgfRpIOetT1KTWYmnWHbgg6R8BK +XYbpJBZAyDfalbk+KGfp0VmxXoZ0jo+gn3oPoN7CIHGL8e2pez9zwTnyMQwaXSSwFr/MxjQl +36a4Ju3WeKyAvYvqVXGPf386WwtN8UWJwO2cjh9/P0dgC+dsXARJihOwTbLsrGtWyrWUytvU +2u3CX41kLk9Qypz3iH7salb7gwPvjSr/h8X2DJYrIDbpVqBoqI06iLDp8FOKwT8l3TPhZySy +fRoaE2tkeaNkhmajk5OVQO9lbccrr+zZAULxjxrtVhm+VqrJWn5KUwgWP2tOf3HbBwqCUdlv +ch51eUnCq7249IdU2VqIs5ZtW1JemoH3N+Q5U3t0zSE= +
diff --git a/crypto/default/cms/testdata/encrypted-rc2-128-cbc.msg b/crypto/default/cms/testdata/encrypted-rc2-128-cbc.msg new file mode 100644 index 0000000..9385c06 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-rc2-128-cbc.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICEAYJKoZIhvcNAQcDoIICATCCAf0CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEA4eLEmHf/6DsHBAcRmooFcZt+N7eZJfpaZ9HCldPY +F7ueAP/xET+DX2fz4R4QN0e8PdWBtT+MN2QY+PSwiGoItIZnv8CSFiDmbZ3wM2CJ +LVGMaY9Vrm0d1jTwRd6ZXtAIQnfC5Yq9+fXS2GTJyU9QOPxj9JeMO2P+9bET8Lnl +68B5jhZeB71vJiBkRxR5B8heVHOs2Fge82eHk6dbrnjIa0kX6OUhBP3iU8VciGKm +JU2KaXJGdwDsAwixhlbrpBxvx+wuM84BrFoX+XWKGo8tjOU+lbdki/SX3ZTUIQxw +KOpFMDxfv8vZYv2j8SwctNBQoo4t+2762sZhwTPoBuInTzBwBgkqhkiG9w0BBwEw +GQYIKoZIhvcNAwIwDQIBOgQI/LSs19+CCdmASJAvygLebvz0imSv+aHAG+JFJ1cZ +wVrOyIYXv/f9Gt7Yxveqxg6jiV/qsbv9UnH0NLCFoQcMI6heWdqKDQ+/SJAU/vfW +5gkT4g== +
diff --git a/crypto/default/cms/testdata/encrypted-rc2-40-cbc.msg b/crypto/default/cms/testdata/encrypted-rc2-40-cbc.msg new file mode 100644 index 0000000..473e0a2 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-rc2-40-cbc.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICEQYJKoZIhvcNAQcDoIICAjCCAf4CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEAq6n28OkHhwvyHF16s0FCQDsL5TYhq1wPx3T/m6+v +b26g1aDhj8xtfnNeTf0lt0swJCaYCWwLYafLr5rAIHRa9h3vDvvj+1HZfz2d8dkA +R8UxnWPpqVi6awKjsHDjY4mqWowme7wj6O180+kokEVGn2/O6NEWH0/qV1t6wHU+ +Je3spkIaiao4jFP+RlDQ+K29Jjxf1iGR7NL1iYFyge7qqM2OhWmvWMUQsKk4//P6 +0HEf2CscNbWERAmuSDygD0vYHaNQZb2Sf1r3x3Yu92Eo2tHDfPQ+Q5ZC7+KYnoeZ +6l0eTzKMMiNMpUttIPbWLVW4HGH8l5d9Zg3dnsGJrSfqJTBxBgkqhkiG9w0BBwEw +GgYIKoZIhvcNAwIwDgICAKAECCpTgWHFxjWBgEifW17fQq0Ofu8CLyPRcXJhLu2l +7BPVaBrajonCxT8AngpycYGCzbmdT4IMTOoWrMQwMEC3DRVOSJ/5OyZoRhEFX5mM +ThLDsSM= +
diff --git a/crypto/default/cms/testdata/encrypted-rc2-64-cbc.msg b/crypto/default/cms/testdata/encrypted-rc2-64-cbc.msg new file mode 100644 index 0000000..cd12987 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-rc2-64-cbc.msg
@@ -0,0 +1,18 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIICEAYJKoZIhvcNAQcDoIICATCCAf0CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw +DQYJKoZIhvcNAQEBBQAEggEAPO1e04MYq0iBnQY95xOohu5Mpy+RsBkD0XVviMj/ +mCoOcnRXQfIh9WirAEAk2CRTn+qxGZUtozaM9tWguk+2C74pPuoZMJqSsmlPK/LW +q7QNO+MEn78RU4tbRncTKmbULU4MU2R8IoypSmMt9LjvVm/NqRj/90XvzGekWEgS +O4iWcx7w7zqiHeroLqejfbjUQOBM9SY2JMioYzm1i208A06cvr4RpQ6mbJxfgOla +5e/na6h/TnkE6donFbiOoNnufVb+Dl+aJ6aowkIKqxZJR8GyKFKA5ng0RWDh0cna +c8WW13rvFhElRQ5+6KYaTn2eqOPNreJMHS7OCEb2Mqs+9TBwBgkqhkiG9w0BBwEw +GQYIKoZIhvcNAwIwDQIBeAQIEYzmcLoeIEiASMBXhqZpmzHUUCeoAS+LzDAggsYX +eiW/sMAkyjOj09Uh+RbnA2J0xCYdyPOY/IHLyBCqUOzu7eXsISEAvQTd3N4aM92i +E9jg1A== +
diff --git a/crypto/default/cms/testdata/encrypted-rsa4096.msg b/crypto/default/cms/testdata/encrypted-rsa4096.msg new file mode 100644 index 0000000..bf4183a --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-rsa4096.msg
@@ -0,0 +1,22 @@ +MIME-Version: 1.0 +Content-Type: application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7m" +Content-Description: S/MIME Encrypted Message + +MIIDbAYJKoZIhvcNAQcDoIIDXTCCA1kCAQAxggLUMIIC0AIBADCBtzCBoTELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQKDAZHb29nbGUxLTAr +BgNVBAsMJEdtYWlsIERvZ2Zvb2QgVGVzdGluZyBSb290IEF1dGhvcml0eTEtMCsGA1UEAwwkR21h +aWwgRG9nZm9vZCBUZXN0aW5nIFJvb3QgQXV0aG9yaXR5AhEAvAjcPjVD8rmZ57/+zBgO4DANBgkq +hkiG9w0BAQEwAASCAgAhAt/dzPIA0ny03QIVfrmDnygycDYue34QTUaisCC5tc74Qc4KdySPsfrc +Vl33kzjhmf1Xelj4GobdQSle5+nnBeZYrprYMlaEcmaXHhCDtHaPgvmt9PntyIrbc6Ir3hi5oiXK +bl5xxaE16ruo76NzRONrjpe0hTlv1akS+2csHRmFb5fM0kq6rklhcBIWk3s4G0Jyrwi8bH1xNiiR +wQ6SjvlFx1KCnbh9oX1KuFNMI9rDEg63uvg09lQkZvHYLdD/SMu8oChcm7mNDvvUbR2YQ6qQ5Z06 +URcxBGZzcDXTDdxNmc8RNioOBpjtjKGM7KP0oGwXx1a8mGv3QkH5Ta5prY/BvrGISPEPFxHLXk6G +sXoX0SdM4yRzPRhZrDKq+ycdp92qbl8BKf3+BN/kQ7Ld42SjcYqdUepownwAI5fKpCxi9vZ3EiOT +dtB4/TSi+CFjHYik+1cfarK8E0AUf7o+OUgbq4RBBkytfVGlRvmyKVciRxL0MGnV3kyuxhBHR1Td +BpN8J4BaeNgAgFGo59R5WesFmH0bhurszpKu3pVqW1RhjjSFSfJXPm3JtJ/f2nC/QlVOv17jp9J3 +qCEvYyYBMfrT2Ep6J1/o0+5XG52KrrWcHTY3GZgNvtPGJ32ChjnVih5EQU9gMm60XSME44vsman9 +xPEa64qitq6yo2FxVjB8BgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBAmlRrJdbBKj8Oa2Zj6qoml +gFAL+8sQV684PH06VDavOhXv4/WGnk2Q9IU8eZ5uVBG8cnXIi3+Ro2cle3+DgcORYT7PJRrqfzcR +w3E7IMbPrs9Hy7+qqs8lX+Z9Vp12Q+VGIw==
diff --git a/crypto/default/cms/testdata/encrypted-thunderbird.msg b/crypto/default/cms/testdata/encrypted-thunderbird.msg new file mode 100644 index 0000000..bdb6cc3 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-thunderbird.msg
@@ -0,0 +1,41 @@ +From - Thu Feb 12 14:29:52 2015 +X-Mozilla-Status: 0800 +X-Mozilla-Status2: 00000000 +X-Mozilla-Keys: +FCC: mailbox://user1@localhost/Sent +X-Identity-Key: id1 +X-Account-Key: account2 +Message-ID: <54DD2960.60809@foo.bar> +Date: Thu, 12 Feb 2015 14:29:52 -0800 +From: Laetitia Baudoin <user1@foo.bar> +X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.4.0 +MIME-Version: 1.0 +To: user2@foo.bar +Subject: Encrypted only +Content-Type: application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7m" +Content-Description: S/MIME Encrypted Message + +MIAGCSqGSIb3DQEHA6CAMIACAQAxggMIMIIBgAIBADBoMGIxCzAJBgNVBAYTAlVTMQswCQYD +VQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBD +QTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUwDQYJKoZIhvcNAQEBBQAEggEAx+kVQ3B4 +enmA5g5/fqQVK+k9IkdRjWIJHfIgbQqxISXDXcB4dOdbmzZfxQ9x8/zo51yqxC7agVF6mjhE +/cJw2Z/3dGi6w3Dcu8zU8++oWahwZLeez9fthXCR4VDjZIU5QqxflkTfAnilmE+YEpSMTiKr +APkxRzzI8qCH0i1A9cjBiBcdaCkROnbwuYz599Z6lr1fVnworj6257aJzkzl6HjWqYCLPyPL +GSniKR1jw6wN+saa2FIlkwtpT0AjpjrdpLW+PMhVTlBtlzzPlFARBSz+BOIe2KlJua0IEKbz +w5SpvzSkOSPwjQd8e4xNHnJrkNDmc/vMqOR/H85oLB3x5TCCAYACAQAwaDBiMQswCQYDVQQG +EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt +aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI2MA0GCSqGSIb3DQEBAQUA +BIIBAINc3XqeWpJUHQ7Rjmxm3JUoo60LbTQZPaRkYGeYyKh1skr9puXfzxgab0thYPmDy9by +lkvM1mvKfRo0XDfvW+7vVziIh35aDAlmlma+wRHaZuRvPDY9bLzGnPZ61s5uLzXx6FXUp+tp +K748+qTSpQhy0v9TBcuK1gu4lHdhunJVKJrNxD+2iKq6qq1lj3y1qlglEhdghFD1R6lCWfH/ +FU1X/cU0ZBiauhaRzA2Hcr8v0HdavUFg21Kt5oDPFHL4cUCrkO/mvkiwF5rML0ddqtdFr6n7 +TqCbgIspR2JGWb9nCIUsVeU1TJ4uwWLy7rjSDnq+ZMBJbAlfAuPslcYOHXkwgAYJKoZIhvcN +AQcBMBQGCCqGSIb3DQMHBAhaDVYjoeekoKCABIGoEHoSw16nwsf/pCclaGkkP15haT1qSww7 +whaMl9Lfpk14rgmkrLAUuZyX86YWkzZCD/JebAzOwd/BR7W6dRfHhucQapEBRzNLa46Yc2JM +g5BRonzGJC77ilxnc9dLe4dIGJ+Yvx4SSrp981yywc9zv4sSZ7V8Zz4xGUBeKVMORKdsU1NU +DtiCnF3FU8tvF6l/Lx1EtTjKTd0PfTTIzbO8eyP90rOA36oTBAhPqUMu+OE8tgAAAAAAAAAA +AAA= +
diff --git a/crypto/default/cms/testdata/encrypted-with-originator-info.msg b/crypto/default/cms/testdata/encrypted-with-originator-info.msg new file mode 100644 index 0000000..9ccca47 --- /dev/null +++ b/crypto/default/cms/testdata/encrypted-with-originator-info.msg
@@ -0,0 +1,191 @@ +MIIqVwYJKoZIhvcNAQcDoIIqSDCCKkQCAQKgghONoIITiTCCBDYwggMeoAMCAQICAQEwDQYJKoZI +hvcNAQEFBQAwbzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1B +ZGRUcnVzdCBFeHRlcm5hbCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwg +Q0EgUm9vdDAeFw0wMDA1MzAxMDQ4MzhaFw0yMDA1MzAxMDQ4MzhaMG8xCzAJBgNVBAYTAlNFMRQw +EgYDVQQKEwtBZGRUcnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdv +cmsxIjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3QwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQC39xoz5vIABC054E5b7R+8bA/Ntfojts7emxEzl6QpTH2Tn71KvJPtAxrj +j8/lbVBa1pcplFqAsEl62y6V/bjKvzc4LR4+kUGtcFbH8E8/6DKedMrIkFTpxl8PeJ2aQDwOrGGq +XhSPnoehalDc15pOrwWzpnGUnHGzUGAKxxOdOAeGAqjpqGkmGJCrTLBPI6s6T4TY386f4Wlvu9dC +12tE5Met7m1BX3JacQg3s3llpFmglDf3AC8NwpJy2tA4ctsUqEXEXSp9t7TWxO6szRNEt8kr3UMA +JfphuWlqWCMRt6czj1Z1WfXNKddGtworZbbTQm8Vsrh7++/pXVPVNFonAgMBAAGjgdwwgdkwHQYD +VR0OBBYEFK29mHo0tCb3+sQmVO8DveAky1QaMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTADAQH/ +MIGZBgNVHSMEgZEwgY6AFK29mHo0tCb3+sQmVO8DveAky1QaoXOkcTBvMQswCQYDVQQGEwJTRTEU +MBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRydXN0IEV4dGVybmFsIFRUUCBOZXR3 +b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBSb290ggEBMA0GCSqGSIb3DQEBBQUA +A4IBAQCwm+CFJcLWI+IPlgaSnUGYnNmEeYHZHlsUByM2ZY+w2He7rEFsR2CDUbD5Mj3n/PYmE8eA +FqW/WvyHz3h5iSGa4kwHCoY1vPLeUcTSlrfcfk7ucP0cOesMAlEULY69FuDB30Z15ySt7PRCtIWT +cBBnup0GNUoY0yt6zFFCoXpj0ea7ocUrwja+Ew3mvWN+eXunCQ1Aq2rdj4rD9vaMGkIFUdRF9Z+n +YiFoFSBDPJnnfL0k2KmRF3OIP1YbMTgYtHEPms3IDp6OLhvhjJiDyx8x8URMxgRzSXZgD8f4vReA +ay7pzEwOWpp5DyAKLtWeYyYeVZKU2IIXWnvQvMePToYEMIIErzCCA5egAwIBAgIRAOAjyxUSg1OJ +rWFuelRnayEwDQYJKoZIhvcNAQELBQAwbzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0 +IEFCMSYwJAYDVQQLEx1BZGRUcnVzdCBFeHRlcm5hbCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRk +VHJ1c3QgRXh0ZXJuYWwgQ0EgUm9vdDAeFw0xNDEyMjIwMDAwMDBaFw0yMDA1MzAxMDQ4MzhaMIGb +MQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxm +b3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNIQS0yNTYg +Q2xpZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCJsQ3aelMZTnBSHbxWpgYmt7hJ4JbnUavx8FoTSRWjtIwbYLx6UUKn +eYykIt8XYU6R1XYjChTTSgJ/th0JgG6lBD3ZursW/qGHqS5DUkMWfK8yUMimT1rpCNjPkyWce4jo +MGTmpPhWgP0qJBQzF5msROVpi6NGBkvCM9TpQJ8GsLGsk0C5tQiTOpwqU6MQ2z0gYTxVA47ZTnYl +AiEp+qN8cXZP7uFfgen7VIDbw3s1UreE3iI9LDAtMX9ZvVI3sDNpLUPr+tal8Zd3Z1GM2e4n67yl +Bzh2jKSpOP/fjPUDrEm+yvdzmToPMquclToTPQ5GOld0YVC+xkA/y+Tin6IhAgMBAAGjggEXMIIB +EzAfBgNVHSMEGDAWgBStvZh6NLQm9/rEJlTvA73gJMtUGjAdBgNVHQ4EFgQUkmFrguGioKpP7Gfx +wqP3tIAAwewwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI +KwYBBQUHAwIGCCsGAQUFBwMEMBEGA1UdIAQKMAgwBgYEVR0gADBEBgNVHR8EPTA7MDmgN6A1hjNo +dHRwOi8vY3JsLnVzZXJ0cnVzdC5jb20vQWRkVHJ1c3RFeHRlcm5hbENBUm9vdC5jcmwwNQYIKwYB +BQUHAQEEKTAnMCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC51c2VydHJ1c3QuY29tMA0GCSqGSIb3 +DQEBCwUAA4IBAQAbKm6sVcE6q4jF2O3NVfOqa2ErwAkQI5kPxWZqb7H1tLV3Xg8CYQDffQX+ErOk +gIAA/PsdW2pyAgpBvAW6wVjVJsLq1U2E+/6CmM9YG+MiY5xS+LsFNqt9WKXeqztj5drVc+/s4Pt7 +4qP/8EIjnMq2jU0+5EsYA7KoLdTYu0JLkGmFENumNzToe+ABEKWcyjrHn0+ING6KZdAairup3MrK +NtH0/MJkKTWv1rGncRHSA0Oxjz6a7J4yU/R2ksqGNAe5LMrmHErYmQ3BhuKQkvtaQmojIRDpZcf1 +1bt+6oyFIAJi6tE6ByxZxZkz8jiJ5bbpFnofeRT2ShAaJvp8ivubMIIFOzCCBCOgAwIBAgIQTcHn +SWxTIEBd+ZpWfd4EbzANBgkqhkiG9w0BAQsFADCBmzELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdy +ZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExp +bWl0ZWQxQTA/BgNVBAMTOENPTU9ETyBTSEEtMjU2IENsaWVudCBBdXRoZW50aWNhdGlvbiBhbmQg +U2VjdXJlIEVtYWlsIENBMB4XDTE1MDgwNjAwMDAwMFoXDTE2MDgwNTIzNTk1OVowIzEhMB8GCSqG +SIb3DQEJARYSdGVyZXJxYTNAZ21haWwuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA8+67AQPfRjgxQRFhj9y6Pt/rkHBVNP1mSDexk2YphscvaWSWsgoSH5M17BZsOC6Kqsq4KmRs +TuzSFusctlJ3v8z1tO75dUy65UjoazCrCOQCx5N+pwlerFSbyM0J3kLf/ynKs279pd39T343qszn +/5P7orJ+f6sPRpWjnOphfXm6vrZSwCphECGtJL4UjBNCRAz2U2tZwhr2iMzrS0lQHq6y1ZFQiaII +ErjWtm1soWqHIgH/Ngp/uLNgdeI/YY+qxd/e8OyG6eS/L+VM0l5qPbm/cuw3tSPB7qWIuo+kNIQv +4pmXhRl13nPTI+jFqTb/ghLpgHlHxAZQreBUF9zHnQIDAQABo4IB8DCCAewwHwYDVR0jBBgwFoAU +kmFrguGioKpP7GfxwqP3tIAAwewwHQYDVR0OBBYEFESuiJz/WV9Y5OfCdn50CZwBlgY7MA4GA1Ud +DwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMCAGA1UdJQQZMBcGCCsGAQUFBwMEBgsrBgEEAbIxAQMF +AjARBglghkgBhvhCAQEEBAMCBSAwRgYDVR0gBD8wPTA7BgwrBgEEAbIxAQIBAQEwKzApBggrBgEF +BQcCARYdaHR0cHM6Ly9zZWN1cmUuY29tb2RvLm5ldC9DUFMwXQYDVR0fBFYwVDBSoFCgToZMaHR0 +cDovL2NybC5jb21vZG9jYS5jb20vQ09NT0RPU0hBMjU2Q2xpZW50QXV0aGVudGljYXRpb25hbmRT +ZWN1cmVFbWFpbENBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwWAYIKwYBBQUHMAKGTGh0dHA6Ly9j +cnQuY29tb2RvY2EuY29tL0NPTU9ET1NIQTI1NkNsaWVudEF1dGhlbnRpY2F0aW9uYW5kU2VjdXJl +RW1haWxDQS5jcnQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTAdBgNVHREE +FjAUgRJ0ZXJlcnFhM0BnbWFpbC5jb20wDQYJKoZIhvcNAQELBQADggEBAECTaBMbOoFKVQyHLsYN +Tx2J+WI2LWMcZhPBixMFJHLVu9Os6cH43Fru/44ewy/CJVgxey9L6ti4MW8LuTrVnsC+IGn3Cx7F +hYEF+Kukxd1V06C9n/SYp1pOIQkdvwwM/7GN02Bcr05tdwfFbYKn4WLQsVxsiuFpI7HssqWSf0fA +eioUJXKK32P3hvPEVkhXGIkuXE+sA0yCGVZMO7rxWB4dkqTAESM2K6zXX5gArghjHEg/gkQXOZoM +arC1VDcRWGxL/kiw7hgpue28DJypf4pB9IlvnZx7bpYWRHPYHzftNt7wi4rBSWUAtLQLpsOEbMte +u7x7Ir6+cdUBR9RlZ0EwggVZMIIEQaADAgECAhBvnurZBa4WY/FPP6LqFfSUMA0GCSqGSIb3DQEB +CwUAMIGbMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQH +EwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNI +QS0yNTYgQ2xpZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EwHhcNMTYwNzE1 +MDAwMDAwWhcNMTcwNzE1MjM1OTU5WjAyMTAwLgYJKoZIhvcNAQkBFiFzbWltZXQzQG5zLWV4MTMu +Z3Rlc3RleGNoYW5nZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDETQpKrUmo +ZGbNiJCN/Pjhj75GmgVt9h+YE5gW1A2wgU5Ofr1FCKU8qeJezakF8GCGChSoHR4GXNnoe/kExYpk +uRBFwXC8Mfln4mGE6iqriOLRePip2IyHf+OBwmVQnlT6CcaG8zI0ul+DwFgVRtwhIXDJrbl/O5V0 +C6UoBXylj7y4hq0+nlx96e1Bh1WHg/cyqjCVQ3FTV/9Ogp/Y4cDNYLISvb6/OA+Edef8JzDuh4RH +Jf2b5cZdS4izS4368c7Zwv9sQYrj3Tc/CplrkrCB9UYfUJqk1qqcKv+0f+re4r6AsLErveri6ASB +YXhHVCSDbKmy/YarcGqccXFk4c1tAgMBAAGjggH/MIIB+zAfBgNVHSMEGDAWgBSSYWuC4aKgqk/s +Z/HCo/e0gADB7DAdBgNVHQ4EFgQUf76LpwQqReGFsj17w239gNIMdrUwDgYDVR0PAQH/BAQDAgWg +MAwGA1UdEwEB/wQCMAAwIAYDVR0lBBkwFwYIKwYBBQUHAwQGCysGAQQBsjEBAwUCMBEGCWCGSAGG ++EIBAQQEAwIFIDBGBgNVHSAEPzA9MDsGDCsGAQQBsjEBAgEBATArMCkGCCsGAQUFBwIBFh1odHRw +czovL3NlY3VyZS5jb21vZG8ubmV0L0NQUzBdBgNVHR8EVjBUMFKgUKBOhkxodHRwOi8vY3JsLmNv +bW9kb2NhLmNvbS9DT01PRE9TSEEyNTZDbGllbnRBdXRoZW50aWNhdGlvbmFuZFNlY3VyZUVtYWls +Q0EuY3JsMIGQBggrBgEFBQcBAQSBgzCBgDBYBggrBgEFBQcwAoZMaHR0cDovL2NydC5jb21vZG9j +YS5jb20vQ09NT0RPU0hBMjU2Q2xpZW50QXV0aGVudGljYXRpb25hbmRTZWN1cmVFbWFpbENBLmNy +dDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMCwGA1UdEQQlMCOBIXNtaW1l +dDNAbnMtZXgxMy5ndGVzdGV4Y2hhbmdlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIYEvm8uBVaFq +1xqE3gnuiSJDBodT8KsYklOvyDhskOuEsQlkX/kr7fBF1qB0a2tFsnuDJWo7MouEoPfQY0V+frtU +S3E0T2dfICCamqTbiSupz/8oc7ty0aI3joKqCeec8h7oFDPwBRllqJ7cSw4rU4kiUb68UU3g95DJ +NuLLNaEZPPjf8fQm5IB24Cb/+0wg4MsLQ6wmWu1lQRKvqCpmLXLXOsmkkO3HEHd43RifkMZqKU3D +DziRXlddWSGo3CdYeMY1zw3iPVEU/yBGq/yYyyVOGByK8OC7/zSPVX9YFVMHKmjjC13JYHFEzpwi +PkHhGe9MhOnqcuiR927lZ7BfPDGCA5owggHJAgEAMIGwMIGbMQswCQYDVQQGEwJHQjEbMBkGA1UE +CBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8g +Q0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNIQS0yNTYgQ2xpZW50IEF1dGhlbnRpY2F0aW9u +IGFuZCBTZWN1cmUgRW1haWwgQ0ECEE3B50lsUyBAXfmaVn3eBG8wDQYJKoZIhvcNAQEBBQAEggEA +yCJHZXhzJNlkJbXYj2xLorWLL2u4so4+e9ctStKO3JX8lVG+OmrdiFlIwEa0cerqrKokjhsEeXJy +sXctrKdD1ObXVJ3v7qke4XrGgv9Tzvnt3/46UWLlPU4vix3Ii1f2kCmlnI9OBbdfuR4mIth2rqcn +2JS0yFjzw+VLp3pmy065szsvQsEw4eOaehFKyNK314gUPiFqEADh8T4JbPnxDp4X7HB27tP8L65o +hf6heWa1ecengtfXKLuDDL1vrHx2Z3yKjjV/iDo0VcSTBXBoyzP/JG0BmVk+lInOV/GmItwypXBv +oZsylm5a94vLIqUQwB/LT4sUt3r3+55xFmiMBzCCAckCAQAwgbAwgZsxCzAJBgNVBAYTAkdCMRsw +GQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcTB1NhbGZvcmQxGjAYBgNVBAoTEUNP +TU9ETyBDQSBMaW1pdGVkMUEwPwYDVQQDEzhDT01PRE8gU0hBLTI1NiBDbGllbnQgQXV0aGVudGlj +YXRpb24gYW5kIFNlY3VyZSBFbWFpbCBDQQIQb57q2QWuFmPxTz+i6hX0lDANBgkqhkiG9w0BAQEF +AASCAQABLaEoK2bv2KGFMGtNyGd9xZkmIUnYS+0JIymsVHUHNqiNTWbPtTrmxsIUPaEfOAQRckMR +TOlhiz8vgupyHjIxFW3toMQoLzwag+XqJrTkwpeOAe2hZqwBbk0o8FEsdnRv8W6Kf+PxesOphwNP +z8dEzBwilcNCRWLcYTuT5KoqV6GVdjpOuAANWxRfTklorCjsjRcLVxe7QCurUTbccSTNrhmbZ9yC +8kGdQQs1d2VMSQHbTvkWeVV8O1zTPyKjOJvMMiSIJCXRewA0wEqdO4PuMlokzlFIua9uVWhHXNYl +dQpvjDGEmpUkeVg8y188ZWY2pMdZvSlQJvdQvpV0Nao3MIITDgYJKoZIhvcNAQcBMB0GCWCGSAFl +AwQBKgQQIi0IuEwxtQGvHvt9pOZOrICCEuDnEHbW3sNxPqw8HeZ6GF3T2viy60WP3USK5m4IrLJv +4m/oZnKGXTy/QjsQGVbKnMfCzhtiaGe/GKEER95DTgC/TcFshSQGFkWJrQXL2slmF9bfCYy9UF9O +tUHZdM+MD2qoQuxgExDkbTo7WsDekDIq7FDq0vFOQY1e7VczauLCP5AQm191u8dAvpZPzVl6pI2Z +fZco5eolPo/domjsOfYhqckKnzcrNHD4gS4LjhvgwK1IghCnQcxeseTPj4yjQXQQJIFOiYLEcNDB +dL9YtFo42hPmeWZj03ePY+o/iecG1KKdQJxlnjNxfCrdbwgq0hUzYityUyx/2rx1cmP7KpOKTCIZ +PBTLAN5XT5+LucGHVDUvGwpmJIh/xoA9rAk+dnG4O/OVwZ+5JBfkEGcXDNMOdtjUqP/Y0YVG5hOz +KjSznW99DPONaCRHho9LtShpREJeNmAWqflShutSdy2uMDf1anbTO2RVkpuycHHDmtSxdjflr5lO +CARk4n2MgitjdBSY4VZfNWj26DBbOPlNAEodTMSR0DEzWrPmQ+xY+2uhc19ZD9o4ksyQ+BgmvyFC +JeDv/FqnsFRTvphMl9onVjbyW2I58dU6WLSZlkZjzje4Hdkhy95TDMNzTueWGGIUCs9a7ebaabut +7+V4sw6ljj3DfpevP7jLydnog9pGo3QxFFJCYnGlreB41qhU5DwzokuSgoLFAVROEN4hHnOPEA8S +7GwtbTt/U1QGi44k4Rc8SIBu6EuYw1vgTlGg1BIyQ9PraEG/O7QwKiexn4CKQaAKRv/wWZqgz1Op +tDb+mbEnerZj/hwSjGPhmASIfDUnxJXcuOz9wqLTwMTqQ4DuHRmxLBF6Ev9m0JenBTtw725jN4kh +GgfolUCLwZIK+EvkG1Imvf1Y+9/hCYttlDoTIzi+hAndStsov9iKCB261HSTgVXlSjq+vmL+l9gs +YhY0yq4ca2YRuefDI/9S0XXoIccco14ksKnh+WGEmEYtSiNJzn2Y5srU75J5zMogpKqYppBLpnqp +c8TNNwVmhr6EJqXuyGBUg3JOtBB0FPyD3TGKvcS20TbPrUaMFb1+ZB/RJpAwaQM5N6uzdIn24XfX +ErpJ6TpVjHxizG7UNbb+q+XWpO7EYmIhOj2bvcikrJZj6O8aSVrMuNwlf92l0UjApkZ6u0yMJuB4 +SgKCuoaKrVaNLuz0Jm/kaMVjaPIW10hbgNVWnMpSjXqUOBFmScH6wg0ShtvZ2HEML5yx/Z/tmzXR +Y9e5R/fSg/vo9BDPEiIZC0y3fuCC4pg1X6Iix6EjAgideMOBeJH1/ym16TrAPaouYuGeQi896xWJ +zj9eukaNsxxWsf9gHFvVguX/7XW9UwNU+BM3DypMrxmAnDioY5g5NrXoi4b1yflZc5biTd+7ysvq +Iv76GgJcxwhJMYaBZ1kCxCIZna05d139XzQ3Q8WSZmSEY20zNI+swRl58/N5/xN3YgsKd3ISHs1a +4HSFbDI6g57OgboGtdtj5CWz148MyBGBTPC802IUkGfvVCWyPaFdfc4pwKDvb3aZ43Y9PX3bb3cs +Li08sKslurE1DWhxqXQlera5qX2QqHAgtp8AfxII1HhwXAbHqF7e9xiyibWkdDBJts4baiFIavlx +3UhYOgCi8KkqLXMPHavoSFcvX1k7rW6Lcp48EfR8pWA8EZ7QU8NjdoWmP6XrRCW64WYt5uCEOM6M +EGYDSJz3hJ4U5X+QHrc+A3MkkXvNXox8kByBo8g/uaX+GsMD+EA6PEF3gIIH/bswC/zB39z3tjM8 +8v238MwiS2+Qhg2TG9/xKMuWoVUqJbxKU3n1aJFVUUmt35As4O0BqvPwv1nQCdtEhd20NUMNQecM +eijwE64ZbuXqaexG3Amq/DbCzUbDpfYkdEvS4dcDTPK8htRzAHKRupXZzBIqgBvbf0I10eUU+/ZV +uX0XwWecSuBbyS8tc+oEA+P+ojNP8GlzN8JKdskWeDLilU+JaMuplea5acLUMoRZVPYk9zjN5be7 +yIZOh5pqyEMDFOG4BdpEnXX/akhfyLk4LlE8Svc/gUofP9N3deJi+l+x+MniuTaP0Q+eVDjQWbrL +hJnkBK9Dr194dCtEo9ncC03ff9Lmu/4QjDoQRzcwzocpDwq95N4XCvZgOgjDUSdVshCiedrClF/F +lpFNmUhIRsy+3TcWDESvv6DnoIctWUoF4gH6G8KKfqC2/6KZjkmXshGk/S93y++pfYwhaO3Qjtpt +dGvpeHYh+4YaKomrral+oaDca4+ZQCwYnwke3vKItOgHQ9jCh6jbJyExGYE7eIVvV43i5x0r1XZL +rjyFpJ6SSUuId4I4iW5/2+hejWQJfWGBoDiDvDB3VRYps1oT96+bXib0p+AC5uD6Ak6N473E/VDh +G5++nnfPPkETb8eOqBP5Mvqs3qe49+VE8U75Uw0Up7JT80JNctIVmnlshBTU2hmZwOrpOIyTvOyd +4Z4En311ZttqbVmTYU8Zsqj9sEfbr2Hm1R4L96y/hAcmJGmz4itgNmjkf4PC5EVAaunJOTS/wAu5 +61KiiFyuK6/urbNJ38tQSjZey1LMaPl9Bd9yu/rswQi4wcS+mSMTzazAib+2pqHvXS8he5oeiCLv +a0TfqvSowLFk3sbHHWj1GbFvLqP6r6aw4V+LQnkU+hO8x80TBykqEldFOa8+j5S7mO4g/W2Y2zpH +CMnMp9J+siQrEVgiKaPV84G9INagFRA/rfLKAFOBD5lclmzIE3AqYWjKSBAzH1NXlcNVZQe4RkdB +kyyQzlFmTcwIUBQAExNwzUBwIalBuondEHiJzu1aQKrmfBeTQLZAio1J7PMFMuypWod+E9qdNLLa +Vt/OOMJq+0p8KkoOc0cTGneDHDpxQGYLFpmU10FcvvrfnxsohRw9lIaI269ZDsAvKHKy6if5Iisw +GiwIUoohxZNyOPpSL+Yrm63tSlGQJ1kyB8aKemVhLFiFzqiN+ySGmCSnuNTiTlG6kDn23lsnQXpU +JwCURDNOmKCWN2MPSqwhDppVy/POQzxRYHHnJimhURNxVO1wbBCKOJQJ0sZASzjtPEiTQ6+n6j57 +QpQIxuX70uLap+7zrGtxySIffjmdIT3kV5ZUF7/acqAcjzN/ODJuDa7PaLKiqM9PkdlH9JR6uV9p +ANsno/S8enLqzxld4g1LPacwJAs7M+t1gqD/frk4QYEZ1lrCrQ4ydYY+jQur1Vr6VhRqSdzVyAh3 +4J69yoeKlo7X17DOtbScpF9DUDVVpBpbERuarEtPPqRZuh+un6XVpTCmX68tayakQg373XsOv6aG +zVg/GsWo/LFeTqLB/MbE4hLmL0WTtbeTbKE6baqxtA61mQ/pVzMoCBO/dzhBBeIpNa0Xz9skeriz +2b9K7JfsBdLwP1J44j2DOIJoUc8N9L4ixkYIMF2O3QW9xkS6T0lwAiRdgCwbkfyzu+mxFfKPPSOe +OQ3JxJGQxT4Qb8K/ofzOD9Css4D3CT8GLEEC00FnLb7+PWBGr/5Xfq4iP6dpiNZZbpyyYdo7Crqi +VfG/rr0UXp3YT/12MA0rXqMqDiTZuTZmKmPhMdz8ZSxrssKpkshaRWfE9EY2Q827CYeWDYl2hedB +IExVVgNXLx8icsD/LXV60qc5ulnFGGin3/NhyWqNYndkOfK7x9qnkMMFwrZ8Mc4c9PmGcXxp6AM5 +APrr+r3EXrgLTlOhWwC9rllveqhQmABhxdMieXBc1JdHhsHHJePaTUucCZHTlXI0iJDo4E6KSd9b +ohGvoFjK8sgubSomFXl6MuIocJEm3tZl9yzrvshVjfWsfPerDW/SHIZ37aUwrr7WSLDE739wPiiJ +qEzo54vPUCdm1GtD3BQFqqv+nucLujCm8sZkk+gW/0/NoaP7OKedav5ZRFkYjBjl+44sEMM8iyyG +0lkHQf1rakq6nWTqy/mIJkrwOmII2tAvC/12VdA9KvWQoXKA/0pY3ndZMr2kQiqd1YL2If8ITSCO +dV7mZ6aAgEFJ3BmxxSGCdF8fs9LJpvNlD4i+VXqwuSOM2tbOmftGJBIUInEfeUN8ZWppfE9Q7C4/ +PHwgqaQ0tb1C7TvgxcLfYN6C8kgpGDuP3deibcOeR/1/wDHgrkdzufktu6nlKbqzy8KgcoIIuWgm +NCS+XtI8x2apX3Xz9XASWfqC1Dgtt69bnfBv+zyikrKc0Yb0kT7qHw6D3vjWXmVL5/m5GeLFkFmU +9YZumpi5pH+BqWVHdIi2nHdDgyo8KsN0n5CJDiuaNzjzIuXwBGvE2TpE6PxlCV0VS76oPJ0h9mE4 +M/sSRpEkrLM3Sg7bB58y/KCem5rwHAM88cjDU7h/hOKXWlpxhjX3LyHJExEArtyicIrDM+abbm6t +ZzKfkYIQ2oTFq+wQSEI25qkjSfW6yc54zGDmgGc7QbMTERgKbq2jQLyICT3SIYvavhcdxzaSd+di +6Wnm49j9BUuieQGduHNHXfniC2jswFpMr4COOqTrHPF9rGMMwbWKYKrF3i7ICtqoX8Ka2Vxi0fL/ +9jP5UJ7OBFmVIBsBI/UUW0evCOn5NU1QbhSY+gkOHaR5QcXDVupWSeyWIhGXg8BSwfkQgXP9AMfb +fkhwIo1RYQ3n+x8jvtLuS52Y4z+AKX79tLwi8viYAJ/VFd4ypyrHPE5F67nD2RXQE+ASXhf0ZOwQ +GP3Bmp2fbHoPwDBzL2d9t1hkza6YTPScsOfWcYDsA0MV5zQUdkJvMTA9O6x9kWNl9ucbpvP10lGR +nlo1xRZ0pb7kEk8wWKFnT4zv6VXO1umeQ6xA5d9X3+eq5tmM+heRahuyUTLref7yomWteQkOiVpm ++/45wdpEhy1BTO1Vqr8yWX7gtLTBUwSR2l6ePksThZIlNN0tyNZNChhfyNJyGAP+cWDLoMX4r9ke +HxBc2VnwdK5La052i+3gsLUH7833ONyyz2ujDiWWJmjDxywvMwAtczRDT1z0O/VD8KimGeoLvJ6F +0lwd4qvId6BWmp3rUB0uCWDdIcs3FMCVqR8pHFZxFDXMdrUDxuENpsOOi8lc1o57YDIM5fNgh7aG +yBsy2/n+rYx7g3JqXf0GhpOq16LDy2r4bEnERVhEPI1yG+opBX8vFc7VMaNqSyx7uSWo/cr+IYsl +3LD9d4sMnBUV8M7fglETIuw6SiIeuEZ2Wr+mxXx5kSs3/2dcBG3k47Z1BTfL00tXGNuEw0/8fxrK +T20T6b7R01l5ClCLR1Z6dKZ0otgcw/dq5FwgZ/dNKgcbqHMpsY2NJHhCpFJx6qP/uriysuXvJVmE +VqjU5e49V5+aJfrLOy7q84Sh9Yhr6zYKkyPRq5PGmvZUgIx7h7TnoRGtB7xPr0goGJYwlC7xTt04 +OA0TO+WsXIok/JRL2BbF2Tp1fDpFAKIJlPVm6Ay96HfaIH+lZH4duf91IkN1GOg+sGL9ZVx678Zk +0SwSelBnXQH/KW70fUma5iOMRgiZVvfZ51lpPBix3uDcgmzBCS6b1OC7YLj22lKHiIQ0jBUkgv+X +dqIebPweHnfPqDy2r0KMtdL1Zq7nEl1pRRih4bWnY1Y2TekYmp0okZ3Md4xXUzQ/Q5ghWDtnZDPf +dMK3PDJmPFEZunCyqRBbrP2j4VzG3xCX366mUtj00Zkrqf5iDVV2ndmucTWjZTMfAtLE8aLyIc93 +r6sY512ut4rJ8mYxZRFM+i+XPSi24JLvz0MW7m+50CBT9ynw+ZJ14WvXAcRGehZUY9oiUbj6aH4s +cRFSrm49cNprcQaFcn4mZu5VONrOqxvsyOcIslK1xdXLjuMaYgtaQFjL5FYTNUW0MgpUVZItWXpX +kfCHTiwy7Y83eXhyg85JaEwkfTa76oqjebxgeL5A0HdJbdhKQDTXJc3ngBsJeteoAZW2NJkbTEWs +pZJcOzt8+mUhzhAj/o4DlYqsEd+ysjFI0okAIsrAUX/IPjN8YSB4knTqQrceguxnkhv/YXoJTaWg +QUAvGueKrbQV79niBd/zD3fXiZqT03BvkkwpKSmzo80K95VdWmdwQAf6r5pbowaocdh5UqjKSjkU +Iuzb0jgR7nfglSStybC27ID5eH2ZsTDRnPfPDTQyTG7YAM3h/7Q+edtcjsnoUKSZDcdEWvNpayXv +0aSjoQJy1F0YsOrntLShMvyeQV8ZL/TU3nRK2FGTHDw2tqRFx7ONjqinRmgeYr7yMvmd1Klv/sF8 +1zFa7AInoWUCTBW305YyTgfZIgudfkzs+IuzqL0qfKZM83VR87LbI/b1kq68wvgzjMsNTB12U7L9 +lCnjuhow49uFW9E1bkQRaQCGNaj8loTtTfiQv6o8Kbhququ2S5/2fJWSiyENPSQmIYxrLm7mrAh1 +xGFyq27+V+c5nWUaaWx7XZcYnneFiOWAi1xrSe8rxPHyXupnQ4Lnxl9a9+is90OImDkEUiXgA/0c +H983BKWx3A5ripsMig==
diff --git a/crypto/default/cms/testdata/message.txt b/crypto/default/cms/testdata/message.txt new file mode 100644 index 0000000..a3f552b --- /dev/null +++ b/crypto/default/cms/testdata/message.txt
@@ -0,0 +1,2 @@ +This is a message. +There are many like it, but this one is mine.
diff --git a/crypto/default/cms/testdata/rfc4134-4.1.msg b/crypto/default/cms/testdata/rfc4134-4.1.msg new file mode 100644 index 0000000..36c695e --- /dev/null +++ b/crypto/default/cms/testdata/rfc4134-4.1.msg
@@ -0,0 +1,23 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIIDlwYJKoZIhvcNAQcCoIIDiDCCA4QCAQExCTAHBgUrDgMCGjArBgkqhkiG9w0BBwGgHgQcVGhp +cyBpcyBzb21lIHNhbXBsZSBjb250ZW50LqCCAuAwggLcMIICm6ADAgECAgIAyDAJBgcqhkjOOAQD +MBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE3MDExMDQ5WhcNMzkxMjMxMjM1OTU5WjATMREw +DwYDVQQDEwhBbGljZURTUzCCAbYwggErBgcqhkjOOAQBMIIBHgKBgQCBjc3tg+oKnjk+wkgoo+RH +k90O16gO7FPFq4QIT/+U4XNIfgzW80RI0f6fr6ShiS/h2TDINt4/m7+3TNxfaYrkddA3DJEIlZve +p175/PSfL91DqItU8T+wBwhHTV2Iw8O1s+NVCHXVOXYQxHi9/52whJc38uRRG7XkCZZc835b2wIV +AOJHphpFZrgTxtqPuDchK2KL95PNAoGAJjjQFIkyqjn7Pm3ZS1lqTHYjOQQCNVzyyxowwx5QXd2b +WeLNqgU9WMB7oja4bgevfYpCJaf0dc9KCF5LPpD4beqcySGKO3YU6c4uXaMHzSOFuC8wAXxtSYkR +iTZEvfjIlUpTVrXi+XPsGmE2HxF/wr3t0VD/mHTC0YFKYDm6NjkDgYQAAoGAXOO5WnUUlgupet3j +P6nsrF7cvbcTETSmFokoESPZNIZndXUTEj1DW2/lUb/6ifKiGz4kfT0HjVtjyLtFpaBK44XWzgaA +P+gjfhryJKtTGrgnDR7vCL9mFIBcYqxl+hWL8bs01NKWN/ZhR7LEMoTwfkFA/UanY04z8qXi9PKD +5bijgYEwfzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBRwRD6CLm+H +3krTdeM9ILxDK5PxHzAdBgNVHQ4EFgQUvmyhs+PB9+1DcKTOEwHi/eOX/s0wHwYDVR0RBBgwFoEU +QWxpY2VEU1NAZXhhbXBsZS5jb20wCQYHKoZIzjgEAwMwADAtAhRVDKQZH0IriXEiM42DarU9Z2u/ +RQIVAJ9hU1JUC1yy3drndh3iEFJbQ169MWMwYQIBATAYMBIxEDAOBgNVBAMTB0NhcmxEU1MCAgDI +MAcGBSsOAwIaMAkGByqGSM44BAMELjAsAhQJkf7r0mn1GLfXzVX0geoqQmqtAwIUOgfMwyG+4RpL +fz61Ddu6HOq8zYk= +
diff --git a/crypto/default/cms/testdata/rfc4134-4.11.msg b/crypto/default/cms/testdata/rfc4134-4.11.msg new file mode 100644 index 0000000..926c40f --- /dev/null +++ b/crypto/default/cms/testdata/rfc4134-4.11.msg
@@ -0,0 +1,37 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename=smime.p7c +Content-Type: application/pkcs7-mime; smime-type=certs-only; name=smime.p7c +Content-Transfer-Encoding: base64 + +MIIGiAYJKoZIhvcNAQcCoIIGeTCCBnUCAQExADALBgkqhkiG9w0BBwGgggV/MIICmzCCAl +qgAwIBAgIBATAJBgcqhkjOOAQDMBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE2MjI1 +MDUwWhcNMzkxMjMxMjM1OTU5WjASMRAwDgYDVQQDEwdDYXJsRFNTMIIBtzCCASsGByqGSM +44BAEwggEeAoGBALZJGD6KRMEpcZRMAcQSwXp5y1RNqx6B+8ZMsw6UCQbrAdSxyHFLx0XA +UCVdnPza5G3T4oZIhIJ9uhWVShb2Ru3d9pjSu36KCoq6Fnu5UAFIk4vrJRVRl1Xcj1MOEK +lQ/HC3zTBU/dreqKoitaGvi8wCiOeLcF+5reEI1G0pLdbpAhUA3cEv31POCzRgdz4CpL+K +XZi5ENUCgYAM7lebS73atgdqdDdPVX+d7bxhDetGWTxWCytbDJHOpWJSacrhbT69v/7ht7 +krYTyty65F4wasjCKdnESHC8fN8BzZtU5dc96vDskdWlH1T0R5NVpzqn9GUR+pQhacSOuK +eWG01S9TIkRjH4a4o1gGJfgpwO+64HXwQsRjZVKbCgOBhQACgYEAmYd0JwNmoLHArdwsdb +vhbESc2iFtTUdtsWIJ6diuHvI6tJSxo456m3FOAJTJtCVOuWCWGSQB82IM/nXA+87YaADj +/dVwT98jlhkGlPSxYY86V7EIEaQLJiXwUnaB6gtiDZUq5oa6crKnUIMLqifNG6lNiZrXjR +g5hD+LxVZNgHqjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1Ud +DgQWBBRwRD6CLm+H3krTdeM9ILxDK5PxHzAJBgcqhkjOOAQDAzAAMC0CFGup8E56Wnnj+b +49K8kGN+kRF6ETAhUAjzRpKouxPAN5lDJNEh/OiftGsjswggLcMIICm6ADAgECAgIAyDAJ +BgcqhkjOOAQDMBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE3MDExMDQ5WhcNMzkxMj +MxMjM1OTU5WjATMREwDwYDVQQDEwhBbGljZURTUzCCAbYwggErBgcqhkjOOAQBMIIBHgKB +gQCBjc3tg+oKnjk+wkgoo+RHk90O16gO7FPFq4QIT/+U4XNIfgzW80RI0f6fr6ShiS/h2T +DINt4/m7+3TNxfaYrkddA3DJEIlZvep175/PSfL91DqItU8T+wBwhHTV2Iw8O1s+NVCHXV +OXYQxHi9/52whJc38uRRG7XkCZZc835b2wIVAOJHphpFZrgTxtqPuDchK2KL95PNAoGAJj +jQFIkyqjn7Pm3ZS1lqTHYjOQQCNVzyyxowwx5QXd2bWeLNqgU9WMB7oja4bgevfYpCJaf0 +dc9KCF5LPpD4beqcySGKO3YU6c4uXaMHzSOFuC8wAXxtSYkRiTZEvfjIlUpTVrXi+XPsGm +E2HxF/wr3t0VD/mHTC0YFKYDm6NjkDgYQAAoGAXOO5WnUUlgupet3jP6nsrF7cvbcTETSm +FokoESPZNIZndXUTEj1DW2/lUb/6ifKiGz4kfT0HjVtjyLtFpaBK44XWzgaAP+gjfhryJK +tTGrgnDR7vCL9mFIBcYqxl+hWL8bs01NKWN/ZhR7LEMoTwfkFA/UanY04z8qXi9PKD5bij +gYEwfzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBRwRD6CLm ++H3krTdeM9ILxDK5PxHzAdBgNVHQ4EFgQUvmyhs+PB9+1DcKTOEwHi/eOX/s0wHwYDVR0R +BBgwFoEUQWxpY2VEU1NAZXhhbXBsZS5jb20wCQYHKoZIzjgEAwMwADAtAhRVDKQZH0IriX +EiM42DarU9Z2u/RQIVAJ9hU1JUC1yy3drndh3iEFJbQ169oYHbMIHYMIGZMAkGByqGSM44 +BAMwEjEQMA4GA1UEAxMHQ2FybERTUxcNOTkwODI3MDcwMDAwWjBpMBMCAgDIFw05OTA4Mj +IwNzAwMDBaMBMCAgDJFw05OTA4MjIwNzAwMDBaMBMCAgDTFw05OTA4MjIwNzAwMDBaMBMC +AgDSFw05OTA4MjIwNzAwMDBaMBMCAgDUFw05OTA4MjQwNzAwMDBaMAkGByqGSM44BAMDLw +AwLAIUfmVSdjP+NHMX0feW+aDU2G1cfT0CFAJ6W7fVWxjBz4fvftok8yqDnDWhMQA=
diff --git a/crypto/default/cms/testdata/rfc4134-4.7.msg b/crypto/default/cms/testdata/rfc4134-4.7.msg new file mode 100644 index 0000000..9611320 --- /dev/null +++ b/crypto/default/cms/testdata/rfc4134-4.7.msg
@@ -0,0 +1,24 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIIDlAYJKoZIhvcNAQcCoIIDhTCCA4ECAQMxCTAHBgUrDgMCGjArBgkqhkiG9w0BBwGgHg +QcVGhpcyBpcyBzb21lIHNhbXBsZSBjb250ZW50LqCCAuAwggLcMIICm6ADAgECAgIAyDAJ +BgcqhkjOOAQDMBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE3MDExMDQ5WhcNMzkxMj +MxMjM1OTU5WjATMREwDwYDVQQDEwhBbGljZURTUzCCAbYwggErBgcqhkjOOAQBMIIBHgKB +gQCBjc3tg+oKnjk+wkgoo+RHk90O16gO7FPFq4QIT/+U4XNIfgzW80RI0f6fr6ShiS/h2T +DINt4/m7+3TNxfaYrkddA3DJEIlZvep175/PSfL91DqItU8T+wBwhHTV2Iw8O1s+NVCHXV +OXYQxHi9/52whJc38uRRG7XkCZZc835b2wIVAOJHphpFZrgTxtqPuDchK2KL95PNAoGAJj +jQFIkyqjn7Pm3ZS1lqTHYjOQQCNVzyyxowwx5QXd2bWeLNqgU9WMB7oja4bgevfYpCJaf0 +dc9KCF5LPpD4beqcySGKO3YU6c4uXaMHzSOFuC8wAXxtSYkRiTZEvfjIlUpTVrXi+XPsGm +E2HxF/wr3t0VD/mHTC0YFKYDm6NjkDgYQAAoGAXOO5WnUUlgupet3jP6nsrF7cvbcTETSm +FokoESPZNIZndXUTEj1DW2/lUb/6ifKiGz4kfT0HjVtjyLtFpaBK44XWzgaAP+gjfhryJK +tTGrgnDR7vCL9mFIBcYqxl+hWL8bs01NKWN/ZhR7LEMoTwfkFA/UanY04z8qXi9PKD5bij +gYEwfzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBRwRD6CLm ++H3krTdeM9ILxDK5PxHzAdBgNVHQ4EFgQUvmyhs+PB9+1DcKTOEwHi/eOX/s0wHwYDVR0R +BBgwFoEUQWxpY2VEU1NAZXhhbXBsZS5jb20wCQYHKoZIzjgEAwMwADAtAhRVDKQZH0IriX +EiM42DarU9Z2u/RQIVAJ9hU1JUC1yy3drndh3iEFJbQ169MWAwXgIBA4AUvmyhs+PB9+1D +cKTOEwHi/eOX/s0wBwYFKw4DAhowCQYHKoZIzjgEAwQvMC0CFQCJw2t7VvfDEgBl8Tf1xF +gXjRFXgwIUCw9DOqrs3nphLIyc9UGZpzwgw7c= +
diff --git a/crypto/default/cms/testdata/rsa4096.key b/crypto/default/cms/testdata/rsa4096.key new file mode 100644 index 0000000..cc29e3f --- /dev/null +++ b/crypto/default/cms/testdata/rsa4096.key
@@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCgv+Rzkh3ZmXpI +uLUsXsN8IMxQt9y0Rjt/IfSZT1D4ydqZTHsBBABmYb2AAzw9i9Be4ps9mBDtiok1 +XmKgljy9Y6nYdqTxmvqKFAYsiiEQsxWBvY+aOoS4HO2WjXGc3Lko2H+HGowPbDve +fSt7NWGJaEyMOYXfhMj/jmmNE/4X64tDNTGShw/Jx2ZSfUvJzO7MQZ04pSt6KxWr +21eGgItBT59lvEny0U/LU8MNKhCRvmz45GWu6Bi6cwxPZ2I67gjafsk4OoJqiEXR +l8Zz3RItgC3wLYO0o6piBjX5W2pX+aDlKyH9O497TMhnRN7Q5L+z4HPyBiPhyu70 +40OaxNFMSkBBaYG835b5D2w5EObKSOCJQbHX6K15gb3BsOtWXrViUPuCLgb39aEN +0v97/tk9dN9+DS58SPLMY3+HU/855WZAFQm13hSlhwLlxvH5PHxeoxggNLLlx6RD +RnP9UfLfG+zp1Sv0MQ2hcQTqGfVY93y1VcUiN838PJ4qbHJ2E44q54cAJ457Y0RH +TGmJFdu9D3jxymy//qbdv/6Q7EteYZ79hnJHIvb5aY5cSpzw5FlB3gmwGc+MLm2+ +ptXE8ohrQVJgxqq7UZeuBhgTtLIIKLMi9lXdHvSAE1laNA7MGCjQQbVuOjiwkUMX +7JbIlToNXAUflN8A/+ms34ZXJ4F3qQIDAQABAoICAAwULznh7l1F85LYUkDJxB2l +J0Ufc7WWG21jo0oL1DVFkaf4P7vlTZRj3sXqOFIVmewvUePwc8Etqj6432dasqi2 +fvI1hBJlh1Lty0L4hCCJnGGZ2VsI76UYA2BPM4zKZx1jGz4PKBwWkW8X77b73V7i +V/IndmBDzYiqblL8ABlTCS1dJ2LvxCdw1Mb5BENfI0h027gAbUJqT2ARBMQmD5Cy +R3NxEQtuX/USz7LDCOiyquSO4PciGI2WDIRA6/gqn76vTTZs6xTPNZPYuEBHidCi +c5E7Ekz2CV0BQ3f/vv9qUgU2VwD8wzzMGbjsy2Nd9Ouyv3Ods3+JbKg5FYDhLirT +OSyRnsL42IpPQ1CBXaSH5aAmGHh03JbR5ZrtTITznXNbTVeXvzRsfC8YiUm0RbHl +MgJ0MjM9OOXq2fE9X7roQROO2StEpiF7uAb3ZKg+MHWdsbz1Txvj52yV+0uaw8kD +Qe/GrAs4sPQX++lcuEEzKKZbnMUeRbQfJpQ6XJ9ocp2l5+hYQi9uibFgf+Th61zD +JfOMvXstqykDtRCrNcw42CVONc0Hq/WkxHwJb1SXrbf5ZH3Z32BnjMjpPrngbaJZ +jg4aRbpxgMKpI2w+jH5CbQ24vqZ1r0FSYqyLW/lirSaubjC7XB+4U8S6TLRVuPKR +D/sksijkEd7avSNiUM6bAoIBAQDg9tMudcCumtgpSS3yPhUHcmqZ36pLJgGdqh1Y +azquTjgrvEtNlmtZLlAne7abGsFG55Q5D1KsLzKeajaZQBXsO2gIt2ndmhj7uHAs +FM28ZxA6g0xPD9XtWpwUhk4RSEVgBgkD1mWQnY7CY97PiZPtrLlpSlhKeofD3IRI +qZAjPkBVNF3LX5Zzt0RcTV/66mw61K889BMumADxQ5HElxnJTSP6ZA3jBa7+VUfV +WhHGIbyBwKrInn3gEAT001devAWpe2tB3h8TWLm6GVDNUPh+XEMzaC6XnD8/9EZ4 +WiznFv2EuQrRBEGzWLLnHKMqqM9zJTY9MxK2QkUbxt+Pq95nAoIBAQC27SsGV8ZA +WIYSa/A78eHbTCvSfpVpfWZYwsnbm/wt37F5qQJeBRZjLmBfGlVBQd5OkWQqH3Or +Qr+naDoFh1HtyJN73pKAwcL0RiKQJgu4MMO0Yg50pOWj5SQEDtINrMT7t09o+9Yr ++3tbbUZqspPQJJa4q4NAVErQNfIXyLPFAehpvuFA1mftIIX0v6Ipda+f6vMBcsDE +ZVR73L3LsUhHTtpVmIRlPge11zp2l/7auWW/csctGisIGTy4z/hlKtbOexfqcEy/ +AImuUgV42MQ9SNs/zZIGBVzj7SAammigKNwkrTTvDDFLDwPiSW3jGxdIyNHIuSFn +KvUN2lZF7A9vAoIBAQCZEVi/0/82zsUl0lUg7n0AhClkaXtFhqay0H0HHG7hCvfN +SmEyeZaxJ4ICW3r0LlA0Hn4MFRD7d2HJyBs+SwyfoAw5YhL2ZVN6cxRf6gfSmY3B +QnU5llzJE03/6SJVQAd0PqcDpW2V6J6qRanlLIcqwMuniJ5qjtSxv+mCR117nW/I +ickICBImyhwJcdxMrhTWMsDJiIlEushpYMBeMzGodoR0an/IaKWZCA3IbXCKj4nj +/2Q0/SJIwp2ct/BK1URE/WUNfEVItKYqqe8AxIdP8xcGc2zAW9/tdBg+65yB4HK/ +S+jAL1VE4qVX+3RKInKEtl0hZw7kVFMRg0+6QgchAoIBAFlQvcQwEUWTs4rFFxw9 +wxhvPapeUheaueE24paOt/eyFBah9dzwQPkWSMnGsr/VKHVWOgDzMGf7hFZ55nKS +mucuGwzNJN0nPkhFAlU9rlU1wiiOBB7h8JowpIAyuE8i/18kJurMo8r/1EBUv6AT +J2bmpozueXxsa8bzwAvq3BOiFdej8pnU1hOK4L0a4FO7+Y9siVA4tnRuQWzk3+rX +2cGvd515hZNlUSBxQUNsfWxQP6TVpviXoVEn+WUHbnH/E7jnk0rNoDtWSONLew6B +UHfX3e1uMxErhypmulki8VjZ+nsR8xmQ63SJlUzkq3nK3XiVTRDji7Ky38nuxFcJ +ppMCggEAWVp1GKF2iB9smH9WluM+UnMdFVqlMamY7zM5EYJOIOpuY+oMGo9WJhQ4 +1BmYNvO9AExY8yqRENCXuh3FZcrwMoOlpkatIAcRhQMrj6zGDDg6pRAQC2jJfII4 +SOB0FpRoSJG5b5f3mPcl7n48STsAKc7V/OfxDhPr1izQrVSH92oGgxa60yBmHhAX +uAUXYGWkG/i6AFd8Uh6DHcdPwVNikNAyGFXOUlONOcbFiV1L9T7NjjpGxInpz9uK +QyfYPVuFZrMME5qWp3s9zF7jNjMnXyYPP6BqZ57VEXvt6JGwcZmZakVeeVrAbt/4 +38JhoT/178W00kVAPWpDXhIUNTA0rQ== +-----END PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/secure_headers_asn1.bin b/crypto/default/cms/testdata/secure_headers_asn1.bin new file mode 100644 index 0000000..8f66446 --- /dev/null +++ b/crypto/default/cms/testdata/secure_headers_asn1.bin Binary files differ
diff --git a/crypto/default/cms/testdata/secure_headers_sec_header_fields_first.bin b/crypto/default/cms/testdata/secure_headers_sec_header_fields_first.bin new file mode 100644 index 0000000..ec94a03 --- /dev/null +++ b/crypto/default/cms/testdata/secure_headers_sec_header_fields_first.bin Binary files differ
diff --git a/crypto/default/cms/testdata/secure_headers_skip_optional_status.bin b/crypto/default/cms/testdata/secure_headers_skip_optional_status.bin new file mode 100644 index 0000000..4d8dfd2 --- /dev/null +++ b/crypto/default/cms/testdata/secure_headers_skip_optional_status.bin Binary files differ
diff --git a/crypto/default/cms/testdata/signed-apple-ecc.msg b/crypto/default/cms/testdata/signed-apple-ecc.msg new file mode 100644 index 0000000..9152c98 --- /dev/null +++ b/crypto/default/cms/testdata/signed-apple-ecc.msg
@@ -0,0 +1,55 @@ +From: Test <user1@foo.bar> +Content-Type: multipart/signed; + boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC"; + protocol="application/pkcs7-signature"; + micalg=sha1 +X-Smtp-Server: 127.0.0.1 +Subject: Signed with ECC +Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar> +X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8 +Date: Thu, 5 Mar 2015 11:13:11 -0800 +To: user2@foo.bar +Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\)) + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +Is this message signed? + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Disposition: attachment; + filename=smime.p7s +Content-Type: application/pkcs7-signature; + name=smime.p7s +Content-Transfer-Encoding: base64 + +MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIC4DCCAtww +ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW +MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN +U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG +SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA +9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj +gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm +aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW +3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw +DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps +d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7 +tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez +hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk +uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB +ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW +MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFKw4D +AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1 +MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE +MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx +FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG +SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50 +YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC +AhI7MAkGByqGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad +Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/signed-encrypted-thunderbird.msg b/crypto/default/cms/testdata/signed-encrypted-thunderbird.msg new file mode 100644 index 0000000..adeeca7 --- /dev/null +++ b/crypto/default/cms/testdata/signed-encrypted-thunderbird.msg
@@ -0,0 +1,95 @@ +From - Thu Feb 12 14:28:37 2015 +X-Mozilla-Status: 0800 +X-Mozilla-Status2: 00000000 +X-Mozilla-Keys: +FCC: mailbox://user1@localhost/Sent +X-Identity-Key: id1 +X-Account-Key: account2 +Message-ID: <54DD2915.9080206@foo.bar> +Date: Thu, 12 Feb 2015 14:28:37 -0800 +From: Laetitia Baudoin <user1@foo.bar> +X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.4.0 +MIME-Version: 1.0 +To: user2@foo.bar +Subject: Signed and encrypted +Content-Type: application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7m" +Content-Description: S/MIME Encrypted Message + +MIAGCSqGSIb3DQEHA6CAMIACAQAxggMIMIIBgAIBADBoMGIxCzAJBgNVBAYTAlVTMQswCQYD +VQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBD +QTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUwDQYJKoZIhvcNAQEBBQAEggEA4y8AAiMI +gqP9jtxvoavTuubxQIL+iSsEJCVBP1ekxtvlZa59gdeB8jXXfG9LoPed3mk5CHQoH2LotVJb +2KTjabtNQCW6GJEUxFJc/GvpotBzjKEcYtnVPS4ROd8FwrpZ8Y/RFJyS+hA7qHRFtaL4psdB +PxCu4DGfqXvqc41yEGJFqlZ8WADr0MKBJqj9//ft/kqoFEFyP/uEu51nlHVe4a1mKwnL+pqN +VYGyARS5D2T4lo9w9IB1RPlrF0p7uKbSXNqug95uKvhAxlzRtQCl1QHVKg9pTLDGq9imwF2R +8r4WNIbXZ0ewK3dDepRdlHkll2BC4oqnp9MHVm56UrB0GDCCAYACAQAwaDBiMQswCQYDVQQG +EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt +aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI2MA0GCSqGSIb3DQEBAQUA +BIIBALXNbVAXRKKTtYXIRq1O6X//VdEKxZ6bCJkIwzcf1JhyIXVALyy0BniW0N0AiapIpfvv +py81HiAZc0Cciyjr6DP6l2Y6MMWLdZaTeGu+U3o+R4WmHkI3njfmIqmBVHhDmRdOKpU0qRWh +Vo/K5Y0gkWLDzN5W/fM7fsTr58UxfNSaX+PXFsvk+S8IgP7Qqi3jAE3rMGyjk6ewxrh7djTY +BU4XoJ3hY6l3KFbzijtbwpzvSMRtArLjFsYa+F5FKO8QYswkCjd//khUoY8YC4Y3YiWRpPRK +qOoyOTE29IJOvVZv6gUsaQyxENNL9IgGtbQ7z+KnpflvWKjVvepYrtimvVgwgAYJKoZIhvcN +AQcBMBQGCCqGSIb3DQMHBAjRvDMHjZagZaCABIIMaKdabxwdRcIGo4ag8VZIIrtv/IY9+Hhh +3FC2sPc1Qyc/RSvIEGFd5SV2NG1z/0XGJfqH2s07ftgtMcGBo+oQTr9TPXiSfQK5pjX+tYYb +MsceN6+HgJ5GzQdhOwrxeLuXYlRKg+MzJMbEJ4oUkkY/bBF6j5K8CoQpYB85nAdyCb4auLRh +EJ/gZv9WSr5PF0I1F4Fm1qzU6Eo3jUhmXw20d9cq6m4N06ub5GdEgb8HFo/Mwaiu2u8jeSi+ +cEkDchYbhNMYGPGYCpbcENiULSPZktRM+I4H7HwMUWc5JgsNGSTUHqyfQH46T87oy6v+mPtd +9iF6OCJMOFwYjfL65pCjxvhKfgFAKtU8epyg0qPMU6wegu+p5zrcmfLKSnku+DwGh94/tGMF +/y1XmJwfsoGc8YI0oGhuoxHCvH/6dV3hB4xXPREjM+8vDzktMe5/+4gbi/y/O4vVaFKukQIY +Gfxr2UV+4rsBrgpaufujScVSmR/QYWXblHhgN0xFha9iqL1nJyKb6REpNlZZaH7XKCFsJGu7 +xRUCoawqUCrP7EfHeBlPgnMaXaodFBjoQm9P+0YV5H9l/0fc59OvaDcmLgfLzm/PB6+aixcX +HhRZffFzRLJm7kpNpeHsnzG0GKUcnD8HcbRPIksCrvL6vtkOlk4KEP8WR8Z49L0TUPW5yQ2x +v/fmH+1um2DV9ZSfvFWJ65cMxtpjy0l+uA0UDARycXt9As52xIVNu8p0zGasEtebi2qQTZ9E +A4NOW2XrsjZ0ErlFET4bi3nzwpOaX9w/KJQ5+gaQNULnNzUSkwXdvgV0v4lYUUL3xdL1A/mK +2Oiz+tUSiLiH/G2tEIpdrOT+bmOv/GSHZGSfqx0O2ibWlG8u4VDLbISCIDpk0oeUHCxMmbTr +HTH+z5e0UuJd5OFh1qEEXppUASzint5F2Q0XgdNmdBPMqE6I3WileaP13tV39NEFml6rujRf +sKjbVVPUZC85tnUGJrYKVhUe4OMViccMyyz2VUBTT1aDhn/SKcPPxwn8sZ8RTK3IZLp8FB1y +7qbKHk4j7dJvwwMVK6K4Zf6xfXOiV8aUivUPTLd8vTCzc9XbW9pQT7uO3mFSp/M8mb0GWL/J +iacY6dnxzAJ6Rv3J1ZHrJE4x1LXYcUqRx82qEI9PKfhComwga949OXBna2Lo8TmjARFDr/og +EwRL7++AMKwBcuqjvjChwM3kP52GPdILmsgvL2+/3IeIeXuz30ep4ushJSpK/mtFY+4fyEBc +xIEI8nAOFGIJAk8qVBZN+rS2Y0JmyTJe0JyaiWRFZswnBP1Upn/PE16i4z5ZWCBPX9Jbavc1 +V2JyQWw2TurEV5F47jLl008XghREyGpLKIG9pAkEBArHE6wUEGMzBsQ+sf4VTGLxP6jjzQQN +ZdsFu6pHW77ECFztOIWa7Qf0gg7aZ/L/J19n/mI4Y+giKcJxckZ+uzYN7nKLMO3rN3qmmJFb +EWvA8Vca4y5VAq14iRepDG23PjldhJscBjji/vizzfN1IBHp4woLqkZUj2WC2bdmrD5KYU+4 +yUmcjGHa5hEzcvDYq5JXXSP8U1aY09w36sc3ZFaHvdKGIccNXWodGyGdA8su4xULag+8A8iy +o7541uOU4bAF0wT41szIvZfuOG8EMnI6OuXdvxswI/Zb7/mxUrLC5G1Px1qqm1JLlrtVoXOf +NsWM3iVQgS3IZd11Z2QSR0qXd0s2tqYRaPsKKoN3Wgtv1bMnrx7gr6OxpeAlLYtwXQp+EnWY +he0Aa1whbEYghyhmS3eRkziUCO1UA/1fXUeIM92/WIDrPzMvnUrWFDRXXfwIEYBmVhRtQwB+ +VLyOf32gwmAUDQLdS6TwDwEpgr1tneXJWDWJgB1Hx0Ca5swaFJd/OAaUgQQ+3b9u1gVX8i7p +RojBZrRyAjbEHbN4ZyCEkXS+2IYOINdKAdjPleOKmeUW5LzZ4F9J+YsvEUKk2pV90jOoddw9 +89gDQrBI9jKcMzP2cvIbxZOdm4eIgURphySeypk4kQaczbTvkqYyoBx+NOr5vHMbV2N7R0MM +fdzbeel7DqKhVM7BCM85Pb8J6LzD+b3rN/ITfO1x79x/FBAduX8/BPgPCTHPBn2B7JLs2l1s +FcXg6TfeEqKemqAstDRBOPKo04kCNDBfo+UVveU987IPIiDLAcas7qdZLK1BMBuvr7kYgkB0 +rQnCWsWfjRjOPQ4hpu1JF2QMgxzp+LqIMm5UjFiXHxzVgA/sEs+JLLeJSf9e1MFTzwTh0y6Q +HIMJzgvRDYQrSB84JU7QM6VH9nst5GIq8AMYb3dtyaBKoqMbdmHz0c4/h85ygDlS+BlvxaR3 +h7pUR/ls0F+0eFNVtCv5l+/JuY6xo/I1i/2kRhvkGb1C4sKAkXoJhSsDFUZYPggW/OYaE7VY +m2e9WHQLzJqQrRl9UuKpnL6CGPu2jsirURWKdSpnzSdBZ4PsiAILy3D83rRpEHh34egoYnWH +5E3LbBS/Wy5wxw7fD4H0prnhLgXlZaXmb3/vPD2Z3xSw9KINVVfrZCkPxjuzkLFEI8TKs0D8 +U9/xdwAfSdudQG3NpctTrhey+JNOT/7xzWve03Nl1JXEUQyk0mlPzPUDd7fcDhZsn39hdCQz +LjURgvbl966N4k2GYNNuEJmyblXD42qs5QIgp2NpX9EX7G2y+wzM+cT2P35Eetiikdi7sBFq +K+kpGWfM3TggNcVTlNe0RlisEcK5gcI5JV7OYhIc8dUiSY4jvb3cMiK9bxPnQ95H9kz7sqx2 +56yz+zyasszWwn2pIaKNxL9pukM1p6OHpNbhDXq4nzuuZ8rW2rUDAVnpQEZJK+EtR7LOv9cd +P8HPaEs9Scra5h+9hQVJT4kpIODpExdGkoin8tumgol+NEXckB2alxRVzHJFCt7o2qk3ivwN +j2eHOYFuhTmKZlVYs6+t5pI7hDbdIpMCI2zkVJ0Is2Vgp5tYMaSwJ+Pw0yOCTn/qWB3FuzM2 +LgNrzit1pC9W8fh9JTmwOn4eKtJY1ha2+0UyaEyyk7ge6oF1lSn6JwmIFIwgoXTa7tZr11SC +ZjTk2+3zfpamx4fbKiEf0cM6qz86YXe6wlTcITQfK/XqUoqwdTC2y3FeYGLpC3nsaLRGq5Js +x8CzAJitvP5e9Z2wR3eJ8TE77lm+bJEiHPO4ft7vvFfu4gL0/d4Bdqz1lg+0kgYXYEZoo8Q7 +I34LVMKEg0wq1KApOSggl5bdVdakYojDlMYc//UZaLmGWkVvfM3IVeuLqmCaxtJ9ySOfmKEe +BJFdkBLSyO7xLa6fRti5AAqJwIj5k2bnmSOfiudvoBP8T19gstUCP1q2Mdkpf/shA6qmyAY+ +a11PpskrnOauitNwdQhT0JT1X5pt0TTf+bb0/kwgHo0R/bW67thP+nqJZ7s0Tkh+t7c8ML9y +5zWt9DtIr3x4kbELmN6R34ZWnU47DhKdLx91G2OF/kJDxnHtFYG7wws+a3Bup/r+Ahd1rkng +uUlR5DoYMLNIVKv49xNw5xBO/S1UtFsupvwM74+VuM/GTHjNChAfKVhiTsB+a29A6pFLNtii +Khv7AG1BbyIvS7Nw1W9UPbuN0akI6wifCA9zy3lGqieGCoZkpWg/2yEDPZtknHh44mNm3Jrf +UHKCvM+O9xhPRVbkJjKZBjiOT/lrM0DP0ly0vN96nT0CAFhr81aLcSEmgAuVRYC/uFnt6quA +wROaK8bA/FMETsjdI1E+/T8kkSS2wINas66H5o6v1v0rCF+FBK6S5c5cRauqZSfHHiqwUSGB +O7xTfU2uJIGWVa5JRpVr4tR2mpxYNbO8otlOEUcNGFGbh//f4oPbu/cQUPVZtZ5LOPYOY4zS +SRHVnNLwiGLkhOd/+TC1EiDF/iu31VGYSSL3uM9DegfgivBRu4FaZYb33JDu71JReT3jKJ/2 +t/3tMiT/uOa+n/fCybpJehLNp0MgMvtLwBiJWJmtKZMBf+tpwQhR2S6GosHlRzs5O84jgdqZ +pzZdHrVtFhuegGHDLJ0gzjwxGhwVR2TvOwRoz0Uzeo1o0N23Xw8hODojAFgXmEOSMxtbxHHP +KQ84sDn+IV0ELj4lSi+oIAYsjsJK9eZ5803HBSzfO8r35BpixAWYqJXBC0dd3hcUt0sH4QPS +g/T9UUODRWSvgssXNhszHGv4dCTcawf03nlQFbwazMS/qhDy8LE+bEss704rZKn82gd2uvU3 +2mOSGsSYX2/h1Tq8+h4bs18B2IFCBAjRm0yO+YyfIQAAAAAAAAAAAAA=
diff --git a/crypto/default/cms/testdata/signed-office365-owa.msg b/crypto/default/cms/testdata/signed-office365-owa.msg new file mode 100644 index 0000000..031af23 --- /dev/null +++ b/crypto/default/cms/testdata/signed-office365-owa.msg
@@ -0,0 +1,124 @@ +Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; + micalg=SHA1; boundary="----=_NextPart_000_8231724B.83F20A4B" +MIME-Version: 1.0 +X-OriginatorOrg: glooey.onmicrosoft.com +X-MS-Exchange-CrossTenant-originalarrivaltime: 19 Mar 2015 16:14:23.9410 + (UTC) +X-MS-Exchange-CrossTenant-fromentityheader: Hosted +X-MS-Exchange-CrossTenant-id: 12676348-5c4f-465d-a51e-9617de6d9d6e +X-MS-Exchange-Transport-CrossTenantHeadersStamped: CO2PR11MB0024 + +------=_NextPart_000_8231724B.83F20A4B +Content-Type: multipart/mixed; + boundary="----=_NextPart_001_8231724B.83F20A4B" + +------=_NextPart_001_8231724B.83F20A4B +Content-Type: multipart/alternative; + boundary="----=_NextPart_002_8231724B.83F20A4B" + +------=_NextPart_002_8231724B.83F20A4B +Content-Type: text/plain; + charset="utf-8" +Content-Transfer-Encoding: base64 + +U2Vjb25kIHRpbWUncyBhIGNoYXJtLg0K + +------=_NextPart_002_8231724B.83F20A4B +Content-Type: text/html; + charset="utf-8" +Content-Transfer-Encoding: base64 + +PGh0bWw+PGhlYWQ+PG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0 +L2h0bWw7IGNoYXJzZXQ9VVRGLTgiPjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyIgc3R5bGU9ImRpc3Bs +YXk6bm9uZTsiPjwhLS0gUCB7bWFyZ2luLXRvcDowO21hcmdpbi1ib3R0b206MDt9IC0tPjwvc3R5 +bGU+PC9oZWFkPjxib2R5IGRpcj0ibHRyIj48ZGl2IGlkPSJkaXZ0YWdkZWZhdWx0d3JhcHBlciIg +c3R5bGU9ImZvbnQtc2l6ZToxMnB0O2NvbG9yOiMwMDAwMDA7YmFja2dyb3VuZC1jb2xvcjojRkZG +RkZGO2ZvbnQtZmFtaWx5OkNhbGlicmksQXJpYWwsSGVsdmV0aWNhLHNhbnMtc2VyaWY7Ij48cD5T +ZWNvbmQgdGltZSdzIGEmbmJzcDtjaGFybS48L3A+PC9kaXY+PC9ib2R5PjwvaHRtbD4= + +------=_NextPart_002_8231724B.83F20A4B-- +------=_NextPart_001_8231724B.83F20A4B-- + +------=_NextPart_000_8231724B.83F20A4B +Content-Type: application/pkcs7-signature; name="smime.p7s" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7s" + +MIIQyQYJKoZIhvcNAQcCoIIQujCCELYCAQExCzAJBgUrDgMCGgUAMAsGCSqGSIb3DQEHAaCCDj8w +ggQ2MIIDHqADAgECAgEBMA0GCSqGSIb3DQEBBQUAMG8xCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtB +ZGRUcnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdvcmsxIjAgBgNV +BAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3QwHhcNMDAwNTMwMTA0ODM4WhcNMjAwNTMwMTA0 +ODM4WjBvMQswCQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRy +dXN0IEV4dGVybmFsIFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBS +b290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt/caM+byAAQtOeBOW+0fvGwPzbX6 +I7bO3psRM5ekKUx9k5+9SryT7QMa44/P5W1QWtaXKZRagLBJetsulf24yr83OC0ePpFBrXBWx/BP +P+gynnTKyJBU6cZfD3idmkA8Dqxhql4Uj56HoWpQ3NeaTq8Fs6ZxlJxxs1BgCscTnTgHhgKo6ahp +JhiQq0ywTyOrOk+E2N/On+Fpb7vXQtdrROTHre5tQV9yWnEIN7N5ZaRZoJQ39wAvDcKSctrQOHLb +FKhFxF0qfbe01sTurM0TRLfJK91DACX6YblpalgjEbenM49WdVn1zSnXRrcKK2W200JvFbK4e/vv +6V1T1TRaJwIDAQABo4HcMIHZMB0GA1UdDgQWBBStvZh6NLQm9/rEJlTvA73gJMtUGjALBgNVHQ8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zCBmQYDVR0jBIGRMIGOgBStvZh6NLQm9/rEJlTvA73gJMtU +GqFzpHEwbzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1BZGRU +cnVzdCBFeHRlcm5hbCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwgQ0Eg +Um9vdIIBATANBgkqhkiG9w0BAQUFAAOCAQEAsJvghSXC1iPiD5YGkp1BmJzZhHmB2R5bFAcjNmWP +sNh3u6xBbEdgg1Gw+TI95/z2JhPHgBalv1r8h894eYkhmuJMBwqGNbzy3lHE0pa33H5O7nD9HDnr +DAJRFC2OvRbgwd9Gdeckrez0QrSFk3AQZ7qdBjVKGNMresxRQqF6Y9Hmu6HFK8I2vhMN5r1jfnl7 +pwkNQKtq3Y+Kw/b2jBpCBVHURfWfp2IhaBUgQzyZ53y9JNipkRdziD9WGzE4GLRxD5rNyA6eji4b +4YyYg8sfMfFETMYEc0l2YA/H+L0XgGsu6cxMDlqaeQ8gCi7VnmMmHlWSlNiCF1p70LzHj06GBDCC +BK8wggOXoAMCAQICEQDgI8sVEoNTia1hbnpUZ2shMA0GCSqGSIb3DQEBCwUAMG8xCzAJBgNVBAYT +AlNFMRQwEgYDVQQKEwtBZGRUcnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQ +IE5ldHdvcmsxIjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3QwHhcNMTQxMjIyMDAw +MDAwWhcNMjAwNTMwMTA0ODM4WjCBmzELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFu +Y2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxQTA/ +BgNVBAMTOENPTU9ETyBTSEEtMjU2IENsaWVudCBBdXRoZW50aWNhdGlvbiBhbmQgU2VjdXJlIEVt +YWlsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAibEN2npTGU5wUh28VqYGJre4 +SeCW51Gr8fBaE0kVo7SMG2C8elFCp3mMpCLfF2FOkdV2IwoU00oCf7YdCYBupQQ92bq7Fv6hh6ku +Q1JDFnyvMlDIpk9a6QjYz5MlnHuI6DBk5qT4VoD9KiQUMxeZrETlaYujRgZLwjPU6UCfBrCxrJNA +ubUIkzqcKlOjENs9IGE8VQOO2U52JQIhKfqjfHF2T+7hX4Hp+1SA28N7NVK3hN4iPSwwLTF/Wb1S +N7AzaS1D6/rWpfGXd2dRjNnuJ+u8pQc4doykqTj/34z1A6xJvsr3c5k6DzKrnJU6Ez0ORjpXdGFQ +vsZAP8vk4p+iIQIDAQABo4IBFzCCARMwHwYDVR0jBBgwFoAUrb2YejS0Jvf6xCZU7wO94CTLVBow +HQYDVR0OBBYEFJJha4LhoqCqT+xn8cKj97SAAMHsMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8E +CDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDARBgNVHSAECjAIMAYGBFUd +IAAwRAYDVR0fBD0wOzA5oDegNYYzaHR0cDovL2NybC51c2VydHJ1c3QuY29tL0FkZFRydXN0RXh0 +ZXJuYWxDQVJvb3QuY3JsMDUGCCsGAQUFBwEBBCkwJzAlBggrBgEFBQcwAYYZaHR0cDovL29jc3Au +dXNlcnRydXN0LmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAGypurFXBOquIxdjtzVXzqmthK8AJECOZ +D8Vmam+x9bS1d14PAmEA330F/hKzpICAAPz7HVtqcgIKQbwFusFY1SbC6tVNhPv+gpjPWBvjImOc +Uvi7BTarfVil3qs7Y+Xa1XPv7OD7e+Kj//BCI5zKto1NPuRLGAOyqC3U2LtCS5BphRDbpjc06Hvg +ARClnMo6x59PiDRuimXQGoq7qdzKyjbR9PzCZCk1r9axp3ER0gNDsY8+muyeMlP0dpLKhjQHuSzK +5hxK2JkNwYbikJL7WkJqIyEQ6WXH9dW7fuqMhSACYurROgcsWcWZM/I4ieW26RZ6H3kU9koQGib6 +fIr7mzCCBU4wggQ2oAMCAQICEQCv2bfqog5Bl9O9Slil7gQKMA0GCSqGSIb3DQEBCwUAMIGbMQsw +CQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3Jk +MRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNIQS0yNTYgQ2xp +ZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EwHhcNMTUwMzA5MDAwMDAwWhcN +MTYwMzA4MjM1OTU5WjAsMSowKAYJKoZIhvcNAQkBFht0ZXN0QGdsb29leS5vbm1pY3Jvc29mdC5j +b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCdAvnls5p3hWM8fm4RhTgAQmLjXj4t +h749GqOVzApWxAfx1PsKTbh4jZ9hFXfTZ+72LP5w6x9cM4/tq9pYP9AuMy+Gyg3h8473uB1Pfisy +3ex06NGH+5wwvqgrzPLi9Q6lIltuJvTDglXx+59RUZIL1uSai2Q5yrxPYn+zV8apOH1IHTt0ziIw +6Z6StxDWFCrsEI7a/JT6/sDStiIU4m9dEk+BAsALYBt+wYfFbfLgKbqK7eUnZ182rgRc3sMQmE6y +Sw9B8hR5MErO7KPCenp2bxYr7p6osWxlPbdjwPOTyOiVWZJ1fIZq5B0dVZkU8oPZeqWc8Bj0eIf6 +otlUqcR9AgMBAAGjggH5MIIB9TAfBgNVHSMEGDAWgBSSYWuC4aKgqk/sZ/HCo/e0gADB7DAdBgNV +HQ4EFgQUopu+re0WFCkToB5oNLTq+Jk6YYQwDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAw +IAYDVR0lBBkwFwYIKwYBBQUHAwQGCysGAQQBsjEBAwUCMBEGCWCGSAGG+EIBAQQEAwIFIDBGBgNV +HSAEPzA9MDsGDCsGAQQBsjEBAgEBATArMCkGCCsGAQUFBwIBFh1odHRwczovL3NlY3VyZS5jb21v +ZG8ubmV0L0NQUzBdBgNVHR8EVjBUMFKgUKBOhkxodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9DT01P +RE9TSEEyNTZDbGllbnRBdXRoZW50aWNhdGlvbmFuZFNlY3VyZUVtYWlsQ0EuY3JsMIGQBggrBgEF +BQcBAQSBgzCBgDBYBggrBgEFBQcwAoZMaHR0cDovL2NydC5jb21vZG9jYS5jb20vQ09NT0RPU0hB +MjU2Q2xpZW50QXV0aGVudGljYXRpb25hbmRTZWN1cmVFbWFpbENBLmNydDAkBggrBgEFBQcwAYYY +aHR0cDovL29jc3AuY29tb2RvY2EuY29tMCYGA1UdEQQfMB2BG3Rlc3RAZ2xvb2V5Lm9ubWljcm9z +b2Z0LmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAZ0Zro8Stt+qIKjF6F7WZ/zmlOPI7y8fxOyXvZs9A +Eh810/GHWFDC97RG6h6xc2zpk6Vro20/10wbCKxevGa8F1ZVPI58VroPxHaQ0f5B7Si+B2xvU9JL +kFCmvtHTQqdtb+hA1WD6TqlqE9xAzTmKyc5E2Mo1pBU3qnwZvxtrV0UuaGu+ThqstQ5qjaefbLrv +DWqqMwZ1tR7wSc+YNW7wy4qK2zIIcy+Nq5dE8z2sJN/OKPWAz0AwKGr9mO1c48YcxIJgVQApBV0e +sjf8YzS+1zPRQJY3P3qFbFeba9XzgDOe0+MrC7AHh+DHcd7+xrZ7Z5fywpLfSNbbX5rTygh1eTGC +AlIwggJOAgEBMIGxMIGbMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVy +MRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4 +Q09NT0RPIFNIQS0yNTYgQ2xpZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EC +EQCv2bfqog5Bl9O9Slil7gQKMAkGBSsOAwIaBQCgdzAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB +MBgGCSqGSIb3DQEJDzELMAkwBwYFKw4DAhowHAYJKoZIhvcNAQkFMQ8XDTE1MDMxOTE2MTQyMlow +IwYJKoZIhvcNAQkEMRYEFLT58XOVd+YVGAWSdX67NTwdw2ddMA0GCSqGSIb3DQEBAQUABIIBACis +Hx+eCFcWmIuqMAf925QbbEGGg99VowWN+4+10PWbOZ4drQifH9G9bordq4voZ92jLpyiMGAuX4Hk +stl7/BIRupFvD96zEZOYoWsKVScNYXPd80PkY9btUnhVzxGuAx5DRI7rsm+lJ/C1D6qk/B965HPa +qKCw3inAOvQGnWzzcjgtk6zeevU1VQlKNH4yQsD617IL+8yYeMEM9GQla8+4FXIe+2Ky1hPDuruc +G84RGy4xKTwLY79N+4gmq0+z8HcRElPzoLYgVwc9ZpdKEIGLGHpaaUUQHRQxfOHCZHVAuQeNhSMk +GhuUz79oh+igHxcypMlmRBm1UsUF59v4QWY= + +------=_NextPart_000_8231724B.83F20A4B-- \ No newline at end of file
diff --git a/crypto/default/cms/testdata/signed-openssl-detached.msg b/crypto/default/cms/testdata/signed-openssl-detached.msg new file mode 100644 index 0000000..4a3076c --- /dev/null +++ b/crypto/default/cms/testdata/signed-openssl-detached.msg
@@ -0,0 +1,73 @@ +MIME-Version: 1.0 +Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----DDD1F9689000B2862D91BC8B50837CE2" + +This is an S/MIME signed message + +------DDD1F9689000B2862D91BC8B50837CE2 +Content-Type: text/plain + +This is a message. +There are many like it, but this one is mine. + +------DDD1F9689000B2862D91BC8B50837CE2 +Content-Type: application/x-pkcs7-signature; name="smime.p7s" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7s" + +MIIJ+gYJKoZIhvcNAQcCoIIJ6zCCCecCAQExDzANBglghkgBZQMEAgEFADALBgkq +hkiG9w0BBwGgggdEMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3DQEB +CwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRh +aW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUg +VGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjELMAkG +A1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYw +FAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9AiYIFq +rOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRLH1bo +Mdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqWXs4B +AhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX3qzv +31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9etO+ +ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8URwID +AQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0jBBgw +FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B +AQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdfvVvV +JxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0DgDKgJ +mPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHqXYOY +CRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK23SFC +kpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI84xs +VYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0GCSqG +SIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwN +TW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN +U21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1Mlow +ADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzp +emUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQf +DjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn +7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304 +iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1 +rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+Q +W7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NM +IEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4T +PXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQD +AgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11 +c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6g +wYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5 +OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI9 +9JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx +6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzO +eYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8D +MYICejCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNV +BAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNV +BAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqGSIb3 +DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4MjUyOVow +LwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT1s7R +MHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjALBglg +hkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3DQMC +AgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUABIIBAG1q +k8Dp+u6wGtF2CHl1+KwnIYB0r+fe9xFSCzrJkVmqYn4ArQfP0MXsp0mVDIlrUd6o +e3BvXKhbH49dqOIUS+/p05bV+/ncK/p6zS8NtvV6yNw9rKKuqFJj/n1WxUS85Ys8 +SbH3gVCsa6BtYgdb58kWWkHVq1JbfPwmMcC3Azm8hMLemqVMCDk/Ni4uoW8uGual +hdpNrrJpSV+2drRRvZ7RBmm+lAsrQKlSpMq8d13AgYLTBKW3GNVWITOkaTAsUNCf +YXbVOb8wDe5sMBS1/j2tHC6jkUnZ23e8XGT962EaWuTXqwc1Mv+kpDlzb70FJcdC +69k6LSNMisrJQvgN0uo= + +------DDD1F9689000B2862D91BC8B50837CE2-- +
diff --git a/crypto/default/cms/testdata/signed-openssl-ecdsa.msg b/crypto/default/cms/testdata/signed-openssl-ecdsa.msg new file mode 100644 index 0000000..4022d1e --- /dev/null +++ b/crypto/default/cms/testdata/signed-openssl-ecdsa.msg
@@ -0,0 +1,29 @@ +To: user2@foo.bar +From: user1@foo.bar +Subject: An ECDSA signed message +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIIDuQYJKoZIhvcNAQcCoIIDqjCCA6YCAQExDzANBglghkgBZQMEAgEFADBuBgkq +hkiG9w0BBwGgYQRfQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNClRoaXMgaXMg +YSBtZXNzYWdlLg0KVGhlcmUgYXJlIG1hbnkgbGlrZSBpdCwgYnV0IHRoaXMgb25l +IGlzIG1pbmUuDQqgggGRMIIBjTCCATOgAwIBAgIUa+8LZ3I2YsVOmR3jZc3fhOKk +As0wCgYIKoZIzj0EAwIwHjEcMBoGCSqGSIb3DQEJARYNdXNlcjFAZm9vLmJhcjAg +Fw0yNTAyMDQxODEwNTFaGA8yMTI1MDExMTE4MTA1MVowHjEcMBoGCSqGSIb3DQEJ +ARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEohl8KB +SAoHYneQBw2mNtMHlwQnhOJ3SkgxIRmtbjZPv4oAhnAdt+DPfgnDzxaYcvVIfisy +9TlBJWunVTLCER2jTTBLMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcD +BAYIKwYBBQUHAwIwHQYDVR0OBBYEFKdS4cMK47gNgHVz3U1LbGaSrgDrMAoGCCqG +SM49BAMCA0gAMEUCIG2Y1uTARUXlwQDv5R6qkJOrqRXf3uB17vSUPA2uHZzdAiEA +kFBdERbZJydkGzQ48CaUErzidj2T6MOBpSNgXoOhAjExggGJMIIBhQIBATA2MB4x +HDAaBgkqhkiG9w0BCQEWDXVzZXIxQGZvby5iYXICFGvvC2dyNmLFTpkd42XN34Ti +pALNMA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEw +HAYJKoZIhvcNAQkFMQ8XDTI1MDIwNTE0NTQxMVowLwYJKoZIhvcNAQkEMSIEIDSJ +ELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT1s7RMHkGCSqGSIb3DQEJDzFsMGow +CwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjALBglghkgBZQMEAQIwCgYIKoZIhvcN +AwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3DQMCAgFAMAcGBSsOAwIHMA0GCCqG +SIb3DQMCAgEoMAoGCCqGSM49BAMCBEYwRAIgDELOU/9nLirfnYfanDwtY0aJTWjf ++cwj3a8b2Wz0LNECIG1rMYy+qKR3qlQMTsnuvcYygeOhYlhUDrjNXwEMkd1Z +
diff --git a/crypto/default/cms/testdata/signed-openssl-opaque.msg b/crypto/default/cms/testdata/signed-openssl-opaque.msg new file mode 100644 index 0000000..10e469d --- /dev/null +++ b/crypto/default/cms/testdata/signed-openssl-opaque.msg
@@ -0,0 +1,62 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIIKXQYJKoZIhvcNAQcCoIIKTjCCCkoCAQExDzANBglghkgBZQMEAgEFADBuBgkq +hkiG9w0BBwGgYQRfQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNClRoaXMgaXMg +YSBtZXNzYWdlLg0KVGhlcmUgYXJlIG1hbnkgbGlrZSBpdCwgYnV0IHRoaXMgb25l +IGlzIG1pbmUuDQqgggdEMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3 +DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91 +bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21p +bWUgVGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjEL +MAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3 +MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENB +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9Ai +YIFqrOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRL +H1boMdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqW +Xs4BAhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX +3qzv31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9 +etO+ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8U +RwIDAQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0j +BBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG +9w0BAQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdf +vVvVJxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0Dg +DKgJmPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHq +XYOYCRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK2 +3SFCkpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI +84xsVYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0G +CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UE +BwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UE +AwwNU21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1 +MlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2 +xBzpemUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoK +LTQfDjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQ +V6vn7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUK +Z304iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3n +L7C1rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRr +mk+QW7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu +U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2 +xe4TPXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0P +BAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAP +gQ11c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert4 +8r6gwYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EB +kRr5OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg +6bI99JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyD +BNqx6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWn +mlzOeYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukj +Kl8DMYICejCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAU +BgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAU +BgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqG +SIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4MjQ0 +NVowLwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT +1s7RMHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjAL +BglghkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3 +DQMCAgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUABIIB +ADI+8+4mqgv0zPwUenNTbav+2n7oT2wy/5a0Zpp5Dmn08H6Azf9zV91e3ihqM90J +hb2KVapTH4+T+zOsM2oP3by1oUrUM3/RXPtjoW5yo/K37zSo2Gh2f9C11jFsREAv +8pk6lpwIxBSWBI4Ivhju3xKJ/ZJCpre5ofJCzqfSyG8Jfd+LJBeXhWqCw6jMsDdq +PIK3n+ujeIbJam8AGSrCQ7MQqZZl1mU9zed3wihhgopqo5hntaYArG5atOsUYN0M +I1XEAu+NtmMegY0J3Rxm6B2H79AvrfGwY3+tz5MNUxyFGVGOM4kLJ0vfClH+UcaK +SxT8MPwB7JqNg7DGN9NDRWU= +
diff --git a/crypto/default/cms/testdata/signed-openssl-pss.msg b/crypto/default/cms/testdata/signed-openssl-pss.msg new file mode 100644 index 0000000..9a7ff08 --- /dev/null +++ b/crypto/default/cms/testdata/signed-openssl-pss.msg
@@ -0,0 +1,52 @@ +MIME-Version: 1.0 +Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg="sha-256"; boundary="----82DD06720081D00BBE9FFA7C74EEA6DF" + +This is an S/MIME signed message + +------82DD06720081D00BBE9FFA7C74EEA6DF +This is a message. There are many like it, but this one is mine. + +------82DD06720081D00BBE9FFA7C74EEA6DF +Content-Type: application/pkcs7-signature; name="smime.p7s" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7s" + +MIIGoAYJKoZIhvcNAQcCoIIGkTCCBo0CAQExDTALBglghkgBZQMEAgEwCwYJKoZI +hvcNAQcBoIIDpzCCA6MwggKLoAMCAQICAhI1MA0GCSqGSIb3DQEBCwUAMGIxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW +MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQTAg +Fw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1MlowADCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzpemUy4teY0brqNg1dFRnL +PhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQfDjbYNB/L37tANWJO+LRb +i6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn7fFUvLFbA7+9t0mxEYwi +Ykx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304iJhsgtY8HwnA8FV3NoI8 +7Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1rCPvLJqRzGCyQQmbpLQ8 +NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+QW7MCAwEAAaOBwjCBvzAJ +BgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0 +aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4TPXN8E50wHwYDVR0jBBgw +FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQG +CCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11c2VyMUBmb28uYmFyMA0G +CSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6gwYVu2BYXUjrjI5eNYNSX +2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5OJWHEoByWNr+XkS0mUeR +ED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI99JWnZfLFcgGmqUosksfc +xcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx6rtf4hfQ+057TSRepOgC +Q/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzOeYB5itIY6pcImNiO8GVe +dfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8DMYICvzCCArsCAQEwaDBi +MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZp +ZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3Qg +Q0ECAhI1MAsGCWCGSAFlAwQCAaCB+jAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB +MBwGCSqGSIb3DQEJBTEPFw0xNTAzMTMyMzI2MzhaMC8GCSqGSIb3DQEJBDEiBCBG +iE9wB6Onej4OhZJT/RkhZtlDY89mRxIrg3O51sSIujCBjgYJKoZIhvcNAQkPMYGA +MH4wCwYJYIZIAWUDBAEqMAgGBiqFAwICCTAIBgYqhQMCAhUwCwYJYIZIAWUDBAEW +MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZI +hvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwPgYJKoZIhvcNAQEKMDGg +DTALBglghkgBZQMEAgGhGjAYBgkqhkiG9w0BAQgwCwYJYIZIAWUDBAIBogQCAgDe +BIIBAL/u6USKX2/x/lEvLjzQacTvIVBumfk3l8kz/gACG1wgi8F8hxyDS9A4VX7+ +biL+ppwFz4TOrYzCNOTObmEO9Lc74oeFrIy/RMJkoXjoIPwXu0Ugd2sMUvtIwlDj +zMURhXro00ihKo7A62CUTNc9IYKLY57om1t/mg0zIs2/okEF+HxLWP8CnT6V3lJn +CcVj7ZEpx6ElQajOHw8qRZ8pGNOlckuby1pOf+GL0ZQYY+S5hRgZYOL9YJGXDchI +3VikLqXbsYQAXZ78zxyJ37VKFhazaDODYDG0C7xJa/djvXNNcHh9SSA16Wai1w8C +AeWak+m9nWOltzxUJ7S4hGPzee8= + +------82DD06720081D00BBE9FFA7C74EEA6DF-- +
diff --git a/crypto/default/cms/testdata/signed-openssl-sha256.msg b/crypto/default/cms/testdata/signed-openssl-sha256.msg new file mode 100644 index 0000000..bac6938 --- /dev/null +++ b/crypto/default/cms/testdata/signed-openssl-sha256.msg
@@ -0,0 +1,51 @@ +MIME-Version: 1.0 +Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg="sha-256"; boundary="----0FA69BB99AAC88C2CB154D35042F71AD" + +This is an S/MIME signed message + +------0FA69BB99AAC88C2CB154D35042F71AD +This is a message. There are many like it, but this one is mine. + +------0FA69BB99AAC88C2CB154D35042F71AD +Content-Type: application/pkcs7-signature; name="smime.p7s" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7s" + +MIIGbwYJKoZIhvcNAQcCoIIGYDCCBlwCAQExDTALBglghkgBZQMEAgEwCwYJKoZI +hvcNAQcBoIIDpzCCA6MwggKLoAMCAQICAhI1MA0GCSqGSIb3DQEBCwUAMGIxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW +MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQTAg +Fw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1MlowADCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzpemUy4teY0brqNg1dFRnL +PhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQfDjbYNB/L37tANWJO+LRb +i6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn7fFUvLFbA7+9t0mxEYwi +Ykx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304iJhsgtY8HwnA8FV3NoI8 +7Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1rCPvLJqRzGCyQQmbpLQ8 +NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+QW7MCAwEAAaOBwjCBvzAJ +BgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0 +aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4TPXN8E50wHwYDVR0jBBgw +FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQG +CCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11c2VyMUBmb28uYmFyMA0G +CSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6gwYVu2BYXUjrjI5eNYNSX +2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5OJWHEoByWNr+XkS0mUeR +ED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI99JWnZfLFcgGmqUosksfc +xcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx6rtf4hfQ+057TSRepOgC +Q/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzOeYB5itIY6pcImNiO8GVe +dfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8DMYICjjCCAooCAQEwaDBi +MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZp +ZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3Qg +Q0ECAhI1MAsGCWCGSAFlAwQCAaCB+jAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB +MBwGCSqGSIb3DQEJBTEPFw0xNTAzMTcxNzU3NTdaMC8GCSqGSIb3DQEJBDEiBCBG +iE9wB6Onej4OhZJT/RkhZtlDY89mRxIrg3O51sSIujCBjgYJKoZIhvcNAQkPMYGA +MH4wCwYJYIZIAWUDBAEqMAgGBiqFAwICCTAIBgYqhQMCAhUwCwYJYIZIAWUDBAEW +MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZI +hvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAE +ggEAssg83jzMPhOkIE08U0rRtRjxF8zTBrkxiEYo1Fqj3v7UMbNYAkGRAlZR9juP +CuSJQIOkidADaTdvu4ghpN1P0ClPPYyti+5mI1+WkGUpvOLJZpZOTESPd5wQ2/r6 +qz3ciLRxVNaaygJ8Twhu3mq1jzzaP5Zprv7pBM44tvtGo2ehVo/yU5vSSeyNPost +fxcst2ODNMzYtlxxMMuPgRtOYDRpMZurWwm6sunhxDGgqsqLfE/w4DR11Plj1+D6 +5HUF9accHsId+TOCS72dCYZgO1CHWOjecl8cW5ccs6f3aqeqy9QP6TnGYzmk7ZHR +k0Xmh9pugoRbYM4Ja1D/DAnmEg== + +------0FA69BB99AAC88C2CB154D35042F71AD-- +
diff --git a/crypto/default/cms/testdata/signed-outlook-mac-2011.msg b/crypto/default/cms/testdata/signed-outlook-mac-2011.msg new file mode 100644 index 0000000..a6c86c8 --- /dev/null +++ b/crypto/default/cms/testdata/signed-outlook-mac-2011.msg
@@ -0,0 +1,97 @@ +User-Agent: Microsoft-MacOutlook/14.3.8.130913 +Date: Wed, 18 Feb 2015 23:19:02 -0800 +Subject: Testing +From: Laetitia <user1@foo.bar> +To: <user2@foo.bar> +Message-ID: <D10ACE66.1%user1@foo.bar> +Thread-Topic: Testing +Mime-version: 1.0 +Content-type: multipart/signed; + protocol="application/pkcs7-signature"; + micalg=sha256; + boundary="B_3507146347_316634" + +> This message is in MIME format. Since your mail reader does not understand +this format, some or all of this message may not be legible. + +--B_3507146347_316634 +Content-type: multipart/alternative; + boundary="B_3507146342_262900" + + +--B_3507146342_262900 +Content-type: text/plain; + charset="US-ASCII" +Content-transfer-encoding: 7bit + +With signature + + + +--B_3507146342_262900 +Content-type: text/html; + charset="US-ASCII" +Content-transfer-encoding: quoted-printable + +<html><head></head><body style=3D"word-wrap: break-word; -webkit-nbsp-mode: s= +pace; -webkit-line-break: after-white-space; color: rgb(0, 0, 0); font-size:= + 14px; font-family: Calibri, sans-serif;"><div>With signature</div></body></= +html> + +--B_3507146342_262900-- + +--B_3507146347_316634 +Content-type: application/pkcs7-signature; name="smime.p7s" +Content-transfer-encoding: base64 +Content-disposition: attachment; + filename="smime.p7s" + +MIIJfgYJKoZIhvcNAQcCoIIJbzCCCWsCAQExDzANBglghkgBZQMEAgEFADALBgkqhkiG9w0B +BwGgggdEMIIDozCCAougAwIBAgICEjUwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWltZSBU +ZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAwMjg1MloYDzIyODgx +MTI1MDAyODUyWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4169/JKdJtFj +cLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQVHEnC8gpn5cHRyKd5E3vMcX+degotNB8O +Ntg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jwT9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsD +v723SbERjCJiTHQ6jbjW8F+jE7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2 +gjztDWFJeah5wuAEA/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02Lfb +xIAC525MF2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQCMAAw +LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQW +BBTh4y7jea7wZBgt7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9 +njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMBgGA1UdEQQR +MA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcNAQELBQADggEBAG/2ISca34xc4ngSu3jyvqDB +hW7YFhdSOuMjl41g1JfayMdV+5iS/7GssN4qjEiig7cWM82YfTMM2ShPgQGRGvk4lYcSgHJY +2v5eRLSZR5EQPVIWlKMM0VfdAPCRzd9pJpAqt+DGJrYOO9o82aDpsj30ladl8sVyAaapSiyS +x9zFylbW5Evp8pE9Ad9Bb8vk9u1hlC0aJQVRU3q4XIME2rHqu1/iF9D7TntNJF6k6AJD/CGO +45vxnFFguMY5RO3xofEGQsvy0/N2df+phaeaXM55gHmK0hjqlwiY2I7wZV51+/X8szHf6bRG +RWRoeGiqD9/HKYqtGAK5hkGa6SMqXwMwggOZMIICgaADAgECAgkAo4dLLJzooZAwDQYJKoZI +hvcNAQELBQAwYjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFp +biBWaWV3MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENB +MCAXDTE1MDIwNjE4MDQyMVoYDzIyODgxMTIxMTgwNDIxWjBiMQswCQYDVQQGEwJVUzELMAkG +A1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3Qg +Q0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDPHpKAE48p1LJ0SToD0CJggWqs4+tBO44dLeyJkn4q5p+f/bujh9FPMDeS9mV2xT1F +NEqDQnTnOZZk5tRuJEsfVugx2PQZhJPCT8m/j3GxTGEmNKr905Qke2mj+Q0CoJAGPq1mn6Xr +PPnIfUwZepZezgECEgYTYend+EmPLfzhalLNUyrz3KwvNVb1zB0eTT9ucin+qKABAoDx4o7p +lxferO/fURcN8Gbix9xtnBkBAIVEmi8ePO8f1MrG/Yovh1V0Ycp0D7vdMa/rNvVom716076m +mOi4djl1UroSeTH52TzgJNoAE6oXMZPSrqv5zeKxFSK8wTS35oo3IYyEjxRHAgMBAAGjUDBO +MB0GA1UdDgQWBBQB+ocKO2tRlty2pKcfSHPfShj9njAfBgNVHSMEGDAWgBQB+ocKO2tRlty2 +pKcfSHPfShj9njAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAZpWWWakp8Euj9 +FE7HjkXfe09eIlOyXxUL53BFlWIr3VO9J1+9W9UnFREQtyWfDt+2giF2bmlFqsRgJb35a1fM +qsCAiaLR8+Fn0AYRQX74r/QnQOAMqAmY8X6uhq6W4tcdtw1d7wZsCl4GVwYwGzcH9+M7kXFC +cz67WOWJY4TCsxvFUepdg5gJFnwoAHBWZdu5yoPXGupUm9JLwsC/FcOHHx6vpvTQrHrDy7h8 +YOymbHdKsrbdIUKSnLnZ/g+YUQ/+roYnO8HDjcODsqIgyCcdMxOOEagEcq1qh0pGewYFnia4 +0QjzjGxVgIvd4SaCOBuc+CGF1a7IDe1z9azLpk2TMYIB/jCCAfoCAQEwaDBiMQswCQYDVQQG +EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt +aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUA +oGkwLwYJKoZIhvcNAQkEMSIEIDP6/P895N5spXIY/Gxr79A4lWq03XNGwd7r7MhqIvuGMBgG +CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxOTA3MTkwMlow +DQYJKoZIhvcNAQEBBQAEggEAtQ0Vg7RNn5bvrO0CSWl2zsY98LQAJtGRmNUT5dbtQBC3qP4m +ld9asxu7afr1mpH+CBpZZ28N/+5Ab8+h2vthkd7Gt2oInfRJ5B9ABeVkM1gbVjADTt3tPO+0 +3rFR2FpqLipg3OiiRDKog4EWtpr/gSaucAUylyf9V2SDD1u6tubzcuYLvxTudy1vSkZ2G76E +BZWOtZRRW8KFlUJwyOFuV1tCp7Hf3T0mplNivM2nEww2BkiTlmRxPYi5Q783n+lLGA9r8P09 +Ozyulu9DeHciQwVCraGRYqhfLxZ2lf939kbXxdQiAmViRf8k0u0bbJWtLD5TDDczGVxbp9gx +Qx7DHQ== + +--B_3507146347_316634-- +
diff --git a/crypto/default/cms/testdata/signed-thunderbird.msg b/crypto/default/cms/testdata/signed-thunderbird.msg new file mode 100644 index 0000000..e68a16a --- /dev/null +++ b/crypto/default/cms/testdata/signed-thunderbird.msg
@@ -0,0 +1,69 @@ +From - Thu Feb 12 14:25:09 2015 +X-Mozilla-Status: 0800 +X-Mozilla-Status2: 00000000 +X-Mozilla-Keys: +FCC: mailbox://user1@localhost/Sent +X-Identity-Key: id1 +X-Account-Key: account2 +Message-ID: <54DD2845.5040101@foo.bar> +Date: Thu, 12 Feb 2015 14:25:09 -0800 +From: Laetitia Baudoin <user1@foo.bar> +X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.4.0 +MIME-Version: 1.0 +To: user2@foo.bar +Subject: Testing +Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha1; boundary="------------ms040407050600080904030003" + +This is a cryptographically signed message in MIME format. + +--------------ms040407050600080904030003 +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: quoted-printable + +This is my message. +There are many like it but this one is mine. + + +--------------ms040407050600080904030003 +Content-Type: application/pkcs7-signature; name="smime.p7s" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7s" +Content-Description: S/MIME Cryptographic Signature + +MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIDpzCC +A6MwggKLoAMCAQICAhI1MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQI +DAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEW +MBQGA1UEAwwNU21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1 +MlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzpemUy +4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQfDjbYNB/L37tA +NWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn7fFUvLFbA7+9t0mxEYwi +Ykx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304iJhsgtY8HwnA8FV3NoI87Q1hSXmo +ecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo +2HVOtP2z0mfQqD6yxf4UWgRrmk+QW7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG ++EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu +8GQYLez2xe4TPXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0P +BAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11c2Vy +MUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6gwYVu2BYXUjrj +I5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5OJWHEoByWNr+XkS0mUeR +ED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI99JWnZfLFcgGmqUosksfcxcpW1uRL +6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjG +OUTt8aHxBkLL8tPzdnX/qYWnmlzOeYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/f +xymKrRgCuYZBmukjKl8DMYIDUjCCA04CAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwC +Q0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAU +BgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MAkGBSsOAwIaBQCgggG/MBgGCSqGSIb3DQEJAzEL +BgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMjIyMjUwOVowIwYJKoZIhvcNAQkE +MRYEFPt0n9vI8+XW0R8C4icgSPkK0ANeMGwGCSqGSIb3DQEJDzFfMF0wCwYJYIZIAWUDBAEq +MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwIC +AUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwdwYJKwYBBAGCNxAEMWowaDBiMQswCQYDVQQG +EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt +aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MHkGCyqGSIb3DQEJEAIL +MWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZp +ZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1 +MA0GCSqGSIb3DQEBAQUABIIBAAMzzvpz9EBUvXj1X2pq+a6sVG4J1SvwaaRxr7Cn+syeBI6L +7CmjKojuzGvae8Or7rnS6GMpcd3m8kOHrlndmu7KP5nbuVX7+CTrunp9Fgr4ymUNUzc69hoS +2gQq29Ow7EMqpleOSeznzh/lr4iXsmow4j8/Cc+2aZfwfsW0sqe3Yvi644z4DsBW94jErjwo +uHHo+l5xCYB1Uu02uwD9Zau3Hkjlr5rqnH2IgASkE+ghK7nxsvMWKp8342ZP+mxOUAy6wPCe +NiAj9UzRwCz+Be877Wd9nProyJ9oKlMUkfqLnLaYDEdVtcTeextGwxulM22yYFOKGxCdC/SF +QHCfeV4AAAAAAAA= +--------------ms040407050600080904030003--
diff --git a/crypto/default/cms/testdata/signed-type-mismatch.msg b/crypto/default/cms/testdata/signed-type-mismatch.msg new file mode 100644 index 0000000..f32e18f --- /dev/null +++ b/crypto/default/cms/testdata/signed-type-mismatch.msg
@@ -0,0 +1,55 @@ +From: Test <user1@foo.bar> +Content-Type: multipart/signed; + boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC"; + protocol="application/pkcs7-signature"; + micalg=sha1 +X-Smtp-Server: 127.0.0.1 +Subject: Signed with ECC +Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar> +X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8 +Date: Thu, 5 Mar 2015 11:13:11 -0800 +To: user2@foo.bar +Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\)) + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +Is this message signed? + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Disposition: attachment; + filename=smime.p7s +Content-Type: application/pkcs7-signature; + name=smime.p7s +Content-Transfer-Encoding: base64 + +MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAgAAoIIC4DCCAtww +ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW +MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN +U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG +SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA +9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj +gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm +aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW +3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw +DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps +d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7 +tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez +hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk +uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB +ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW +MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFKw4D +AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1 +MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE +MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx +FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG +SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50 +YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC +AhI7MAkGByqGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad +Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/signed-unknown-hash-algorithm.msg b/crypto/default/cms/testdata/signed-unknown-hash-algorithm.msg new file mode 100644 index 0000000..aa78792 --- /dev/null +++ b/crypto/default/cms/testdata/signed-unknown-hash-algorithm.msg
@@ -0,0 +1,55 @@ +From: Test <user1@foo.bar> +Content-Type: multipart/signed; + boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC"; + protocol="application/pkcs7-signature"; + micalg=sha1 +X-Smtp-Server: 127.0.0.1 +Subject: Signed with ECC +Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar> +X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8 +Date: Thu, 5 Mar 2015 11:13:11 -0800 +To: user2@foo.bar +Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\)) + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +Is this message signed? + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Disposition: attachment; + filename=smime.p7s +Content-Type: application/pkcs7-signature; + name=smime.p7s +Content-Transfer-Encoding: base64 + +MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIC4DCCAtww +ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW +MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN +U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG +SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA +9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj +gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm +aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW +3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw +DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps +d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7 +tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez +hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk +uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB +ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW +MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFLA4D +AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1 +MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE +MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx +FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG +SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50 +YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC +AhI7MAkGByqGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad +Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/signed-unknown-signature-algorithm.msg b/crypto/default/cms/testdata/signed-unknown-signature-algorithm.msg new file mode 100644 index 0000000..1dba888 --- /dev/null +++ b/crypto/default/cms/testdata/signed-unknown-signature-algorithm.msg
@@ -0,0 +1,55 @@ +From: Test <user1@foo.bar> +Content-Type: multipart/signed; + boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC"; + protocol="application/pkcs7-signature"; + micalg=sha1 +X-Smtp-Server: 127.0.0.1 +Subject: Signed with ECC +Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar> +X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8 +Date: Thu, 5 Mar 2015 11:13:11 -0800 +To: user2@foo.bar +Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\)) + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +Is this message signed? + + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC +Content-Disposition: attachment; + filename=smime.p7s +Content-Type: application/pkcs7-signature; + name=smime.p7s +Content-Transfer-Encoding: base64 + +MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIC4DCCAtww +ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW +MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN +U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG +SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA +9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj +gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm +aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW +3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw +DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps +d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7 +tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez +hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk +uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB +ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW +MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFKw4D +AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1 +MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE +MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx +FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG +SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50 +YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC +AhI7MAkGByuGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad +Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA + +--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/two-signatures-openssl-detached.msg b/crypto/default/cms/testdata/two-signatures-openssl-detached.msg new file mode 100644 index 0000000..3738509 --- /dev/null +++ b/crypto/default/cms/testdata/two-signatures-openssl-detached.msg
@@ -0,0 +1,107 @@ +MIME-Version: 1.0 +Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----B206BF1D504B79DB9C6B134A1649A155" + +This is an S/MIME signed message + +------B206BF1D504B79DB9C6B134A1649A155 +Content-Type: text/plain + +Content-Type: text/plain + +This is a message. +There are many like it, but this one is mine. + +------B206BF1D504B79DB9C6B134A1649A155 +Content-Type: application/x-pkcs7-signature; name="smime.p7s" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7s" + +MIIP/QYJKoZIhvcNAQcCoIIP7jCCD+oCAQExDzANBglghkgBZQMEAgEFADALBgkq +hkiG9w0BBwGgggrrMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3DQEB +CwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRh +aW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUg +VGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjELMAkG +A1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYw +FAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9AiYIFq +rOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRLH1bo +Mdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqWXs4B +AhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX3qzv +31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9etO+ +ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8URwID +AQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0jBBgw +FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B +AQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdfvVvV +JxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0DgDKgJ +mPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHqXYOY +CRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK23SFC +kpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI84xs +VYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0GCSqG +SIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwN +TW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN +U21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1Mlow +ADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzp +emUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQf +DjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn +7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304 +iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1 +rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+Q +W7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NM +IEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4T +PXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQD +AgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11 +c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6g +wYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5 +OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI9 +9JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx +6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzO +eYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8D +MIIDozCCAougAwIBAgICEjYwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw +MzAwM1oYDzIyODgxMTI1MDAzMDAzWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnp +pXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1w +u4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiu +NVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/ +20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45 +TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQCMAAw +LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G +A1UdDgQWBBQhRBpKe5lzyXs+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tR +lty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG +CCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcNAQEL +BQADggEBAEuJfaW7Rq3qyphR3BVnZ1aColHkQLcEsddzbf57GWJjrVbwvV1UIeMA +f0H2UCsc+8GLYcXCvy34IultcxGL7g6n7wWnfCV/lY0Jd5eRQSit0BGygQdib7cT +20JJy/3et2UnekVHnJhrEv5CigwaOs9HkIL9C3XJHqYYHyHL6QTLFp5JnxqV4YWU +hq16Jfk5no7BA6Sfhpn2cyn4rGoHUKmFBeN9VePpV2aKRZ31HqEtSHKZmxZXAzbY +SrIXYRBsMVab+TnwNkiQXAZD73JbboblOiXgNUbxe2/aLcRNW7uY42DRU1QGR9Ww +edKgv9ZRRDmd3vSSrdP5X9BZNDNOhtcxggTWMIICWAIBATBoMGIxCzAJBgNVBAYT +AlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UE +CgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjYwDQYJ +YIZIAWUDBAIBBQCggcYwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAvBgkqhkiG +9w0BCQQxIgQgNIkQuj6NwdURYu6dxjTIoLVXR5rpXFksBE3IUBPWztEweQYJKoZI +hvcNAQkPMWwwajALBglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQB +AjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYF +Kw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAs64WiItCT6GU +hM2COtpThWxNeQZdR6Mzk7IzctoGr7nDjWEOhR7yWw/uT9F98XnIR2+wLcHMTtNH +qaxLax7BUI0xmAJk6hsDfYfZyz0DxGKHH5b1/rO+hD81ao0Wf+3eu+OJHp1FoRYb +RLEWzDnPFFSYjgJ7MJ8vKaTA77ibQfniq2O25EbGnW/A4LZ3KZIbfB2mqgJvPmJF +2N4yf35Voke2X4APsJB/LEjMbS72QIlaAYFqNw1UR+9dE9NgOGs0LrrHNQ8Ebi1g +mzx2GIZF5pS2gViURaec8UKy7IXL0c5+NbuJBAgJmYVA3RpvoO/58GiFiffohrHE +YIofgJWi6zCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAU +BgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAU +BgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqG +SIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4MjUy +OVowLwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT +1s7RMHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjAL +BglghkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3 +DQMCAgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUABIIB +AG1qk8Dp+u6wGtF2CHl1+KwnIYB0r+fe9xFSCzrJkVmqYn4ArQfP0MXsp0mVDIlr +Ud6oe3BvXKhbH49dqOIUS+/p05bV+/ncK/p6zS8NtvV6yNw9rKKuqFJj/n1WxUS8 +5Ys8SbH3gVCsa6BtYgdb58kWWkHVq1JbfPwmMcC3Azm8hMLemqVMCDk/Ni4uoW8u +GualhdpNrrJpSV+2drRRvZ7RBmm+lAsrQKlSpMq8d13AgYLTBKW3GNVWITOkaTAs +UNCfYXbVOb8wDe5sMBS1/j2tHC6jkUnZ23e8XGT962EaWuTXqwc1Mv+kpDlzb70F +JcdC69k6LSNMisrJQvgN0uo= + +------B206BF1D504B79DB9C6B134A1649A155-- +
diff --git a/crypto/default/cms/testdata/two-signatures-openssl-opaque.msg b/crypto/default/cms/testdata/two-signatures-openssl-opaque.msg new file mode 100644 index 0000000..01da657 --- /dev/null +++ b/crypto/default/cms/testdata/two-signatures-openssl-opaque.msg
@@ -0,0 +1,94 @@ +MIME-Version: 1.0 +Content-Disposition: attachment; filename="smime.p7m" +Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m" +Content-Transfer-Encoding: base64 + +MIIQYAYJKoZIhvcNAQcCoIIQUTCCEE0CAQExDzANBglghkgBZQMEAgEFADBuBgkq +hkiG9w0BBwGgYQRfQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNClRoaXMgaXMg +YSBtZXNzYWdlLg0KVGhlcmUgYXJlIG1hbnkgbGlrZSBpdCwgYnV0IHRoaXMgb25l +IGlzIG1pbmUuDQqgggrrMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3 +DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91 +bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21p +bWUgVGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjEL +MAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3 +MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENB +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9Ai +YIFqrOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRL +H1boMdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqW +Xs4BAhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX +3qzv31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9 +etO+ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8U +RwIDAQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0j +BBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG +9w0BAQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdf +vVvVJxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0Dg +DKgJmPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHq +XYOYCRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK2 +3SFCkpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI +84xsVYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0G +CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UE +BwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UE +AwwNU21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1 +MlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2 +xBzpemUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoK +LTQfDjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQ +V6vn7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUK +Z304iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3n +L7C1rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRr +mk+QW7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu +U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2 +xe4TPXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0P +BAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAP +gQ11c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert4 +8r6gwYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EB +kRr5OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg +6bI99JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyD +BNqx6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWn +mlzOeYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukj +Kl8DMIIDozCCAougAwIBAgICEjYwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMC +VVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQK +DA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIx +MDAwMzAwM1oYDzIyODgxMTI1MDAzMDAzWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSk +aTnppXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU +8F1wu4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno +3iiuNVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+ +j0f/20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/a +Yv45TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQC +MAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRl +MB0GA1UdDgQWBBQhRBpKe5lzyXs+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocK +O2tRlty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUH +AwIGCCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcN +AQELBQADggEBAEuJfaW7Rq3qyphR3BVnZ1aColHkQLcEsddzbf57GWJjrVbwvV1U +IeMAf0H2UCsc+8GLYcXCvy34IultcxGL7g6n7wWnfCV/lY0Jd5eRQSit0BGygQdi +b7cT20JJy/3et2UnekVHnJhrEv5CigwaOs9HkIL9C3XJHqYYHyHL6QTLFp5JnxqV +4YWUhq16Jfk5no7BA6Sfhpn2cyn4rGoHUKmFBeN9VePpV2aKRZ31HqEtSHKZmxZX +AzbYSrIXYRBsMVab+TnwNkiQXAZD73JbboblOiXgNUbxe2/aLcRNW7uY42DRU1QG +R9WwedKgv9ZRRDmd3vSSrdP5X9BZNDNOhtcxggTWMIICWAIBATBoMGIxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG +A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjYw +DQYJYIZIAWUDBAIBBQCggcYwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAvBgkq +hkiG9w0BCQQxIgQgNIkQuj6NwdURYu6dxjTIoLVXR5rpXFksBE3IUBPWztEweQYJ +KoZIhvcNAQkPMWwwajALBglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFl +AwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAw +BwYFKw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAs64WiItC +T6GUhM2COtpThWxNeQZdR6Mzk7IzctoGr7nDjWEOhR7yWw/uT9F98XnIR2+wLcHM +TtNHqaxLax7BUI0xmAJk6hsDfYfZyz0DxGKHH5b1/rO+hD81ao0Wf+3eu+OJHp1F +oRYbRLEWzDnPFFSYjgJ7MJ8vKaTA77ibQfniq2O25EbGnW/A4LZ3KZIbfB2mqgJv +PmJF2N4yf35Voke2X4APsJB/LEjMbS72QIlaAYFqNw1UR+9dE9NgOGs0LrrHNQ8E +bi1gmzx2GIZF5pS2gViURaec8UKy7IXL0c5+NbuJBAgJmYVA3RpvoO/58GiFiffo +hrHEYIofgJWi6zCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0Ex +FjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0Ex +FjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgG +CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4 +MjQ0NVowLwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARN +yFAT1s7RMHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQB +FjALBglghkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqG +SIb3DQMCAgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUA +BIIBADI+8+4mqgv0zPwUenNTbav+2n7oT2wy/5a0Zpp5Dmn08H6Azf9zV91e3ihq +M90Jhb2KVapTH4+T+zOsM2oP3by1oUrUM3/RXPtjoW5yo/K37zSo2Gh2f9C11jFs +REAv8pk6lpwIxBSWBI4Ivhju3xKJ/ZJCpre5ofJCzqfSyG8Jfd+LJBeXhWqCw6jM +sDdqPIK3n+ujeIbJam8AGSrCQ7MQqZZl1mU9zed3wihhgopqo5hntaYArG5atOsU +YN0MI1XEAu+NtmMegY0J3Rxm6B2H79AvrfGwY3+tz5MNUxyFGVGOM4kLJ0vfClH+ +UcaKSxT8MPwB7JqNg7DGN9NDRWU= +
diff --git a/crypto/default/cms/testdata/user1.key b/crypto/default/cms/testdata/user1.key new file mode 100644 index 0000000..7eb2f6c --- /dev/null +++ b/crypto/default/cms/testdata/user1.key
@@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA4169/JKdJtFjcLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHgg +HzQVHEnC8gpn5cHRyKd5E3vMcX+degotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/ +W5jwT9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW +8F+jE7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5 +wuAEA/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC +525MF2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABAoIBAQDENkJl8HBFy3Sx +FEfxW1sQcfVocKytTOkQZz+KXuZbqfY8vp2Zz+xX2R+umGRI1hYfvsaxlag8WhCf +/KA91n8BvVpeBiYxBZwMFnJx0n6XMk2sooEWnsjjXeAJxPdXWtVLdYNIA6ULJCcO +LiM6ao/gL2wW8aAum53Mrk2iTRnfNt3KEhKpMdKMlq4R01mVBVEEEGPXDvkrbNRg +d2uMoNooUo6X7339tX/iZZFnbyT+oen7mdzB7L0juT+ZAXKx8imvqr3LEHrQ9nz8 +9ah2+bnGCk3u19x28i8QBCO0MN8MbLccVc2WAvf50jppmpQ42IZifKsJVp2AzpTt +9Z5lPIXBAoGBAPVhnhv3ZkprY5PBYtppBYVFsax0dDgryONIGybpOIixyv2wVTJ7 +IsP125LaizNTMDuH9F6i1vhHxR8gAoDnKngasx2kWs5W3uno9sCA/UahwdzxKe4t +qVaGb9S6aZTOT2uzxZF2dW6dgbPAiMN+JefqRYJn6Fge+ZuDdnTezYNJAoGBAO01 +l7G96QQS4U68ZEQGIfHpdYFGWO9APx3EsVkTQ8/Xp3IW3Vr61uRd2FlzzNQCVKXh +5dLBslKU7TuMU2qdfEaFtG8NnVUNp2njqlucayKSZkpshoGhCNpHjUcqc+X1wAlT +0yYoWL7IdGMHD21YddIIo0IOnzvPqOLXBZEV5GsbAoGAQU85CNKBLD+G4lWvXNhk ++sLA9liMckLdFfkkGOgr/t3rXDgphOUXLLbsORrnv8JdbQrTdLi8pJpdkdowMGTW +fd8FW1daP98JN5Kk0jnCr6Dv+0FI2rbWgkwHycPPxCZhJxZS+FNiEA3l7pTJEdcq +eZ51366OPZ4I62ZDCfofEtkCgYA6c2tGxAooE9LrCOcYD/7LUoC+8yu1ALrMxgwB +bArYq0Y6icHPymGrcih+mldNoqljGs/75/jd+o6ejsx7SAU4h3fXqZ4sOCoZoJz4 +6AlKQpLsIePfC2RYDSuV7rMSZkUEx2APVf/QOyrawAGgKx0VPUWlYF8CaQAvrVZU +0vUvIwKBgDL5Wb+AHv1ffWJkPYPiR6JyEEpWExBIuZc9L3+YXCIzJDk7nVJfYUrb +M0Yl6DVgISn/joxEOuvFeq+De88WIoKxXcQo6oFVOihAgooA3AncpHuLIY0cy8oS +1MFE8Ms4p4RpVZYedYOIugIU6sS3V6vFXe9L1Rr5Edji4cg6qYQ6 +-----END RSA PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user1_bad_public_key.pem b/crypto/default/cms/testdata/user1_bad_public_key.pem new file mode 100644 index 0000000..f536e93 --- /dev/null +++ b/crypto/default/cms/testdata/user1_bad_public_key.pem
@@ -0,0 +1,69 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4661 (0x1235) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C = US, ST = CA, L = Mountain View, O = Smime Test CA, CN = Smime Test CA + Validity + Not Before: Feb 10 00:28:52 2015 GMT + Not After : Nov 25 00:28:52 2288 GMT + Subject: + Subject Public Key Info: + Public Key Algorithm: 1.2.840.113554.4.1.72585.2 + Unable to load Public Key +140710245086528:error:0609E09C:digital envelope routines:pkey_set_type:unsupported algorithm:../crypto/evp/p_lib.c:210: +140710245086528:error:0B09406F:x509 certificate routines:x509_pubkey_decode:unsupported algorithm:../crypto/x509/x_pubkey.c:114: + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + X509v3 Subject Alternative Name: + email:user1@foo.bar + Signature Algorithm: sha256WithRSAEncryption + 6f:f6:21:27:1a:df:8c:5c:e2:78:12:bb:78:f2:be:a0:c1:85: + 6e:d8:16:17:52:3a:e3:23:97:8d:60:d4:97:da:c8:c7:55:fb: + 98:92:ff:b1:ac:b0:de:2a:8c:48:a2:83:b7:16:33:cd:98:7d: + 33:0c:d9:28:4f:81:01:91:1a:f9:38:95:87:12:80:72:58:da: + fe:5e:44:b4:99:47:91:10:3d:52:16:94:a3:0c:d1:57:dd:00: + f0:91:cd:df:69:26:90:2a:b7:e0:c6:26:b6:0e:3b:da:3c:d9: + a0:e9:b2:3d:f4:95:a7:65:f2:c5:72:01:a6:a9:4a:2c:92:c7: + dc:c5:ca:56:d6:e4:4b:e9:f2:91:3d:01:df:41:6f:cb:e4:f6: + ed:61:94:2d:1a:25:05:51:53:7a:b8:5c:83:04:da:b1:ea:bb: + 5f:e2:17:d0:fb:4e:7b:4d:24:5e:a4:e8:02:43:fc:21:8e:e3: + 9b:f1:9c:51:60:b8:c6:39:44:ed:f1:a1:f1:06:42:cb:f2:d3: + f3:76:75:ff:a9:85:a7:9a:5c:ce:79:80:79:8a:d2:18:ea:97: + 08:98:d8:8e:f0:65:5e:75:fb:f5:fc:b3:31:df:e9:b4:46:45: + 64:68:78:68:aa:0f:df:c7:29:8a:ad:18:02:b9:86:41:9a:e9: + 23:2a:5f:03 +-----BEGIN CERTIFICATE----- +MIIDpjCCAo6gAwIBAgICEjUwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw +Mjg1MloYDzIyODgxMTI1MDAyODUyWjAAMIIBJTAQBgwqhkiG9xIEAYS3CQIFAAOC +AQ8AMIIBCgKCAQEA4169/JKdJtFjcLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHgg +HzQVHEnC8gpn5cHRyKd5E3vMcX+degotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/ +W5jwT9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW +8F+jE7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5 +wuAEA/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC +525MF2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQC +MAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRl +MB0GA1UdDgQWBBTh4y7jea7wZBgt7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocK +O2tRlty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUH +AwIGCCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcN +AQELBQADggEBAG/2ISca34xc4ngSu3jyvqDBhW7YFhdSOuMjl41g1JfayMdV+5iS +/7GssN4qjEiig7cWM82YfTMM2ShPgQGRGvk4lYcSgHJY2v5eRLSZR5EQPVIWlKMM +0VfdAPCRzd9pJpAqt+DGJrYOO9o82aDpsj30ladl8sVyAaapSiySx9zFylbW5Evp +8pE9Ad9Bb8vk9u1hlC0aJQVRU3q4XIME2rHqu1/iF9D7TntNJF6k6AJD/CGO45vx +nFFguMY5RO3xofEGQsvy0/N2df+phaeaXM55gHmK0hjqlwiY2I7wZV51+/X8szHf +6bRGRWRoeGiqD9/HKYqtGAK5hkGa6SMqXwM= +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_both.pem b/crypto/default/cms/testdata/user1_both.pem new file mode 100644 index 0000000..ca6d320 --- /dev/null +++ b/crypto/default/cms/testdata/user1_both.pem
@@ -0,0 +1,88 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4664 (0x1238) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 10 18:14:33 2015 GMT + Not After : Nov 25 18:14:33 2288 GMT + Subject: emailAddress=user1@foo.bar + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:e3:5e:bd:fc:92:9d:26:d1:63:70:b6:c4:1c:e9: + 7a:65:32:e2:d7:98:d1:ba:ea:36:0d:5d:15:19:cb: + 3e:18:ce:7d:24:78:20:1f:34:15:1c:49:c2:f2:0a: + 67:e5:c1:d1:c8:a7:79:13:7b:cc:71:7f:9d:7a:0a: + 2d:34:1f:0e:36:d8:34:1f:cb:df:bb:40:35:62:4e: + f8:b4:5b:8b:a8:a9:34:3b:19:3f:5b:98:f0:4f:d7: + 75:45:47:24:af:21:6d:30:72:34:e4:f6:84:6b:e1: + 72:75:50:57:ab:e7:ed:f1:54:bc:b1:5b:03:bf:bd: + b7:49:b1:11:8c:22:62:4c:74:3a:8d:b8:d6:f0:5f: + a3:13:b8:9c:a4:0f:69:ca:f6:e5:82:8e:2e:d4:e7: + a0:3c:59:68:55:0a:67:7d:38:88:98:6c:82:d6:3c: + 1f:09:c0:f0:55:77:36:82:3c:ed:0d:61:49:79:a8: + 79:c2:e0:04:03:ff:56:6d:b1:9b:6e:cd:25:4b:ab: + ab:b8:06:e7:32:69:d1:bd:e7:2f:b0:b5:ac:23:ef: + 2c:9a:91:cc:60:b2:41:09:9b:a4:b4:3c:34:d8:b7: + db:c4:80:02:e7:6e:4c:17:68:d8:75:4e:b4:fd:b3: + d2:67:d0:a8:3e:b2:c5:fe:14:5a:04:6b:9a:4f:90: + 5b:b3 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + X509v3 Subject Alternative Name: + email:user1@foo.bar + Signature Algorithm: sha256WithRSAEncryption + 70:f5:eb:dd:49:85:89:34:66:71:df:b9:30:dd:f7:89:1a:63: + 6a:68:60:c3:b1:0e:b1:f1:73:66:46:d6:02:d4:58:48:83:49: + a3:8d:98:e1:b8:0b:e8:71:e7:cb:8c:b1:15:2e:31:08:bc:17: + 69:b5:8d:d6:02:27:b9:04:6e:ab:b2:36:28:25:de:b0:49:da: + 00:b3:2b:25:56:49:52:5a:f0:e5:6a:51:6e:25:f0:a2:bc:7c: + e7:0f:aa:ea:10:18:ac:24:95:f5:2c:7b:1c:37:fa:e4:b9:72: + 32:02:a4:fb:1d:e4:a0:2b:39:76:07:11:04:4a:36:9a:b6:86: + a2:77:3f:e7:31:c8:13:35:4c:97:ec:30:fc:3f:d0:23:79:bf: + ea:11:6d:b8:97:0c:13:6f:28:76:11:30:5e:b7:a4:2a:d3:d7: + f0:ee:9a:19:c9:e4:2d:8a:44:84:95:6e:68:a4:3e:84:4b:30: + b9:98:bb:53:1d:5f:55:9e:ff:9e:db:3f:74:a6:6c:30:c8:c6: + 8f:9a:6d:cc:f3:f9:48:6e:82:5c:a0:a2:13:d7:d3:59:07:18: + 83:78:be:59:2f:6e:52:20:7d:95:43:8d:ce:e2:ed:76:96:98: + 5a:5d:ba:cd:f8:b7:b2:dc:62:9d:86:55:4a:45:ee:d7:9b:51: + f8:85:d3:68 +-----BEGIN CERTIFICATE----- +MIIDwTCCAqmgAwIBAgICEjgwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4 +MTQzM1oYDzIyODgxMTI1MTgxNDMzWjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMUBm +b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4169/JKdJtFj +cLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQVHEnC8gpn5cHRyKd5E3vMcX+d +egotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jwT9d1RUckryFtMHI05PaEa+Fy +dVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW8F+jE7icpA9pyvblgo4u1OegPFlo +VQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5wuAEA/9WbbGbbs0lS6uruAbnMmnR +vecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC525MF2jYdU60/bPSZ9CoPrLF/hRa +BGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w +ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBTh4y7jea7wZBgt +7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV +HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMBgGA1UdEQQR +MA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcNAQELBQADggEBAHD1691JhYk0ZnHf +uTDd94kaY2poYMOxDrHxc2ZG1gLUWEiDSaONmOG4C+hx58uMsRUuMQi8F2m1jdYC +J7kEbquyNigl3rBJ2gCzKyVWSVJa8OVqUW4l8KK8fOcPquoQGKwklfUsexw3+uS5 +cjICpPsd5KArOXYHEQRKNpq2hqJ3P+cxyBM1TJfsMPw/0CN5v+oRbbiXDBNvKHYR +MF63pCrT1/DumhnJ5C2KRISVbmikPoRLMLmYu1MdX1We/57bP3SmbDDIxo+abczz ++UhuglygohPX01kHGIN4vlkvblIgfZVDjc7i7XaWmFpdus34t7LcYp2GVUpF7teb +UfiF02g= +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_cn_only.pem b/crypto/default/cms/testdata/user1_cn_only.pem new file mode 100644 index 0000000..86135dd --- /dev/null +++ b/crypto/default/cms/testdata/user1_cn_only.pem
@@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4665 (0x1239) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 10 18:16:16 2015 GMT + Not After : Nov 25 18:16:16 2288 GMT + Subject: emailAddress=user1@foo.bar + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:e3:5e:bd:fc:92:9d:26:d1:63:70:b6:c4:1c:e9: + 7a:65:32:e2:d7:98:d1:ba:ea:36:0d:5d:15:19:cb: + 3e:18:ce:7d:24:78:20:1f:34:15:1c:49:c2:f2:0a: + 67:e5:c1:d1:c8:a7:79:13:7b:cc:71:7f:9d:7a:0a: + 2d:34:1f:0e:36:d8:34:1f:cb:df:bb:40:35:62:4e: + f8:b4:5b:8b:a8:a9:34:3b:19:3f:5b:98:f0:4f:d7: + 75:45:47:24:af:21:6d:30:72:34:e4:f6:84:6b:e1: + 72:75:50:57:ab:e7:ed:f1:54:bc:b1:5b:03:bf:bd: + b7:49:b1:11:8c:22:62:4c:74:3a:8d:b8:d6:f0:5f: + a3:13:b8:9c:a4:0f:69:ca:f6:e5:82:8e:2e:d4:e7: + a0:3c:59:68:55:0a:67:7d:38:88:98:6c:82:d6:3c: + 1f:09:c0:f0:55:77:36:82:3c:ed:0d:61:49:79:a8: + 79:c2:e0:04:03:ff:56:6d:b1:9b:6e:cd:25:4b:ab: + ab:b8:06:e7:32:69:d1:bd:e7:2f:b0:b5:ac:23:ef: + 2c:9a:91:cc:60:b2:41:09:9b:a4:b4:3c:34:d8:b7: + db:c4:80:02:e7:6e:4c:17:68:d8:75:4e:b4:fd:b3: + d2:67:d0:a8:3e:b2:c5:fe:14:5a:04:6b:9a:4f:90: + 5b:b3 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + Signature Algorithm: sha256WithRSAEncryption + c3:cc:70:5c:23:0f:d8:b4:35:75:48:7d:01:6c:21:fb:23:06: + b3:c0:fc:e1:14:57:2f:93:98:57:66:48:03:6c:4b:a4:61:b9: + d1:b2:69:1c:23:1a:2a:9d:ed:d5:3d:22:61:88:b9:f7:f4:3a: + b9:8b:e6:02:74:5f:67:de:84:4d:73:4d:c1:e0:dd:f6:e6:b9: + 00:4a:54:14:d7:70:9e:ac:11:4b:5d:bc:07:fe:2e:d2:9a:99: + dc:da:88:3f:98:c0:e1:1a:10:53:66:81:0f:e0:18:4b:a7:83: + a5:eb:bb:27:cc:44:95:be:6b:7b:eb:4d:82:1e:a2:52:da:78: + f7:b1:1d:7c:ce:91:97:0e:4d:14:34:8c:b3:8d:86:ac:4f:1f: + 45:d1:bc:eb:40:f7:a7:56:55:7e:45:a2:2b:1c:12:33:6a:7d: + b8:7e:fa:1d:79:5a:0c:d4:83:19:22:d9:f5:04:3c:89:28:0b: + 3e:38:d7:71:93:fa:d9:45:d5:7a:5e:ce:63:47:91:2f:74:c9: + 50:dd:8d:9f:cf:f0:ab:7e:c6:b2:4f:48:e8:61:71:7a:28:12: + ea:f2:bf:4c:e8:7e:f7:6f:44:a8:35:a7:82:47:bd:7a:ae:75: + c8:f8:94:7c:42:7a:2e:bf:30:b4:31:31:b3:2e:98:0a:25:a0: + 57:f7:11:77 +-----BEGIN CERTIFICATE----- +MIIDpzCCAo+gAwIBAgICEjkwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4 +MTYxNloYDzIyODgxMTI1MTgxNjE2WjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMUBm +b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4169/JKdJtFj +cLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQVHEnC8gpn5cHRyKd5E3vMcX+d +egotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jwT9d1RUckryFtMHI05PaEa+Fy +dVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW8F+jE7icpA9pyvblgo4u1OegPFlo +VQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5wuAEA/9WbbGbbs0lS6uruAbnMmnR +vecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC525MF2jYdU60/bPSZ9CoPrLF/hRa +BGuaT5BbswIDAQABo4GoMIGlMAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w +ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBTh4y7jea7wZBgt +7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV +HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMA0GCSqGSIb3 +DQEBCwUAA4IBAQDDzHBcIw/YtDV1SH0BbCH7IwazwPzhFFcvk5hXZkgDbEukYbnR +smkcIxoqne3VPSJhiLn39Dq5i+YCdF9n3oRNc03B4N325rkASlQU13CerBFLXbwH +/i7Smpnc2og/mMDhGhBTZoEP4BhLp4Ol67snzESVvmt7602CHqJS2nj3sR18zpGX +Dk0UNIyzjYasTx9F0bzrQPenVlV+RaIrHBIzan24fvodeVoM1IMZItn1BDyJKAs+ +ONdxk/rZRdV6Xs5jR5EvdMlQ3Y2fz/CrfsayT0joYXF6KBLq8r9M6H73b0SoNaeC +R716rnXI+JR8QnouvzC0MTGzLpgKJaBX9xF3 +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_ecc.key b/crypto/default/cms/testdata/user1_ecc.key new file mode 100644 index 0000000..fc16b97 --- /dev/null +++ b/crypto/default/cms/testdata/user1_ecc.key
@@ -0,0 +1,8 @@ +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEII1hkPn+AzrDtXMdbb2Z0F1dZOKiW/ZtxmI2J8ugy8lioAoGCCqGSM49 +AwEHoUQDQgAELeZnmRirlkD1IVa7hRIK6DVio+ztb2UuewxyQHYTKx8Js8GISIqf +o3DfBCCUnKvQFZo4h63rbA7qa/IwqsVC5g== +-----END EC PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user1_ecc.pem b/crypto/default/cms/testdata/user1_ecc.pem new file mode 100644 index 0000000..bbf2120 --- /dev/null +++ b/crypto/default/cms/testdata/user1_ecc.pem
@@ -0,0 +1,68 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4667 (0x123b) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 28 00:22:18 2015 GMT + Not After : Dec 13 00:22:18 2288 GMT + Subject: emailAddress=user1@foo.bar + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:2d:e6:67:99:18:ab:96:40:f5:21:56:bb:85:12: + 0a:e8:35:62:a3:ec:ed:6f:65:2e:7b:0c:72:40:76: + 13:2b:1f:09:b3:c1:88:48:8a:9f:a3:70:df:04:20: + 94:9c:ab:d0:15:9a:38:87:ad:eb:6c:0e:ea:6b:f2: + 30:aa:c5:42:e6 + ASN1 OID: prime256v1 + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 9B:6E:AE:67:94:5D:61:FB:73:63:71:44:8C:E0:F2:EC:BA:43:CB:A0 + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + Signature Algorithm: sha256WithRSAEncryption + a8:7a:ae:b8:a3:ce:00:24:9e:08:11:01:6d:87:31:ef:db:01: + 4a:e9:23:ab:c1:08:c3:3e:0b:50:e7:c5:0c:57:0a:d8:2e:d5: + 0a:6c:77:89:4c:24:01:4e:bf:b0:5f:55:d4:92:cc:9c:01:3a: + f1:3a:4a:60:3c:e6:63:a7:ed:30:f6:bf:55:63:90:1c:79:09: + 03:34:14:e6:7a:48:ca:66:b2:b1:51:90:99:e7:0a:e2:62:09: + 6f:bf:f1:46:bb:b5:ab:3a:6a:f1:ea:09:4b:c9:ee:1f:3e:fd: + 1d:53:4c:d2:54:3f:98:55:45:df:4e:7e:0d:72:5e:74:86:24: + 20:57:a7:93:98:cf:7d:8c:ee:67:85:23:72:77:d3:4f:89:77: + e0:54:f7:7a:e1:0f:91:33:85:26:e5:bb:25:4a:a4:21:f7:02: + 68:bd:7b:fa:96:d7:77:24:81:d6:8c:17:63:31:2a:39:db:fd: + c9:37:ca:57:66:b6:13:01:a4:59:93:ac:32:7a:03:1f:fe:b3: + 04:a4:7e:3b:54:68:d5:d5:f3:26:64:b9:24:83:58:85:9a:43: + 66:1e:a5:ae:17:d5:d3:9a:0c:dc:e3:e6:85:67:7c:74:5e:64: + f6:4b:37:33:7e:fd:90:3b:33:7e:2a:46:9f:34:e7:9d:10:b9: + 6b:0d:4c:87 +-----BEGIN CERTIFICATE----- +MIIC3DCCAcSgAwIBAgICEjswDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIyODAw +MjIxOFoYDzIyODgxMjEzMDAyMjE4WjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMUBm +b28uYmFyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELeZnmRirlkD1IVa7hRIK +6DVio+ztb2UuewxyQHYTKx8Js8GISIqfo3DfBCCUnKvQFZo4h63rbA7qa/IwqsVC +5qOBqDCBpTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVy +YXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUm26uZ5RdYftzY3FEjODy7LpDy6Aw +HwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0G +A1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDANBgkqhkiG9w0BAQsFAAOCAQEA +qHquuKPOACSeCBEBbYcx79sBSukjq8EIwz4LUOfFDFcK2C7VCmx3iUwkAU6/sF9V +1JLMnAE68TpKYDzmY6ftMPa/VWOQHHkJAzQU5npIymaysVGQmecK4mIJb7/xRru1 +qzpq8eoJS8nuHz79HVNM0lQ/mFVF305+DXJedIYkIFenk5jPfYzuZ4UjcnfTT4l3 +4FT3euEPkTOFJuW7JUqkIfcCaL17+pbXdySB1owXYzEqOdv9yTfKV2a2EwGkWZOs +MnoDH/6zBKR+O1Ro1dXzJmS5JINYhZpDZh6lrhfV05oM3OPmhWd8dF5k9ks3M379 +kDszfipGnzTnnRC5aw1Mhw== +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_ecdsa.key b/crypto/default/cms/testdata/user1_ecdsa.key new file mode 100644 index 0000000..4031e9e --- /dev/null +++ b/crypto/default/cms/testdata/user1_ecdsa.key
@@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9bRcGuN/nDg8xxhv +HLlXiemA1xCKiken7KBwWNMXuPuhRANCAARKIZfCgUgKB2J3kAcNpjbTB5cEJ4Ti +d0pIMSEZrW42T7+KAIZwHbfgz34Jw88WmHL1SH4rMvU5QSVrp1UywhEd +-----END PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user1_ecdsa.pem b/crypto/default/cms/testdata/user1_ecdsa.pem new file mode 100644 index 0000000..8adce2e --- /dev/null +++ b/crypto/default/cms/testdata/user1_ecdsa.pem
@@ -0,0 +1,46 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 6b:ef:0b:67:72:36:62:c5:4e:99:1d:e3:65:cd:df:84:e2:a4:02:cd + Signature Algorithm: ecdsa-with-SHA256 + Issuer: emailAddress=user1@foo.bar + Validity + Not Before: Feb 4 18:10:51 2025 GMT + Not After : Jan 11 18:10:51 2125 GMT + Subject: emailAddress=user1@foo.bar + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:4a:21:97:c2:81:48:0a:07:62:77:90:07:0d:a6: + 36:d3:07:97:04:27:84:e2:77:4a:48:31:21:19:ad: + 6e:36:4f:bf:8a:00:86:70:1d:b7:e0:cf:7e:09:c3: + cf:16:98:72:f5:48:7e:2b:32:f5:39:41:25:6b:a7: + 55:32:c2:11:1d + ASN1 OID: prime256v1 + NIST CURVE: P-256 + X509v3 extensions: + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + E-mail Protection, TLS Web Client Authentication + X509v3 Subject Key Identifier: + A7:52:E1:C3:0A:E3:B8:0D:80:75:73:DD:4D:4B:6C:66:92:AE:00:EB + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:45:02:20:6d:98:d6:e4:c0:45:45:e5:c1:00:ef:e5:1e:aa: + 90:93:ab:a9:15:df:de:e0:75:ee:f4:94:3c:0d:ae:1d:9c:dd: + 02:21:00:90:50:5d:11:16:d9:27:27:64:1b:34:38:f0:26:94: + 12:bc:e2:76:3d:93:e8:c3:81:a5:23:60:5e:83:a1:02:31 +-----BEGIN CERTIFICATE----- +MIIBjTCCATOgAwIBAgIUa+8LZ3I2YsVOmR3jZc3fhOKkAs0wCgYIKoZIzj0EAwIw +HjEcMBoGCSqGSIb3DQEJARYNdXNlcjFAZm9vLmJhcjAgFw0yNTAyMDQxODEwNTFa +GA8yMTI1MDExMTE4MTA1MVowHjEcMBoGCSqGSIb3DQEJARYNdXNlcjFAZm9vLmJh +cjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEohl8KBSAoHYneQBw2mNtMHlwQn +hOJ3SkgxIRmtbjZPv4oAhnAdt+DPfgnDzxaYcvVIfisy9TlBJWunVTLCER2jTTBL +MAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDBAYIKwYBBQUHAwIwHQYD +VR0OBBYEFKdS4cMK47gNgHVz3U1LbGaSrgDrMAoGCCqGSM49BAMCA0gAMEUCIG2Y +1uTARUXlwQDv5R6qkJOrqRXf3uB17vSUPA2uHZzdAiEAkFBdERbZJydkGzQ48CaU +Erzidj2T6MOBpSNgXoOhAjE= +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_no_cn.pem b/crypto/default/cms/testdata/user1_no_cn.pem new file mode 100644 index 0000000..f6e97a6 --- /dev/null +++ b/crypto/default/cms/testdata/user1_no_cn.pem
@@ -0,0 +1,87 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4661 (0x1235) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 10 00:28:52 2015 GMT + Not After : Nov 25 00:28:52 2288 GMT + Subject: + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:e3:5e:bd:fc:92:9d:26:d1:63:70:b6:c4:1c:e9: + 7a:65:32:e2:d7:98:d1:ba:ea:36:0d:5d:15:19:cb: + 3e:18:ce:7d:24:78:20:1f:34:15:1c:49:c2:f2:0a: + 67:e5:c1:d1:c8:a7:79:13:7b:cc:71:7f:9d:7a:0a: + 2d:34:1f:0e:36:d8:34:1f:cb:df:bb:40:35:62:4e: + f8:b4:5b:8b:a8:a9:34:3b:19:3f:5b:98:f0:4f:d7: + 75:45:47:24:af:21:6d:30:72:34:e4:f6:84:6b:e1: + 72:75:50:57:ab:e7:ed:f1:54:bc:b1:5b:03:bf:bd: + b7:49:b1:11:8c:22:62:4c:74:3a:8d:b8:d6:f0:5f: + a3:13:b8:9c:a4:0f:69:ca:f6:e5:82:8e:2e:d4:e7: + a0:3c:59:68:55:0a:67:7d:38:88:98:6c:82:d6:3c: + 1f:09:c0:f0:55:77:36:82:3c:ed:0d:61:49:79:a8: + 79:c2:e0:04:03:ff:56:6d:b1:9b:6e:cd:25:4b:ab: + ab:b8:06:e7:32:69:d1:bd:e7:2f:b0:b5:ac:23:ef: + 2c:9a:91:cc:60:b2:41:09:9b:a4:b4:3c:34:d8:b7: + db:c4:80:02:e7:6e:4c:17:68:d8:75:4e:b4:fd:b3: + d2:67:d0:a8:3e:b2:c5:fe:14:5a:04:6b:9a:4f:90: + 5b:b3 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + X509v3 Subject Alternative Name: + email:user1@foo.bar + Signature Algorithm: sha256WithRSAEncryption + 6f:f6:21:27:1a:df:8c:5c:e2:78:12:bb:78:f2:be:a0:c1:85: + 6e:d8:16:17:52:3a:e3:23:97:8d:60:d4:97:da:c8:c7:55:fb: + 98:92:ff:b1:ac:b0:de:2a:8c:48:a2:83:b7:16:33:cd:98:7d: + 33:0c:d9:28:4f:81:01:91:1a:f9:38:95:87:12:80:72:58:da: + fe:5e:44:b4:99:47:91:10:3d:52:16:94:a3:0c:d1:57:dd:00: + f0:91:cd:df:69:26:90:2a:b7:e0:c6:26:b6:0e:3b:da:3c:d9: + a0:e9:b2:3d:f4:95:a7:65:f2:c5:72:01:a6:a9:4a:2c:92:c7: + dc:c5:ca:56:d6:e4:4b:e9:f2:91:3d:01:df:41:6f:cb:e4:f6: + ed:61:94:2d:1a:25:05:51:53:7a:b8:5c:83:04:da:b1:ea:bb: + 5f:e2:17:d0:fb:4e:7b:4d:24:5e:a4:e8:02:43:fc:21:8e:e3: + 9b:f1:9c:51:60:b8:c6:39:44:ed:f1:a1:f1:06:42:cb:f2:d3: + f3:76:75:ff:a9:85:a7:9a:5c:ce:79:80:79:8a:d2:18:ea:97: + 08:98:d8:8e:f0:65:5e:75:fb:f5:fc:b3:31:df:e9:b4:46:45: + 64:68:78:68:aa:0f:df:c7:29:8a:ad:18:02:b9:86:41:9a:e9: + 23:2a:5f:03 +-----BEGIN CERTIFICATE----- +MIIDozCCAougAwIBAgICEjUwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw +Mjg1MloYDzIyODgxMTI1MDAyODUyWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA4169/JKdJtFjcLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQV +HEnC8gpn5cHRyKd5E3vMcX+degotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jw +T9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW8F+j +E7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5wuAE +A/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC525M +F2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQCMAAw +LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G +A1UdDgQWBBTh4y7jea7wZBgt7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tR +lty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG +CCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcNAQEL +BQADggEBAG/2ISca34xc4ngSu3jyvqDBhW7YFhdSOuMjl41g1JfayMdV+5iS/7Gs +sN4qjEiig7cWM82YfTMM2ShPgQGRGvk4lYcSgHJY2v5eRLSZR5EQPVIWlKMM0Vfd +APCRzd9pJpAqt+DGJrYOO9o82aDpsj30ladl8sVyAaapSiySx9zFylbW5Evp8pE9 +Ad9Bb8vk9u1hlC0aJQVRU3q4XIME2rHqu1/iF9D7TntNJF6k6AJD/CGO45vxnFFg +uMY5RO3xofEGQsvy0/N2df+phaeaXM55gHmK0hjqlwiY2I7wZV51+/X8szHf6bRG +RWRoeGiqD9/HKYqtGAK5hkGa6SMqXwM= +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2.key b/crypto/default/cms/testdata/user2.key new file mode 100644 index 0000000..87ebb2a --- /dev/null +++ b/crypto/default/cms/testdata/user2.key
@@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSk +aTnppXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU +8F1wu4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno +3iiuNVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+ +j0f/20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/a +Yv45TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABAoIBAQCqCnHHSdC62jXK +r721uq9Xq+RC1guRFfGvc42F1Q3EJTeGvQTyWT+z+/zbiucv4XaSfdtsdyn2o7tw +JQisukAv/DJeve0GQfGUcNkmi0j16hdgiIQYKd5oxmQ8SXTf2CfKWXWtVM4GO6u8 +7Ila9TB0BHuV0Vmb2I3jrLVQuInK+oMdoOb/UMiMJs6FYxrhbiO4ded18w04cD4G ++0IhJqogVNm19F2ls25YGkk/Dhk0fCofPvBYCbS75vntrPzkIhq30/PrHpWTHs3m +c8k4acwkWFhjpqGxtlktBUVROIUJw7WrK4j4aOxXbT+9n9PqLXcG7OCPWWlYFEwr +XnhXwUSRAoGBAOovNkLFvkKOe/EcINHTt3wR/DnvSvnxvi1u9JttROAY1BtiHGlV +rmz2pXouMfeoV3b9RCmnlHuBFiPBYx/KyDqVSwbNfxXEAQQAseNIgg3xoz9EApGO +fJ2TXl+RNCLHbIwu3B65wog1qh1UmnhmT6qpqYNcL9HFAPIgFInhWQHJAoGBANzW +hlLaPBjFrKRK9Y9kvc/blRVBjYVPutN95VznoZ/PBumqUyZXX9prx8XrdVMmFKGX +9mgHWxjYeJ4TO5Oq+nC6HM+OeVW2FMNWiZnvr3AjuNev5+gubQWi4xQFKoOS5w7u +SrQ7PgSZ8sUDR1OzAweLjcxJkj7vo0uqK0icGCDHAoGBALRZTZlYvjB1mJ/TwLa3 +LLxBAZUAYyZf9Mz6gShdm0hV9qtsAGNil+TUghW5E8QdgdZiL6dApooCnLFGknb+ +bPso1FHV2kRB4WJawqQ9x+LDL6xeTPgZ2oBj6K8CnTsXh9HhmKWU+2p4oZza9ec5 +K6tL4/8E9NLAu/y7lfWldS15AoGAGT0jSODMMYCWBIjUm7jFwLW1yiIdzHiaP4WJ +6x5F6H++qIHg0ub1e38J6+LHG+OkQ/ucG7OLg8Fpt5c3Hd9SlAORO19rGpKeuNxx +CXjNMiHDBFOES5LSvuwoy6bzfuoRIjG1s/d5VQlLJ50CT9yLohce85QM9Mhqf0FJ +hRjh4Q0CgYBnR2JyTu1C8ziFIddw0nIlAYycjzlHD68xOxi/0LWk7jGQnvRQbvNd +UBGtJdcPPpNRnGtWkZGU9+ywffFLjfdFc3IBKBfQ+dgnGxGOmRux5I6SPFtI3axU +RQw+WNaETshLiyrzyjEDUvR//rbSRX9x5Ae7R9rJmEWhhZzTz2+JIg== +-----END RSA PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user2_both.pem b/crypto/default/cms/testdata/user2_both.pem new file mode 100644 index 0000000..d50bada --- /dev/null +++ b/crypto/default/cms/testdata/user2_both.pem
@@ -0,0 +1,88 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4663 (0x1237) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 10 18:14:13 2015 GMT + Not After : Nov 25 18:14:13 2288 GMT + Subject: emailAddress=user2@foo.bar + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:ca:04:d0:f9:3e:ea:51:11:d8:ca:1a:b4:77:f9: + bf:ca:aa:1a:6f:f4:b4:c8:5b:f9:8b:e7:04:bc:42: + d3:56:83:78:dc:24:a4:69:39:e9:a5:79:ab:ea:7b: + f3:1c:8c:6c:55:9d:85:a2:12:b6:b2:a1:c7:42:d7: + f0:99:ca:75:73:43:df:8b:f1:c7:8f:3e:16:a1:5a: + 60:a5:74:9d:bc:9e:de:9c:c6:54:f0:5d:70:bb:82: + a1:09:e3:5a:67:6d:f1:7c:66:f8:52:ba:9f:2f:19: + 15:56:23:96:3e:48:2e:1d:d3:79:72:3f:28:94:7a: + 6e:a0:d5:84:a5:25:83:8f:41:74:44:b9:e8:de:28: + ae:35:56:06:53:f6:02:f0:d1:81:53:e6:64:ac:1f: + 15:96:52:89:ca:df:75:eb:02:af:37:f2:3b:b0:38: + 0e:36:4e:bc:29:c7:8f:d3:71:cd:bc:2d:26:b1:5d: + be:8f:47:ff:db:4a:25:9b:1f:f0:bf:32:40:6e:b3: + 9b:3b:34:3f:5c:33:3b:df:4a:27:0b:aa:3e:6d:25: + af:36:67:96:97:61:fc:97:35:61:e3:c2:41:9f:02: + 56:9f:df:da:62:fe:39:4e:63:59:30:51:38:74:f0: + 4c:61:a5:84:ac:9a:64:f3:c3:92:8f:d4:4f:f3:41: + 83:3f + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 21:44:1A:4A:7B:99:73:C9:7B:3E:C6:E7:4C:C0:4D:D1:D6:1F:DA:E0 + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + X509v3 Subject Alternative Name: + email:user2@foo.bar + Signature Algorithm: sha256WithRSAEncryption + 06:35:8f:bb:63:7a:5e:c6:0e:95:ed:f3:4b:13:fc:d1:af:3b: + cd:3a:23:2e:02:df:5d:b7:b1:2a:90:a6:4e:84:09:85:70:d3: + f2:86:02:52:66:0e:66:e0:1a:be:fe:72:5f:18:2d:8f:57:ed: + ff:e8:2b:5b:28:b2:28:b8:94:05:b1:01:5e:9c:90:8f:6a:b8: + 36:bb:20:49:b7:dd:cf:95:1d:ac:f0:e2:2a:7b:4e:f3:bb:89: + ae:49:6c:00:34:5c:09:2e:20:9e:c0:d2:81:07:64:89:ff:e5: + 7b:eb:96:12:68:fe:19:96:44:69:e3:95:10:c1:c5:2e:3e:07: + 70:ef:1e:d1:b1:02:af:9e:d8:23:46:39:62:f2:72:6a:4b:f4: + 9d:d9:0d:4d:7f:f9:9d:a0:0a:0e:53:26:7d:c5:5d:d8:30:36: + 06:05:5c:9e:19:6e:b2:8a:5f:b1:4f:3e:31:cb:9a:a8:6b:89: + 52:a8:38:ee:37:13:99:de:a4:0d:f2:7e:3e:ed:a5:28:9b:99: + 1d:8d:bd:fb:dd:e8:59:6b:5d:04:35:99:04:93:ed:76:ce:3c: + 8a:ab:67:9b:49:9f:d0:bd:94:89:58:03:5b:e8:ba:61:cc:78: + d2:82:a2:48:2f:f5:ad:3e:ac:9a:b6:d3:64:ac:f7:11:8b:f8: + f2:d0:e9:37 +-----BEGIN CERTIFICATE----- +MIIDwTCCAqmgAwIBAgICEjcwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4 +MTQxM1oYDzIyODgxMTI1MTgxNDEzWjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMkBm +b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAygTQ+T7qURHY +yhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnppXmr6nvzHIxsVZ2FohK2sqHH +Qtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1wu4KhCeNaZ23xfGb4UrqfLxkV +ViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiuNVYGU/YC8NGBU+ZkrB8VllKJ +yt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/20olmx/wvzJAbrObOzQ/XDM7 +30onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45TmNZMFE4dPBMYaWErJpk88OS +j9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w +ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBQhRBpKe5lzyXs+ +xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV +HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMBgGA1UdEQQR +MA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcNAQELBQADggEBAAY1j7tjel7GDpXt +80sT/NGvO806Iy4C3123sSqQpk6ECYVw0/KGAlJmDmbgGr7+cl8YLY9X7f/oK1so +sii4lAWxAV6ckI9quDa7IEm33c+VHazw4ip7TvO7ia5JbAA0XAkuIJ7A0oEHZIn/ +5XvrlhJo/hmWRGnjlRDBxS4+B3DvHtGxAq+e2CNGOWLycmpL9J3ZDU1/+Z2gCg5T +Jn3FXdgwNgYFXJ4ZbrKKX7FPPjHLmqhriVKoOO43E5nepA3yfj7tpSibmR2Nvfvd +6FlrXQQ1mQST7XbOPIqrZ5tJn9C9lIlYA1voumHMeNKCokgv9a0+rJq202Ss9xGL ++PLQ6Tc= +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2_cn_only.pem b/crypto/default/cms/testdata/user2_cn_only.pem new file mode 100644 index 0000000..3a284fd --- /dev/null +++ b/crypto/default/cms/testdata/user2_cn_only.pem
@@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4666 (0x123a) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 10 18:16:29 2015 GMT + Not After : Nov 25 18:16:29 2288 GMT + Subject: emailAddress=user2@foo.bar + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:ca:04:d0:f9:3e:ea:51:11:d8:ca:1a:b4:77:f9: + bf:ca:aa:1a:6f:f4:b4:c8:5b:f9:8b:e7:04:bc:42: + d3:56:83:78:dc:24:a4:69:39:e9:a5:79:ab:ea:7b: + f3:1c:8c:6c:55:9d:85:a2:12:b6:b2:a1:c7:42:d7: + f0:99:ca:75:73:43:df:8b:f1:c7:8f:3e:16:a1:5a: + 60:a5:74:9d:bc:9e:de:9c:c6:54:f0:5d:70:bb:82: + a1:09:e3:5a:67:6d:f1:7c:66:f8:52:ba:9f:2f:19: + 15:56:23:96:3e:48:2e:1d:d3:79:72:3f:28:94:7a: + 6e:a0:d5:84:a5:25:83:8f:41:74:44:b9:e8:de:28: + ae:35:56:06:53:f6:02:f0:d1:81:53:e6:64:ac:1f: + 15:96:52:89:ca:df:75:eb:02:af:37:f2:3b:b0:38: + 0e:36:4e:bc:29:c7:8f:d3:71:cd:bc:2d:26:b1:5d: + be:8f:47:ff:db:4a:25:9b:1f:f0:bf:32:40:6e:b3: + 9b:3b:34:3f:5c:33:3b:df:4a:27:0b:aa:3e:6d:25: + af:36:67:96:97:61:fc:97:35:61:e3:c2:41:9f:02: + 56:9f:df:da:62:fe:39:4e:63:59:30:51:38:74:f0: + 4c:61:a5:84:ac:9a:64:f3:c3:92:8f:d4:4f:f3:41: + 83:3f + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 21:44:1A:4A:7B:99:73:C9:7B:3E:C6:E7:4C:C0:4D:D1:D6:1F:DA:E0 + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + Signature Algorithm: sha256WithRSAEncryption + 92:ef:53:62:8d:21:66:0c:50:da:21:cc:db:f0:a7:a1:c8:af: + 2a:28:de:17:cb:91:98:b6:14:f7:fa:fa:85:a1:b4:41:cf:e4: + e1:73:75:79:43:2b:24:6c:7a:5b:63:d7:a3:3f:80:b9:3d:25: + 8e:6d:65:c4:b1:72:5a:93:3e:8b:94:b0:d2:f1:01:30:b0:64: + c1:23:28:f0:a9:e9:d1:3c:f0:d1:4e:af:98:1a:59:1b:ec:ac: + 05:49:d0:b0:ce:6f:35:cf:3b:56:aa:32:05:d4:8d:af:c3:ff: + ef:ee:c8:6e:af:84:6b:c6:4b:64:87:a8:89:ac:d1:5a:3d:7b: + 83:93:66:96:3d:68:64:20:6b:31:7f:31:a2:80:4b:fb:ff:66: + d5:00:b9:0d:67:8a:2a:a2:e9:56:92:35:c8:00:ff:6a:86:3b: + 75:41:f5:bd:08:4a:c4:ff:25:0b:c1:94:a7:b0:0b:23:a3:bb: + 00:39:fa:42:04:88:09:b3:7f:4b:ad:92:f6:36:68:24:31:80: + b6:07:95:de:90:ca:98:47:b3:3a:79:c5:8a:2d:6d:9d:c0:54: + f2:a2:02:62:8d:a5:34:95:82:36:65:94:da:35:a8:64:76:09: + 12:de:93:9d:cd:6d:1d:af:af:d7:d8:6a:2a:41:c2:39:29:c4: + d4:f0:5e:dd +-----BEGIN CERTIFICATE----- +MIIDpzCCAo+gAwIBAgICEjowDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4 +MTYyOVoYDzIyODgxMTI1MTgxNjI5WjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMkBm +b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAygTQ+T7qURHY +yhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnppXmr6nvzHIxsVZ2FohK2sqHH +Qtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1wu4KhCeNaZ23xfGb4UrqfLxkV +ViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiuNVYGU/YC8NGBU+ZkrB8VllKJ +yt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/20olmx/wvzJAbrObOzQ/XDM7 +30onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45TmNZMFE4dPBMYaWErJpk88OS +j9RP80GDPwIDAQABo4GoMIGlMAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w +ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBQhRBpKe5lzyXs+ +xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV +HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMA0GCSqGSIb3 +DQEBCwUAA4IBAQCS71NijSFmDFDaIczb8KehyK8qKN4Xy5GYthT3+vqFobRBz+Th +c3V5QyskbHpbY9ejP4C5PSWObWXEsXJakz6LlLDS8QEwsGTBIyjwqenRPPDRTq+Y +Glkb7KwFSdCwzm81zztWqjIF1I2vw//v7shur4Rrxktkh6iJrNFaPXuDk2aWPWhk +IGsxfzGigEv7/2bVALkNZ4oqoulWkjXIAP9qhjt1QfW9CErE/yULwZSnsAsjo7sA +OfpCBIgJs39LrZL2NmgkMYC2B5XekMqYR7M6ecWKLW2dwFTyogJijaU0lYI2ZZTa +NahkdgkS3pOdzW0dr6/X2GoqQcI5KcTU8F7d +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2_ecc.key b/crypto/default/cms/testdata/user2_ecc.key new file mode 100644 index 0000000..d09dfe1 --- /dev/null +++ b/crypto/default/cms/testdata/user2_ecc.key
@@ -0,0 +1,8 @@ +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJPRuV2jcPLfORpncLK4uD8R98vMzztQSRlpMAHjanqAoAoGCCqGSM49 +AwEHoUQDQgAEPcc9MtQC+axgNFfX3kwGJR4WcKQvYeAZuRVV4I9n1spqEqsl3lDG +sjeSBOQkJTPEUfPG1VKe3/1wUzHikzaqfw== +-----END EC PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user2_ecc.pem b/crypto/default/cms/testdata/user2_ecc.pem new file mode 100644 index 0000000..77ac5ac --- /dev/null +++ b/crypto/default/cms/testdata/user2_ecc.pem
@@ -0,0 +1,68 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4668 (0x123c) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Mar 4 02:00:42 2015 GMT + Not After : Dec 17 02:00:42 2288 GMT + Subject: emailAddress=user2@foo.bar + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:3d:c7:3d:32:d4:02:f9:ac:60:34:57:d7:de:4c: + 06:25:1e:16:70:a4:2f:61:e0:19:b9:15:55:e0:8f: + 67:d6:ca:6a:12:ab:25:de:50:c6:b2:37:92:04:e4: + 24:25:33:c4:51:f3:c6:d5:52:9e:df:fd:70:53:31: + e2:93:36:aa:7f + ASN1 OID: prime256v1 + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 78:59:B0:BC:C0:8D:22:79:2B:75:24:87:BE:EC:DB:00:10:96:7E:0F + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + Signature Algorithm: sha256WithRSAEncryption + 6c:f1:10:48:4c:bc:4d:03:3e:af:73:de:63:f5:ec:7b:bb:00: + 9b:fb:02:f2:23:7e:d8:5f:cd:e2:db:99:ac:fb:18:45:76:8f: + 1c:53:ce:ca:4e:e8:75:30:ee:75:ad:0b:38:1a:a7:c2:3f:1e: + 64:83:de:5c:e0:b0:0b:a3:a2:75:4c:c0:74:2e:e8:88:aa:e2: + be:49:42:28:1e:9c:68:7a:98:71:40:34:97:f0:7a:92:35:4d: + dd:89:6b:04:08:51:17:90:49:b3:b6:00:5a:21:3e:42:5d:71: + 4a:a1:8e:68:10:3f:96:af:a4:ed:9e:d7:63:e4:24:ea:24:0e: + 8d:df:72:a8:32:c8:de:ed:4f:63:9b:98:53:4b:44:be:b4:0b: + 70:65:6d:b6:53:1e:0b:04:fe:80:ef:46:12:4a:8b:f6:10:bb: + 1c:0b:b4:13:44:75:35:61:ba:f0:08:a0:d2:46:0d:ab:19:7c: + 9e:bc:bf:e6:9e:96:54:bb:c1:57:ee:64:22:4f:d0:7a:39:71: + 85:1c:50:bf:7e:28:25:5e:5b:17:1b:4a:48:1b:31:91:99:70: + b7:cf:b8:02:86:df:9d:67:fb:92:84:0b:ca:79:61:f0:9c:b6: + 3e:38:77:79:f2:d3:8e:09:7b:51:e0:ab:c0:dd:b3:da:75:ee: + 40:d9:a9:77 +-----BEGIN CERTIFICATE----- +MIIC3DCCAcSgAwIBAgICEjwwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDMwNDAy +MDA0MloYDzIyODgxMjE3MDIwMDQyWjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMkBm +b28uYmFyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPcc9MtQC+axgNFfX3kwG +JR4WcKQvYeAZuRVV4I9n1spqEqsl3lDGsjeSBOQkJTPEUfPG1VKe3/1wUzHikzaq +f6OBqDCBpTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVy +YXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUeFmwvMCNInkrdSSHvuzbABCWfg8w +HwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0G +A1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDANBgkqhkiG9w0BAQsFAAOCAQEA +bPEQSEy8TQM+r3PeY/Xse7sAm/sC8iN+2F/N4tuZrPsYRXaPHFPOyk7odTDuda0L +OBqnwj8eZIPeXOCwC6OidUzAdC7oiKrivklCKB6caHqYcUA0l/B6kjVN3YlrBAhR +F5BJs7YAWiE+Ql1xSqGOaBA/lq+k7Z7XY+Qk6iQOjd9yqDLI3u1PY5uYU0tEvrQL +cGVttlMeCwT+gO9GEkqL9hC7HAu0E0R1NWG68Aig0kYNqxl8nry/5p6WVLvBV+5k +Ik/QejlxhRxQv34oJV5bFxtKSBsxkZlwt8+4AobfnWf7koQLynlh8Jy2Pjh3efLT +jgl7UeCrwN2z2nXuQNmpdw== +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2_no_cn.pem b/crypto/default/cms/testdata/user2_no_cn.pem new file mode 100644 index 0000000..d1d665d --- /dev/null +++ b/crypto/default/cms/testdata/user2_no_cn.pem
@@ -0,0 +1,87 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 4662 (0x1236) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA + Validity + Not Before: Feb 10 00:30:03 2015 GMT + Not After : Nov 25 00:30:03 2288 GMT + Subject: + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:ca:04:d0:f9:3e:ea:51:11:d8:ca:1a:b4:77:f9: + bf:ca:aa:1a:6f:f4:b4:c8:5b:f9:8b:e7:04:bc:42: + d3:56:83:78:dc:24:a4:69:39:e9:a5:79:ab:ea:7b: + f3:1c:8c:6c:55:9d:85:a2:12:b6:b2:a1:c7:42:d7: + f0:99:ca:75:73:43:df:8b:f1:c7:8f:3e:16:a1:5a: + 60:a5:74:9d:bc:9e:de:9c:c6:54:f0:5d:70:bb:82: + a1:09:e3:5a:67:6d:f1:7c:66:f8:52:ba:9f:2f:19: + 15:56:23:96:3e:48:2e:1d:d3:79:72:3f:28:94:7a: + 6e:a0:d5:84:a5:25:83:8f:41:74:44:b9:e8:de:28: + ae:35:56:06:53:f6:02:f0:d1:81:53:e6:64:ac:1f: + 15:96:52:89:ca:df:75:eb:02:af:37:f2:3b:b0:38: + 0e:36:4e:bc:29:c7:8f:d3:71:cd:bc:2d:26:b1:5d: + be:8f:47:ff:db:4a:25:9b:1f:f0:bf:32:40:6e:b3: + 9b:3b:34:3f:5c:33:3b:df:4a:27:0b:aa:3e:6d:25: + af:36:67:96:97:61:fc:97:35:61:e3:c2:41:9f:02: + 56:9f:df:da:62:fe:39:4e:63:59:30:51:38:74:f0: + 4c:61:a5:84:ac:9a:64:f3:c3:92:8f:d4:4f:f3:41: + 83:3f + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 21:44:1A:4A:7B:99:73:C9:7B:3E:C6:E7:4C:C0:4D:D1:D6:1F:DA:E0 + X509v3 Authority Key Identifier: + keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication, E-mail Protection + X509v3 Subject Alternative Name: + email:user2@foo.bar + Signature Algorithm: sha256WithRSAEncryption + 4b:89:7d:a5:bb:46:ad:ea:ca:98:51:dc:15:67:67:56:82:a2: + 51:e4:40:b7:04:b1:d7:73:6d:fe:7b:19:62:63:ad:56:f0:bd: + 5d:54:21:e3:00:7f:41:f6:50:2b:1c:fb:c1:8b:61:c5:c2:bf: + 2d:f8:22:e9:6d:73:11:8b:ee:0e:a7:ef:05:a7:7c:25:7f:95: + 8d:09:77:97:91:41:28:ad:d0:11:b2:81:07:62:6f:b7:13:db: + 42:49:cb:fd:de:b7:65:27:7a:45:47:9c:98:6b:12:fe:42:8a: + 0c:1a:3a:cf:47:90:82:fd:0b:75:c9:1e:a6:18:1f:21:cb:e9: + 04:cb:16:9e:49:9f:1a:95:e1:85:94:86:ad:7a:25:f9:39:9e: + 8e:c1:03:a4:9f:86:99:f6:73:29:f8:ac:6a:07:50:a9:85:05: + e3:7d:55:e3:e9:57:66:8a:45:9d:f5:1e:a1:2d:48:72:99:9b: + 16:57:03:36:d8:4a:b2:17:61:10:6c:31:56:9b:f9:39:f0:36: + 48:90:5c:06:43:ef:72:5b:6e:86:e5:3a:25:e0:35:46:f1:7b: + 6f:da:2d:c4:4d:5b:bb:98:e3:60:d1:53:54:06:47:d5:b0:79: + d2:a0:bf:d6:51:44:39:9d:de:f4:92:ad:d3:f9:5f:d0:59:34: + 33:4e:86:d7 +-----BEGIN CERTIFICATE----- +MIIDozCCAougAwIBAgICEjYwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx +CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T +bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw +MzAwM1oYDzIyODgxMTI1MDAzMDAzWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnp +pXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1w +u4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiu +NVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/ +20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45 +TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQCMAAw +LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G +A1UdDgQWBBQhRBpKe5lzyXs+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tR +lty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG +CCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcNAQEL +BQADggEBAEuJfaW7Rq3qyphR3BVnZ1aColHkQLcEsddzbf57GWJjrVbwvV1UIeMA +f0H2UCsc+8GLYcXCvy34IultcxGL7g6n7wWnfCV/lY0Jd5eRQSit0BGygQdib7cT +20JJy/3et2UnekVHnJhrEv5CigwaOs9HkIL9C3XJHqYYHyHL6QTLFp5JnxqV4YWU +hq16Jfk5no7BA6Sfhpn2cyn4rGoHUKmFBeN9VePpV2aKRZ31HqEtSHKZmxZXAzbY +SrIXYRBsMVab+TnwNkiQXAZD73JbboblOiXgNUbxe2/aLcRNW7uY42DRU1QGR9Ww +edKgv9ZRRDmd3vSSrdP5X9BZNDNOhtc= +-----END CERTIFICATE-----
diff --git a/crypto/default/cms/verify_signature.cc b/crypto/default/cms/verify_signature.cc new file mode 100644 index 0000000..1a01172 --- /dev/null +++ b/crypto/default/cms/verify_signature.cc
@@ -0,0 +1,335 @@ +// 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 "crypto/default/cms/verify_signature.h" + +#include <stddef.h> +#include <stdint.h> + +#include <memory> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "crypto/default/cms/cms_parser.h" +#include "crypto/default/cms/oids.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/crypto.h" +#include "openssl/digest.h" +#include "openssl/err.h" +#include "openssl/evp.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/obj_mac.h" +#include "openssl/objects.h" +#include "openssl/rsa.h" +#include "openssl/x509.h" + +using absl::StatusCode; + +namespace credentio_cms { +namespace { +// Append the OpenSSL error strings to the status message. +absl::Status OpenSslError(absl::StatusCode code, + absl::string_view error_message) { + std::string message(error_message); + const char *file, *data; + int line, flags; + while (uint32_t err = ERR_get_error_line_data(&file, &line, &data, &flags)) { + if (file) { + absl::StrAppendFormat(&message, "\n%s:%d ", file, line); + } else { + absl::StrAppend(&message, "\n"); + } + + if (const char* reason = ERR_reason_error_string(err)) { + absl::StrAppend(&message, reason); + } else { + absl::StrAppend(&message, err); + } + + if (data && (flags & ERR_TXT_STRING)) { + absl::StrAppend(&message, " - ", data); + } + } + return absl::Status(code, message); +} + +// For PSS signatures, verify the parameters and apply them to 'pkey_ctx'. +// - 'certificate' is needed for the extra checks needed when a PSS +// certificate is used. +// - 'md' is the message digest algorithm used on the content (or signed +// attributes), it will be used to initialize the masking function. +absl::Status SetPssParameters(const SignerInfo& signer, const X509& certificate, + int content_digest_nid, const EVP_MD* md, + EVP_PKEY_CTX* pkey_ctx) { + // See https://tools.ietf.org/html/rfc4056 for the checks performed. + const unsigned char* p = + CBS_data(signer.signature_algorithm.parameter.cbs_ptr()); + if (p == nullptr || + CBS_len(signer.signature_algorithm.parameter.cbs_ptr()) == 0) { + return OpenSslError(absl::StatusCode::kInvalidArgument, + "Missing PSS parameters"); + } + bssl::UniquePtr<RSA_PSS_PARAMS> pss_params(d2i_RSA_PSS_PARAMS( + nullptr, &p, CBS_len(&(signer.signature_algorithm.parameter).cbs()))); + if (pss_params == nullptr) { + // http://tools.ietf.org/html/rfc4056#section-2.2 + return OpenSslError(absl::StatusCode::kInvalidArgument, + "Missing PSS parameters"); + } + if (pss_params->trailerField && + ASN1_INTEGER_get(pss_params->trailerField) != 1) { + return OpenSslError(absl::StatusCode::kUnimplemented, + "Unsupported PSS trailer value."); + } + if (X509_get_signature_nid(&certificate) == NID_rsassaPss) { + return OpenSslError( + absl::StatusCode::kUnimplemented, + "PSS signatures are not supported with RSASSA-PSS certificates."); + } + if (EVP_PKEY_CTX_set_rsa_padding(pkey_ctx, RSA_PKCS1_PSS_PADDING) != 1) { + return OpenSslError(absl::StatusCode::kInternal, + "Failed to set PSS padding"); + } + if (pss_params->hashAlgorithm) { + // http://tools.ietf.org/html/rfc4056#section-3 + const ASN1_OBJECT* algorithm; + X509_ALGOR_get0(&algorithm, /*out_param_type=*/nullptr, + /*out_param_value=*/nullptr, pss_params->hashAlgorithm); + if (OBJ_obj2nid(algorithm) != content_digest_nid) { + return absl::Status( + absl::StatusCode::kInvalidArgument, + "The PSS Hash algorithm doesn't match the content hash algorithm."); + } + } + if (pss_params->maskGenAlgorithm) { + const ASN1_OBJECT* algorithm; + X509_ALGOR_get0(&algorithm, /*out_param_type=*/nullptr, + /*out_param_value=*/nullptr, pss_params->maskGenAlgorithm); + if (OBJ_obj2nid(algorithm) != NID_mgf1) { + return OpenSslError(absl::StatusCode::kUnimplemented, + "Unsupported PSS mask generation algorithm."); + } + } + if (EVP_PKEY_CTX_set_rsa_mgf1_md(pkey_ctx, md) != 1) { + return OpenSslError(absl::StatusCode::kInvalidArgument, + "Failed to set the PSS MGF1 hash"); + } + int salt_length = 20; + if (pss_params->saltLength != nullptr) { + salt_length = ASN1_INTEGER_get(pss_params->saltLength); + } + if (EVP_PKEY_CTX_set_rsa_pss_saltlen(pkey_ctx, salt_length) != 1) { + return OpenSslError(absl::StatusCode::kInvalidArgument, + "Invalid PSS parameters"); + } + return absl::OkStatus(); +} + +// Check that the hash of the 'contents' matches the digest present in the +// authenticated attributes. +absl::Status VerifySignedAttributesHash(const SignerInfo& signer, + const std::vector<ByteString>& contents, + int digest_nid) { + const EVP_MD* md = EVP_get_digestbynid(digest_nid); + if (md == nullptr) { + return OpenSslError(absl::StatusCode::kUnimplemented, + "Failed to get the content hashing EVP_MD object."); + } + // When the authenticated attributes are present the signature is computed + // over them so we need two checks: + // 1) That the digest of the contents matches the attribute's + // message_digest. + // 2) That the signature of the authenticated attributes is valid. + bssl::ScopedEVP_MD_CTX md_context; + if (!EVP_DigestInit_ex(md_context.get(), md, nullptr)) { + return OpenSslError(absl::StatusCode::kInternal, + "EVP_DigestInit_ex failed."); + } + for (const auto& chunk : contents) { + if (!EVP_DigestUpdate(md_context.get(), CBS_data(&(chunk).cbs()), + CBS_len(chunk.cbs_ptr()))) { + return OpenSslError(absl::StatusCode::kInternal, + "EVP_DigestUpdate failed."); + } + } + uint8_t md_value[EVP_MAX_MD_SIZE]; + unsigned md_len; + if (!EVP_DigestFinal_ex(md_context.get(), md_value, &md_len)) { + return OpenSslError(absl::StatusCode::kInternal, + "EVP_DigestFinal_ex failed."); + } + if (CBS_len(&(signer.message_digest).cbs()) != md_len || + CRYPTO_memcmp(md_value, CBS_data(&(signer.message_digest).cbs()), + CBS_len(&(signer.message_digest).cbs())) != 0) { + return OpenSslError(absl::StatusCode::kInvalidArgument, + "Hash attribute mismatch"); + } + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<SignatureInfo> VerifySignature( + const Content& cms_content, const SignerInfo& signer, + const std::vector<ByteString>& contents, const X509& certificate) { + ERR_clear_error(); // Clear OpenSSL's error queue for this thread. + // Signed attributes checks, see + // https://tools.ietf.org/html/rfc5652#section-5.6 + if (!CompareOid(cms_content.content_type.cbs(), kDataOid, sizeof(kDataOid)) && + CBS_len(signer.raw_signed_attributes.cbs_ptr()) == 0) { + return absl::Status(absl::StatusCode::kInvalidArgument, + "The signed attributes are needed when the " + "encapsultated content type is not id-data."); + } + if (CBS_len(signer.raw_signed_attributes.cbs_ptr()) > 0) { + if (CBS_len(signer.content_type_signed.cbs_ptr()) == 0 || + !CompareOid(signer.content_type_signed.cbs(), + cms_content.content_type.cbs())) { + return absl::Status(absl::StatusCode::kInvalidArgument, + "Mismatch between the signed attributes content type " + "and the encapsultated content type."); + } + if (CBS_len(signer.message_digest.cbs_ptr()) == 0) { + return absl::Status(absl::StatusCode::kInvalidArgument, + "Missing message digest in the signed attributes."); + } + } + bssl::UniquePtr<EVP_PKEY> key( + X509_get_pubkey(const_cast<X509*>(&certificate))); + if (key == nullptr) { + return OpenSslError(absl::StatusCode::kInvalidArgument, + "Failed to get the public key from the certificate."); + } + int digest_nid = OBJ_cbs2nid(&signer.digest_algorithm.algorithm_oid.cbs()); + if (digest_nid == NID_undef) { + return OpenSslError(absl::StatusCode::kUnimplemented, + "Unknown digest algorithm."); + } + int signature_algorithm_nid = + OBJ_cbs2nid(signer.signature_algorithm.algorithm_oid.cbs_ptr()); + // Get the digest NID from the signature NID if possible, otherwise use the + // signer's digest algorithm. + int signature_digest_nid = NID_undef; + if (OBJ_find_sigid_algs(signature_algorithm_nid, &signature_digest_nid, + nullptr /* pkey nid */) != 1 || + signature_digest_nid == NID_undef) { + signature_digest_nid = digest_nid; + } + + // Check that the signature algorithm is supported. + switch (signature_algorithm_nid) { + case NID_rsassaPss: + case NID_rsaEncryption: + case NID_sha224WithRSAEncryption: + case NID_sha256WithRSAEncryption: + case NID_sha384WithRSAEncryption: + case NID_sha512WithRSAEncryption: + case NID_X9_62_id_ecPublicKey: + break; + + // ECDSA support is defined in RFC 5753 and includes instructions for + // additional verification that `digest_nid` must match the value derived + // from `signature_algorithm_nid`. (See RFC 5753 section 2.1.1.) Note + // that the RFC permits the use of SHA-1 for ECDSA signatures, but we do + // not, as at the time of writing it had long been obsolete. + case NID_ecdsa_with_SHA224: + case NID_ecdsa_with_SHA256: + case NID_ecdsa_with_SHA384: + case NID_ecdsa_with_SHA512: + if (digest_nid != signature_digest_nid) { + return absl::InvalidArgumentError(absl::StrFormat( + R"(The digest algorithm does not match the value derived from the signature algorithm: digest_nid=%d, signature_digest_nid=%d)", + digest_nid, signature_digest_nid)); + } + break; + + default: + return OpenSslError( + absl::StatusCode::kUnimplemented, + absl::StrCat("Signature algorithm not implemented, NID: ", + signature_algorithm_nid)); + } + + const EVP_MD* signature_md = EVP_get_digestbynid(signature_digest_nid); + if (signature_md == nullptr) { + return OpenSslError(absl::StatusCode::kUnimplemented, + "Failed to get the signature EVP_MD object."); + } + bssl::ScopedEVP_MD_CTX md_context; + EVP_PKEY_CTX* pkey_ctx = nullptr; + if (EVP_DigestVerifyInit(md_context.get(), &pkey_ctx, signature_md, + nullptr /* engine */, key.get()) != 1) { + return OpenSslError(absl::StatusCode::kInternal, + "EVP_DigestVerifyInit failed."); + } + if (signature_algorithm_nid == NID_rsassaPss) { + auto status = SetPssParameters(signer, certificate, digest_nid, + signature_md, pkey_ctx); + if (!status.ok()) { + return status; + } + } + + if (CBS_len(signer.raw_signed_attributes.cbs_ptr()) > 0) { + auto status = VerifySignedAttributesHash(signer, contents, digest_nid); + if (!status.ok()) { + return status; + } + // The signature is computed after replacing the implicit tag. + // https://tools.ietf.org/html/rfc2315#section-9.3 + uint8_t tag = 0x31; // SET + if (EVP_DigestVerifyUpdate(md_context.get(), &tag, 1) != 1 || + EVP_DigestVerifyUpdate( + md_context.get(), + reinterpret_cast<const char*>( + CBS_data(signer.raw_signed_attributes.cbs_ptr()) + 1), + CBS_len(signer.raw_signed_attributes.cbs_ptr()) - 1) != 1) { + return OpenSslError(absl::StatusCode::kInternal, + "EVP_DigestVerifyUpdate failed."); + } + } else { + // When there are no authenticated attributes the signature is computed on + // the contents. + for (const auto& chunk : contents) { + if (EVP_DigestVerifyUpdate(md_context.get(), CBS_data(chunk.cbs_ptr()), + CBS_len(chunk.cbs_ptr())) != 1) { + return OpenSslError(absl::StatusCode::kInternal, + "EVP_DigestVerifyUpdate failed."); + } + } + } + if (EVP_DigestVerifyFinal(md_context.get(), + const_cast<unsigned char*>( + CBS_data(signer.signature_value.cbs_ptr())), + CBS_len(signer.signature_value.cbs_ptr())) != 1) { + return OpenSslError(absl::StatusCode::kInvalidArgument, + "Invalid signature"); + } + SignatureInfo signature_info; + signature_info.digest_algorithm_nid = digest_nid; + signature_info.signature_algorithm_id = signature_algorithm_nid; + return signature_info; +} + +} // namespace credentio_cms
diff --git a/crypto/default/cms/verify_signature.h b/crypto/default/cms/verify_signature.h new file mode 100644 index 0000000..4aabaa3 --- /dev/null +++ b/crypto/default/cms/verify_signature.h
@@ -0,0 +1,46 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_VERIFY_SIGNATURE_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_VERIFY_SIGNATURE_H_ + +#include <vector> + +#include "absl/status/statusor.h" +#include "crypto/default/cms/cms_parser.h" +#include "openssl/obj_mac.h" +#include "openssl/ossl_typ.h" + +namespace credentio_cms { +struct SignatureInfo { + // OpenSSL NID for the digest algorithm. + int digest_algorithm_nid = NID_undef; + // OpenSSL id for the signature algorithm. + int signature_algorithm_id = NID_undef; +}; + +// Verify a signature on the embedded content. +// The X509 certificate trustworthiness is not validated by this function. +// When the status is not OK the signature cannot be verified, the failure +// code returned is only intended for debugging purposes. +// The caller should also check that the digest and signature algorithms are +// reasonable (i.e. not MD5...) +absl::StatusOr<SignatureInfo> VerifySignature( + const Content& cms_content, const SignerInfo& signer, + const std::vector<ByteString>& contents, const X509& certificate); + +} // namespace credentio_cms + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_VERIFY_SIGNATURE_H_
diff --git a/crypto/default/cms/verify_signature_test.cc b/crypto/default/cms/verify_signature_test.cc new file mode 100644 index 0000000..48ff8a5 --- /dev/null +++ b/crypto/default/cms/verify_signature_test.cc
@@ -0,0 +1,340 @@ +// 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 "crypto/default/cms/verify_signature.h" + +#include <stdint.h> + +#include <memory> +#include <string> +#include <vector> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "crypto/default/cms/certificates.h" +#include "crypto/default/cms/cms_error_code.h" +#include "crypto/default/cms/cms_parser.h" +#include "crypto/default/cms/cms_test_helper.h" +#include "crypto/default/cms/oids.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/obj_mac.h" +#include "openssl/safestack.h" +#include "openssl/stack.h" + +namespace credentio_cms { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; + +ByteString ByteStringFromString(const std::string& value) { + ByteString bs; + CBS_init(bs.cbs_ptr(), reinterpret_cast<const unsigned char*>(value.data()), + value.length()); + return bs; +} + +std::string ByteStringToString(const ByteString& bs) { + return std::string(reinterpret_cast<const char*>(CBS_data(bs.cbs_ptr())), + CBS_len(bs.cbs_ptr())); +} + +class VerifySignatureTest : public ::testing::Test { + protected: + void ParseOpaqueMessage(const char* filename) { + std::string message = GetFileContents(filename); + cms_object_der_ = GetCms(message); + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms_object_der_.data(), cms_object_der_.length(), &cms_, + &error_message)) + << "Failed to parse: " << filename << " error: " << error_message; + } + + void ParseMessage(const char* filename) { + std::string message = GetFileContents(filename); + // The first mime part in the message, in the tests this is the clear text. + first_part_ = GetFirstMimePart(message); + cms_object_der_ = GetCms(message); + std::string error_message; + EXPECT_EQ(ErrorCode::OK, + ParseCms(cms_object_der_.data(), cms_object_der_.length(), &cms_, + &error_message)) + << error_message; + } + + // Pick the first certificate matching the signer restrictions, or null if + // none was found. + X509* GetFirstMatchingCert(const Content& content, const SignerInfo& signer) { + certificate_stack_.reset(GetAllCertificates(content)); + if (certificate_stack_ == nullptr) { + return nullptr; + } + std::vector<X509*> matches = + GetSignerCertificates(certificate_stack_.get(), signer); + if (matches.empty()) { + return nullptr; + } + return matches[0]; + } + + std::string first_part_; + std::string cms_object_der_; + Content cms_; + bssl::UniquePtr<STACK_OF(X509)> certificate_stack_; +}; + +class VerifyOpaqueSignaturesTest + : public VerifySignatureTest, + public ::testing::WithParamInterface<const char*> {}; + +TEST_P(VerifyOpaqueSignaturesTest, CheckSignature) { + ParseOpaqueMessage(GetParam()); + ASSERT_EQ(1, cms_.signers.size()); + ASSERT_EQ(1, cms_.content.size()); + + for (const auto& signer : cms_.signers) { + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr) + << "Failed to get the certificate for: " << GetParam() + << " and Issuer: " << CBSToName(signer.issuer_name); + auto status = VerifySignature(cms_, signer, cms_.content, *certificate); + EXPECT_TRUE(status.ok()) << status.status(); + + // Sanity check: change the first bit and check that the signature fails. + std::string data = ByteStringToString(cms_.content[0]); + data[0] ^= 0x80; + ByteString modified = ByteStringFromString(data); + EXPECT_FALSE(VerifySignature(cms_, signer, {modified}, *certificate).ok()); + } +} + +INSTANTIATE_TEST_SUITE_P(EmbeddedContent, VerifyOpaqueSignaturesTest, + testing::Values("signed-openssl-opaque.msg", + "signed-openssl-ecdsa.msg")); + +class VerifyDetachedSignaturesTest + : public VerifySignatureTest, + public ::testing::WithParamInterface<const char*> {}; + +TEST_P(VerifyDetachedSignaturesTest, CheckSignatureWithExternalContent) { + ParseMessage(GetParam()); + ASSERT_EQ(1, cms_.signers.size()); + EXPECT_EQ(0, cms_.content.size()); + ByteString data = ByteStringFromString(first_part_); + + for (const auto& signer : cms_.signers) { + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_TRUE(status.ok()) << status.status(); + } +} + +INSTANTIATE_TEST_SUITE_P(ExternalContent, VerifyDetachedSignaturesTest, + testing::Values("signed-openssl-detached.msg", + "signed-outlook-mac-2011.msg", + "signed-thunderbird.msg", + "signed-openssl-pss.msg", + "signed-openssl-sha256.msg")); + +class VerifyFailedSignaturesTest + : public VerifySignatureTest, + public ::testing::WithParamInterface<const char*> {}; + +TEST_P(VerifyFailedSignaturesTest, CheckSignatureWithExternalContent) { + ParseMessage(GetParam()); + ASSERT_EQ(1, cms_.signers.size()); + EXPECT_EQ(0, cms_.content.size()); + ByteString data = ByteStringFromString(first_part_); + + for (const auto& signer : cms_.signers) { + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); + } +} +INSTANTIATE_TEST_SUITE_P( + ExternalContent, VerifyFailedSignaturesTest, + testing::Values("signed-type-mismatch.msg", + "signed-unknown-hash-algorithm.msg", + "signed-unknown-signature-algorithm.msg")); + +TEST_F(VerifySignatureTest, MissingSignedAttributes) { + // The signed attributes are needed if the the envelope content type is not + // data. + ParseMessage("signed-openssl-detached.msg"); + auto& signer = cms_.signers[0]; + // Clear the signed attributes. + CBS_init(signer.raw_signed_attributes.cbs_ptr(), nullptr, 0); + CBS_init(cms_.content_type.cbs_ptr(), kSignedDataOid, sizeof(kSignedDataOid)); + + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, MissingSignerDigest) { + ParseMessage("signed-openssl-detached.msg"); + auto& signer = cms_.signers[0]; + // Clear the signed message digest attribute. + CBS_init(signer.message_digest.cbs_ptr(), nullptr, 0); + + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, MissingContentTypeSigned) { + ParseMessage("signed-openssl-detached.msg"); + auto& signer = cms_.signers[0]; + // Clear the signed content_type_signed attribute. + CBS_init(signer.content_type_signed.cbs_ptr(), nullptr, 0); + + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, CertificateWithBrokenPubKey) { + ParseMessage("signed-openssl-detached.msg"); + ByteString data = ByteStringFromString(first_part_); + // user1_bad_public_key has a public key with an unknown OID. + bssl::UniquePtr<X509> certificate( + PemToCertificate(GetFileContents("user1_bad_public_key.pem"))); + auto status = VerifySignature(cms_, cms_.signers[0], {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, BadSignatureDigestAlgorithm) { + ParseMessage("signed-openssl-detached.msg"); + auto& signer = cms_.signers[0]; + // Use an OID known to OpenSSL but that is not a valid hash or signature + // algorithm with an implied hash function. + // 1.2.840.113549.3.7 DES-EDE3-CBC + auto oid = NidToDerString(NID_des_ede3_cbc); + signer.signature_algorithm.algorithm_oid = ByteStringFromString(oid); + signer.digest_algorithm.algorithm_oid = ByteStringFromString(oid); + + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, BadHashDigestAlgorithm) { + ParseMessage("signed-openssl-sha256.msg"); + auto& signer = cms_.signers[0]; + // Use a signature OID that provides an explicit hashing algorithm. + auto sig_oid = NidToDerString(NID_sha256WithRSAEncryption); + signer.signature_algorithm.algorithm_oid = ByteStringFromString(sig_oid); + + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + // Make sure that the signature algorithm is correct. + EXPECT_TRUE(status.ok()) << status.status(); + + // Use an OID known to OpenSSL but that is not a valid hash algorithm. + // 1.2.840.113549.3.7 DES-EDE3-CBC + auto digest_oid = NidToDerString(NID_des_ede3_cbc); + signer.digest_algorithm.algorithm_oid = ByteStringFromString(digest_oid); + status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, WrongSignedHashLength) { + ParseMessage("signed-openssl-sha256.msg"); + auto& signer = cms_.signers[0]; + // Shrink the digest length by one. + CBS_init(signer.message_digest.cbs_ptr(), + CBS_data(signer.message_digest.cbs_ptr()), + CBS_len(signer.message_digest.cbs_ptr()) - 1); + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, MissingPSSParameters) { + ParseMessage("signed-openssl-pss.msg"); + auto& signer = cms_.signers[0]; + CBS_init(signer.signature_algorithm.parameter.cbs_ptr(), nullptr, 0); + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + EXPECT_FALSE(status.ok()) << status.status(); +} + +TEST_F(VerifySignatureTest, CheckSignatureInfo) { + ParseMessage("signed-openssl-pss.msg"); + auto& signer = cms_.signers[0]; + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + auto status = VerifySignature(cms_, signer, {data}, *certificate); + ASSERT_TRUE(status.ok()) << status.status(); + EXPECT_EQ(NID_rsassaPss, status.value().signature_algorithm_id); + EXPECT_EQ(NID_sha256, status.value().digest_algorithm_nid); +} + +TEST_F(VerifySignatureTest, MismatchedHashDigestAlgorithmEcdsa) { + ParseOpaqueMessage("signed-openssl-ecdsa.msg"); + auto& signer = cms_.signers[0]; + // Use a signature OID that provides an explicit hashing algorithm and use the + // right hash algorithm to make sure the test case is valid. + auto sig_oid = NidToDerString(NID_ecdsa_with_SHA256); + signer.signature_algorithm.algorithm_oid = ByteStringFromString(sig_oid); + auto good_digest_oid = NidToDerString(NID_sha256); + signer.digest_algorithm.algorithm_oid = ByteStringFromString(good_digest_oid); + + ByteString data = ByteStringFromString(first_part_); + X509* certificate = GetFirstMatchingCert(cms_, signer); + ASSERT_FALSE(certificate == nullptr); + // Make sure signature algorithm is correct. + EXPECT_THAT(VerifySignature(cms_, signer, cms_.content, *certificate), + IsOk()); + + // Set hash algorithm to a supported value that is different from the one + // implied by the signature algorithm. + auto bad_digest_oid = NidToDerString(NID_sha384); + signer.digest_algorithm.algorithm_oid = ByteStringFromString(bad_digest_oid); + EXPECT_THAT( + VerifySignature(cms_, signer, {data}, *certificate), + StatusIs( + absl::StatusCode::kInvalidArgument, + R"(The digest algorithm does not match the value derived from the signature algorithm: digest_nid=673, signature_digest_nid=672)")); +} + +} // namespace +} // namespace credentio_cms
diff --git a/crypto/default/compliance_checker.cc b/crypto/default/compliance_checker.cc new file mode 100644 index 0000000..da71c11 --- /dev/null +++ b/crypto/default/compliance_checker.cc
@@ -0,0 +1,184 @@ +// 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 "crypto/default/compliance_checker.h" + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/ec.h" +#include "openssl/ec_key.h" +#include "openssl/evp.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/rsa.h" +#include "openssl/x509.h" + +namespace credentio { +namespace { + +constexpr int kMinRsaModulusBits = 2048; + +absl::Status IsValidCurve(const EVP_PKEY* absl_nonnull evp_key) { + EC_KEY* ec_key = EVP_PKEY_get0_EC_KEY(evp_key); + if (ec_key == nullptr) { + // This should never happen. Return an internal error to indicate a bug. + return absl::InternalError("EVP key is not an EC key"); + } + const EC_GROUP* ec_group = EC_KEY_get0_group(ec_key); + if (ec_group == nullptr) { + // This should never happen. Return an internal error to indicate a bug. + return absl::InvalidArgumentError("EC key does not have an EC group"); + } + int curve_name = EC_GROUP_get_curve_name(ec_group); + switch (curve_name) { + case NID_X9_62_prime256v1: + case NID_secp384r1: + case NID_secp521r1: + return absl::OkStatus(); + default: + return absl::InvalidArgumentError(absl::StrCat( + "EC curve does not fulfill C2PA requirements: ", curve_name)); + } +} + +absl::Status IsValidModulus(const EVP_PKEY* absl_nonnull evp_key) { + RSA* rsa = EVP_PKEY_get0_RSA(evp_key); + if (rsa == nullptr) { + // This should never happen. Return an internal error to indicate a bug. + return absl::InternalError("EVP key is not an RSA key"); + } + if (unsigned int length = RSA_bits(rsa); length < kMinRsaModulusBits) { + return absl::InvalidArgumentError(absl::StrFormat( + "RSA modulus does not fulfill C2PA requirements: The modulus length n " + "= %d bits (minimum required is %d bits)", + length, kMinRsaModulusBits)); + } + return absl::OkStatus(); +} + +absl::Status IsValidTbsCertificate(const X509* absl_nonnull x509) { + const ASN1_BIT_STRING *issuer_uid, *subject_uid; + X509_get0_uids(x509, &issuer_uid, &subject_uid); + if (issuer_uid != nullptr || subject_uid != nullptr) { + return absl::InvalidArgumentError( + "Certificate contains issuerUniqueID or subjectUniqueID fields"); + } + return absl::OkStatus(); +} + +} // namespace + +// This method checks the requirements in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements. +absl::Status ComplianceChecker::IsValidC2paCertificate(const X509* x509, + bool is_leaf) { + if (x509 == nullptr) { + return absl::InvalidArgumentError("Certificate is null"); + } + + if (is_leaf) { + int bc_critical = -1; + bssl::UniquePtr<BASIC_CONSTRAINTS> basic_constraints( + static_cast<BASIC_CONSTRAINTS*>(X509_get_ext_d2i( + x509, NID_basic_constraints, &bc_critical, nullptr))); + if (basic_constraints == nullptr) { + if (bc_critical != -1) { + return absl::InvalidArgumentError( + "Malformed or invalid Basic Constraints extension in the " + "end-entity certificate."); + } + } else if (basic_constraints->ca) { + return absl::InvalidArgumentError( + "The cA boolean flag in the Basic Constraints extension of the " + "end-entity certificate MUST be set to FALSE."); + } + + int ku_critical = -1; + bssl::UniquePtr<ASN1_BIT_STRING> key_usage(static_cast<ASN1_BIT_STRING*>( + X509_get_ext_d2i(x509, NID_key_usage, &ku_critical, nullptr))); + if (key_usage == nullptr) { + if (ku_critical != -1) { + return absl::InvalidArgumentError( + "Malformed or invalid Key Usage extension in the end-entity " + "certificate."); + } + } else if (ASN1_BIT_STRING_get_bit(key_usage.get(), 5) != 0) { + return absl::InvalidArgumentError( + "The keyCertSign bit in the Key Usage extension of the " + "end-entity certificate MUST be set to FALSE."); + } + } + EVP_PKEY* pubkey = X509_get0_pubkey(x509); + if (pubkey == nullptr) { + return absl::InvalidArgumentError("Certificate does not have a public key"); + } + + // * The algorithm requirements in the spec must be satisfied. + // A subset of these checks are performed when COSE verifier is constructing + // the `CoseSign1` structure so we do not need to repeat them here. + switch (EVP_PKEY_id(pubkey)) { + case EVP_PKEY_EC: // id_ecPublicKey + // Quoting + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements, + // if the `algorithm` field of the `algorithm` field of the certificate’s + // `subjectPublicKeyInfo` is `id-ecPublicKey`, the `parameters` field + // shall be one of the following named curves from RFC 5480, + // section 2.1.1.1: "prime256v1", "secp384r1", "secp521r1". + ABSL_RETURN_IF_ERROR(IsValidCurve(pubkey)); + break; + case EVP_PKEY_RSA: // rsaEncryption + case EVP_PKEY_RSA_PSS: // rsaPSS + // Quoting + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements, + // if the `algorithm` field of the `algorithm` field of the certificate’s + // `subjectPublicKeyInfo` is `rsaEncryption` or `rsaPSS`, the modulus + // field of the parameters field shall have a length of at least 2048 + // bits. + ABSL_RETURN_IF_ERROR(IsValidModulus(pubkey)); + // BoringSSL does not support id-RSASSA-PSS key encoding, and + // `RSA_get0_pss_params` will always return nullptr. So we cannot perform + // the following checks required by the C2PA spec for RSA-PSS: + // * `hashAlgorithm` and `maskGenAlgorithm` are present in the PSS params + // and they have the same value. + // * The value is one of the following: `id-sha256`, id-sha385` or + // `id-sha512`. + // + // In the case of a malformed key where the `hashAlgorithm` and + // `maskGenAlgorithm` have different values, the signature verification + // will fail. `CoseSign1Verifier::Verify` will also return an error if the + // signing algorithm is RSA-PSS but the hash algorithm is not SHA-256, + // SHA-358 or SHA-512. So we do not need to check these two items here. + break; + } + + // * The version must be equal to v3. + if (X509_get_version(x509) != X509_VERSION_3) { + return absl::InvalidArgumentError("Certificate version is not v3"); + } + + // * The `issuerUniqueID` and `subjectUniqueID` optional fields of the + // `TBSCertificate` sequence shall not be present, as per RFC 5280, + // section 4.1.2.8. + ABSL_RETURN_IF_ERROR(IsValidTbsCertificate(x509)); + + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/crypto/default/compliance_checker.h b/crypto/default/compliance_checker.h new file mode 100644 index 0000000..8a288c0 --- /dev/null +++ b/crypto/default/compliance_checker.h
@@ -0,0 +1,31 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_COMPLIANCE_CHECKER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_COMPLIANCE_CHECKER_H_ + +#include "absl/status/status.h" +#include "openssl/base.h" + +namespace credentio { + +class ComplianceChecker { + public: + static absl::Status IsValidC2paCertificate(const X509* x509, bool is_leaf); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_COMPLIANCE_CHECKER_H_
diff --git a/crypto/default/compliance_checker_test.cc b/crypto/default/compliance_checker_test.cc new file mode 100644 index 0000000..7cccf70 --- /dev/null +++ b/crypto/default/compliance_checker_test.cc
@@ -0,0 +1,253 @@ +// 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 "crypto/default/compliance_checker.h" + +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/x509.h" +#include "testing/crypto_testing.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(ComplianceCheckerTest, NullCertificate) { + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(nullptr, /*is_leaf=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate is null"))); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(nullptr, /*is_leaf=*/false), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate is null"))); +} + +TEST(ComplianceCheckerTest, NoPublicKey) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_1); + EXPECT_THAT(ComplianceChecker::IsValidC2paCertificate(cert.get(), + /*is_leaf=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate does not have a public key"))); + EXPECT_THAT(ComplianceChecker::IsValidC2paCertificate(cert.get(), + /*is_leaf=*/false), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate does not have a public key"))); +} + +TEST(ComplianceCheckerTest, VersionNotV3) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_1); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEcKey(NID_secp384r1); + X509_set_pubkey(cert.get(), evp_key.get()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate version is not v3"))); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate version is not v3"))); +} + +TEST(ComplianceCheckerTest, EcdsaGoodCurve) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEcKey(NID_secp384r1); + X509_set_pubkey(cert.get(), evp_key.get()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + IsOk()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + IsOk()); +} + +TEST(ComplianceCheckerTest, EcdsaBadCurve) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEcKey(NID_secp224r1); + X509_set_pubkey(cert.get(), evp_key.get()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("EC curve does not fulfill C2PA requirements"))); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("EC curve does not fulfill C2PA requirements"))); +} + +TEST(ComplianceCheckerTest, Rsa) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestRsaKey(2048); + X509_set_pubkey(cert.get(), evp_key.get()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + IsOk()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + IsOk()); +} + +TEST(ComplianceCheckerTest, RsaBadModulus) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestRsaKey(1024); + X509_set_pubkey(cert.get(), evp_key.get()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "RSA modulus does not fulfill C2PA requirements: The modulus " + "length n = 1024 bits (minimum required is 2048 bits)"))); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "RSA modulus does not fulfill C2PA requirements: The modulus " + "length n = 1024 bits (minimum required is 2048 bits)"))); +} + +TEST(ComplianceCheckerTest, Ed) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey(); + X509_set_pubkey(cert.get(), evp_key.get()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + IsOk()); + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + IsOk()); +} + +TEST(ComplianceCheckerTest, CALeafCertificate) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey(); + X509_set_pubkey(cert.get(), evp_key.get()); + + bssl::UniquePtr<BASIC_CONSTRAINTS> bc(BASIC_CONSTRAINTS_new()); + bc->ca = 1; + ASSERT_TRUE(X509_add1_ext_i2d(cert.get(), NID_basic_constraints, bc.get(), + /*crit=*/1, /*flags=*/0)); + + // Valid as non-leaf. + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + IsOk()); + + // Invalid as leaf. + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("end-entity certificate MUST be set to FALSE"))); +} + +TEST(ComplianceCheckerTest, KeyCertSignLeafCertificate) { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey(); + X509_set_pubkey(cert.get(), evp_key.get()); + + bssl::UniquePtr<ASN1_BIT_STRING> ku(ASN1_BIT_STRING_new()); + std::vector<uint8_t> bits = {0b00000100}; // Bit 5 is keyCertSign + ASSERT_TRUE(ASN1_BIT_STRING_set(ku.get(), bits.data(), bits.size())); + ASSERT_TRUE(X509_add1_ext_i2d(cert.get(), NID_key_usage, ku.get(), + /*crit=*/1, /*flags=*/0)); + + // Valid as non-leaf. + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false), + IsOk()); + + // Invalid as leaf. + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("keyCertSign bit in the Key Usage extension"))); +} + +TEST(ComplianceCheckerTest, MalformedExtensionsLeafCertificate) { + // Malformed Basic Constraints in leaf certificate. + { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey(); + X509_set_pubkey(cert.get(), evp_key.get()); + + bssl::UniquePtr<ASN1_OCTET_STRING> malformed_data(ASN1_OCTET_STRING_new()); + std::vector<uint8_t> invalid_der = { + 0x05, 0x00}; // Invalid DER for BASIC_CONSTRAINTS + ASSERT_TRUE(ASN1_OCTET_STRING_set(malformed_data.get(), invalid_der.data(), + invalid_der.size())); + + bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_new()); + ASSERT_TRUE(X509_EXTENSION_set_object(ext.get(), + OBJ_nid2obj(NID_basic_constraints))); + ASSERT_TRUE(X509_EXTENSION_set_data(ext.get(), malformed_data.get())); + ASSERT_TRUE(X509_add_ext(cert.get(), ext.get(), -1)); + + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("Malformed or invalid Basic Constraints extension"))); + } + + // Malformed Key Usage in leaf certificate. + { + bssl::UniquePtr<X509> cert(X509_new()); + X509_set_version(cert.get(), X509_VERSION_3); + bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey(); + X509_set_pubkey(cert.get(), evp_key.get()); + + bssl::UniquePtr<ASN1_OCTET_STRING> malformed_data(ASN1_OCTET_STRING_new()); + std::vector<uint8_t> invalid_der = {0x05, + 0x00}; // Invalid DER for KeyUsage + ASSERT_TRUE(ASN1_OCTET_STRING_set(malformed_data.get(), invalid_der.data(), + invalid_der.size())); + + bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_new()); + ASSERT_TRUE( + X509_EXTENSION_set_object(ext.get(), OBJ_nid2obj(NID_key_usage))); + ASSERT_TRUE(X509_EXTENSION_set_data(ext.get(), malformed_data.get())); + ASSERT_TRUE(X509_add_ext(cert.get(), ext.get(), -1)); + + EXPECT_THAT( + ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Malformed or invalid Key Usage extension"))); + } +} + +} // namespace +} // namespace credentio
diff --git a/crypto/default/default_crypto_read_handler.cc b/crypto/default/default_crypto_read_handler.cc new file mode 100644 index 0000000..b92dd8d --- /dev/null +++ b/crypto/default/default_crypto_read_handler.cc
@@ -0,0 +1,359 @@ +// 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 "crypto/default/default_crypto_read_handler.h" + +#include <cstddef> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/log/absl_check.h" +#include "absl/log/absl_log.h" +#include "absl/log/check.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "cose/simple_cms_parser.h" +#include "crypto/algorithms.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/default/eku_verifier.h" +#include "crypto/default/trust_store.h" +#include "crypto/default/x509_certificate.h" +#include "openssl/pki/ocsp.h" +#include "openssl/pki/verify.h" +#include "openssl/pki/verify_error.h" +#include "tsp/timestamp_verifier.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +class DefaultParsedCertificates : public ParsedCertificates { + public: + DefaultParsedCertificates() = default; + ~DefaultParsedCertificates() override = default; + // If `all_claim_signer_roots_` is nullptr, VerifyClaimSignerTrust skips trust + // checks. If `legacy_claim_signer_roots_` is nullptr, VerifyClaimSignerTrust + // accepts only the C2PA EKU. + explicit DefaultParsedCertificates( + std::vector<std::unique_ptr<X509Certificate>> certificate_chain_x509, + std::vector<std::string> certificate_chain_der, + bssl::VerifyTrustStore* absl_nullable all_claim_signer_roots, + bssl::VerifyTrustStore* absl_nullable legacy_claim_signer_roots) + : certificate_chain_x509_(std::move(certificate_chain_x509)), + certificate_chain_der_(std::move(certificate_chain_der)), + all_claim_signer_roots_(all_claim_signer_roots), + legacy_claim_signer_roots_(legacy_claim_signer_roots) { + ABSL_CHECK(!certificate_chain_x509_.empty()); + ABSL_CHECK_EQ(certificate_chain_der_.size(), + certificate_chain_x509_.size()); + } + + absl::StatusOr<std::vector<std::string>> VerifyClaimSignerTrust( + absl::Time content_time) const override { + if (all_claim_signer_roots_ == nullptr) { + ABSL_LOG_EVERY_N_SEC(INFO, 60) + << "Bypassing C2PA claim signer certificate trust check."; + return std::vector<std::string>(); + } + if (certificate_chain_der_.size() > 3) { + return absl::UnauthenticatedError( + absl::StrCat("Excessive certificate chain length: ", + certificate_chain_der_.size())); + } + + bssl::CertificateVerifyOptions opts; + opts.leaf_cert = certificate_chain_der_.at(0); + if (EkuVerifier::CheckLeafCertEku(opts.leaf_cert)) { + // `EkuVerifier::CheckLeafCertEku` returns true iff the version 2.2 + // requirements are met: + // 1. The leaf certificate asserts the `digitalSignature` KU bit. + // 2. The leaf certificate has the C2PA claim signing EKU. + // 3. The leaf certificate does not have the `anyExtendedKeyUsage` EKU. + // So, we can skip the EKU checks in BoringSSL. + opts.key_purpose = bssl::CertificateVerifyOptions::KeyPurpose::ANY_EKU; + opts.trust_store = all_claim_signer_roots_; + } else { + // Version 2.2 requirements are not met. Fall back to version 2.1 checks + // in BoringSSL. + opts.key_purpose = + bssl::CertificateVerifyOptions::KeyPurpose::C2PA_MANIFEST; + if (legacy_claim_signer_roots_ == nullptr) { + return absl::InvalidArgumentError( + "Extended Key Usage c2pa-kp-claimSigning not present"); + } + opts.trust_store = legacy_claim_signer_roots_; + } + for (int i = 1; i < certificate_chain_der_.size(); ++i) { + opts.intermediates.push_back(certificate_chain_der_.at(i)); + } + opts.time = absl::ToTimeT(content_time); + bssl::VerifyError error; + // This check only verifies that there is a valid chain from the leaf + // certificate to one of the trusted root certificates. Note that this is + // a relaxed version of the requirement in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#x509_certificates + // which requires the certificates in the chain to be ordered starting with + // the certificate containing the end-entity key (i.e., the leaf + // certificate) followed by the certificate that signed it, and so on. + auto chain = bssl::CertificateVerify(opts, &error); + if (!chain) { + switch (error.Code()) { + case bssl::VerifyError::StatusCode::CERTIFICATE_NOT_YET_VALID: + case bssl::VerifyError::StatusCode::CERTIFICATE_EXPIRED: + return absl::OutOfRangeError( + absl::StrCat("certificate outside validity period: ", + error.DiagnosticString())); + case bssl::VerifyError::StatusCode::PATH_NOT_FOUND: + return absl::UnauthenticatedError(absl::StrCat( + "untrusted claim signer: ", error.DiagnosticString())); + default: + return absl::InvalidArgumentError(absl::StrCat( + "Failed to verify the trust chain: ", error.DiagnosticString())); + } + } + return *chain; + } + + absl::Status VerifySignature(absl::string_view signature, + absl::string_view data, + SigningAlgorithm algorithm) const override { + return leaf().VerifySignature(signature, data, algorithm); + } + + size_t GetCertificateCount() const override { + return certificate_chain_x509_.size(); + } + + absl::StatusOr<std::string> GetSubject(size_t index) const override { + if (index >= certificate_chain_x509_.size()) { + return absl::OutOfRangeError("Certificate index out of bounds"); + } + return certificate_chain_x509_[index]->GetSubject(); + } + + absl::StatusOr<std::string> GetIssuer(size_t index) const override { + if (index >= certificate_chain_x509_.size()) { + return absl::OutOfRangeError("Certificate index out of bounds"); + } + return certificate_chain_x509_[index]->GetIssuer(); + } + + absl::StatusOr<absl::Time> GetStartTime(size_t index) const override { + if (index >= certificate_chain_x509_.size()) { + return absl::OutOfRangeError("Certificate index out of bounds"); + } + return certificate_chain_x509_[index]->StartTime(); + } + + absl::StatusOr<absl::Time> GetEndTime(size_t index) const override { + if (index >= certificate_chain_x509_.size()) { + return absl::OutOfRangeError("Certificate index out of bounds"); + } + return certificate_chain_x509_[index]->EndTime(); + } + + absl::StatusOr<std::string> GetSerialNumberHex(size_t index) const override { + if (index >= certificate_chain_x509_.size()) { + return absl::OutOfRangeError("Certificate index out of bounds"); + } + return certificate_chain_x509_[index]->GetSerialNumberHex(); + } + + absl::StatusOr<std::string> GetAssuranceLevel(size_t index) const override { + if (index >= certificate_chain_x509_.size()) { + return absl::OutOfRangeError("Certificate index out of bounds"); + } + return certificate_chain_x509_[index]->GetAssuranceLevel(); + } + + absl::StatusOr<std::string> GetConformingProductId( + size_t index) const override { + if (index >= certificate_chain_x509_.size()) { + return absl::OutOfRangeError("Certificate index out of bounds"); + } + return certificate_chain_x509_[index]->GetConformingProductId(); + } + + const X509Certificate& leaf() const { + return *certificate_chain_x509_.front(); + } + +#ifndef NDEBUG + [[maybe_unused]] std::string CertChainDebugString() const { + std::string s; + for (const auto& cert : certificate_chain_x509_) { + absl::StrAppend(&s, cert->DebugString()); + } + return s; + } +#endif + + private: + std::vector<std::unique_ptr<X509Certificate>> certificate_chain_x509_; + std::vector<std::string> certificate_chain_der_; + bssl::VerifyTrustStore* absl_nullable all_claim_signer_roots_; + bssl::VerifyTrustStore* absl_nullable legacy_claim_signer_roots_; +}; + +// An implementation of CryptoReadHandler intended for use in production. +// It uses Tink, BoringSSL, and SimpleCMS. +class DefaultCryptoReadHandler : public CryptoReadHandler { + public: + DefaultCryptoReadHandler( + absl_nullable std::unique_ptr<bssl::VerifyTrustStore> + all_claim_signer_roots, + absl_nullable std::unique_ptr<bssl::VerifyTrustStore> + legacy_claim_signer_roots, + absl_nullable std::unique_ptr<bssl::VerifyTrustStore> tsa_roots, + TrustEnvironment trust_environment) + : all_claim_signer_roots_(std::move(all_claim_signer_roots)), + legacy_claim_signer_roots_(std::move(legacy_claim_signer_roots)), + tsa_roots_(std::move(tsa_roots)), + timestamp_verifier_( + std::make_unique<TimestampVerifier>(tsa_roots_.get())), + cms_parser_(std::make_unique<SimpleCmsParser>()), + trust_environment_(trust_environment) {} + ~DefaultCryptoReadHandler() override = default; + + absl::StatusOr<std::unique_ptr<ParsedCertificates>> ParseCertificatesDer( + absl::Span<const absl::string_view> certificates) const override; + + absl::StatusOr<OCSPRevocationStatus> VerifyOcspResponse( + absl::string_view ocsp_response_der, absl::string_view certificate_der, + absl::string_view issuer_certificate_der, + absl::Time verify_time) const override { + bssl::OCSPVerifyResult::ResponseStatus response_status; + bssl::OCSPRevocationStatus revocation_status = bssl::CheckOCSP( + ocsp_response_der, certificate_der, issuer_certificate_der, + absl::ToUnixSeconds(verify_time), std::nullopt, &response_status); + + switch (response_status) { + case bssl::OCSPVerifyResult::PROVIDED: + switch (revocation_status) { + case bssl::OCSPRevocationStatus::GOOD: + return OCSPRevocationStatus::kGood; + case bssl::OCSPRevocationStatus::REVOKED: + return OCSPRevocationStatus::kRevoked; + case bssl::OCSPRevocationStatus::UNKNOWN: + return OCSPRevocationStatus::kUnknown; + default: + return absl::InternalError("Unknown revocation status"); + } + case bssl::OCSPVerifyResult::NO_MATCHING_RESPONSE: + return absl::UnauthenticatedError( + "OCSP response signature failed verification or no matching " + "response found."); + case bssl::OCSPVerifyResult::INVALID_DATE: + case bssl::OCSPVerifyResult::BAD_PRODUCED_AT: + return absl::OutOfRangeError( + "verify_time is outside the valid time window of the OCSP " + "response."); + default: + return absl::InvalidArgumentError( + "OCSP response is malformed or invalid."); + } + } + + absl::StatusOr<VerifiedTimestamp> VerifyTimestamp( + absl::string_view cms) const override { + ABSL_ASSIGN_OR_RETURN(auto parsed_token, + cms_parser_->ParseTimestampToken(cms)); + return timestamp_verifier_->VerifyTimestampToken(*parsed_token); + } + + TrustEnvironment trust_environment() const override { + return trust_environment_; + } + + private: + absl_nullable std::unique_ptr<bssl::VerifyTrustStore> all_claim_signer_roots_; + absl_nullable std::unique_ptr<bssl::VerifyTrustStore> + legacy_claim_signer_roots_; + absl_nullable std::unique_ptr<bssl::VerifyTrustStore> tsa_roots_; + std::unique_ptr<TimestampVerifier> timestamp_verifier_; + std::unique_ptr<SimpleCmsParser> cms_parser_; + TrustEnvironment trust_environment_; +}; + +} // namespace + +absl::StatusOr<std::unique_ptr<ParsedCertificates>> +DefaultCryptoReadHandler::ParseCertificatesDer( + absl::Span<const absl::string_view> certificates) const { + if (certificates.empty()) { + return absl::InvalidArgumentError("No leaf certificate provided"); + } + // Construct the X.509 public key certificate from DER bytes. + // The first element of the certificate chain is the signer's public key DER + // bytes. The certificate chain starts with the signer's certificate and the + // following elements constitute the rest of the chain. + std::vector<std::unique_ptr<X509Certificate>> certificates_x509; + std::vector<std::string> certificates_der; + certificates_der.reserve(certificates.size()); + for (int i = 0; i < certificates.size(); ++i) { + const auto& cert_der = certificates[i]; + certificates_der.push_back(std::string(cert_der)); + ABSL_ASSIGN_OR_RETURN(auto x509, X509Certificate::Create(/*der=*/cert_der)); + ABSL_DVLOG(1) << "Cert validity start time = " << x509->StartTime(); + ABSL_DVLOG(1) << "Cert validity end time = " << x509->EndTime(); + if (x509 == nullptr) { + return absl::InternalError("X509Certificate::Create returned nullptr"); + } + ABSL_RETURN_IF_ERROR(x509->IsValidC2paCertificate(/*is_leaf=*/(i == 0))); + certificates_x509.push_back(std::move(x509)); + } + return std::make_unique<DefaultParsedCertificates>( + std::move(certificates_x509), std::move(certificates_der), + all_claim_signer_roots_.get(), legacy_claim_signer_roots_.get()); +} + +absl::StatusOr<std::unique_ptr<CryptoReadHandler>> +CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options) { + std::unique_ptr<bssl::VerifyTrustStore> all_claim_signer_roots; + std::unique_ptr<bssl::VerifyTrustStore> legacy_claim_signer_roots; + if (!options.skip_claim_signer_trust_checks_for_test) { + if (!options.legacy_claim_signer_trust_anchors_pem.empty()) { + // If the string is nonempty, it must contain at least one cert. + ABSL_ASSIGN_OR_RETURN( + legacy_claim_signer_roots, + LoadTrustStore(options.legacy_claim_signer_trust_anchors_pem)); + } + ABSL_ASSIGN_OR_RETURN(all_claim_signer_roots, + LoadTrustStore(absl::StrCat( + options.claim_signer_trust_anchors_pem, + options.legacy_claim_signer_trust_anchors_pem))); + } + std::unique_ptr<bssl::VerifyTrustStore> tsa_roots; + if (!options.skip_tsa_trust_checks_for_test) { + ABSL_ASSIGN_OR_RETURN(tsa_roots, + LoadTrustStore(options.tsa_trust_anchors_pem)); + } + return std::make_unique<DefaultCryptoReadHandler>( + /*all_claim_signer_roots=*/std::move(all_claim_signer_roots), + /*legacy_claim_signer_roots=*/std::move(legacy_claim_signer_roots), + std::move(tsa_roots), options.trust_environment); +} + +} // namespace credentio
diff --git a/crypto/default/default_crypto_read_handler.h b/crypto/default/default_crypto_read_handler.h new file mode 100644 index 0000000..005872a --- /dev/null +++ b/crypto/default/default_crypto_read_handler.h
@@ -0,0 +1,64 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_DEFAULT_CRYPTO_READ_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_DEFAULT_CRYPTO_READ_HANDLER_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/crypto_read_handler.h" + +namespace credentio { + +struct DefaultCryptoReadHandlerOptions { + // The content of the PEM file containing claim signer trust anchors, + // trusted for claim signer certs containing the `c2pa-kp-claimSigning` EKU. + // + // At least one of `claim_signer_trust_anchors_pem` or + // `legacy_claim_signer_trust_anchors_pem` must be nonempty unless + // `skip_claim_signer_trust_checks_for_test` is true. + absl::string_view claim_signer_trust_anchors_pem; + + // The content of the PEM file containing legacy claim signer trust anchors, + // trusted for claim signer certs containing any of the EKUs + // `id-kp-emailProtection`, `id-kp-documentSigning`, and + // `c2pa-kp-claimSigning`. + absl::string_view legacy_claim_signer_trust_anchors_pem; + + // The content of the PEM file containing TSA trust anchors. + // Required unless `skip_tsa_trust_checks` is true. + absl::string_view tsa_trust_anchors_pem; + + // Skips claim signer trust checks. + bool skip_claim_signer_trust_checks_for_test = false; + + // Skips timestamp trust checks. + bool skip_tsa_trust_checks_for_test = false; + + // The trust level of the handler. + CryptoReadHandler::TrustEnvironment trust_environment = + CryptoReadHandler::TrustEnvironment::kUnspecified; +}; + +// Creates an implementation of CryptoReadHandler intended for use in +// production. It uses Tink, BoringSSL, and SimpleCmsParser. +absl::StatusOr<std::unique_ptr<CryptoReadHandler>> +CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_DEFAULT_CRYPTO_READ_HANDLER_H_
diff --git a/crypto/default/eku_verifier.cc b/crypto/default/eku_verifier.cc new file mode 100644 index 0000000..0bb1808 --- /dev/null +++ b/crypto/default/eku_verifier.cc
@@ -0,0 +1,114 @@ +// 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 "crypto/default/eku_verifier.h" + +#include "absl/strings/string_view.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/x509.h" + +namespace credentio { +namespace { + +constexpr absl::string_view kAnyEkuOid = "2.5.29.37.0"; +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_trust_lists. +constexpr absl::string_view kC2paClaimSigningEkuOid = "1.3.6.1.4.1.62558.2.1"; +// The maximum number of EKU values that can be present in a certificate. +// We have this upper limit to prevent malicious certificates with arbitrarily +// large EKU lists to be processed by the check. +constexpr size_t kMaxEkuCount = 100; +// The bit index of the `digitalSignature` KU bit in the keyUsage bit string. +constexpr int kDigitalSignatureKeyUsageBit = 0; + +} // namespace + +bool EkuVerifier::CheckLeafCertEku(absl::string_view leaf_cert_der) { + const unsigned char* leaf_cert_der_bytes = + reinterpret_cast<const unsigned char*>(leaf_cert_der.data()); + bssl::UniquePtr<BIO> bio( + BIO_new_mem_buf(leaf_cert_der_bytes, leaf_cert_der.size())); + if (bio == nullptr) { + return false; + } + bssl::UniquePtr<X509> leaf_cert(d2i_X509_bio(bio.get(), /*x509=*/nullptr)); + if (leaf_cert == nullptr) { + return false; + } + + // Check that the certificate asserts the `digitalSignature` KU bit per + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements. + bssl::UniquePtr<ASN1_BIT_STRING> key_usage(static_cast<ASN1_BIT_STRING*>( + X509_get_ext_d2i(leaf_cert.get(), NID_key_usage, /*out_critical=*/nullptr, + /*out_idx=*/nullptr))); + if (key_usage == nullptr) { + return false; + } + if (ASN1_BIT_STRING_get_bit(key_usage.get(), kDigitalSignatureKeyUsageBit) == + 0) { + return false; + } + + int loc_eku = + X509_get_ext_by_NID(leaf_cert.get(), NID_ext_key_usage, /*lastpos=*/-1); + if (loc_eku < 0) { + return false; + } + X509_EXTENSION* ext = X509_get_ext(leaf_cert.get(), loc_eku); + if (!ext) { + return false; + } + bssl::UniquePtr<EXTENDED_KEY_USAGE> eku( + (EXTENDED_KEY_USAGE*)X509V3_EXT_d2i(ext)); + if (!eku) { + return false; + } + bssl::UniquePtr<ASN1_OBJECT> target_oid( + OBJ_txt2obj(kC2paClaimSigningEkuOid.data(), 0)); + if (!target_oid) { + return false; + } + bssl::UniquePtr<ASN1_OBJECT> any_eku_oid(OBJ_txt2obj(kAnyEkuOid.data(), 0)); + if (!any_eku_oid) { + return false; + } + bool found = false; + bool any_eku_found = false; + int num_usages = sk_ASN1_OBJECT_num(eku.get()); + if (num_usages > kMaxEkuCount) { + return false; + } + for (int i = 0; i < num_usages; ++i) { + ASN1_OBJECT* obj = sk_ASN1_OBJECT_value(eku.get(), i); + if (!obj) { + continue; + } + if (OBJ_cmp(obj, target_oid.get()) == 0) { + // The C2PA claim signing EKU is present + found = true; + } + if (OBJ_cmp(obj, any_eku_oid.get()) == 0) { + // The `anyExtendedKeyUsage` EKU is present + any_eku_found = true; + } + } + return found && !any_eku_found; +} + +} // namespace credentio
diff --git a/crypto/default/eku_verifier.h b/crypto/default/eku_verifier.h new file mode 100644 index 0000000..492985a --- /dev/null +++ b/crypto/default/eku_verifier.h
@@ -0,0 +1,48 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_EKU_VERIFIER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_EKU_VERIFIER_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +class EkuVerifier { + public: + EkuVerifier() = default; + ~EkuVerifier() = default; + + // Performs the EKU checks described in + // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_general_requirements + // for claim signing certificates. Returns true if the leaf certificate + // asserts the `digitalSignature` key usage bit, it has the C2PA claim signing + // EKU and the `anyExtendedKeyUsage` EKU is not present. Returns false + // otherwise (including for parsing errors). If the certificate is malformed, + // the verification that will later be performed by BoringSSL will fail to + // handle the malformed certificate. + + // This additional check is needed because the corresponding BoringSSL + // verification option + // (`bssl::CertificateVerifyOptions::KeyPurpose::C2PA_MANIFEST`) used by the + // `DefaultParsedCertificates:VerifyClaimSignerTrust` does not check the + // requirements of the most recent version of the spec (2.2). BoringSSL can + // only be used to check the requirements of the version 2.1. + static bool CheckLeafCertEku(absl::string_view leaf_cert_der); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_EKU_VERIFIER_H_
diff --git a/crypto/default/eku_verifier_test.cc b/crypto/default/eku_verifier_test.cc new file mode 100644 index 0000000..328795f --- /dev/null +++ b/crypto/default/eku_verifier_test.cc
@@ -0,0 +1,139 @@ +// 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 "crypto/default/eku_verifier.h" + +#include <string> + +#include "absl/log/check.h" +#include "absl/strings/escaping.h" +#include "absl/strings/string_view.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +// This certificate is self-signed and has no EKU. +// The certificate is created by following the steps below: +// 1. Create a configuration file called `cert.cnf` with the following content: +// +// [ req ] +// distinguished_name = req_distinguished_name +// prompt = no +// +// [ req_distinguished_name ] +// C = US +// ST = MA +// L = Cambridge +// O = Unit Test +// CN = Unit Test +// +// [ v3_ext ] +// basicConstraints = CA:false +// keyUsage = digitalSignature, keyEncipherment +// +// 2. Generate the self-signed certificate by running the following command: +// openssl req -x509 -new -newkey rsa:2048 -keyout mycert.key -out mycert.crt \ +// -days 365 -config cert.cnf -extensions v3_ext -nodes +// +// 3. Generate the DER bytes of the certificate by running the following +// command: +// openssl req -x509 -new -newkey rsa:2048 -keyout mycert.key -out mycert.der \ +// -days 365 -config cert.cnf -extensions v3_ext -nodes -outform DER +// +// 4. Encode the DER bytes to base64 by running the following command: +// cat mycert.der | base64 -w 0 +constexpr absl::string_view kSelfSignedCertNoEkuBase64 = + R"(MIIC+TCCAeGgAwIBAgIUYSYg4BtYfbjcgKpPxDjiPGSmolMwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNTA4MDkxNzA5MzhaFw0yNjA4MDkxNzA5MzhaMBcxFTATBgNVBAMMDG15c2VydmVyLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPNaCaGamYc4i98Jqzkvi3+RW1cDQnLeWfxayJmo2f821iphgT+SnndzGqgzKnBDqoyxzu4oAOPxvm2AB/IVnnpth4UHYCvLkjYH5KLAWGCy3cJ+9IiN5XqLBB1gi4hert4kZfPRsZkLuCLiNPnFx3De9JRqd+S9kPcGkXZS2WIdjkk4MK9fmy2RsCUsvywQCDmmoEUpyhB7cj1ZiML4JH46wXRwQdoHGa1DGde13suhtCnf2QfFvVnP+SEGbhHkGAydwWi30YJojzQTEpI8wZa1oTMbXgfYZ13AObE2g2N84BVfk4CUivMI0Agrdx4Ix1nuPqPqwh7zyx6rc49kt+kCAwEAAaNCMEAwHQYDVR0OBBYEFEkKD+6lAuhRcKIs3BqU1VA12nB8MB8GA1UdIwQYMBaAFBdH5e5xEcv9yEjPOO2+XNgncNkXMA0GCSqGSIb3DQEBCwUAA4IBAQAelWiUlQsMtdZRn5OfwnQC1p5jlbni+HMYofZRWmFqNrFqVURxHZmtfN6bqI0gNARVKJzTy/y8g006603zPBIu4Ax715TSbhEhPsnOucB6b0ydvA6ynYm3n/QLsWeT9ummbbWJjITkhzHmu4t8tHyKMXOnX8LPTi0ZBnZ/Jw6Lb+7kM0X1at7KfhBbAqcCiIsQLS7zURehijoAxcJWdC8OQZZ/3HeqWzuAH52jHrF/g/H6r15JzXCyPVSbBey4GbPh/+1MHe8RQyMmCGyq1gEk1K51XGKjepLoNgxUs4l10lMpGFptEHph+bZfrVKgfi4okq/D5EPdje6N5sCSZL6Q)"; +// This certificate is self-signed and it has both the C2PA claim signing EKU +// and the `anyExtendedKeyUsage` EKU. +// The certificate is created by following the steps above for the +// `kSelfSignedCertNoEkuBase64` with the following change: +// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ] +// section: +// extendedKeyUsage = 2.5.29.37.0, 1.3.6.1.4.1.62558.2.1 +constexpr absl::string_view kSelfSignedCertWithAnyEkuAndC2paEkuBase64 = + R"(MIIDkDCCAnigAwIBAgIUFtIA85O7PUfkXi1HpHaUHhci8DEwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgwOTE3NDI1MVoXDTI2MDgwOTE3NDI1MVowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmR3ZSWgPSGo2W6z/JVyV7CHVhYv0BGEkMCfL0vFiHt2ThYQZCQeHV+cd4++acnPuPwvnoZxWPbTemJ7D9bMbWeuQ6XKSrV4nUx+2ZFi/CcwqqdawoiSThTW/Rw48qXacHfGRiM2r1tt7nmJ7gMUcM6kwXeHr1VabiIZqy0kW9+h71UqMW22ntGilfESsZq2QududiWB4ivLnSIFJWHTpGaNSsMdA4AwVV4cfpmf3dyj88u4CrV51k9T2q0ARocgXLVZdv6KoC6MAqvNykRg7qQGlH6nWgYW0DRGu9EHoqaB9JnkSWBkkrBewkTePeRL3eLRmUXrNJV4r8tuL20uq4QIDAQABo1YwVDAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDAbBgNVHSUEFDASBgRVHSUABgorBgEEAYPoXgIBMB0GA1UdDgQWBBRVzemKLsqLTYmfYVynHKW2PD3UPTANBgkqhkiG9w0BAQsFAAOCAQEARgFgmk4rUx8xcZQsP7RS16/3tMZvPZ59M8yS+v7y/sHN8lim3jJBtBN0OE/rFQzzH93gkPo0slJzD28GqIV495GyJSi4iOsreGa2j9kqrVGcP8BXTJYYyN5mAHixRq7XgTpjQuJ+d9nFXJo9g3Yy4OrV/SIC1poXb++YX4hdDmwCmoSnutiJQJbGBw1lx2XHaQtrMZFtrsxRUbgOZhMKlaFyBXEJZs4V8oSOH24iiK/qIo2DNUwmOuZngJXfEFB1W8cRPu1gM25wX6uxUjo0Jf/j7ooBNrnC5HFj700RR5jYjau1W6ZilXK41nHo3BsIdmNZLPmwBWCp4zumt9XV4g==)"; +// This certificate is self-signed and it only has the C2PA claim signing EKU. +// The certificate is created by following the steps above for the +// `kSelfSignedCertNoEkuBase64` with the following change: +// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ] +// section: +// extendedKeyUsage = 1.3.6.1.4.1.62558.2.1 +constexpr absl::string_view kSelfSignedCertWithC2paEkuBase64 = + R"(MIIDijCCAnKgAwIBAgIUbvfHtjDh0li8c7UsrlX+7HZANhkwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgwOTE3NDUyNloXDTI2MDgwOTE3NDUyNlowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlohRQoQJeWRV1A6W70Xmaq+SV2ftqGIW+dj9u/JccI/1WI6sXk2hIJPp5LvKDoyiN3gA+cgaTIhiJ2BbnJzFkzAKZhV2KdQyD2OOHefgycRc18Rkg3zJlylubV7/O2kfZsZIwUByqPi/KlMtWwkLTnYDnZ8VvifKSvCVDLA1m6nkhkBCaVlHXEtlCIzYYcbYXX31HsrKpMhYBbRcGaqbWAkSLg3qyovwDyjEKVWRxswUYyfi4IJgB+Fld27YSSBslEHhH2WaTWLIInnNxkR/gzw3fMkNMJTH/l4jUsOR6Kl/9ofzJg371hPp7MKuY37JPyVOFi9l+jw0Gk41eakICwIDAQABo1AwTjAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMB0GA1UdDgQWBBTBfCBm97Ba5p/5yjlhNqcesYD2VDANBgkqhkiG9w0BAQsFAAOCAQEAfBwfVUAXh6ZLEtWV4Nc9IqpL6hkF4HJhkPlxPVcUH6nsdBUkr9tGbEBtrg4FyqwbVfsAr6geZK/UZ/fT6iltwU8qQB0+LbT1wvjPgPgVapwMioh8whyNUOHuT3P1DtaJ/ma507Da2osdqk3n9+J+Gc7TUVupetfoDhOowgjhqI8oPrqIUki/arS7T6QfiNlHK0f1/l+nhLaaC4W84l+jCZn54GcZh39aw+I+oG/JhVG+k8bQ+6STG/WiPlXP9+2f8Ufh5U6srUkN0zQ3CEI+jpuvCH+Kco7CTcLjtWeDDOMyr/H7Sgy9gWN1UsdOTjIiJiqM2R9AGJ7Qki1ug8JwuA==)"; +// This certificate is self-signed and it has 109 EKU values. +// The certificate is created by following the steps above for the +// `kSelfSignedCertNoEkuBase64` with the following change: +// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ] +// section: +// extendedKeyUsage = 1.3.6.1.4.1.62558.2.1, 1.3.6.1.4.1.62558.2.2, +// ..., 1.3.6.1.4.1.62558.2.109 +constexpr absl::string_view kSelfSignedCertWithLargeEkuBase64 = + R"(MIIIpDCCB4ygAwIBAgIUSeQijsSqcxpRYnFjxo0nHwCVuN4wDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgwOTE4NTQ0MVoXDTI2MDgwOTE4NTQ0MVowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsELqKytAjSQmygv+le4WsIAPNL3653/fsHZM4Sxx6ytJRK3fg8s55sd6SQJpLHtjzJh1CgkjjOkVGMHZHCP9F5/rYNosRpN3dvljbOGpoRLv0uSJRbDQVe5/3pdejLutjFfV4GRwWj0eYj1NShBP17bTO1NhCmWvyYb17KoV8ynprDvix4eYnYm/39jQUM0k4QpZDW+OaWMzjqC6hRrHLEU/pAXKY9H4zwM4O1R4dNWrMT92PhwxlaGvcUHNK8bVb61QjVmNTaPNnMY5udJkQpY6kdIMBzWoHB0gzKDVrqpvS0tIgy30IpKipHKNFya/emcICZ6eCzz20S9xCCXwpwIDAQABo4IFaDCCBWQwCQYDVR0TBAIwADALBgNVHQ8EBAMCBaAwggUpBgNVHSUEggUgMIIFHAYKKwYBBAGD6F4CAQYKKwYBBAGD6F4CAgYKKwYBBAGD6F4CAwYKKwYBBAGD6F4CBAYKKwYBBAGD6F4CBQYKKwYBBAGD6F4CBgYKKwYBBAGD6F4CBwYKKwYBBAGD6F4CCAYKKwYBBAGD6F4CCQYKKwYBBAGD6F4CCgYKKwYBBAGD6F4CCwYKKwYBBAGD6F4CDAYKKwYBBAGD6F4CDQYKKwYBBAGD6F4CDgYKKwYBBAGD6F4CDwYKKwYBBAGD6F4CEAYKKwYBBAGD6F4CEQYKKwYBBAGD6F4CEgYKKwYBBAGD6F4CEwYKKwYBBAGD6F4CFAYKKwYBBAGD6F4CFQYKKwYBBAGD6F4CFgYKKwYBBAGD6F4CFwYKKwYBBAGD6F4CGAYKKwYBBAGD6F4CGQYKKwYBBAGD6F4CGgYKKwYBBAGD6F4CGwYKKwYBBAGD6F4CHAYKKwYBBAGD6F4CHQYKKwYBBAGD6F4CHgYKKwYBBAGD6F4CHwYKKwYBBAGD6F4CIAYKKwYBBAGD6F4CIQYKKwYBBAGD6F4CIgYKKwYBBAGD6F4CIwYKKwYBBAGD6F4CJAYKKwYBBAGD6F4CJQYKKwYBBAGD6F4CJgYKKwYBBAGD6F4CJwYKKwYBBAGD6F4CKAYKKwYBBAGD6F4CKQYKKwYBBAGD6F4CKgYKKwYBBAGD6F4CKwYKKwYBBAGD6F4CLAYKKwYBBAGD6F4CLQYKKwYBBAGD6F4CLgYKKwYBBAGD6F4CLwYKKwYBBAGD6F4CMAYKKwYBBAGD6F4CMQYKKwYBBAGD6F4CMgYKKwYBBAGD6F4CMwYKKwYBBAGD6F4CNAYKKwYBBAGD6F4CNQYKKwYBBAGD6F4CNgYKKwYBBAGD6F4CNwYKKwYBBAGD6F4COAYKKwYBBAGD6F4COQYKKwYBBAGD6F4COgYKKwYBBAGD6F4COwYKKwYBBAGD6F4CPAYKKwYBBAGD6F4CPQYKKwYBBAGD6F4CPgYKKwYBBAGD6F4CPwYKKwYBBAGD6F4CQAYKKwYBBAGD6F4CQQYKKwYBBAGD6F4CQgYKKwYBBAGD6F4CQwYKKwYBBAGD6F4CRAYKKwYBBAGD6F4CRQYKKwYBBAGD6F4CRgYKKwYBBAGD6F4CRwYKKwYBBAGD6F4CSAYKKwYBBAGD6F4CSQYKKwYBBAGD6F4CSgYKKwYBBAGD6F4CSwYKKwYBBAGD6F4CTAYKKwYBBAGD6F4CTQYKKwYBBAGD6F4CTgYKKwYBBAGD6F4CTwYKKwYBBAGD6F4CUAYKKwYBBAGD6F4CUQYKKwYBBAGD6F4CUgYKKwYBBAGD6F4CUwYKKwYBBAGD6F4CVAYKKwYBBAGD6F4CVQYKKwYBBAGD6F4CVgYKKwYBBAGD6F4CVwYKKwYBBAGD6F4CWAYKKwYBBAGD6F4CWQYKKwYBBAGD6F4CWgYKKwYBBAGD6F4CWwYKKwYBBAGD6F4CXAYKKwYBBAGD6F4CXQYKKwYBBAGD6F4CXgYKKwYBBAGD6F4CXwYKKwYBBAGD6F4CYAYKKwYBBAGD6F4CYQYKKwYBBAGD6F4CYgYKKwYBBAGD6F4CYwYKKwYBBAGD6F4CZAYKKwYBBAGD6F4CZQYKKwYBBAGD6F4CZgYKKwYBBAGD6F4CZwYKKwYBBAGD6F4CaAYKKwYBBAGD6F4CaQYKKwYBBAGD6F4CagYKKwYBBAGD6F4CawYKKwYBBAGD6F4CbAYKKwYBBAGD6F4CbTAdBgNVHQ4EFgQUm/QVqHjDI3Qh2JRa7gPCEQedFZIwDQYJKoZIhvcNAQELBQADggEBALAlt2egdhqsUcrrFAjGfk8wo7g4BwMXpZrCZPM7aEjzkoIDygHjiYeMyHPIgBizYDukxa+0arWF9OSW+nVyboPZgz2JqLAGIagCwqjxfvneEaU2aLONxJX2om8INg5yXdTkp+ZbtchhIACyzfbu2YOoGIEaldIgB9cw2Wzo8UYTkmLIlnPkciomYwjYjObnfF1oyDlGhIqWLHb6r5DzhA2bkMwuvR/AjRicJHXevl02nAR9B3YVZ8XhhQAxi5thVFsy4phYQFA1uhL6+YczRY/0cob8x2JAS21WlyCyucPrqzCESCitK8n1t9Kr8oN33hZQ05QSz2q4F4GqSVZrLuI=)"; +// This certificate is self-signed. It has the C2PA claim signing EKU but does +// not assert the `digitalSignature` KU bit. The certificate is created by +// following the steps above for the `kSelfSignedCertNoEkuBase64` with the +// following changes: +// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ] +// section: +// extendedKeyUsage = 1.3.6.1.4.1.62558.2.1 +// 2. In the same [v3_ext] section, update the line for the `keyUsage` extension +// as follows: +// keyUsage = keyEncipherment +constexpr absl::string_view kSelfSignedCertWithoutDigitalSignatureKuBase64 = + R"(MIIDijCCAnKgAwIBAgIULLgHRj+25oLuEWteEKtmNySP7SYwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgxMTE1NTc1OVoXDTI2MDgxMTE1NTc1OVowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtTI0nTndMHK9cImhhfWaXa4k/6bHwyQk0S71ic+ieibdBFV4mzYbDPZ2u84Z80hFAlyet725PB8j9hJiaT8eG4Tacw19rUN1CimoMb7SJ9a1qshcmNqxNJcfLBmkntzdVwgwxqGtkS66LOCJ2SHqREkrzqBZMB8OCAsf/gbS5vgY/3b5h6Xcdw1B1aIoKtRz8A8y5IK1DZF/0vzs0IqWjXlr5A4znEmdV1p18tcnL920QWim/winTyFXOj/IO+IK8tF1atjbS1RZChmQnN0bGJbc/8hmH5qpy1qDS9qS8sHK2BKTphPpqE5Zjrrb5S+4HUFUQNGxZSve9ghd7vj08wIDAQABo1AwTjAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFIDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMB0GA1UdDgQWBBQAJkqyo/wRZ7N+SfmmbE/9WOuZszANBgkqhkiG9w0BAQsFAAOCAQEAa15O9d799osT9m8DfS1acQ0PYlhqpgLeeWuKWk/5KkJbNr8nI6SIOmyzXZ/aTvrU+EDkDWVJG8QGN+hRN2J1zhWTJ5zefpkfkXvdUT8Io/TVDkDT/EBzFOC865+yTe9pxcTcS5RjXLC6hCn4Rt37haeBe+dmBESflUpJMql+lkXgIuzRc6U7atb3NcdC2biKSt1FgG07Z4UqjbmwkpwU9nkaoioGZ+qEWEThoKbCdBSeaINP94/QPBnZiJPx0gqqc6v++6gZ5wnYRA3DxT00+5v3WfGzscfq5t5J0hW1dqXWE8svOB/lNmo2Zjz17EO8tqUEeHZgoNeE72ZCTpbo3Q==)"; + +std::string Base64Decode(absl::string_view base64) { + std::string decoded; + CHECK(absl::Base64Unescape(base64, &decoded)); + return decoded; +} + +TEST(EkuVerifierTest, CheckLeafCertEku_EmptyCert) { + EXPECT_FALSE(EkuVerifier::CheckLeafCertEku("")); +} + +TEST(EkuVerifierTest, CheckLeafCertEku_MalformedCert) { + EXPECT_FALSE(EkuVerifier::CheckLeafCertEku("malformed cert")); +} + +TEST(EkuVerifierTest, CheckLeafCertEku_NoEku) { + EXPECT_FALSE( + EkuVerifier::CheckLeafCertEku(Base64Decode(kSelfSignedCertNoEkuBase64))); +} + +TEST(EkuVerifierTest, CheckLeafCertEku_C2paEkuAndAnyEku) { + EXPECT_FALSE(EkuVerifier::CheckLeafCertEku( + Base64Decode(kSelfSignedCertWithAnyEkuAndC2paEkuBase64))); +} + +TEST(EkuVerifierTest, CheckLeafCertEku_C2paEkuOnly) { + EXPECT_TRUE(EkuVerifier::CheckLeafCertEku( + Base64Decode(kSelfSignedCertWithC2paEkuBase64))); +} + +TEST(EkuVerifierTest, CheckLeafCertEku_LargeEku) { + EXPECT_FALSE(EkuVerifier::CheckLeafCertEku( + Base64Decode(kSelfSignedCertWithLargeEkuBase64))); +} + +TEST(EkuVerifierTest, CheckLeafCertEku_WithoutDigitalSignatureKu) { + EXPECT_FALSE(EkuVerifier::CheckLeafCertEku( + Base64Decode(kSelfSignedCertWithoutDigitalSignatureKuBase64))); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/default/hasher.cc b/crypto/default/hasher.cc new file mode 100644 index 0000000..4e7b920 --- /dev/null +++ b/crypto/default/hasher.cc
@@ -0,0 +1,155 @@ +// 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 "crypto/default/hasher.h" + +#include <memory> +#include <string> +#include <utility> + +#include "absl/base/no_destructor.h" +#include "absl/base/nullability.h" +#include "absl/log/absl_log.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "openssl/base.h" +#include "openssl/digest.h" + +namespace credentio { +namespace { + +class DefaultHashChecker : public HashChecker { + public: + explicit DefaultHashChecker(std::unique_ptr<Hasher> hasher) + : hasher_(std::move(hasher)) {} + + void Update(absl::string_view content) override { hasher_->Update(content); } + + bool Check(absl::string_view expected_hash) override { + std::string computed_hash = hasher_->Digest(); + if (computed_hash != expected_hash) { + ABSL_DVLOG(1) << "Hash mismatch: base64(computed) = " + << absl::Base64Escape(computed_hash) + << " base64(expected) = " + << absl::Base64Escape(expected_hash); + return false; + } + return true; + } + + private: + std::unique_ptr<Hasher> hasher_; +}; + +class DefaultHashCheckerFactoryImpl : public HashCheckerFactory { + public: + DefaultHashCheckerFactoryImpl() = default; + + absl::StatusOr<std::unique_ptr<HashChecker>> Create( + HashAlgorithm algorithm) const override { + ABSL_ASSIGN_OR_RETURN(auto hasher, CreateHasher(algorithm)); + return std::make_unique<DefaultHashChecker>(std::move(hasher)); + } +}; + +class DefaultHasher : public Hasher { + public: + static absl::StatusOr<std::unique_ptr<DefaultHasher>> Create( + const EVP_MD* md) { + bssl::UniquePtr<EVP_MD_CTX> ctx(EVP_MD_CTX_new()); + if (ctx == nullptr || EVP_DigestInit_ex(ctx.get(), md, nullptr) != 1) { + return absl::InternalError("Failed to initialize hasher"); + } + return std::unique_ptr<DefaultHasher>(new DefaultHasher(std::move(ctx))); + } + + void Update(absl::string_view content) override { + // We don't check the return value because BoringSSL's implementation always + // returns 1. + EVP_DigestUpdate(ctx_.get(), content.data(), content.size()); + } + + std::string Digest() override { + unsigned int len = 0; + unsigned char digest[EVP_MAX_MD_SIZE]; + // We don't check the return value because BoringSSL's implementation always + // returns 1. + EVP_DigestFinal_ex(ctx_.get(), digest, &len); + return std::string(reinterpret_cast<char*>(digest), len); + } + + private: + explicit DefaultHasher(bssl::UniquePtr<EVP_MD_CTX> ctx) + : ctx_(std::move(ctx)) {} + + bssl::UniquePtr<EVP_MD_CTX> ctx_; +}; + +} // namespace + +absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher( + HashAlgorithm algorithm) { + switch (algorithm) { + case HashAlgorithm::kSha256: + return DefaultHasher::Create(EVP_sha256()); + case HashAlgorithm::kSha384: + return DefaultHasher::Create(EVP_sha384()); + case HashAlgorithm::kSha512: + return DefaultHasher::Create(EVP_sha512()); + } + std::string error_msg = absl::StrCat( + "fell through end of exhaustive switch statement with HashAlgorithm " + "value: ", + static_cast<int>(algorithm)); + ABSL_LOG(DFATAL) << error_msg; + return absl::FailedPreconditionError(error_msg); +} + +absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher( + absl::string_view algorithm) { + ABSL_ASSIGN_OR_RETURN(auto ha, ParseHashAlgorithm(algorithm)); + return CreateHasher(ha); +} + +const HashCheckerFactory& DefaultHashCheckerFactory() { + static const absl::NoDestructor<DefaultHashCheckerFactoryImpl> singleton; + return *singleton; +} + +const HasherFactory& DefaultHasherFactory() { + static const absl::NoDestructor<Sha256HasherFactory> singleton; + return *singleton; +} + +absl::StatusOr<std::unique_ptr<Hasher>> Sha256HasherFactory::Create() const { + return DefaultHasher::Create(EVP_sha256()); +} + +absl::StatusOr<std::unique_ptr<Hasher>> Sha384HasherFactory::Create() const { + return DefaultHasher::Create(EVP_sha384()); +} + +absl::StatusOr<std::unique_ptr<Hasher>> Sha512HasherFactory::Create() const { + return DefaultHasher::Create(EVP_sha512()); +} + +} // namespace credentio
diff --git a/crypto/default/hasher.h b/crypto/default/hasher.h new file mode 100644 index 0000000..025a0f6 --- /dev/null +++ b/crypto/default/hasher.h
@@ -0,0 +1,64 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_HASHER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_HASHER_H_ + +#include <memory> + +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" + +namespace credentio { + +// Returns a `Hasher` for `algorithm` or error if the algorithm is not +// supported. The returned hasher uses the default OpenSSL implementation. +absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher( + HashAlgorithm algorithm); + +// Returns a `Hasher`, or error if the algorithm is not supported. +absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher( + absl::string_view algorithm); + +// Returns the default `HashCheckerFactory`. +const HashCheckerFactory& DefaultHashCheckerFactory(); + +// Returns the default `HasherFactory`. +const HasherFactory& DefaultHasherFactory(); + +class Sha256HasherFactory : public HasherFactory { + public: + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override; + HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; } +}; + +class Sha384HasherFactory : public HasherFactory { + public: + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override; + HashAlgorithm algorithm() const override { return HashAlgorithm::kSha384; } +}; + +class Sha512HasherFactory : public HasherFactory { + public: + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override; + HashAlgorithm algorithm() const override { return HashAlgorithm::kSha512; } +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_HASHER_H_
diff --git a/crypto/default/hasher_test.cc b/crypto/default/hasher_test.cc new file mode 100644 index 0000000..542afa4 --- /dev/null +++ b/crypto/default/hasher_test.cc
@@ -0,0 +1,167 @@ +// 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 "crypto/default/hasher.h" + +#include <string> +#include <utility> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/escaping.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::Eq; +using ::testing::IsFalse; +using ::testing::IsTrue; +using ::testing::NotNull; + +std::string BytesFromHexOrDie(absl::string_view hex) { + std::string bytes; + CHECK(absl::HexStringToBytes(hex, &bytes)); + return bytes; +} + +TEST(CreateHasherTest, SHA256) { + auto hasher_or = CreateHasher("sha256"); + ASSERT_TRUE(hasher_or.ok()); + auto hasher = std::move(hasher_or).value(); + hasher->Update(absl::string_view("hello")); + hasher->Update(absl::string_view("world")); + EXPECT_THAT( + absl::BytesToHexString(hasher->Digest()), + "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af"); +} + +TEST(CreateHasherTest, SHA384) { + auto hasher_or = CreateHasher("sha384"); + ASSERT_TRUE(hasher_or.ok()); + auto hasher = std::move(hasher_or).value(); + hasher->Update(absl::string_view("hello")); + hasher->Update(absl::string_view("world")); + EXPECT_THAT(absl::BytesToHexString(hasher->Digest()), + "97982a5b1414b9078103a1c008c4e3526c27b41cdbcf80790560a40f2a9bf2ed" + "4427ab1428789915ed4b3dc07c454bd9"); +} + +TEST(CreateHasherTest, SHA512) { + auto hasher_or = CreateHasher("sha512"); + ASSERT_TRUE(hasher_or.ok()); + auto hasher = std::move(hasher_or).value(); + hasher->Update(absl::string_view("hello")); + hasher->Update(absl::string_view("world")); + EXPECT_THAT( + absl::BytesToHexString(hasher->Digest()), + "1594244d52f2d8c12b142bb61f47bc2eaf503d6d9ca8480cae9fcf112f66e4967dc5e8fa" + "98285e36db8af1b8ffa8b84cb15e0fbcf836c3deb803c13f37659a60"); +} + +TEST(CreateHasherTest, UnknownAlgorithm) { + EXPECT_THAT(CreateHasher("blah"), + StatusIs(absl::StatusCode::kInvalidArgument, + "unrecognized hash algorithm name")); +} + +TEST(HashCheckerTest, Valid) { + auto checker_or = DefaultHashCheckerFactory().Create(HashAlgorithm::kSha256); + ASSERT_TRUE(checker_or.ok()); + auto checker = std::move(checker_or).value(); + ASSERT_THAT(checker, NotNull()); + checker->Update("hello"); + checker->Update("world"); + EXPECT_THAT( + checker->Check(BytesFromHexOrDie( + "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af")), + IsTrue()); +} + +TEST(HashCheckerTest, Invalid) { + auto checker_or = DefaultHashCheckerFactory().Create(HashAlgorithm::kSha256); + ASSERT_TRUE(checker_or.ok()); + auto checker = std::move(checker_or).value(); + ASSERT_THAT(checker, NotNull()); + checker->Update("hello"); + checker->Update("world2"); + EXPECT_THAT( + checker->Check(BytesFromHexOrDie( + "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af")), + IsFalse()); +} + +TEST(CreateHasherTest, SHA256HashFactory) { + Sha256HasherFactory factory; + EXPECT_EQ(factory.algorithm(), HashAlgorithm::kSha256); + + auto hasher_or = factory.Create(); + ASSERT_TRUE(hasher_or.ok()); + auto hasher = std::move(hasher_or).value(); + hasher->Update(absl::string_view("hello")); + hasher->Update(absl::string_view("world")); + EXPECT_THAT( + absl::BytesToHexString(hasher->Digest()), + "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af"); +} + +TEST(CreateHasherTest, SHA384HashFactory) { + Sha384HasherFactory factory; + EXPECT_EQ(factory.algorithm(), HashAlgorithm::kSha384); + + auto hasher_or = factory.Create(); + ASSERT_TRUE(hasher_or.ok()); + auto hasher = std::move(hasher_or).value(); + hasher->Update(absl::string_view("hello")); + hasher->Update(absl::string_view("world")); + EXPECT_THAT(absl::BytesToHexString(hasher->Digest()), + "97982a5b1414b9078103a1c008c4e3526c27b41cdbcf80790560a40f2a9bf2ed" + "4427ab1428789915ed4b3dc07c454bd9"); +} + +TEST(CreateHasherTest, SHA512HashFactory) { + Sha512HasherFactory factory; + EXPECT_EQ(factory.algorithm(), HashAlgorithm::kSha512); + + auto hasher_or = factory.Create(); + ASSERT_TRUE(hasher_or.ok()); + auto hasher = std::move(hasher_or).value(); + hasher->Update(absl::string_view("hello")); + hasher->Update(absl::string_view("world")); + EXPECT_THAT( + absl::BytesToHexString(hasher->Digest()), + "1594244d52f2d8c12b142bb61f47bc2eaf503d6d9ca8480cae9fcf112f66e4967dc5e8fa" + "98285e36db8af1b8ffa8b84cb15e0fbcf836c3deb803c13f37659a60"); +} + +TEST(HasherFactoryTest, DefaultIsSha256) { + const HasherFactory& factory = DefaultHasherFactory(); + EXPECT_THAT(factory.algorithm(), Eq(HashAlgorithm::kSha256)); +} + +TEST(HasherFactoryTest, DefaultIsSingleton) { + const HasherFactory& factory1 = DefaultHasherFactory(); + const HasherFactory& factory2 = DefaultHasherFactory(); + EXPECT_THAT(&factory1, Eq(&factory2)); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/default/pem.cc b/crypto/default/pem.cc new file mode 100644 index 0000000..12f8c09 --- /dev/null +++ b/crypto/default/pem.cc
@@ -0,0 +1,62 @@ +// 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 "crypto/default/pem.h" + +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "openssl/bio.h" +#include "openssl/mem.h" +#include "openssl/pem.h" +#include "openssl/x509.h" + +namespace credentio { + +absl::StatusOr<std::vector<std::string>> LoadCertsFromPem( + absl::string_view pem) { + bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(pem.data(), pem.size())); + if (bio == nullptr) { + return absl::InternalError("Failed to create BIO."); + } + + std::vector<std::string> chain; + while (true) { + bssl::UniquePtr<X509> cert( + PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr)); + if (cert == nullptr) { + break; + } + + unsigned char* der = nullptr; + int len = i2d_X509(cert.get(), &der); + if (len < 0) { + return absl::InternalError("Failed to convert cert to DER."); + } + chain.push_back(std::string(reinterpret_cast<char*>(der), len)); + OPENSSL_free(der); + } + + if (chain.empty()) { + return absl::InvalidArgumentError("No certificates found"); + } + + return chain; +} + +} // namespace credentio
diff --git a/crypto/default/pem.h b/crypto/default/pem.h new file mode 100644 index 0000000..f424951 --- /dev/null +++ b/crypto/default/pem.h
@@ -0,0 +1,33 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_PEM_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_PEM_H_ + +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +// Returns a vector of DER-encoded certificates loaded from a PEM string. +absl::StatusOr<std::vector<std::string>> LoadCertsFromPem( + absl::string_view pem); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_PEM_H_
diff --git a/crypto/default/pem_test.cc b/crypto/default/pem_test.cc new file mode 100644 index 0000000..ab5c580 --- /dev/null +++ b/crypto/default/pem_test.cc
@@ -0,0 +1,68 @@ +// 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 "crypto/default/pem.h" + +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::SizeIs; + +constexpr absl::string_view kPemCertChain = R"(-----BEGIN CERTIFICATE----- +MIIBtDCCATqgAwIBAgIRANPe8rJVADfD8bhzA2jtwl4wCgYIKoZIzj0EAwMwFzEV +MBMGA1UEAxMMVGVzdCBSb290IENBMB4XDTI2MDMyOTEyMDAwMFoXDTI3MDMyOTEy +MDAwMFowHzEdMBsGA1UEAxMUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwdjAQBgcqhkjO +PQIBBgUrgQQAIgNiAAQ7UFokBR4PPW+a73HQPyS6myzyxvDqtr++Xo/TrELi0Y+e +7+gJO/OmI78lzBdVdPcNxrQ4PJxCcHDI1ArOr9F6M3YYBR5x0ykRZSpFR2in2HU3 +6rvjimlPtT/l4OTeDZOjQjBAMA4GA1UdDwEB/wQEAwICBDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBT0u2grl7HnYYbNpUeacyT8BlqZ+DAKBggqhkjOPQQDAwNo +ADBlAjEAzOZu5HO8gtLheQgoE7gdj6qpB8MBnAO8zIDZ2byp+E3Q3lOc8TGWro0O +sY/1iAyMAjAQcNb21dwi/ZPRo70+RG5Hx70tDWzk6BtvWEr6NkEuNV/8j7Jn7/ED +/GwOJzwrywk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBajCB8aADAgECAhABfuUxwcNoiwWqcTHpsCU8MAoGCCqGSM49BAMDMB8xHTAb +BgNVBAMTFFRlc3QgSW50ZXJtZWRpYXRlIENBMB4XDTI2MDMyOTEyMDAwMFoXDTI3 +MDMyOTEyMDAwMFowHDEaMBgGA1UEAxMRVGVzdCBjbGFpbSBzaWduZXIwWTATBgcq +hkjOPQIBBggqhkjOPQMBBwNCAAQZCL12JyasTpL9oU/mHZlDVP7j5DAAHIH+jauM +d4ufXdNahmxHAy5dJ3n9pvnMfQeXh8ZQTjNHzqZVTvlN5o0koxIwEDAOBgNVHQ8B +Af8EBAMCB4AwCgYIKoZIzj0EAwMDaAAwZQIxALDh3HqsATwFtu9KMOHdVq7jX0nm +Rm13/7yh6oREzQsHX125BW5NO/95+oUY+AjbqAIwBwO4P3XxDGuIlMJBi5bc5baR +QKnNhfW2ruLN754O4M+lDQdvVWKz5ghGCwQtfpv+ +-----END CERTIFICATE-----)"; + +TEST(LoadCertsFromPemTest, FailsOnInvalidPem) { + auto chain_or = LoadCertsFromPem("not pem data"); + EXPECT_THAT(chain_or, StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(LoadCertsFromPemTest, Succeeds) { + auto der_chain = LoadCertsFromPem(kPemCertChain); + ASSERT_TRUE(der_chain.ok()); + EXPECT_THAT(*der_chain, SizeIs(2)); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/default/timestamp_verifier.cc b/crypto/default/timestamp_verifier.cc new file mode 100644 index 0000000..b054880 --- /dev/null +++ b/crypto/default/timestamp_verifier.cc
@@ -0,0 +1,264 @@ +// 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 "crypto/default/timestamp_verifier.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/pki/verify.h" +#include "openssl/pki/verify_error.h" +#include "openssl/stack.h" +#include "openssl/x509.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/timestamp_parsing.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { + +namespace { + +// Represents the `EssCertIdV2` SEQUENCE, part of the `SigningCertificateV2` +// attribute as defined in RFC 5035. +struct EssCertIdV2 { + HashAlgorithm hash_algorithm; + std::string cert_hash; +}; + +absl::Status CertMatchesEssCertId(absl::string_view cert, EssCertIdV2 id) { + auto hasher = CreateHasher(id.hash_algorithm); + if (!hasher.ok()) { + return absl::Status( + absl::StatusCode::kFailedPrecondition, + absl::StrCat("Invalid HashAlgorithm value: ", id.hash_algorithm, "; ", + hasher.status().message())); + } + (*hasher)->Update(cert); + if ((*hasher)->Digest() == id.cert_hash) { + return absl::OkStatus(); + } else { + return absl::UnauthenticatedError( + R"(certificate does not match EssCertIDV2 value)"); + } +} + +absl::Status CheckTsaCertificateTrust( + const bssl::VerifyTrustStore& tsa_roots, + const VerifiedTimestamp& verified_timestamp, + absl::Span<const EssCertIdV2> ess_cert_ids) { + if (ess_cert_ids.empty()) { + return absl::InvalidArgumentError( + R"(SigningCertificateV2 signed attribute is empty; at minimum, TSAs must record their own leaf certificate in this attribute)"); + } + absl::Status status = CertMatchesEssCertId( + verified_timestamp.tsa_certificate(), ess_cert_ids[0]); + if (!status.ok()) { + return absl::Status( + status.code(), + absl::StrCat("TSA cert does not match SigningCertificateV2 attribute: ", + status.message())); + } + + auto intermediate_certs_der = + verified_timestamp.certificate_chain().subspan(1); + if (intermediate_certs_der.size() > 2) { + return absl::UnauthenticatedError( + absl::StrCat("excessive timestamp certificate chain length: ", + intermediate_certs_der.size() + 1)); + } + + bssl::CertificateVerifyOptions opts; + + opts.key_purpose = + bssl::CertificateVerifyOptions::KeyPurpose::C2PA_TIMESTAMPING; + opts.leaf_cert = verified_timestamp.tsa_certificate(); + opts.intermediates.insert(opts.intermediates.end(), + intermediate_certs_der.begin(), + intermediate_certs_der.end()); + // Timestamp cert chain validity periods are evaluated with respect to the + // attested time, per + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_time_stamp: + // "Validate that the attested time, as found in the genTime field (in the + // timeStampToken), falls within the validity period of the TSA’s signing + // certificate." + opts.time = absl::ToTimeT(verified_timestamp.asserted_time()); + opts.trust_store = &tsa_roots; + bssl::VerifyError error; + if (!bssl::CertificateVerify(opts, &error)) { + if (error.Code() == bssl::VerifyError::StatusCode::CERTIFICATE_EXPIRED || + error.Code() == + bssl::VerifyError::StatusCode::CERTIFICATE_NOT_YET_VALID) { + return absl::OutOfRangeError(error.DiagnosticString()); + } + return absl::UnauthenticatedError( + absl::StrCat("timestamp certificate chain could not be validated: ", + error.DiagnosticString())); + } + return absl::OkStatus(); +} + +// Extracts the `certs` field of the `SigningCertificateV2` attribute (attribute +// defined in RFC 5035). +absl::StatusOr<std::vector<EssCertIdV2>> GetEssCertIds( + absl::string_view signing_certificate_v2_bytes) { + std::vector<EssCertIdV2> out; + CBS signing_certificate_cbs; + CBS_init( + &signing_certificate_cbs, + reinterpret_cast<const uint8_t*>(signing_certificate_v2_bytes.data()), + signing_certificate_v2_bytes.size()); + + CBS signing_certificate_sequence; + if (!CBS_get_asn1(&signing_certificate_cbs, &signing_certificate_sequence, + CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError( + R"(could not parse DER SEQUENCE for SigningCertificateV2 attribute value)"); + } + + CBS certs_sequence; + if (!CBS_get_asn1(&signing_certificate_sequence, &certs_sequence, + CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError( + R"(could not parse `certs` field (DER SEQUENCE) of SigningCertificateV2 attribute value)"); + } + + CBS ess_cert_id_cbs; + while (CBS_get_asn1(&certs_sequence, &ess_cert_id_cbs, CBS_ASN1_SEQUENCE)) { + // The hash algorithm is an optional field, if it is not present it defaults + // to SHA-256. + int algorithm_nid = NID_sha256; + CBS original = ess_cert_id_cbs; + CBS oid_cbs; + if (CBS_get_asn1(&ess_cert_id_cbs, &oid_cbs, CBS_ASN1_OBJECT)) { + algorithm_nid = OBJ_cbs2nid(&oid_cbs); + if (algorithm_nid == NID_undef) { + bssl::UniquePtr<char> oid_txt_uniq(CBS_asn1_oid_to_text(&oid_cbs)); + absl::string_view oid_txt; + if (oid_txt_uniq != nullptr) { + oid_txt = oid_txt_uniq.get(); + } else { + oid_txt = "<unprintable OID>"; + } + return absl::InvalidArgumentError(absl::StrCat( + R"(unrecognized OID in `certs.hashAlgorithm` field of SigningCertificateV2 attribute value; OID value: )", + oid_txt)); + } + } else { + ess_cert_id_cbs = original; + } + auto hash_algorithm = NidToHashAlgorithm(algorithm_nid); + if (!hash_algorithm.ok()) { + return absl::Status( + hash_algorithm.status().code(), + absl::StrCat( + "unsupported hash algorithm in `certs.hashAlgorithm` field of " + "SigningCertificateV2 attribute value: ", + hash_algorithm.status().message())); + } + + CBS hash_cbs; + if (!CBS_get_asn1(&ess_cert_id_cbs, &hash_cbs, CBS_ASN1_OCTETSTRING)) { + return absl::InvalidArgumentError( + R"(could not parse `certs.hash` field (DER OCTETSTRING) of SigningCertificateV2 attribute value)"); + } + out.push_back(EssCertIdV2{ + .hash_algorithm = *hash_algorithm, + .cert_hash = + std::string(reinterpret_cast<const char*>(CBS_data(&hash_cbs)), + CBS_len(&hash_cbs))}); + } + + return out; +} + +} // namespace + +absl::StatusOr<VerifiedTimestamp> TimestampVerifier::VerifyTimestampToken( + const ParsedTimestampToken& parsed_token) const { + ABSL_ASSIGN_OR_RETURN(auto chain, parsed_token.GetCertificateChain()); + + // CMS supports more digest algorithms than C2PA, so we need to check that the + // digest algorithm is supported. + ABSL_RETURN_IF_ERROR(parsed_token.GetMessageImprintHashAlgorithm().status()); + + if (chain.empty()) { + // This would indicate a bug in the CMS library. + return absl::InternalError("TSA leaf cert missing"); + } + + absl::Status verify_status = parsed_token.VerifySignature(); + if (!verify_status.ok()) { + return absl::UnauthenticatedError( + absl::StrCat("the timestamp signature could not be verified: ", + verify_status.message())); + } + + ABSL_ASSIGN_OR_RETURN(auto signing_certificate_v2_bytes, + parsed_token.GetSigningCertificateV2Bytes()); + + ABSL_ASSIGN_OR_RETURN(auto ess_cert_ids, + GetEssCertIds(signing_certificate_v2_bytes)); + + ABSL_ASSIGN_OR_RETURN(auto tst_info_bytes, parsed_token.GetTstInfoBytes()); + CBS cbs; + CBS_init(&cbs, reinterpret_cast<const uint8_t*>(tst_info_bytes.data()), + tst_info_bytes.size()); + + absl::Time time; + std::string message_imprint_hash; + HashAlgorithm hash_algorithm; + std::string nonce; + ABSL_RETURN_IF_ERROR( + ParseTstInfo(cbs, &time, &message_imprint_hash, &hash_algorithm, &nonce)); + ABSL_ASSIGN_OR_RETURN( + auto verified_timestamp_or, + VerifiedTimestamp::Create(time, std::move(chain), + std::move(message_imprint_hash), hash_algorithm, + nonce)); + VerifiedTimestamp verified_timestamp = verified_timestamp_or; + + if (tsa_roots_ == nullptr) { + ABSL_LOG_EVERY_N_SEC(INFO, 60) + << "Bypassing C2PA TSA certificate trust check."; + } else { + absl::Status trust_status = + CheckTsaCertificateTrust(*tsa_roots_, verified_timestamp, ess_cert_ids); + ABSL_RETURN_IF_ERROR(trust_status); + } + ABSL_VLOG(1) << "Verified timestamp: " << verified_timestamp.asserted_time(); + return verified_timestamp; +} + +} // namespace credentio
diff --git a/crypto/default/timestamp_verifier.h b/crypto/default/timestamp_verifier.h new file mode 100644 index 0000000..5b31fbf --- /dev/null +++ b/crypto/default/timestamp_verifier.h
@@ -0,0 +1,52 @@ +// 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. +// + +// Support for RFC 3161 TimestampToken verification. +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TIMESTAMP_VERIFIER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TIMESTAMP_VERIFIER_H_ + +#include "absl/base/attributes.h" +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "openssl/pki/verify.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { + +// Verifier for RFC 3161 TimestampTokens. +class TimestampVerifier { + public: + // If `tsa_roots` is `nullptr`, the verifier will skip trust checks (only for + // use in tests). + explicit TimestampVerifier(const bssl::VerifyTrustStore* absl_nullable + tsa_roots ABSL_ATTRIBUTE_LIFETIME_BOUND) + : tsa_roots_(tsa_roots) {} + + // Verifies the signature of an RFC 3161 TimestampToken, and + // checks that the TSA is trusted. Error codes reported include + // kInvalidArgument: Malformed timestamp token + // kUnauthenticated: Signature verification errors, missing/untrusted certs + // kOutOfRange: A certificate was not within its validity period + absl::StatusOr<VerifiedTimestamp> VerifyTimestampToken( + const ParsedTimestampToken& parsed_token) const; + + private: + const bssl::VerifyTrustStore* tsa_roots_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TIMESTAMP_VERIFIER_H_
diff --git a/crypto/default/timestamp_verifier_test.cc b/crypto/default/timestamp_verifier_test.cc new file mode 100644 index 0000000..7cba807 --- /dev/null +++ b/crypto/default/timestamp_verifier_test.cc
@@ -0,0 +1,754 @@ +// 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 "crypto/default/timestamp_verifier.h" + +#include <cstddef> +#include <cstdint> +#include <iterator> +#include <memory> +#include <optional> +#include <string> +#include <vector> + +#include "absl/algorithm/container.h" +#include "absl/base/no_destructor.h" +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "crypto/cbs_utils.h" +#include "crypto/default/trust_store.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/bytestring.h" +#include "openssl/digest.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/pem.h" +#include "openssl/pki/verify.h" +#include "openssl/x509.h" +#include "proto/validation_result.pb.h" +#include "tsp/constants.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/test_helpers.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::Return; + +constexpr absl::string_view kCaPem = R"(-----BEGIN CERTIFICATE----- +MIIB3zCCAWagAwIBAgIUXJz+6VKGMVOVs+xupRS3Cne3or0wCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw +NDE1MTMyMFoYDzIxMjUwMTExMTUxMzIwWjAmMSQwIgYDVQQDDBtHb29nbGUgQzJQ +QSBUZXN0aW5nIFJvb3QgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATWa/wOwQMD +5zNmXVNGbPJuptbvXrNBhgKZI6SHRzngJ4bV+SShgLDqFdtnDfwAO/nxHyACTNl6 +sJ9ue407+HW6W1WEODD9a/jVYizAVkzUaEDElDEA5PPkegHqtVJuf8CjUzBRMB0G +A1UdDgQWBBTTMOrQr2ny9gYyywPGUZGx/KENtzAfBgNVHSMEGDAWgBTTMOrQr2ny +9gYyywPGUZGx/KENtzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQC +MCsAxaLyEkp2nyQ2BtBoSuu52k3qfWLNAOHiaifoaxhQzDo1juNYuITM8I88PwVO +QAIwJBRVRNysxvouMf7cQ2VSYTPc32BY/m38wnWS3K94l7eS3sANM9U++e8dgEtG +U2Tv +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kTsaPem = R"(-----BEGIN CERTIFICATE----- +MIIB1zCCAVygAwIBAgIUaUM3tWVucTzvVWUNY0HjFKBAEkwwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw +NDIxNDIxNFoYDzIxMjUwMTExMjE0MjE0WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ +QSBUZXN0aW5nIFRTQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLlLssu8MJdD +CS3UV4CPcI1l4lnQ7Y6V7jAaAXTt7wbRnN8lbjcMmfzY4mqhc6HMtFYqYj4UeO7t +tjkvzI2PfGmjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEF +BQcDCDAdBgNVHQ4EFgQUeWySrF5tKc2Cwe7AzkxALXVXyIwwHwYDVR0jBBgwFoAU +0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaQAwZgIxAPwCKKGMymYV +8p2gXDDyJSMbHJnfHu/FvfeaWYF6a6twCCt7IFehJWbyg/f1p/3Q7QIxAK0yLI6L +/FTQPRaB3nZBm4Ocw9QZ171qisDKd1wW83mjmFAp9aNFlFv2yQt9A1NeEQ== +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kTsaRsaPem = R"(-----BEGIN CERTIFICATE----- +MIIDoDCCAyegAwIBAgIUDDONK2J0GuK8OSkZYI4sN4GGJaYwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw +NTAwNDU1N1oYDzIxMjUwMTEyMDA0NTU3WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ +QSBUZXN0aW5nIFRTQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO5N +FYAVihiznVBVhnpoOIvz790xxqdpZXRYrCXkj9L2o07sjUza8cotpvDzd9W8EkPg +CJSl0dJ4VeZ7wq4PH0azLgpWDppTVtUtzbYGWacvgWsgLQ96SqAmPYnuHwD+b+Wv +q/p35JB3csFDpg2F9SKFHZH50KQJ4z+WLAKf9Gexfs0y8DsN3DtQgzaVF2vS7/SL +jfFbnS1aHXoeKvPiu475pltkuw0velbXi8/cHIq3VuQOwxgyCIE4FuWJYYPMMu5j +HJ0+bBtDI+Nn8rWmN7hpOV1Lo268gzwR2HtGmdM1LABj+RY8nBdyXJmfAY9kqm/D +VuEVrsGdIOYiAw7iHLvjc4CfNaKs6l9bhTppsVydLZE1fKuMFvmSs5Gc9yIermeu +jmoPB6QSVJh76mKOug7BmEzLHipFRxfDOmdr+eP/5w3L1ZOA+nEG2XWVICbIH/ND +rteeFCGJsDV7wDx6Yhpfvjh8A5qP9Z41MW8CtqpzNakivtVaDKmULZ/dyKeI8BZ5 +DvEg/6mpwN6Eu06czep0tO4LQ/l3935w3d/lkpn1yKDu9nm02uvVBEqwZW2lR5BT +MeqxQTMrrbmoi0mjsyX+5XmsUzAzBFlZ9HJi14p6vd70FJOlRXttGxCxiLC/QkmN +krFnFH7v1bhbRGGtKqttUK4yogdnxaOhZ6gr/s45AgMBAAGjajBoMA4GA1UdDwEB +/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAdBgNVHQ4EFgQU+rL+AIZ5 +EsQVhIN6SfX34LSTS7YwHwYDVR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcw +CgYIKoZIzj0EAwIDZwAwZAIwDrCJbyS2Z4yA+82o7slKap3VpBwqBW5xrYjIJyV3 +IGzPI3i55bQb0wKRf6SMBHmCAjAfrVYauA+rpNxEYEIoM/ziaJ0xEbF/tw/JQyVE +0JW905o/hzXHO5LV3z/x0SYRPiY= +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kTsaWithIntermediatePem = + R"(-----BEGIN CERTIFICATE----- +MIIBvzCCAWSgAwIBAgIUImopbS3q7uF8Wz88f39/wfxAIYowCgYIKoZIzj0EAwIw +LjEsMCoGA1UEAwwjR29vZ2xlIEMyUEEgVGVzdGluZyBJbnRlcm1lZGlhdGUgQ0Ew +IBcNMjUwMzEzMTM1NzI2WhgPMjEyNTAyMTcxMzU3MjZaMCIxIDAeBgNVBAMMF0dv +b2dsZSBDMlBBIFRlc3RpbmcgVFNBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +Z9YEmeJodxdGQdhJbSjIeccIbxjIQY5rPp/c6zqF4/mRr8VOdMur3r/r0Gyxju8k +7R+6/j5IjQwzci01Qs7W06NqMGgwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM +MAoGCCsGAQUFBwMIMB0GA1UdDgQWBBQGI2mf9lUe+oNSZE/WRQlwVGMgoTAfBgNV +HSMEGDAWgBRmRE2zkZe58xIqg//9Iw76XyOLKjAKBggqhkjOPQQDAgNJADBGAiEA +onFfvfflN96uF0ECnej2aZ7ikuyz+OsDEQVDQZNBTHMCIQCo5YiNm7EeeMexhDzM +vVdDSKzUFmzJXGZAO/kkEQyNXw== +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kIntermediatePem = R"(-----BEGIN CERTIFICATE----- +MIIByjCCAVGgAwIBAgIUVhW73Ry+Zn7eKwhnxTiyLDJ3cKowCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDMx +MzEzMzc0MVoYDzIxMjUwMjE3MTMzNzQxWjAuMSwwKgYDVQQDDCNHb29nbGUgQzJQ +QSBUZXN0aW5nIEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABJKYZuxD1F5Eqkgl+fqnubgtLr4MHlAh2p/7n8E/BOZuemrAAXVw7p27D1Oy +n6RNyobJVRZHcm61i09hfTKw/NajUzBRMB0GA1UdDgQWBBRmRE2zkZe58xIqg//9 +Iw76XyOLKjAfBgNVHSMEGDAWgBTTMOrQr2ny9gYyywPGUZGx/KENtzAPBgNVHRMB +Af8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQCMBqHAcfcOv21PmRMWchgZ37ZRoor +4+XlKPnh+L0gBBRykqI49VvvpgxtCC2jVA2BnQIwZf0DOnk6c4lUvSzrEJ7+ToR1 +dJMoNuufOTVoMQdojCLwnH4GrwID0qsdXJm8X0XY +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kClaimSignerPem = R"(-----BEGIN CERTIFICATE----- +MIICDzCCAZWgAwIBAgIUR8DAqLjMOExo0KJS3DGC4Qsy79kwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIy +MzA1MDMwMFoYDzIxMjUwMTMwMDUwMzAwWjBbMSYwJAYDVQQDDB1Hb29nbGUgVGVz +dCBDMlBBIENsYWltIFNpZ25lcjEdMBsGA1UECgwUR29vZ2xlIEMyUEEgVGVzdCBP +cmcxEjAQBgNVBAsMCVRlc3QgVW5pdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA +BKw1/4qPpbSgEWNUy7B5R48dxnsx92d5lqh7HY204TpvmxQgukcwavpW8XBxSdYr +F+XEgNStkprWpnkoKWNpShyjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E +DDAKBggrBgEFBQcDBDAdBgNVHQ4EFgQU6wle6tElLcBSIwsoBICb7gInPiQwHwYD +VR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaAAwZQIw +YNgGpaaZxI2km45t/Wd+njvQlF0JJ4+9GvZ0bDQWH7kjAQTmKrkxuNMT77cA2IMW +AjEArZqFNmE/5yv8tzHbPP73c3n8BPUmjIpZR5A2nzjfhPzpClFp70lkO+mTu82d +ULnv +-----END CERTIFICATE-----)"; + +class MockParsedTimestampToken : public ParsedTimestampToken { + public: + MOCK_METHOD(absl::Status, VerifySignature, (), (const, override)); + MOCK_METHOD(absl::StatusOr<HashAlgorithm>, GetMessageImprintHashAlgorithm, (), + (const, override)); + MOCK_METHOD(absl::StatusOr<std::vector<std::string>>, GetCertificateChain, (), + (const, override)); + MOCK_METHOD(absl::StatusOr<absl::string_view>, GetSigningCertificateV2Bytes, + (), (const, override)); + MOCK_METHOD(absl::StatusOr<absl::string_view>, GetTstInfoBytes, (), + (const, override)); +}; + +std::string GetSigningCertificateV2Value(absl::string_view attribute_bytes) { + CBS cbs = FromStringView(attribute_bytes); + CBS seq, oid, set; + CHECK(CBS_get_asn1(&cbs, &seq, CBS_ASN1_SEQUENCE)); + CHECK(CBS_get_asn1(&seq, &oid, CBS_ASN1_OBJECT)); + CHECK(CBS_get_asn1(&seq, &set, CBS_ASN1_SET)); + CBS val_elem; + CHECK(CBS_get_any_asn1_element(&set, &val_elem, nullptr, nullptr)); + return std::string(ToStringView(val_elem)); +} + +constexpr absl::string_view kSha384OidTxt = "2.16.840.1.101.3.4.2.2"; + +constexpr absl::string_view kMessageImprintHash = "fake message imprint"; +constexpr absl::string_view kValidTimestamp = "20240101000000Z"; + +absl::Time ValidTimestampTime() { + static absl::Time time = []() { + absl::Time time; + std::string err; + CHECK(absl::ParseTime(kGenTimeFormat, kValidTimestamp, &time, &err)) << err; + return time; + }(); + + return time; +} + +bssl::UniquePtr<X509> ParseCert(absl::string_view pem) { + CBS cbs = FromStringView(pem); + bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(CBS_data(&cbs), CBS_len(&cbs))); + CHECK(bio != nullptr); + bssl::UniquePtr<X509> x509( + PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr)); + CHECK(x509 != nullptr); + return x509; +} + +std::string RawDer(X509* x509) { + uint8_t* der_ptr = nullptr; + size_t der_len = i2d_X509(x509, &der_ptr); + bssl::UniquePtr<uint8_t> der(der_ptr); + + return std::string(reinterpret_cast<char*>(der_ptr), der_len); +} + +std::string RawDer(absl::string_view pem) { + return RawDer(ParseCert(pem).get()); +} + +absl::Time ParseGenTime(absl::string_view timestamp) { + absl::Time time; + std::string parse_error; + CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error)) + << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp + << "\"): " << parse_error; + return time; +} + +absl::string_view ValidNonce() { + static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe)); + return *nonce; +} + +std::string SigningCertificateV2AttributeFromPem( + absl::Span<const absl::string_view> pems) { + std::vector<bssl::UniquePtr<X509>> certs; + for (absl::string_view pem : pems) { + certs.push_back(ParseCert(pem)); + } + std::vector<X509*> ptr_vec; + absl::c_transform(certs, std::back_inserter(ptr_vec), + [](const auto& cert) { return cert.get(); }); + return CreateSigningCertificateV2Attribute(absl::MakeSpan(ptr_vec), + HashAlgorithm::kSha256); +} + +class TimestampVerifierTest : public testing::Test { + protected: + TimestampVerifierTest() : no_trust_checks_(/*tsa_roots=*/nullptr) {} + + void SetupMockDefaults(MockParsedTimestampToken& mock_token, + const std::vector<std::string>& cert_chain, + absl::string_view v2_val, absl::string_view tst_info) { + ON_CALL(mock_token, GetCertificateChain()) + .WillByDefault(Return(cert_chain)); + ON_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillByDefault(Return(HashAlgorithm::kSha384)); + ON_CALL(mock_token, VerifySignature()) + .WillByDefault(Return(absl::OkStatus())); + ON_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillByDefault(Return(v2_val)); + ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info)); + } + + TimestampVerifier no_trust_checks_; +}; + +TEST_F(TimestampVerifierTest, AcceptsRsaTimestamp) { + std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash, + kSha384OidTxt, ValidNonce()); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaRsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token); + ASSERT_TRUE(timestamp.ok()); + EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime()); + EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash)); + EXPECT_THAT(timestamp->message_imprint_hash_algorithm(), + Eq(HashAlgorithm::kSha384)); + EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce())); + EXPECT_THAT(timestamp->tsa_certificate(), Eq(RawDer(kTsaRsaPem))); + EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(RawDer(kTsaRsaPem))); +} + +TEST_F(TimestampVerifierTest, AcceptsEcdsaTimestamp) { + std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash, + kSha384OidTxt, ValidNonce()); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token); + ASSERT_TRUE(timestamp.ok()); + EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime()); + EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash)); + EXPECT_THAT(timestamp->message_imprint_hash_algorithm(), + Eq(HashAlgorithm::kSha384)); + EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce())); + EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der)); + EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(cert_der)); +} + +TEST_F(TimestampVerifierTest, AcceptsTimestampWithIntermediateCertificate) { + std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash, + kSha384OidTxt, ValidNonce()); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + std::string intermediate_der = RawDer(kIntermediatePem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info); + + auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token); + ASSERT_TRUE(timestamp.ok()); + EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime()); + EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash)); + EXPECT_THAT(timestamp->message_imprint_hash_algorithm(), + Eq(HashAlgorithm::kSha384)); + EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce())); + EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der)); + EXPECT_THAT(timestamp->certificate_chain(), + ElementsAre(cert_der, intermediate_der)); +} + +TEST_F(TimestampVerifierTest, GetSigningCertificateV2BytesFails) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(absl::InvalidArgumentError("Missing attribute"))); + + EXPECT_THAT( + no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, "Missing attribute")); +} + +TEST_F(TimestampVerifierTest, InvalidTstInfoFails) { + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {RawDer(kTsaRsaPem)}, v2_val, + "THIS IS NOT BER OR DER OR CER OR AER OR ..."); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("could not read top-level sequence"))); +} + +TEST_F(TimestampVerifierTest, SignatureVerificationFails) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()) + .WillOnce(Return(absl::UnauthenticatedError("Signature failed"))); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("Signature failed"))); +} + +TEST_F(TimestampVerifierTest, EmptyChainFails) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInternal, + HasSubstr("TSA leaf cert missing"))); +} + +TEST_F(TimestampVerifierTest, InvalidSigningCertificateAttribute) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return("THIS IS NOT A DER SEQUENCE")); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "could not parse DER SEQUENCE for SigningCertificateV2 " + "attribute value")); +} + +TEST_F(TimestampVerifierTest, UnrecognizedSiginingCertificateHashAlgorithm) { + CBB cbb; + CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + constexpr absl::string_view kFakeOid = "1.2.840.113549.1.800.888.8888"; + CBS hash_cbs = FromStringView( + "thisisnotarealhashbutthetestwontcarebecauseitwillfailduetothebadoidanywa" + "ys"); + + CBB certs_entry_cbb; + CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + CBB obj_cbb; + CHECK(CBB_add_asn1(&certs_entry_cbb, &obj_cbb, CBS_ASN1_OBJECT)); + CHECK( + CBB_add_asn1_oid_from_text(&obj_cbb, kFakeOid.data(), kFakeOid.length())); + CHECK(CBB_flush(&certs_entry_cbb)); + + CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs), + CBS_len(&hash_cbs))); + + auto value_bytes = FinishToString(&cbb); + ASSERT_TRUE(value_bytes.ok()); + + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(*value_bytes)); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "unrecognized OID in `certs.hashAlgorithm` field of " + "SigningCertificateV2 attribute value; OID value: " + "1.2.840.113549.1.800.888.8888")); +} + +TEST_F(TimestampVerifierTest, NoSigningCertificateHash) { + CBB cbb; + CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + CBB certs_entry_cbb; + CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha384)); + CHECK(CBB_flush(&certs_entry_cbb)); + + auto value_bytes = FinishToString(&cbb); + ASSERT_TRUE(value_bytes.ok()); + + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(*value_bytes)); + + EXPECT_THAT( + no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "could not parse `certs.hash` field (DER OCTETSTRING) of " + "SigningCertificateV2 attribute value")); +} + +TEST_F(TimestampVerifierTest, UnsupportedSigningCertificateHashAlgorithm) { + CBB cbb; + CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + CBS hash_cbs = FromStringView("Not a real hash"); + + CBB certs_entry_cbb; + CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha1)); + CHECK(CBB_flush(&certs_entry_cbb)); + + CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs), + CBS_len(&hash_cbs))); + + auto value_bytes = FinishToString(&cbb); + ASSERT_TRUE(value_bytes.ok()); + + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(*value_bytes)); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "unsupported hash algorithm in `certs.hashAlgorithm` " + "field of SigningCertificateV2 attribute value: hash " + "algorithm NID is not on C2PA allowlist: 64")); +} + +class TrustChecksTest : public ::testing::Test { + protected: + TrustChecksTest() + : tsa_roots_(LoadTestRoots().value()), verifier_(tsa_roots_.get()) {} + + absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTestRoots() { + return LoadTrustStore(kCaPem); + } + + void SetupMockDefaults(MockParsedTimestampToken& mock_token, + const std::vector<std::string>& cert_chain, + absl::string_view v2_val, absl::string_view tst_info) { + ON_CALL(mock_token, GetCertificateChain()) + .WillByDefault(Return(cert_chain)); + ON_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillByDefault(Return(HashAlgorithm::kSha384)); + ON_CALL(mock_token, VerifySignature()) + .WillByDefault(Return(absl::OkStatus())); + ON_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillByDefault(Return(v2_val)); + ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info)); + } + + const std::unique_ptr<bssl::VerifyTrustStore> tsa_roots_; + TimestampVerifier verifier_; +}; + +TEST_F(TrustChecksTest, Trusted) { + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token); + ASSERT_TRUE(verified_timestamp.ok()); + EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime))); +} + +TEST_F(TrustChecksTest, TrustedWithIntermediate) { + constexpr absl::string_view kTime = "20250514000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + std::string intermediate_der = RawDer(kIntermediatePem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info); + + auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token); + ASSERT_TRUE(verified_timestamp.ok()); + EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime))); +} + +TEST_F(TrustChecksTest, TooManyIntermediateCerts) { + constexpr absl::string_view kTime = "20250514000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + std::string intermediate_der = RawDer(kIntermediatePem); + + MockParsedTimestampToken mock_token; + // Chain length 4 (1 leaf + 3 intermediates) + SetupMockDefaults( + mock_token, + {cert_der, intermediate_der, intermediate_der, intermediate_der}, v2_val, + tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("excessive timestamp certificate chain length"))); +} + +TEST_F(TrustChecksTest, TrustedMissingIntermediate) { + constexpr absl::string_view kTime = "20250514000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + + MockParsedTimestampToken mock_token; + // Missing intermediate! + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("No matching issuer found"))); +} + +TEST_F(TrustChecksTest, Untrusted) { + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + + std::string dummy_cert = "DUMMY_CERT_DER"; + // Compute SHA256 of dummy_cert using internal OpenSSL without hasher.h + uint8_t hash[32]; + unsigned int hash_len; + CHECK(EVP_Digest(dummy_cert.data(), dummy_cert.length(), hash, &hash_len, + EVP_sha256(), nullptr)); + std::string digest(reinterpret_cast<char*>(hash), hash_len); + + CBB cbb; + CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_entry_cbb; + CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha256)); + CHECK(CBB_flush(&certs_entry_cbb)); + CBS hash_cbs = FromStringView(digest); + CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs), + CBS_len(&hash_cbs))); + auto v2_val = FinishToString(&cbb); + ASSERT_TRUE(v2_val.ok()); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {dummy_cert}, *v2_val, tst_info); + + EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("timestamp certificate chain"))); +} + +TEST_F(TrustChecksTest, InvalidEku) { + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kClaimSignerPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kClaimSignerPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + // The certificate has Email Protection EKU, which is not allowed for TSAs. + EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("prohibited key usage"))); +} + +TEST_F(TrustChecksTest, SigningCertificateV2AttributeLeafMismatch) { + constexpr absl::string_view kTime = "21250204161300Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs( + absl::StatusCode::kUnauthenticated, + HasSubstr("TSA cert does not match SigningCertificateV2 attribute: " + "certificate does not match EssCertIDV2 value"))); +} + +TEST_F(TrustChecksTest, EmptySigningCertificateAttribute) { + CBB cbb; + CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + auto value_bytes = FinishToString(&cbb); + ASSERT_TRUE(value_bytes.ok()); + + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string cert_der = RawDer(kTsaRsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, *value_bytes, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs( + absl::StatusCode::kInvalidArgument, + "SigningCertificateV2 signed attribute is empty; at minimum, " + "TSAs must record their own leaf certificate in this attribute")); +} + +TEST_F(TrustChecksTest, OutsideTsaCertValidityWindow) { + // This time is outside the validity window of the test TSA certificate, + // but inside the validity window of the test CA certificate. + constexpr absl::string_view kTime = "20250204161300Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("before notBefore"))); +} + +TEST_F(TrustChecksTest, OutsideCaCertValidityWindow) { + // This time is inside the validity window of the test TSA certificate, + // but outside the validity window of the test CA certificate. + constexpr absl::string_view kTime = "21250204161300Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("after notAfter"))); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/default/trust_store.cc b/crypto/default/trust_store.cc new file mode 100644 index 0000000..9c6fb91 --- /dev/null +++ b/crypto/default/trust_store.cc
@@ -0,0 +1,58 @@ +// 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 "crypto/default/trust_store.h" + +#include <memory> +#include <string> +#include <vector> + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/default/pem.h" +#include "google/protobuf/timestamp.pb.h" +#include "openssl/pki/verify.h" +#include "proto/signature_info.pb.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTrustStore( + absl::string_view trust_anchor_pem) { + // Construct a trust store from certs in the PEM file. + // + // 1) Parse the PEM file contents to a set of DER certs. + ABSL_ASSIGN_OR_RETURN(std::vector<std::string> certs, + LoadCertsFromPem(trust_anchor_pem)); + if (certs.empty()) { + return absl::InvalidArgumentError("No certs found in PEM file"); + } + + // 2) Construct the trust store from the DER certs. + std::vector<std::string_view> der_certs_views(certs.begin(), certs.end()); + std::string diagnostics; + std::unique_ptr<bssl::VerifyTrustStore> roots = + bssl::VerifyTrustStore::FromDER(der_certs_views, &diagnostics); + if (roots == nullptr) { + return absl::InternalError( + absl::StrCat("Failed to construct the trust store: ", diagnostics)); + } + return roots; +} + +} // namespace credentio
diff --git a/crypto/default/trust_store.h b/crypto/default/trust_store.h new file mode 100644 index 0000000..f56be28 --- /dev/null +++ b/crypto/default/trust_store.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TRUST_STORE_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TRUST_STORE_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "openssl/pki/verify.h" + +namespace credentio { + +// Loads a trust store from a PEM file containing a set of certificates. +// Returns an error if the PEM file is malformed or no certs are found. +absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTrustStore( + absl::string_view trust_anchor_pem); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TRUST_STORE_H_
diff --git a/crypto/default/trust_store_test.cc b/crypto/default/trust_store_test.cc new file mode 100644 index 0000000..d5bafb5 --- /dev/null +++ b/crypto/default/trust_store_test.cc
@@ -0,0 +1,60 @@ +// 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 "crypto/default/trust_store.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "testing/test_file_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +constexpr absl::string_view kTrustAnchors = + "c2pa/testing/testdata/keys/trust_anchors.pem"; + +TEST(LoadTrustStore, Success) { + auto pem = credentio_testing::GetContents(kTrustAnchors); + ASSERT_TRUE(pem.ok()); + EXPECT_THAT(credentio::LoadTrustStore(*pem), IsOk()); +} + +TEST(LoadTrustStore, EmptyPem) { + EXPECT_THAT(credentio::LoadTrustStore(""), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No certificates found"))); +} + +TEST(LoadTrustStore, MalformedPem) { + EXPECT_THAT(credentio::LoadTrustStore(R"( +-----BEGIN CERTIFICATE----- +blah blah +-----END CERTIFICATE----- +)"), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("No certificates found"))); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/default/x509_certificate.cc b/crypto/default/x509_certificate.cc new file mode 100644 index 0000000..7f42d85 --- /dev/null +++ b/crypto/default/x509_certificate.cc
@@ -0,0 +1,457 @@ +// 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 "crypto/default/x509_certificate.h" + +#include <cstdint> +#include <ctime> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/no_destructor.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "crypto/default/compliance_checker.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/bn.h" +#include "openssl/evp.h" +#include "openssl/obj.h" +#include "openssl/pem.h" +#include "openssl/pool.h" +#include "openssl/rsa.h" +#include "openssl/x509.h" +#include "proto/common.pb.h" +#include "tink/cleartext_keyset_handle.h" +#include "tink/public_key_verify.h" +#include "tink/signature/config_v0.h" +#include "tink/signature/signature_pem_keyset_reader.h" + +namespace credentio { +namespace { + +using ::crypto::tink::PemKeyParams; +using ::crypto::tink::SignaturePemKeysetReaderBuilder; + +// c2pa-al from +// https://github.com/c2pa-org/conformance-public/blob/main/docs/current/schemas/mib/oid.txt +constexpr char kAssuranceLevelOid[] = "1.3.6.1.4.1.62558.3"; +// c2pa-cpl-record from +// https://github.com/c2pa-org/conformance-public/blob/main/docs/current/schemas/mib/oid.txt +constexpr char kConformanceRecordOid[] = "1.3.6.1.4.1.62558.4"; + +absl::Status CheckCompatibleWithAlgorithm(EVP_PKEY* pub_key, + SigningAlgorithm algorithm) { + int key_type = EVP_PKEY_id(pub_key); + switch (algorithm) { + case SigningAlgorithm::kPs256: + case SigningAlgorithm::kPs384: + case SigningAlgorithm::kPs512: + if (key_type == EVP_PKEY_RSA || key_type == EVP_PKEY_RSA_PSS) { + return absl::OkStatus(); + } + break; + case SigningAlgorithm::kEs256: + case SigningAlgorithm::kEs384: + case SigningAlgorithm::kEs512: + if (key_type == EVP_PKEY_EC) { + return absl::OkStatus(); + } + break; + case SigningAlgorithm::kEdDsa: + if (key_type == EVP_PKEY_ED25519) { + return absl::OkStatus(); + } + break; + } + return absl::InvalidArgumentError(absl::Substitute( + "Wrong key type ($0) for algorithm $1", key_type, algorithm)); +} + +// Returns a list of C2PA supported algorithms. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#certificate-requirements. +// Note: bssl::GetDefaultEVPAlgorithms() does not include RSA_PSS. +const std::vector<const EVP_PKEY_ALG*>& GetC2paEVPAlgorithms() { + static const absl::NoDestructor<std::vector<const EVP_PKEY_ALG*>> kAlgorithms( + { + EVP_pkey_ec_p256(), + EVP_pkey_ec_p384(), + EVP_pkey_ec_p521(), + EVP_pkey_rsa(), + EVP_pkey_rsa_pss_sha256(), + EVP_pkey_rsa_pss_sha384(), + EVP_pkey_rsa_pss_sha512(), + EVP_pkey_ed25519(), + }); + return *kAlgorithms; +} + +absl::StatusOr<bssl::UniquePtr<X509>> GetCertificateFromDER( + absl::string_view der_data) { + auto crypto_buffer = bssl::UniquePtr<CRYPTO_BUFFER>( + CRYPTO_BUFFER_new(reinterpret_cast<const uint8_t*>(der_data.data()), + der_data.length(), /*pool=*/nullptr)); + const auto& algorithms = GetC2paEVPAlgorithms(); + bssl::UniquePtr<X509> cert(X509_parse_with_algorithms( + crypto_buffer.get(), algorithms.data(), algorithms.size())); + if (cert == nullptr) { + return absl::InvalidArgumentError( + "Failed to parse the X.509 certificate from DER bytes"); + } + return cert; +} + +absl::StatusOr<std::string> PemEncode(EVP_PKEY* pub_key) { + bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem())); + if (PEM_write_bio_PUBKEY(bio.get(), pub_key) != 1) { + return absl::InternalError("Failed to write pub key to BIO"); + } + const uint8_t* pem; + size_t pem_length = 0; + if (BIO_mem_contents(bio.get(), &pem, &pem_length) != 1) { + return absl::InternalError("Failed to get PEM string from BIO"); + } + std::string pem_str(reinterpret_cast<const char*>(pem), pem_length); + return pem_str; +} + +bssl::UniquePtr<EVP_PKEY> MaybeConvertPssToRsa( + bssl::UniquePtr<EVP_PKEY> pub_key) { + // Tink uses BoringSSL to parse public keys from PEM, which does not support + // id-RSASSA-PSS by default. Since Tink uses key templates, it doesn't care if + // the original public key had the "restricted" RSA-PSS OID or the "standard" + // RSA OID. It only cares about the modulus and exponent, then applies the PSS + // logic defined in the GetPemKeyParams. Therefore, for compatibility, we turn + // the "restricted" RSA-PSS public key to a "generic" RSA public key. + RSA* rsa_params = EVP_PKEY_get1_RSA(pub_key.get()); + if (rsa_params != nullptr) { + // Ideally, we should check if it is a RSA_PSS key. However, BoringSSL does + // not implement RSA_get0_pss_params yet. + bssl::UniquePtr<EVP_PKEY> rsa_key(EVP_PKEY_new()); + EVP_PKEY_assign_RSA(rsa_key.get(), rsa_params); + return rsa_key; + } + return pub_key; +} + +std::string X509NamePrintEx(const X509_NAME* name, uint64_t flags) { + if (!name) return ""; + + bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem())); + X509_NAME_print_ex(bio.get(), name, 0, flags); + int size = BIO_pending(bio.get()); + std::string buffer(size, '\0'); + int bytes_read = BIO_read(bio.get(), buffer.data(), size); + if (bytes_read != size) { + return ""; + } + return buffer; +} + +} // namespace + +std::string X509Certificate::GetSubject() const { + return X509NamePrintEx(X509_get_subject_name(cert_.get()), XN_FLAG_RFC2253); +} + +std::string X509Certificate::GetIssuer() const { + return X509NamePrintEx(X509_get_issuer_name(cert_.get()), XN_FLAG_RFC2253); +} + +absl::StatusOr<std::unique_ptr<X509Certificate>> X509Certificate::Create( + absl::string_view der) { + ABSL_ASSIGN_OR_RETURN(auto x509, GetCertificateFromDER(der)); + return std::make_unique<X509Certificate>(std::move(x509)); +} + +absl::Status X509Certificate::IsValidC2paCertificate(bool is_leaf) const { + return ComplianceChecker::IsValidC2paCertificate(cert_.get(), is_leaf); +} + +absl::Status X509Certificate::VerifySignature( + absl::string_view signature, absl::string_view data, + SigningAlgorithm algorithm) const { + bssl::UniquePtr<EVP_PKEY> pub_key(X509_get_pubkey(cert_.get())); + if (pub_key == nullptr) { + return absl::InvalidArgumentError( + "Certificate does not have a valid public key"); + } + pub_key = MaybeConvertPssToRsa(std::move(pub_key)); + ABSL_ASSIGN_OR_RETURN(auto pem_str, PemEncode(pub_key.get())); + auto builder = SignaturePemKeysetReaderBuilder( + SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_VERIFY); + auto params = GetPemKeyParams(algorithm); + if (!params.ok()) { + return absl::Status(params.status().code(), + absl::StrCat(params.status().message(), + "; Failed to get PEM key params")); + } + for (const auto& param : *params) { + builder.Add({.serialized_key = pem_str, .parameters = param}); + } + auto reader = builder.Build(); + if (!reader.ok()) { + return absl::Status( + reader.status().code(), + absl::StrCat(reader.status().message(), + "; Failed to construct the PEM KeysetReader")); + } + auto handle = crypto::tink::CleartextKeysetHandle::Read(*std::move(reader)); + if (!handle.ok()) { + return absl::Status(handle.status().code(), + absl::StrCat(handle.status().message(), + "; Failed to construct the KeysetHandle")); + } + auto verifier = (*handle)->GetPrimitive<crypto::tink::PublicKeyVerify>( + crypto::tink::ConfigSignatureV0()); + if (!verifier.ok()) { + return absl::Status( + verifier.status().code(), + absl::StrCat(verifier.status().message(), + "; Failed to construct the PublicKeyVerify primitive")); + } + auto status = (*verifier)->Verify(signature, data); + if (!status.ok()) { + return absl::InvalidArgumentError(absl::StrCat( + "Tink verification failed with the status error: ", status.message())); + } + return absl::OkStatus(); +} + +std::string X509Certificate::DebugString() const { + bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem())); + if (!X509_print(bio.get(), cert_.get())) { + return "<X509_print failed>"; + } + const uint8_t* data; + size_t len; + if (!BIO_mem_contents(bio.get(), &data, &len) || len == 0) { + return "BIO_mem_contents failed"; + } + return std::string(reinterpret_cast<const char*>(data), len); +} + +absl::StatusOr<absl::Time> X509Certificate::StartTime() const { + const ASN1_TIME* not_before = X509_get0_notBefore(cert_.get()); + if (not_before == nullptr) { + return absl::InternalError("failed to get certificate start time"); + } + int64_t posix_time; + if (ASN1_TIME_to_posix(not_before, &posix_time) == 0) { + return absl::InternalError("Invalid certificate start time"); + } + return absl::FromUnixSeconds(posix_time); +} + +absl::StatusOr<absl::Time> X509Certificate::EndTime() const { + int64_t posix_time; + const ASN1_TIME* not_after = X509_get0_notAfter(cert_.get()); + if (not_after == nullptr) { + return absl::InternalError("failed to get certificate end time"); + } + if (ASN1_TIME_to_posix(not_after, &posix_time) == 0) { + return absl::InternalError("Invalid certificate end time"); + } + return absl::FromUnixSeconds(posix_time); +} + +absl::StatusOr<std::vector<PemKeyParams>> X509Certificate::GetPemKeyParams( + SigningAlgorithm algorithm) const { + bssl::UniquePtr<EVP_PKEY> pub_key(X509_get_pubkey(cert_.get())); + if (pub_key == nullptr) { + return absl::InvalidArgumentError( + "Certificate does not have a valid public key"); + } + ABSL_RETURN_IF_ERROR(CheckCompatibleWithAlgorithm(pub_key.get(), algorithm)); + size_t key_size = EVP_PKEY_bits(pub_key.get()); + switch (algorithm) { + case SigningAlgorithm::kPs256: + return std::vector<PemKeyParams>{PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_RSA, + .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA256, + }}; + case SigningAlgorithm::kPs384: + return std::vector<PemKeyParams>{PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_RSA, + .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA384, + }}; + case SigningAlgorithm::kPs512: + return std::vector<PemKeyParams>{PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_RSA, + .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA512, + }}; + case SigningAlgorithm::kEs256: + return std::vector<PemKeyParams>{ + PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA256, + }, + PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA256, + }}; + case SigningAlgorithm::kEs384: + return std::vector<PemKeyParams>{ + PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA384, + }, + PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA384, + }}; + case SigningAlgorithm::kEs512: + return std::vector<PemKeyParams>{ + PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA512, + }, + PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA512, + }, + }; + case SigningAlgorithm::kEdDsa: + return std::vector<PemKeyParams>{PemKeyParams{ + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ED25519, + .key_size_in_bits = key_size, + .hash_type = google::crypto::tink::HashType::SHA512, + }}; + } +} + +absl::StatusOr<std::string> X509Certificate::GetSerialNumberHex() const { + const ASN1_INTEGER* serial = X509_get0_serialNumber(cert_.get()); + if (serial == nullptr) { + return absl::InternalError("failed to get certificate serial number"); + } + bssl::UniquePtr<BIGNUM> bn(ASN1_INTEGER_to_BN(serial, nullptr)); + if (!bn) { + return absl::InternalError("failed to convert serial number to BIGNUM"); + } + bssl::UniquePtr<char> hex(BN_bn2hex(bn.get())); + if (!hex) { + return absl::InternalError("failed to convert BIGNUM to hex"); + } + return absl::AsciiStrToLower(hex.get()); +} + +absl::StatusOr<std::string> X509Certificate::GetAssuranceLevel() const { + static const ASN1_OBJECT* const assurance_level_oid = []() { + return OBJ_txt2obj(kAssuranceLevelOid, /*dont_search_names=*/1); + }(); + if (assurance_level_oid == nullptr) { + return absl::InternalError("Failed to parse assurance level OID"); + } + + int index = X509_get_ext_by_OBJ(cert_.get(), assurance_level_oid, -1); + if (index == -1) { + return absl::NotFoundError("assurance level extension not found"); + } + + const X509_EXTENSION* extension = X509_get_ext(cert_.get(), index); + if (extension == nullptr) { + return absl::NotFoundError("cannot extract assurance level extension"); + } + + const ASN1_OCTET_STRING* octet_str = X509_EXTENSION_get_data(extension); + if (octet_str == nullptr) { + return absl::NotFoundError("cannot extract assurance level extension data"); + } + + const unsigned char* p = ASN1_STRING_get0_data(octet_str); + int64_t len = ASN1_STRING_length(octet_str); + bssl::UniquePtr<ASN1_OBJECT> val_obj(d2i_ASN1_OBJECT(nullptr, &p, len)); + if (!val_obj) { + return absl::NotFoundError("cannot parse assurance level value"); + } + + char buf[128]; + int res = OBJ_obj2txt(buf, sizeof(buf), val_obj.get(), /*no_name=*/1); + if (res <= 0) { + return absl::NotFoundError( + "cannot convert assurance level value to string"); + } + return std::string(buf); +} + +absl::StatusOr<std::string> X509Certificate::GetConformingProductId() const { + static const ASN1_OBJECT* const conformance_record_oid = []() { + return OBJ_txt2obj(kConformanceRecordOid, /*dont_search_names=*/1); + }(); + if (conformance_record_oid == nullptr) { + return absl::InternalError("Failed to parse conformance record OID"); + } + + int index = X509_get_ext_by_OBJ(cert_.get(), conformance_record_oid, -1); + if (index == -1) { + return absl::NotFoundError("Conformance record extension not found"); + } + + const X509_EXTENSION* extension = X509_get_ext(cert_.get(), index); + if (extension == nullptr) { + return absl::NotFoundError( + "Failed to retrieve conformance record extension"); + } + + const ASN1_OCTET_STRING* octet_str = X509_EXTENSION_get_data(extension); + if (octet_str == nullptr) { + return absl::NotFoundError("Conformance record extension data is null"); + } + + const unsigned char* p = ASN1_STRING_get0_data(octet_str); + int64_t len = ASN1_STRING_length(octet_str); + bssl::UniquePtr<ASN1_UTF8STRING> utf8_str( + d2i_ASN1_UTF8STRING(nullptr, &p, len)); + if (!utf8_str) { + return absl::NotFoundError("Failed to parse UUID as UTF8String"); + } + + return std::string( + reinterpret_cast<const char*>(ASN1_STRING_get0_data(utf8_str.get())), + ASN1_STRING_length(utf8_str.get())); +} + +} // namespace credentio
diff --git a/crypto/default/x509_certificate.h b/crypto/default/x509_certificate.h new file mode 100644 index 0000000..13279cb --- /dev/null +++ b/crypto/default/x509_certificate.h
@@ -0,0 +1,66 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_X509_CERTIFICATE_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_X509_CERTIFICATE_H_ + +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "openssl/base.h" +#include "openssl/x509.h" +#include "tink/signature/signature_pem_keyset_reader.h" + +namespace credentio { + +// Class encapsulating the OpenSSL X.509 certificate. +class X509Certificate { + public: + explicit X509Certificate(bssl::UniquePtr<X509> cert) + : cert_(std::move(cert)) {} + std::string GetSubject() const; + std::string GetIssuer() const; + std::string DebugString() const; + static absl::StatusOr<std::unique_ptr<X509Certificate>> Create( + absl::string_view der); + absl::Status IsValidC2paCertificate(bool is_leaf) const; + // Verifies a signature using the public key encapsulated by this + // `X509Certificate` instance. + // Returns an OK status if the verification is successful. + absl::Status VerifySignature(absl::string_view signature, + absl::string_view data, + SigningAlgorithm algorithm) const; + absl::StatusOr<absl::Time> StartTime() const; + absl::StatusOr<absl::Time> EndTime() const; + absl::StatusOr<std::vector<crypto::tink::PemKeyParams>> GetPemKeyParams( + SigningAlgorithm algorithm) const; + absl::StatusOr<std::string> GetSerialNumberHex() const; + absl::StatusOr<std::string> GetAssuranceLevel() const; + absl::StatusOr<std::string> GetConformingProductId() const; + + private: + bssl::UniquePtr<X509> cert_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_X509_CERTIFICATE_H_
diff --git a/crypto/default/x509_certificate_test.cc b/crypto/default/x509_certificate_test.cc new file mode 100644 index 0000000..0cd976e --- /dev/null +++ b/crypto/default/x509_certificate_test.cc
@@ -0,0 +1,422 @@ +// 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 "crypto/default/x509_certificate.h" + +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/escaping.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/mem.h" +#include "openssl/obj.h" +#include "openssl/x509.h" +#include "proto/common.pb.h" +#include "tink/signature/signature_pem_keyset_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::NotNull; +using ::testing::SizeIs; + +constexpr absl::string_view kDerRsaBase64 = + "MIIGDDCCA/SgAwIBAgIQf/" + "J0H9HG3kO1lHqOZXCenjANBgkqhkiG9w0BAQsFADB1MQswCQYDVQQGEwJVUzEjMCEGA1UEChMa" + "QWRvYmUgU3lzdGVtcyBJbmNvcnBvcmF0ZWQxHTAbBgNVBAsTFEFkb2JlIFRydXN0IFNlcnZpY2" + "VzMSIwIAYDVQQDExlBZG9iZSBQcm9kdWN0IFNlcnZpY2VzIEczMB4XDTI0MDExMTAwMDAwMFoX" + "DTI1MDExMDIzNTk1OVowfzERMA8GA1UEAwwIY2FpLXByb2QxEzARBgNVBAoMCkFkb2JlIEluYy" + "4xETAPBgNVBAcMCFNhbiBKb3NlMRMwEQYDVQQIDApDYWxpZm9ybmlhMQswCQYDVQQGEwJVUzEg" + "MB4GCSqGSIb3DQEJARYRY2FpLW9wc0BhZG9iZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDw" + "AwggEKAoIBAQDv0wCnfYY9lkHDsykrTG7FYlnYHBcyualuD5u9ULjzv+" + "FsTWOLg8Nhrz9RTxI9EeBbu3Wm7f4vqPsQYfxkEp0wIa6DVmEOZhbAeRfbhybWQ/" + "D4Wvvck4mEZR+QZmTpIn7HX61gbtMFHlJUxkYBctIoAt9TBAFbz9d5Ig2WG0bsH39b6/" + "8USXnIIk4FB6dtBRT1xeEwSDtwOd1YBpXXNBJmRWHXNJZZ85of2tBTesg/" + "kMvTrOJJxYDZ97U416wcvbVomlees+NXUY3/" + "ZB4xH+" + "NsZils7m1bL2v2hSnHiLoqZeUKklbHUmT4ZpjrZCdtuIsWm5j2PPXKr7HKUuCbZSXBAgMBAAWj" + "ggGMMIIBiDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/" + "wQEAwIHgDAeBgNVHSUEFzAVBgkqhkiG9y8BAQwGCCsGAQUFBwMEMIGOBgNVHSAEgYYwgYMwgYA" + "GCSqGSIb3LwECAzBzMHEGCCsGAQUFBwICMGUMY1lvdSBhcmUgbm90IHBlcm1pdHRlZCB0byB1c" + "2UgdGhpcyBMaWNlbnNlIENlcnRpZmljYXRlIGV4Y2VwdCBhcyBwZXJtaXR0ZWQgYnkgdGhlIGx" + "pY2Vuc2UgYWdyZWVtZW50LjBdBgNVHR8EVjBUMFKgUKBOhkxodHRwOi8vcGtpLWNybC5zeW1hd" + "XRoLmNvbS9jYV83YTVjM2EwYzczMTE3NDA2YWRkMTkzMTJiYzFiYzIzZi9MYXRlc3RDUkwuY3J" + "sMDcGCCsGAQUFBwEBBCswKTAnBggrBgEFBQcwAYYbaHR0cDovL3BraS1vY3NwLnN5bWF1dGguY" + "29tMB8GA1UdIwQYMBaAFFcpejJNzP7kNU7AHyRzznNTq99qMA0GCSqGSIb3DQEBCwUAA4ICAQA" + "hY9X822Lvow9+KlSdkAI/95WsS/" + "n1Y5se5YJkePUHLxsablSaNOA9BiVfJOPxcT+JjbumGCseDSmIOxYPO7mzwHpMqRoyZS+" + "C2T5SC6TJtt920ELoq+" + "0aDLjRw7gOQRSg8OK0PFPWKBtCp0qiAChUqoMRWqcX4IVBDO1sZxmEUj1cpOwJDB/" + "2HDsuEkKjqMA0EgAE3JC1LBYik95IQmE9WBYvIp6mBDEkyovqyBhnEar2kJJCfaBLylp/" + "JAUsHJnAQEP8PHK1FlkvpvdleoX59lmwT1+wHGlxYWK/" + "ykW+AFA60LOVJt8byjtuAE8lebuYADyPe3eEMR4THQbHwH/" + "1rz2nNN5tZWsPdIR7S5mX0RtUL74XgSixEkV1jDO4/" + "WIk7V3kjdXEZr0WtWr8VgEY5G+" + "8NpPBqd2LMLBp7jejMIj3tA8RS1L6MnO3PWRqkdwGwsCHsvGcTzauWarWji990WxBTPscSk6Q8" + "WTIqiHI9OfUUiriWQkpfeMEDo1r+zHJRmj26+" + "zgID7QB41xNeV3K6BFQX6ayl1swuIh26KB0Mu74sndRzG+8tMTffvjBoO9QPW/" + "OtdWyYmsLGT0Kcj9EaaIg1JmgNtRBwo9f89eYJSYwenQ+GddpqdnBIjk63Q+K3gCdlpTs++" + "tJCZ7DX9yndmYlx94HgUZFPcNimun6Q=="; + +constexpr absl::string_view kDerEcdsaBase64 = + "MIIChzCCAi6gAwIBAgIUcCTmJHYF8dZfG0d1UdT6/" + "LXtkeYwCgYIKoZIzj0EAwIwgYwxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBww" + "JU29tZXdoZXJlMScwJQYDVQQKDB5DMlBBIFRlc3QgSW50ZXJtZWRpYXRlIFJvb3QgQ0ExGTAXB" + "gNVBAsMEEZPUiBURVNUSU5HX09OTFkxGDAWBgNVBAMMD0ludGVybWVkaWF0ZSBDQTAeFw0yMjA" + "2MTAxODQ2NDBaFw0zMDA4MjYxODQ2NDBaMIGAMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExE" + "jAQBgNVBAcMCVNvbWV3aGVyZTEfMB0GA1UECgwWQzJQQSBUZXN0IFNpZ25pbmcgQ2VydDEZMBc" + "GA1UECwwQRk9SIFRFU1RJTkdfT05MWTEUMBIGA1UEAwwLQzJQQSBTaWduZXIwWTATBgcqhkjOP" + "QIBBggqhkjOPQMBBwNCAAQPaL6RkAkYkKU4+IryBSYxJM3h77sFiMrbvbI8fG7w2Bbl9otNG/" + "cch3DAw5rGAPV7NWkyl3QGuV/wt0MrAPDoo3gwdjAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/" + "wQMMAoGCCsGAQUFBwMEMA4GA1UdDwEB/" + "wQEAwIGwDAdBgNVHQ4EFgQUFznP0y83joiNOCedQkxTtAMyNcowHwYDVR0jBBgwFoAUDnyNcma" + "/osnlAJTvtW6A4rYOL2swCgYIKoZIzj0EAwIDRwAwRAIgOY/2szXjslg/" + "MyJFZ2y7OH8giPYTsvS7UPRP9GI9NgICIDQPMKrELQUJEtipZ0TqvI/" + "4mieoyRCeIiQtyuS0LACz"; + +// Base64 encoded string of DER bytes of an X.509 certificate signed with +// Ed25519 algorithm, which is generated by following commands: +// +// openssl genpkey -algorithm Ed25519 -outform PEM -out private.pem +// openssl pkey -in private.pem -pubout -outform PEM -out public.pem +// openssl req -x509 -key private.pem -out cert.pem -days 99999 +// openssl x509 -in cert.pem -outform DER -out cert.der +// cat cert.der | base64 +// +// This certificate is self-signed on 2024-09-18 and it will expire on +// 2298-07-03. So, if this code is still around in 2298 and you wonder why your +// test that relies on this certificate being valid fails, please update your +// test certificate. + +constexpr absl::string_view kDerEdBase64 = + "MIIBoTCCAVOgAwIBAgIUZnrWsn3k+" + "VyZMdOt2jHxA4Z6QNIwBQYDK2VwMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXR" + "lMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjQwOTE4MTkxMTMyWhgPM" + "jI5ODA3MDMxOTExMzJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwY" + "DVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwKjAFBgMrZXADIQAYkLGZHs4BuUq11RLkR" + "V3lX6U1CMxpkKMI3dFBkQjDFKNTMFEwHQYDVR0OBBYEFPl9A331I54qf4vhLTs5KZqsJznSMB8" + "GA1UdIwQYMBaAFPl9A331I54qf4vhLTs5KZqsJznSMA8GA1UdEwEB/" + "wQFMAMBAf8wBQYDK2VwA0EAxWKyUVcH4hoW/" + "mS66Ot4UGqaEKUANiyT6khvcUZLUpM2m7Cmh7ZwmjBz7b6X9nqHwd+8WnSMUZHKL5bLmZmsDw=" + "="; + +// Base64 encoded string of the signature obtained by running the following +// commands: +// +// openssl pkeyutl -sign -inkey private.pem -rawin -in data.txt -out sig.bin +// cat sig.bin | base64 +// +// where data.txt contains the text "test data" (without a new line). +constexpr absl::string_view kEdSignatureBase64 = + "wB5+rUKWp47TZ8VPpUlA32IO033ggYB63qXLPyb8bDEGJyD8TBM/" + "psRxl5Z8EsglhzdmvB29VL62xp4+3On/Bg=="; + +std::string Base64Unescape(absl::string_view base64) { + std::string bytes; + CHECK(absl::Base64Unescape(base64, &bytes)); + return bytes; +} + +TEST(X509CertificateTest, GetPemKeyParamsForRsaKey) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + std::vector<crypto::tink::PemKeyParams> params; + + auto params_ps256 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs256); + ASSERT_THAT(params_ps256.status(), IsOk()); + params = *params_ps256; + ASSERT_THAT(params, SizeIs(1)); + EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA); + EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS); + EXPECT_EQ(params[0].key_size_in_bits, 2048); + EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA256); + + auto params_ps384 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs384); + ASSERT_THAT(params_ps384.status(), IsOk()); + params = *params_ps384; + ASSERT_THAT(params, SizeIs(1)); + EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA); + EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS); + EXPECT_EQ(params[0].key_size_in_bits, 2048); + EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA384); + + auto params_ps512 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs512); + ASSERT_THAT(params_ps512.status(), IsOk()); + params = *params_ps512; + ASSERT_THAT(params, SizeIs(1)); + EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA); + EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS); + EXPECT_EQ(params[0].key_size_in_bits, 2048); + EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512); + + EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEs256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Wrong key type"))); + EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Wrong key type"))); +} + +TEST(X509CertificateTest, GetPemKeyParamsForEcdsaKey) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + std::vector<crypto::tink::PemKeyParams> params; + + auto params_es256 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs256); + ASSERT_THAT(params_es256.status(), IsOk()); + params = *params_es256; + ASSERT_THAT(params, SizeIs(2)); + EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); + EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE); + EXPECT_EQ(params[0].key_size_in_bits, 256); + EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA256); + EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC); + EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER); + EXPECT_EQ(params[1].key_size_in_bits, 256); + EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA256); + + auto params_es384 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs384); + ASSERT_THAT(params_es384.status(), IsOk()); + params = *params_es384; + ASSERT_THAT(params, SizeIs(2)); + EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); + EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE); + EXPECT_EQ(params[0].key_size_in_bits, 256); + EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA384); + EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC); + EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER); + EXPECT_EQ(params[1].key_size_in_bits, 256); + EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA384); + + auto params_es512 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs512); + ASSERT_THAT(params_es512.status(), IsOk()); + params = *params_es512; + ASSERT_THAT(params, SizeIs(2)); + EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); + EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE); + EXPECT_EQ(params[0].key_size_in_bits, 256); + EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512); + EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC); + EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER); + EXPECT_EQ(params[1].key_size_in_bits, 256); + EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA512); + + EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Wrong key type"))); + EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Wrong key type"))); +} + +TEST(X509CertificateTest, GetPemKeyParamsForEdKey) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64)); + ASSERT_THAT(x509.status(), IsOk()); + std::vector<crypto::tink::PemKeyParams> params; + + auto params_eddsa = (*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa); + ASSERT_THAT(params_eddsa.status(), IsOk()); + params = *params_eddsa; + ASSERT_THAT(params, SizeIs(1)); + EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC); + EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ED25519); + // BoringSSL will return 253 bits here instead of 256 bits because the lower + // three bits of the private key scalar are cleared. + EXPECT_EQ(params[0].key_size_in_bits, 253); + EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512); + + EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Wrong key type"))); + EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEs256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Wrong key type"))); +} +TEST(X509CertificateTest, VerifyEdSignedData) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64)); + ASSERT_THAT(x509.status(), IsOk()); + EXPECT_THAT( + (*x509)->VerifySignature(Base64Unescape(kEdSignatureBase64), + "this is a test", SigningAlgorithm::kEdDsa), + IsOk()); +} + +TEST(X509CertificateTest, VerifySignatureWithRsaKey) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + // The actual signature and data don't matter for covering the lines in + // MaybeConvertPssToRsa, as the key conversion happens before signature + // verification. We expect a failure because the signature is invalid. + EXPECT_THAT((*x509)->VerifySignature("fake_signature", "fake_data", + SigningAlgorithm::kPs256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Tink verification failed"))); +} + +TEST(X509CertificateTest, GetStartTime) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + auto start_time = (*x509)->StartTime(); + ASSERT_THAT(start_time.status(), IsOk()); + EXPECT_EQ(absl::ToUnixSeconds(*start_time), 1704931200); +} + +TEST(X509CertificateTest, GetEndTime) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + auto end_time = (*x509)->EndTime(); + ASSERT_THAT(end_time.status(), IsOk()); + EXPECT_EQ(absl::ToUnixSeconds(*end_time), 1736553599); +} + +TEST(X509CertificateTest, GetSubjectIssuerNullName) { + bssl::UniquePtr<X509> x509(X509_new()); + X509Certificate cert(std::move(x509)); + EXPECT_THAT(cert.GetSubject(), IsEmpty()); + EXPECT_THAT(cert.GetIssuer(), IsEmpty()); +} + +TEST(X509CertificateTest, GetSerialNumberHexRsa) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + auto serial_hex = (*x509)->GetSerialNumberHex(); + ASSERT_THAT(serial_hex.status(), IsOk()); + EXPECT_EQ(*serial_hex, "7ff2741fd1c6de43b5947a8e65709e9e"); +} + +TEST(X509CertificateTest, GetSerialNumberHexEcdsa) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + auto serial_hex = (*x509)->GetSerialNumberHex(); + ASSERT_THAT(serial_hex.status(), IsOk()); + EXPECT_EQ(*serial_hex, "7024e6247605f1d65f1b477551d4fafcb5ed91e6"); +} + +TEST(X509CertificateTest, GetSerialNumberHexEdDsa) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64)); + ASSERT_THAT(x509.status(), IsOk()); + auto serial_hex = (*x509)->GetSerialNumberHex(); + ASSERT_THAT(serial_hex.status(), IsOk()); + EXPECT_EQ(*serial_hex, "667ad6b27de4f95c9931d3adda31f103867a40d2"); +} + +TEST(X509CertificateTest, GetAssuranceLevelNotFound) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + EXPECT_THAT((*x509)->GetAssuranceLevel(), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(X509CertificateTest, GetAssuranceLevelCorrectlyDecodesExtension) { + constexpr absl::string_view kAssuranceLevelValueOid = + "1.3.6.1.4.1.62558.3.20"; + constexpr absl::string_view kAssuranceLevelExtensionOid = + "1.3.6.1.4.1.62558.3"; + bssl::UniquePtr<X509> x509(X509_new()); + ASSERT_NE(x509, nullptr); + + bssl::UniquePtr<ASN1_OBJECT> ext_oid( + OBJ_txt2obj(kAssuranceLevelExtensionOid.data(), /*dont_search_names=*/1)); + ASSERT_NE(ext_oid, nullptr); + + bssl::UniquePtr<ASN1_OBJECT> val_oid( + OBJ_txt2obj(kAssuranceLevelValueOid.data(), /*dont_search_names=*/1)); + ASSERT_NE(val_oid, nullptr); + + unsigned char* der_data = nullptr; + int der_len = i2d_ASN1_OBJECT(val_oid.get(), &der_data); + ASSERT_GT(der_len, 0); + + bssl::UniquePtr<ASN1_OCTET_STRING> octet_str(ASN1_OCTET_STRING_new()); + ASSERT_TRUE(ASN1_OCTET_STRING_set(octet_str.get(), der_data, der_len)); + OPENSSL_free(der_data); + + bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_create_by_OBJ( + nullptr, ext_oid.get(), /*crit=*/0, octet_str.get())); + ASSERT_NE(ext, nullptr); + ASSERT_TRUE(X509_add_ext(x509.get(), ext.get(), -1)); + + X509Certificate cert(std::move(x509)); + EXPECT_THAT(cert.GetAssuranceLevel(), IsOkAndHolds(kAssuranceLevelValueOid)); +} + +TEST(X509CertificateTest, GetConformingProductIdNotFound) { + auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64)); + ASSERT_THAT(x509.status(), IsOk()); + EXPECT_THAT((*x509)->GetConformingProductId(), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(X509CertificateTest, GetConformingProductIdCorrectlyDecodesExtension) { + // Create a cert with the conforming product id extension. + bssl::UniquePtr<X509> x509(X509_new()); + + bssl::UniquePtr<ASN1_OBJECT> ext_oid( + OBJ_txt2obj("1.3.6.1.4.1.62558.4", /*dont_search_names=*/1)); + ASSERT_THAT(ext_oid, NotNull()); + + bssl::UniquePtr<ASN1_UTF8STRING> val_str(ASN1_UTF8STRING_new()); + constexpr absl::string_view kUuid = "c25c0e15-84dc-4cd7-95de-1d9ea4c7f0db"; + ASSERT_TRUE(ASN1_STRING_set(val_str.get(), kUuid.data(), kUuid.size())); + + unsigned char* der_data = nullptr; + int der_len = i2d_ASN1_UTF8STRING(val_str.get(), &der_data); + ASSERT_GT(der_len, 0); + + bssl::UniquePtr<ASN1_OCTET_STRING> octet_str(ASN1_OCTET_STRING_new()); + ASSERT_TRUE(ASN1_OCTET_STRING_set(octet_str.get(), der_data, der_len)); + OPENSSL_free(der_data); + + bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_create_by_OBJ( + nullptr, ext_oid.get(), /*crit=*/0, octet_str.get())); + ASSERT_THAT(ext, NotNull()); + ASSERT_TRUE(X509_add_ext(x509.get(), ext.get(), -1)); + + // Parse cert and extract the conforming product id. + X509Certificate cert(std::move(x509)); + EXPECT_THAT(cert.GetConformingProductId(), IsOkAndHolds(kUuid)); +} + +TEST(X509CertificateTest, CertificateWithoutValidPublicKey) { + bssl::UniquePtr<X509> x509(X509_new()); + ASSERT_NE(x509, nullptr); + X509Certificate cert(std::move(x509)); + EXPECT_THAT( + cert.VerifySignature("sig", "data", SigningAlgorithm::kPs256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate does not have a valid public key"))); + EXPECT_THAT( + cert.GetPemKeyParams(SigningAlgorithm::kPs256), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Certificate does not have a valid public key"))); +} + +} // namespace +} // namespace credentio
diff --git a/crypto/hash.h b/crypto/hash.h new file mode 100644 index 0000000..eedf3ba --- /dev/null +++ b/crypto/hash.h
@@ -0,0 +1,103 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_HASH_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_HASH_H_ + +#include <memory> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" + +namespace credentio { + +// A single-use object for computing a hash of some content. +// For hash checking, use `HashChecker` instead. +class Hasher { + public: + virtual ~Hasher() = default; + + // Adds `content` to the data to be hashed. Can be called repeatedly. + virtual void Update(absl::string_view content) = 0; + + // Returns digest of the previously provided content. + virtual std::string Digest() = 0; +}; + +// A hasher for size estimation. Returns all-zero hashes. +class TemplatedHasher : public Hasher { + public: + explicit TemplatedHasher(HashAlgorithm algorithm) : algorithm_(algorithm) {} + void Update(absl::string_view content) override {} + std::string Digest() override { + return std::string(HashSizeBytes(algorithm_), '\0'); + } + + private: + HashAlgorithm algorithm_; +}; + +// A factory for creating Hashers. +class HasherFactory { + public: + virtual ~HasherFactory() = default; + virtual absl::StatusOr<std::unique_ptr<Hasher>> Create() const = 0; + virtual HashAlgorithm algorithm() const = 0; +}; + +// A factory for creating Hashers for size estimation, which return all-zero +// hashes. +class TemplatedHasherFactory : public HasherFactory { + public: + explicit TemplatedHasherFactory(HashAlgorithm algorithm) + : algorithm_(algorithm) {} + absl::StatusOr<std::unique_ptr<Hasher>> Create() const override { + return std::make_unique<TemplatedHasher>(algorithm_); + } + HashAlgorithm algorithm() const override { return algorithm_; } + + private: + HashAlgorithm algorithm_; +}; + +// A single-use checker for checking content against an expected hash. +class HashChecker { + public: + virtual ~HashChecker() = default; + + // Adds `content` to the data to be hashed. Can be called repeatedly. + virtual void Update(absl::string_view content) = 0; + + // Returns true if the hash of the previously provided content matches + // `expected_hash`. + virtual bool Check(absl::string_view expected_hash) = 0; +}; + +// A factory for creating `HashChecker`s. +class HashCheckerFactory { + public: + virtual ~HashCheckerFactory() = default; + + // Returns a `HashChecker` for `algorithm` or an error if the algorithm is not + // supported. + virtual absl::StatusOr<std::unique_ptr<HashChecker>> Create( + HashAlgorithm algorithm) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_HASH_H_
diff --git a/crypto/mock_hash.h b/crypto/mock_hash.h new file mode 100644 index 0000000..37ad463 --- /dev/null +++ b/crypto/mock_hash.h
@@ -0,0 +1,57 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_MOCK_HASH_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_MOCK_HASH_H_ + +#include <memory> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" + +namespace credentio { + +class MockHasher : public Hasher { + public: + MOCK_METHOD(void, Update, (absl::string_view content), (override)); + MOCK_METHOD(std::string, Digest, (), (override)); +}; + +class MockHasherFactory : public HasherFactory { + public: + MOCK_METHOD(absl::StatusOr<std::unique_ptr<Hasher>>, Create, (), + (const, override)); + MOCK_METHOD(HashAlgorithm, algorithm, (), (const, override)); +}; + +class MockHashChecker : public HashChecker { + public: + MOCK_METHOD(void, Update, (absl::string_view content), (override)); + MOCK_METHOD(bool, Check, (absl::string_view expected_hash), (override)); +}; + +class MockHashCheckerFactory : public HashCheckerFactory { + public: + MOCK_METHOD(absl::StatusOr<std::unique_ptr<HashChecker>>, Create, + (HashAlgorithm algorithm), (const, override)); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_MOCK_HASH_H_
diff --git a/crypto/timestamp_read_handler.h b/crypto/timestamp_read_handler.h new file mode 100644 index 0000000..b25a2e9 --- /dev/null +++ b/crypto/timestamp_read_handler.h
@@ -0,0 +1,48 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_READ_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_READ_HANDLER_H_ + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { + +// A consumer of RFC3161 TimeStampTokens. +class TimestampReadHandler { + public: + // Validates an RFC 3161 TimeStampToken. Checks CMS signature and certificate + // trustworthiness. Caller must verify that the message imprint of the + // returned `VerifiedTimestamp` matches the signature to which the timestamp + // applies. + // + // Returns: + // - UNAUTHENTICATED if the TSA's certificate cannot be trusted or if the + // timestamp's signature is invalid. + // - INVALID_ARGUMENT if `cms` cannot be parsed. + // - OUT_OF_RANGE if the TSA's certificate was not within its validity + // period at the asserted time. + // - UNAVAILABLE if the root trust list cannot be accessed (only possible on + // platforms where it is not stored in-process.) + virtual absl::StatusOr<VerifiedTimestamp> VerifyTimestamp( + absl::string_view cms) const = 0; + virtual ~TimestampReadHandler() = default; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_READ_HANDLER_H_
diff --git a/external/boringssl_pki_visibility.patch b/external/boringssl_pki_visibility.patch new file mode 100644 index 0000000..50d3acb --- /dev/null +++ b/external/boringssl_pki_visibility.patch
@@ -0,0 +1,9 @@ +--- a/BUILD.bazel ++++ b/BUILD.bazel +@@ -199,5 +199,6 @@ + hdrs = pki_headers, + copts = ["-DBORINGSSL_IMPLEMENTATION"], + internal_hdrs = pki_internal_headers, + deps = [":crypto"], ++ visibility = ["//visibility:public"], + )
diff --git a/external/libcppbor.BUILD b/external/libcppbor.BUILD new file mode 100644 index 0000000..58d1e77 --- /dev/null +++ b/external/libcppbor.BUILD
@@ -0,0 +1,18 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +cc_library( + name = "libcppbor", + srcs = ["src/cppbor.cpp", "src/cppbor_parse.cpp"], + deps = ["@boringssl//:crypto"], + hdrs = [ + "include/cppbor/cppbor.h", + "include/cppbor/cppbor_parse.h", + "include/cppbor/span.h", + ], + includes = ["include", "include/cppbor"], + copts = [ + "-D__TRUSTY__", + "-include limits", + ], + visibility = ["//visibility:public"], +)
diff --git a/formats/BUILD b/formats/BUILD new file mode 100644 index 0000000..20f7bd9 --- /dev/null +++ b/formats/BUILD
@@ -0,0 +1,158 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "byte_range", + hdrs = ["byte_range.h"], +) + +cc_library( + name = "asset_box", + hdrs = ["asset_box.h"], + deps = [ + ":byte_range", + ], +) + +cc_library( + name = "asset_byte_info", + hdrs = ["asset_byte_info.h"], + deps = [ + ":asset_box", + ":byte_range", + ], +) + +cc_library( + name = "extractor_result", + testonly = 1, + hdrs = ["extractor_result.h"], + deps = [ + ":asset_byte_info", + ], +) + +cc_library( + name = "extractor", + hdrs = ["extractor.h"], + deps = [ + ":asset_box", + ":byte_range", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "assessor", + hdrs = ["assessor.h"], + deps = [ + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "format", + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + ], +) + +cc_test( + name = "format_test", + srcs = ["format_test.cc"], + deps = [ + ":assessor", + ":asset_box", + ":byte_range", + ":extractor", + ":format", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "registry", + srcs = ["registry.cc"], + hdrs = ["registry.h"], + deps = [ + ":format", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "registry_test", + srcs = ["registry_test.cc"], + deps = [ + ":assessor", + ":asset_box", + ":byte_range", + ":extractor", + ":format", + ":registry", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "core_registry", + srcs = ["core_registry.cc"], + hdrs = ["core_registry.h"], + deps = [ + ":format", + ":registry", + "//formats/bmff:format", + "//formats/gif:format", + "//formats/id3:format", + "//formats/jpeg:format", + "//formats/pdf:format", + "//formats/png:format", + "//formats/riff:format", + "//formats/tiff:format", + "//formats/zip:format", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +)
diff --git a/formats/assessor.h b/formats/assessor.h new file mode 100644 index 0000000..4378b4e --- /dev/null +++ b/formats/assessor.h
@@ -0,0 +1,38 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// FormatAssessors determine if a given asset is applicable. +class FormatAssessor { + public: + virtual ~FormatAssessor() = default; + + // Returns true if the supplied asset is supported. Returns an error if the + // asset cannot be assessed. Will assess the input at the current position, + // and will return the position to the start of the input unless a failure + // occurs. + virtual absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ASSESSOR_H_
diff --git a/formats/asset_box.h b/formats/asset_box.h new file mode 100644 index 0000000..1dc4e4a --- /dev/null +++ b/formats/asset_box.h
@@ -0,0 +1,44 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BOX_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BOX_H_ + +#include <ostream> +#include <string> + +#include "formats/byte_range.h" + +namespace credentio { + +// The box and its location in the asset. +struct AssetBox { + std::string identifier; + ByteRange byte_range; + + bool operator==(const AssetBox& other) const { + return identifier == other.identifier && byte_range == other.byte_range; + } + bool operator!=(const AssetBox& other) const { return !(*this == other); } +}; + +inline std::ostream& operator<<(std::ostream& os, const AssetBox& box) { + return os << "AssetBox{identifier: " << box.identifier << ", byte_range: {" + << box.byte_range.offset << ", " << box.byte_range.length << "}}"; +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BOX_H_
diff --git a/formats/asset_byte_info.h b/formats/asset_byte_info.h new file mode 100644 index 0000000..08d0f72 --- /dev/null +++ b/formats/asset_byte_info.h
@@ -0,0 +1,59 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BYTE_INFO_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BYTE_INFO_H_ + +#include <cstddef> +#include <ostream> +#include <vector> + +#include "formats/asset_box.h" +#include "formats/byte_range.h" + +namespace credentio { + +// Metadata about the bytes in the asset. +struct AssetByteInfo { + // The location of the C2PA Manifest Store in the asset. + ByteRange manifest_store_location; + // A list of the file format's native boxes/segments within the asset. This + // vector will be populated for file formats that are based upon box + // structures. Eg: JPEG, PNG + std::vector<AssetBox> boxes; + + bool operator==(const AssetByteInfo& other) const { + return manifest_store_location == other.manifest_store_location && + boxes == other.boxes; + } + bool operator!=(const AssetByteInfo& other) const { + return !(*this == other); + } +}; + +inline std::ostream& operator<<(std::ostream& os, const AssetByteInfo& info) { + os << "AssetByteInfo{manifest_store_location: {" + << info.manifest_store_location.offset << ", " + << info.manifest_store_location.length << "}, boxes: ["; + for (size_t i = 0; i < info.boxes.size(); ++i) { + if (i > 0) os << ", "; + os << info.boxes[i]; + } + return os << "]}"; +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BYTE_INFO_H_
diff --git a/formats/bmff/BUILD b/formats/bmff/BUILD new file mode 100644 index 0000000..584fc68 --- /dev/null +++ b/formats/bmff/BUILD
@@ -0,0 +1,194 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "box_header", + srcs = ["box_header.cc"], + hdrs = ["box_header.h"], + deps = [ + ":constants", + ":xpath", + "//constants:labels", + "//formats:byte_range", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = ["@abseil-cpp//absl/strings:string_view"], +) + +cc_library( + name = "xpath", + srcs = ["xpath.cc"], + hdrs = ["xpath.h"], + deps = ["@abseil-cpp//absl/strings"], +) + +cc_library( + name = "test_utils", + testonly = 1, + hdrs = ["test_utils.h"], + deps = [ + "//constants:labels", + "//testing:test_string_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "xpath_test", + srcs = ["xpath_test.cc"], + deps = [ + ":xpath", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "box_matcher", + srcs = ["box_matcher.cc"], + hdrs = ["box_matcher.h"], + deps = [ + ":box_header", + ":xpath", + "//proto:bmff_based_hash_assertion_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "box_matcher_test", + srcs = ["box_matcher_test.cc"], + deps = [ + ":box_header", + ":box_matcher", + ":test_utils", + "//constants:labels", + "//proto:bmff_based_hash_assertion_cc_proto", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":box_header", + "//constants:labels", + "//formats:asset_byte_info", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "//utils:riegeli", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":extractor", + ":test_utils", + "//constants:labels", + "//formats:asset_box", + "//formats:asset_byte_info", + "//formats:byte_range", + "//formats:extractor_result", + "//testing:jumbf_utils", + "//testing:test_string_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":box_header", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + ":test_utils", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/bmff/assessor.cc b/formats/bmff/assessor.cc new file mode 100644 index 0000000..e17f960 --- /dev/null +++ b/formats/bmff/assessor.cc
@@ -0,0 +1,110 @@ +// 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 "formats/bmff/assessor.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/string_view.h" +#include "formats/bmff/box_header.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +bool IsSupportedBrand(absl::string_view brand) { + return brand == "qt " || absl::StartsWith(brand, "mp4") || brand == "heic" || + brand == "avif" || brand == "M4A " || absl::StartsWith(brand, "iso"); +} + +absl::StatusOr<std::string> ExtractBrand(riegeli::Reader& input) { + std::string brand; + if (!input.Read(4, brand)) { + return input.StatusOrAnnotate(absl::InternalError("failed to read brand")); + } + return brand; +} + +} // namespace + +absl::StatusOr<bool> BmffAssessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + bool is_supported = false; + absl::Status iteration_status = IterateOverBmffBoxes( + input, + [&input, + &is_supported](const BmffBoxHeader& box_header) -> absl::StatusOr<bool> { + if (box_header.type == "ftyp") { + uint64_t box_end = box_header.start + box_header.box_size; + if (box_header.box_size < box_header.header_size + 8) { + return false; // Terminate loop + } + + ABSL_ASSIGN_OR_RETURN(auto major_brand, ExtractBrand(input)); + if (IsSupportedBrand(major_brand)) { + is_supported = true; + return false; // Terminate loop + } + if (!input.Skip(4)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to skip minor brand")); + } + + std::string compatible_brand; + while (input.pos() <= box_end && box_end - input.pos() >= 4) { + ABSL_ASSIGN_OR_RETURN(compatible_brand, ExtractBrand(input)); + if (IsSupportedBrand(compatible_brand)) { + is_supported = true; + return false; // Terminate loop + } + } + + // We didn't find a supported brand. + return false; // Terminate loop + } else if (box_header.type == "moov") { + // We found a moov box, confident that this file is a QuickTime Movie. + is_supported = true; + return false; // Terminate loop + } + + // Only process top-level atoms. + if (!input.Seek(box_header.start + box_header.box_size)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to seek to end of box")); + } + return true; // Continue + }); + + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate( + absl::InternalError("failed to seek to start of asset")); + } + if (!iteration_status.ok() && !absl::IsDataLoss(iteration_status)) { + return iteration_status; + } + if (absl::IsDataLoss(iteration_status)) { + return false; + } + return is_supported; +} + +} // namespace credentio
diff --git a/formats/bmff/assessor.h b/formats/bmff/assessor.h new file mode 100644 index 0000000..29d6bef --- /dev/null +++ b/formats/bmff/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the BMFF image format. +class BmffAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_ASSESSOR_H_
diff --git a/formats/bmff/assessor_test.cc b/formats/bmff/assessor_test.cc new file mode 100644 index 0000000..96e97fa --- /dev/null +++ b/formats/bmff/assessor_test.cc
@@ -0,0 +1,132 @@ +// 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 "formats/bmff/assessor.h" + +#include <string> + +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "formats/bmff/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; + +TEST(IsSupportedTest, FailsForTooFewBytes) { + std::string image = "a"; + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, FailsForNotStartingWithFtyp) { + std::string image = "this_is_not_a_bmff"; + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, ValidStartingFtype) { + std::string image = credentio_testing::Box("ftyp", "heictest"); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidStartingMoov) { + std::string image = credentio_testing::Box("moov", "data"); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, UnsupportedFileType) { + std::string image = credentio_testing::Box("ftyp", "testtest"); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, SupportedFileTypeWithCompatibleBrand) { + std::string image = credentio_testing::Box("ftyp", "testtestmp42"); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidStartingBytesAtOffset2) { + std::string image = + absl::StrCat("ab", credentio_testing::Box("ftyp", "heictest")); + riegeli::StringReader<> input(image); + + // Invalid at 0 + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +TEST(IsSupportedTest, StopsEarlyAfterFtyp) { + // Supported major brand followed by invalid truncated box data. + // Assessor should stop immediately and declare it supported, ignoring later + // invalid data. + std::string image = absl::StrCat(credentio_testing::Box("ftyp", "heictest"), + "invalid_truncated_box_data"); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, StopsEarlyAfterFtypCompatibleBrand) { + // Supported compatible brand in ftyp followed by truncated box data should be + // supported. + std::string image = + absl::StrCat(credentio_testing::Box("ftyp", "testtestmp42"), + "invalid_truncated_box_data"); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, StopsEarlyOnUnsupportedFtypAndIgnoresMoov) { + // Unsupported ftyp followed by a moov box. + // Assessor should terminate early and return unsupported (false) since it + // shouldn't inspect moov. + std::string image = absl::StrCat(credentio_testing::Box("ftyp", "testtest"), + credentio_testing::Box("moov", "data")); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, StopsEarlyAfterMoov) { + // Supported moov box followed by invalid truncated box data. + // Assessor should terminate early indicating supported (true). + std::string image = absl::StrCat(credentio_testing::Box("moov", "data"), + "invalid_truncated_box_data"); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, FtypBoxTooSmallForBrandsDoesNotUnderflow) { + // ftyp box size of 8 bytes followed by data containing a supported brand "qt + // " + std::string image = + absl::StrCat(credentio_testing::BadBox("ftyp", "", 8), "unkn0000qt "); + riegeli::StringReader<> input(image); + EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/bmff/box_header.cc b/formats/bmff/box_header.cc new file mode 100644 index 0000000..8f7f7d7 --- /dev/null +++ b/formats/bmff/box_header.cc
@@ -0,0 +1,403 @@ +// 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 "formats/bmff/box_header.h" + +#include <cstdint> +#include <limits> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/bmff/constants.h" +#include "formats/bmff/xpath.h" +#include "formats/byte_range.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_reading.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +namespace { + +// Returns true if the given type is "uuid". +inline bool IsUuid(absl::string_view type) { return type == "uuid"; } + +// ISOBMFF and QTFF define "meta" boxes differently. ISOBMFF defines it +// as a full box with flags and version (see 4.2 'Object Structure' in ISO/IEC +// 14496-12:2005), while QuickTime does not have such a full box structure. +// Here, we check the 8 bytes and see if it is the "hdlr" box, as defined by +// https://developer.apple.com/documentation/quicktime-file-format/metadata_atom. +bool IsQuickTimeMetaBox(const BmffBoxHeader& header, riegeli::Reader& input) { + if (header.type != "meta") { + return false; + } + // The next 8 bytes can be either: + // (iso) [1 byte version + 3 bytes flags][4 byte size of next atom] + // (qt) [4 byte size of next atom ][4 byte hdlr atom type ] + auto pin = input.pos(); + bool success = input.Seek(pin + 4); + std::string type; + success &= input.Read(4, type); + success &= input.Seek(pin); + return success && type == "hdlr"; +}; + +constexpr int kAtomTypeSize = 4; +constexpr int kVersionFlagsSize = 4; +constexpr int kSizeOf32BitSize = 4; +constexpr int kSizeOf64BitSize = 8; + +bool ShouldWriteSize64Bits(uint64_t box_size) { + return box_size > std::numeric_limits<uint32_t>::max(); +} + +bool ShouldWriteUserType(const BmffBox& box) { return IsUuid(box.type); } + +bool ShouldWriteVersionFlags(const BmffBox& box) { + return IsBmffFullBox(box.type) || IsUuid(box.type); +} + +constexpr int kMaxBmffDepth = 64; + +void SetInsertionPoint(std::optional<ByteRange> candidate, int64_t c2pa_offset, + std::optional<ByteRange>& result) { + if (!candidate.has_value()) { + // No candidate + return; + } + + if (!result.has_value()) { + // No existing + result = candidate; + return; + } + + if (c2pa_offset == -1) { + // No C2PA range found yet, use the larger range + if (result->length < candidate->length) { + // Existing is smaller and no C2PA range to account for + result = candidate; + } + return; + } + + if (c2pa_offset >= candidate->offset && + c2pa_offset <= candidate->offset + candidate->length) { + // C2PA is contained within the candidate, use it. + result = candidate; + } +} + +absl::StatusOr<bool> CanOverwrite(const BmffBoxHeader& box, + riegeli::Reader& reader) { + if (box.type == "free") { + // Free boxes are always overwritable. + return true; + } + if (box.type != "uuid" || box.user_type != kC2paBmffBoxUuid) { + // The only other overwritable box is a C2PA uuid box. + return false; + } + + std::string purpose; + if (!reader.Read(8, purpose)) { + return reader.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; purpose")); + } + + if (absl::StartsWith(purpose, kBmffC2paBoxPurposeMerkle)) { + // Merkle boxes are not overwritable, we replace them with a free box. + return false; + } + if (absl::StartsWith(purpose, kBmffC2paBoxPurposeUpdate)) { + // Update boxes are not overwritable, we remove them. + return false; + } + + // Original and Manifest boxes are overwritable. + return true; +} + +absl::StatusOr<bool> IterateOverBmffBoxesInternal(XPath* parent, + riegeli::Reader& reader, + uint64_t end_offset, + BmffBoxProcessor processor, + int depth = 0) { + if (depth > kMaxBmffDepth) { + return absl::InvalidArgumentError("Too many nested BMFF boxes"); + } + absl::flat_hash_map<std::string, int> count_by_type; + while (reader.pos() < (end_offset - kBmffMinSizeofBoxHeader)) { + BmffBoxHeader box; + if (auto box_or = ReadBmffBoxHeader(reader); box_or.ok()) { + box = std::move(box_or.value()); + } else { + return box_or.status(); + } + + if (box.start > (std::numeric_limits<uint64_t>::max() - box.box_size)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Final box offset exceeds uint64_t::max")); + } + uint64_t box_end_offset = box.start + box.box_size; + + // If the box extends beyond the end of the input, it's invalid. + if (box_end_offset > end_offset) { + if (parent == nullptr) { + return reader.StatusOrAnnotate( + absl::DataLossError("truncated BMFF box")); + } + return reader.StatusOrAnnotate( + absl::DataLossError("sub-box extends beyond the parent")); + } + + XPath xpath(parent, box.type); + xpath.SetPosition(++count_by_type[box.type]); + box.xpath = xpath.ToString(); + + // If it fails during processing, we can't continue. + bool continue_processing; + if (auto result = processor(box); result.ok()) { + continue_processing = *result; + } else { + return result.status(); + } + if (!continue_processing) { + return false; + } + + if (reader.pos() < box_end_offset && box.IsContainerBox()) { + if (auto sub_continue_or = IterateOverBmffBoxesInternal( + &xpath, reader, box_end_offset, processor, depth + 1); + sub_continue_or.ok()) { + continue_processing = *sub_continue_or; + } else { + return sub_continue_or.status(); + } + if (!continue_processing) { + return false; + } + } + + reader.Seek(box_end_offset); + } + return true; +} + +} // namespace + +bool BmffBoxHeader::IsContainerBox() const { + // This list mirrors the C2PA SDK's implementation: + // https://github.com/contentauth/c2pa-rs/blob/main/sdk/src/asset_handlers/bmff_io.rs#L982. + return type == "moov" || type == "trak" || type == "mdia" || type == "minf" || + type == "stbl" || type == "moof" || type == "traf" || type == "edts" || + type == "udta" || type == "dinf" || type == "tref" || type == "treg" || + type == "mvex" || type == "mfra" || type == "meta" || type == "schi"; +} + +absl::StatusOr<BmffBoxHeader> ReadBmffBoxHeader(riegeli::Reader& input) { + BmffBoxHeader header; + + header.start = input.pos(); + + uint32_t box_size32; + if (!riegeli::ReadBigEndian<uint32_t>(input, box_size32)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; box_size32")); + } + if (!input.Read(4, header.type)) { + return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; type")); + } + header.header_size = 8; + + if (box_size32 == 1) { + if (!riegeli::ReadBigEndian<uint64_t>(input, header.box_size)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; box_size64")); + } + header.header_size = 16; + } else { + header.box_size = box_size32; + } + + if (header.box_size < kBmffMinSizeofBoxHeader) { + // Unsupported box size. + return input.StatusOrAnnotate( + absl::DataLossError("kInvalidData; unsupported box size")); + } + + if (IsUuid(header.type)) { + if (!input.Read(kBmffUserTypeSize, header.user_type)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; user_type")); + } + header.header_size += kBmffUserTypeSize; + } + if ((IsBmffFullBox(header.type) || + (IsUuid(header.type) && header.user_type == kC2paBmffBoxUuid)) && + !IsQuickTimeMetaBox(header, input)) { + header.version_and_flags_size = 4; + if (!input.ReadByte(header.version)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; version")); + } + if (!input.Read(3, header.flags)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; flags")); + } + } + if (header.box_size < header.header_size + header.version_and_flags_size) { + return input.StatusOrAnnotate( + absl::DataLossError("kInvalidData; box size too small for header")); + } + return header; +} + +int64_t BmffBoxHeaderSize(const BmffBox& box) { + uint64_t header_size = kSizeOf32BitSize + kAtomTypeSize; + if (ShouldWriteUserType(box)) { + header_size += kBmffUserTypeSize; + } + if (ShouldWriteVersionFlags(box)) { + header_size += kVersionFlagsSize; + } + if (ShouldWriteSize64Bits(header_size + box.data_size)) { + header_size += kSizeOf64BitSize; + } + return header_size; +} + +absl::StatusOr<int64_t> WriteBmffBoxHeader(const BmffBox& box, + riegeli::Writer& destination) { + bool success = true; + + uint64_t header_size = BmffBoxHeaderSize(box); + uint64_t box_size = header_size + box.data_size; + bool should_write_size_64bits = ShouldWriteSize64Bits(box_size); + uint32_t box_size32 = should_write_size_64bits ? 1 : box_size; + + success &= riegeli::WriteBigEndian<uint32_t>(box_size32, destination); + success &= box.type.size() == kAtomTypeSize && destination.Write(box.type); + if (should_write_size_64bits) { + success &= riegeli::WriteBigEndian<uint64_t>(box_size, destination); + } + if (ShouldWriteUserType(box)) { + success &= box.user_type.size() == kBmffUserTypeSize && + destination.Write(box.user_type); + } + if (ShouldWriteVersionFlags(box)) { + success &= destination.WriteByte(box.version); + success &= box.flags.size() == (kVersionFlagsSize - 1) && + destination.Write(box.flags); + } + if (!success) { + return absl::InternalError("failed to write box header"); + } + return header_size; +} + +absl::StatusOr<std::vector<BmffBoxHeader>> ReadBmffBoxHeaders( + riegeli::Reader& input) { + std::vector<BmffBoxHeader> result; + if (!input.Seek(0) || input.pos() != 0) { + return input.StatusOrAnnotate( + absl::DataLossError("failed to seek to start of input")); + } + auto result_status = + IterateOverBmffBoxes(input, [&result](const BmffBoxHeader& box) { + result.push_back(std::move(box)); + return true; + }); + if (result_status.ok()) { + return std::move(result); + } + return result_status; +} + +absl::Status IterateOverBmffBoxes(riegeli::Reader& reader, + BmffBoxProcessor processor) { + if (!reader.SupportsSize() || !reader.Size().has_value()) { + return absl::InvalidArgumentError( + "manifest store not embedded: reader size cannot be determined"); + } + if ((*reader.Size() - reader.pos()) == 0) { + return absl::OkStatus(); + } + + return IterateOverBmffBoxesInternal(nullptr, reader, *reader.Size(), + processor) + .status(); +} + +absl::StatusOr<ByteRange> LocateManifestInsertionPoint( + riegeli::Reader& reader) { + std::optional<ByteRange> result = std::nullopt; + std::optional<ByteRange> range = std::nullopt; + + int64_t c2pa_offset = -1; + std::string last_atom_type_processed = ""; + auto iterate_status = IterateOverBmffBoxes( + reader, + [&reader, &range, &c2pa_offset, &last_atom_type_processed, + &result](const BmffBoxHeader& box) -> absl::StatusOr<bool> { + ABSL_ASSIGN_OR_RETURN(bool overwritable, CanOverwrite(box, reader)); + + // We only care about the top level boxes, so skip all sub-boxes. + reader.Seek(box.start + box.box_size); + last_atom_type_processed = box.type; + + if (!overwritable) { + SetInsertionPoint(range, c2pa_offset, result); + range = std::nullopt; + + if (box.type == "mdat" || box.type == "moov") { + SetInsertionPoint(ByteRange{.offset = box.start, .length = 0}, + c2pa_offset, result); + return false; // Terminate loop + } + return true; // Continue + } + + if (box.type == "uuid") { + c2pa_offset = box.start; + } + + if (!range.has_value()) { + range = ByteRange{.offset = box.start, .length = 0}; + } + range->length += box.box_size; + + return true; // Continue + }); + ABSL_RETURN_IF_ERROR(iterate_status); + if (last_atom_type_processed != "mdat" && + last_atom_type_processed != "moov") { + return absl::InvalidArgumentError("no mdat/moov box found"); + } + SetInsertionPoint(range, c2pa_offset, result); + return result.value_or(ByteRange{.offset = 0, .length = 0}); +} + +} // namespace credentio
diff --git a/formats/bmff/box_header.h b/formats/bmff/box_header.h new file mode 100644 index 0000000..8b77193 --- /dev/null +++ b/formats/bmff/box_header.h
@@ -0,0 +1,181 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_HEADER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_HEADER_H_ + +#include <array> +#include <cstdint> +#include <cstring> +#include <string> + +#include "absl/base/no_destructor.h" +#include "absl/container/flat_hash_set.h" +#include "absl/functional/function_ref.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/string_view.h" +#include "formats/byte_range.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// A BMFF box header for reading. +struct BmffBoxHeader { + uint64_t start; // Absolute offset of the box header within input. + uint64_t box_size; // Includes header and version/flags when present. + uint64_t header_size; // Size of the header. + // Size of version and flags, if it is a full box (atom). + uint64_t version_and_flags_size = 0; + std::string type; // The type of the box. + std::string xpath; // The XPath of the box, like "/moov[1]/pssh". + std::string user_type = ""; // Only present in UUID. + // Version and flags only present in a full box (atom) or UUID. + // Check `version_and_flags_size > 0` to determine if these are present. + uint8_t version = 0; + std::string flags = ""; + + // Returns true if the box is at the root level (has no parent). + bool IsRootBox() const { + return xpath.empty() || !absl::StrContains(xpath.substr(1), "/"); + } + + // Returns true if the box is a Full Box (contains version and flags). + bool IsFullBox() const { return version_and_flags_size > 0; } + + // Returns true if the box is a container box that can have sub-boxes. + bool IsContainerBox() const; +}; + +inline bool operator==(const BmffBoxHeader& a, const BmffBoxHeader& b) { + return a.start == b.start && a.box_size == b.box_size && + a.header_size == b.header_size && + a.version_and_flags_size == b.version_and_flags_size && + a.type == b.type && a.xpath == b.xpath && a.user_type == b.user_type && + a.version == b.version && a.flags == b.flags; +} + +// A BMFF box header for writing. +struct BmffBox { + std::string type; + std::string user_type; + uint8_t version = 0; + std::string flags; + uint64_t data_size = 0; +}; + +// Reads a BMFF box header from the input reader. +// Returns the parsed header or an error status. +absl::StatusOr<BmffBoxHeader> ReadBmffBoxHeader(riegeli::Reader& input); + +// Writes the header of the given BMFF box and returns the number of written +// bytes. +absl::StatusOr<int64_t> WriteBmffBoxHeader(const BmffBox& box, + riegeli::Writer& destination); + +// Returns the size of the encoded BMFF box header. +int64_t BmffBoxHeaderSize(const BmffBox& box); + +// Returns true if the given box type is a Full Box. +inline bool IsBmffFullBox(absl::string_view type); + +// The function signature for processing a BMFF box. The function is called for +// each box in a BMFF file. +using BmffBoxProcessor = + absl::FunctionRef<absl::StatusOr<bool>(const BmffBoxHeader&)>; + +// Iterates over all BMFF boxes in the given reader, including sub-boxes in a +// depth-first traversal, calling the given `processor` function for each box. +// The method will seek to position 0 in the reader before iterating. +// +// The `processor` function controls the iteration by returning an +// `absl::StatusOr<bool>`: +// - Return `true` to continue iterating over subsequent boxes. +// - Return `false` to stop iterating early. `IterateOverBmffBoxes` will stop +// and return `absl::OkStatus()`. +// - Return an error status to abort iteration immediately and propagate the +// error back to the caller. +// +// The processor can also affect iteration by seeking the reader: +// - For container boxes, sub-box iteration will start reading from wherever +// the processor leaves the reader. +// - To skip processing sub-boxes of a container box, the processor can seek +// the reader to the end of the current box (`box.start + box.box_size`). +// - The iterator will always reset the reader position to the end of the +// current box before proceeding to the next sibling box. +absl::Status IterateOverBmffBoxes(riegeli::Reader& reader, + BmffBoxProcessor processor); + +// Reads all BMFF box headers from the given input, including sub-boxes. +absl::StatusOr<std::vector<BmffBoxHeader>> ReadBmffBoxHeaders( + riegeli::Reader& input); + +// Locates the insertion point for the C2PA manifest in the given reader. +absl::StatusOr<ByteRange> LocateManifestInsertionPoint(riegeli::Reader& reader); + +namespace credentio_internal { + +// This list mirrors the C2PA SDK's implementation: +// https://github.com/contentauth/c2pa-rs/blob/main/sdk/src/asset_handlers/bmff_io.rs#L58. +constexpr std::array<std::string_view, 80> kFullBoxTypes = { + "pdin", "mvhd", "tkhd", "mdhd", "hdlr", "nmhd", "elng", "stsd", "stdp", + "stts", "ctts", "cslg", "stss", "stsh", "stdp", "elst", "dref", "stsz", + "stz2", "stsc", "stco", "co64", "padb", "subs", "saiz", "saio", "mehd", + "trex", "mfhd", "tfhd", "trun", "tfra", "mfro", "tfdt", "leva", "trep", + "assp", "sbgp", "sgpd", "csgp", "cprt", "tsel", "kind", "meta", "xml ", + "bxml", "iloc", "pitm", "ipro", "infe", "iinf", "iref", "ipma", "schm", + "fiin", "fpar", "fecr", "gitn", "fire", "stri", "stsg", "stvi", "csch", + "sidx", "ssix", "prft", "srpp", "vmhd", "smhd", "srat", "chnl", "dmix", + "txtC", "mime", "uri ", "uriI", "hmhd", "sthd", "vvhd", "medc"}; + +constexpr bool AllTypesHaveSize4() { + for (const std::string_view type : kFullBoxTypes) { + if (type.size() != 4) { + return false; + } + } + return true; +} + +// Requires that `type` is exactly 4 bytes long. +inline uint32_t TypeAsUint32(absl::string_view type) { + uint32_t ret; + std::memcpy(&ret, type.data(), sizeof(ret)); + return ret; +} + +} // namespace credentio_internal + +bool IsBmffFullBox(absl::string_view type) { + static_assert( + credentio_internal::AllTypesHaveSize4(), + "The following algorithm works only if all types are 4 bytes long."); + static const absl::NoDestructor<absl::flat_hash_set<uint32_t>> + kFullBoxTypesSet([]() { + absl::flat_hash_set<uint32_t> ret; + ret.reserve(credentio_internal::kFullBoxTypes.size()); + for (const std::string_view type : credentio_internal::kFullBoxTypes) { + ret.insert(credentio_internal::TypeAsUint32(type)); + } + return ret; + }()); + return type.size() == 4 && + kFullBoxTypesSet->contains(credentio_internal::TypeAsUint32(type)); +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_HEADER_H_
diff --git a/formats/bmff/box_matcher.cc b/formats/bmff/box_matcher.cc new file mode 100644 index 0000000..3bcddbe --- /dev/null +++ b/formats/bmff/box_matcher.cc
@@ -0,0 +1,115 @@ +// 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 "formats/bmff/box_matcher.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "formats/bmff/box_header.h" +#include "formats/bmff/xpath.h" +#include "proto/bmff_based_hash_assertion.pb.h" + +namespace credentio { +namespace { + +uint32_t NetworkUInt24(std::string value) { + uint32_t result = 0; + for (int i = 0; i < 3; ++i) { + uint8_t byte = (i < value.size()) ? static_cast<uint8_t>(value[i]) : 0; + result = (result << 8) | byte; + } + return result; +} + +bool MatchLength(const BmffRange& bmff_range, const BmffBoxHeader& header) { + if (!bmff_range.has_length()) { + return true; + } + return header.box_size == bmff_range.length(); +} + +bool MatchVersion(const BmffRange& bmff_range, const BmffBoxHeader& header) { + if (!bmff_range.has_version()) { + return true; + } + return header.version == bmff_range.version(); +} + +bool MatchFlags(const BmffRange& bmff_range, const BmffBoxHeader& header) { + if (!bmff_range.has_flags()) { + return true; + } + if (!header.IsFullBox()) { + return false; + } + if (!bmff_range.has_exact() || bmff_range.exact()) { + return header.flags == bmff_range.flags(); + } + auto desired_flags = NetworkUInt24(std::string(bmff_range.flags())); + auto box_flags = NetworkUInt24(header.flags); + return (desired_flags & box_flags) == desired_flags; +} + +} // namespace + +bool BMFFBoxMatcher::Matches(BmffBoxHeader header) const { + if (!(XPathMatcher(bmff_range_.xpath()).Matches(header.xpath)) || + !MatchLength(bmff_range_, header) || !MatchVersion(bmff_range_, header) || + !MatchFlags(bmff_range_, header)) { + return false; + } + for (const auto& data : bmff_range_.data()) { + uint64_t offset = header.start + data.offset(); + input_.Seek(offset); + if (input_.pos() != offset) { + return false; + } + std::string value; + if (!input_.Read(data.value().size(), value)) { + return false; + } + if (value != data.value()) { + return false; + } + } + return true; +} + +std::vector<BMFFBoxMatcher::Range> BMFFBoxMatcher::MatchedRanges( + BmffBoxHeader header) const { + std::vector<Range> ranges; + if (bmff_range_.subsets_size() == 0) { + ranges.push_back({.offset = header.start, + .length = static_cast<int64_t>(header.box_size)}); + return ranges; + } + for (const auto& subset : bmff_range_.subsets()) { + int64_t offset = header.start + subset.offset(); + int64_t end = header.start + subset.offset() + subset.length(); + if (offset > end) { + // Invalid subset. + continue; + } + if (subset.length() == 0 || end > header.start + header.box_size) { + end = header.start + header.box_size; + } + ranges.push_back( + {.offset = header.start + subset.offset(), .length = end - offset}); + } + return ranges; +} +} // namespace credentio
diff --git a/formats/bmff/box_matcher.h b/formats/bmff/box_matcher.h new file mode 100644 index 0000000..ef80281 --- /dev/null +++ b/formats/bmff/box_matcher.h
@@ -0,0 +1,61 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_MATCHER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_MATCHER_H_ + +#include <cstdint> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "formats/bmff/box_header.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// A matcher to match BMFF boxes against an exclusion in the +// BmffBasedHashAssertion. +class BMFFBoxMatcher { + public: + struct Range { + uint64_t offset; // The offset within the file. + int64_t length; + + bool operator==(const Range& other) const = default; + }; + BMFFBoxMatcher(BmffRange bmff_range, riegeli::Reader* absl_nonnull input) + : bmff_range_(std::move(bmff_range)), input_(*ABSL_DIE_IF_NULL(input)) {} + // Returns true if the given Box matches the exclusion BmffRange in a + // BmffBasedHashAssertion. + bool Matches(BmffBoxHeader header) const; + // Returns a list of data ranges that should be excluded in the hash, which is + // empty if the box does not match the BmffRange. The caller is responsible + // for checking any out order or any overlap of the returned ranges. + // NOTE: callers must check `Matches` first. + std::vector<Range> MatchedRanges(BmffBoxHeader header) const; + // Returns true if this is for matching the entirety of the box, rather than + // some subsets of the box. + bool IsMatchingEntireBox() const { return bmff_range_.subsets_size() == 0; } + + private: + BmffRange bmff_range_; + riegeli::Reader& input_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_MATCHER_H_
diff --git a/formats/bmff/box_matcher_test.cc b/formats/bmff/box_matcher_test.cc new file mode 100644 index 0000000..de559e2 --- /dev/null +++ b/formats/bmff/box_matcher_test.cc
@@ -0,0 +1,495 @@ +// 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 "formats/bmff/box_matcher.h" + +#include <string> +#include <vector> + +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/bmff/box_header.h" +#include "formats/bmff/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::credentio_testing::BigBox; +using ::credentio_testing::Box; +using ::credentio_testing::BoxFlags; +using ::credentio_testing::BoxVersion; +using ::credentio_testing::UuidBoxPayload; +using ::testing::ElementsAre; +using ::testing::IsEmpty; + +constexpr absl::string_view kFlagsForZero("\x00\x00\x00", 3); +constexpr absl::string_view kFlagsForOne("\x00\x00\x01", 3); +constexpr absl::string_view kFlagsForTwo("\x00\x00\x02", 3); + +std::vector<std::string> MatchBmffBoxes( + std::vector<credentio::BmffBoxHeader> boxes, + const credentio::BMFFBoxMatcher* matcher) { + std::vector<std::string> matched; + for (const auto& box : boxes) { + if (matcher->Matches(box)) { + matched.push_back(box.xpath); + } + } + return matched; +} + +std::vector<credentio::BMFFBoxMatcher::Range> MatchBmffBoxRanges( + std::vector<credentio::BmffBoxHeader> boxes, + const credentio::BMFFBoxMatcher* matcher) { + std::vector<credentio::BMFFBoxMatcher::Range> matched; + for (const auto& box : boxes) { + if (!matcher->Matches(box)) { + continue; + } + auto ranges = matcher->MatchedRanges(box); + matched.insert(matched.end(), ranges.begin(), ranges.end()); + } + return matched; +} + +TEST(BoxMatcher, MatchXPath) { + std::string uuid = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")); + std::string moov = Box("moov", "moov"); + std::string trak_trak1_trak1 = Box("trak", "abcd"); + std::string trak_trak1 = Box("trak", trak_trak1_trak1); + std::string trak_trak2 = BigBox("trak", "efgh"); + std::string trak1 = + BigBox("trak", absl::StrCat(trak_trak1, moov, trak_trak2)); + std::string trak2 = Box("trak", "ijkl"); + std::string contents = absl::StrCat(uuid, trak1, trak2); + riegeli::StringReader<> input(contents); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + { + credentio::BmffRange range; + range.set_xpath("/trak[1]/trak[1]/trak"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), + ElementsAre("/trak[1]/trak[1]/trak[1]")); + } + { + credentio::BmffRange range; + range.set_xpath("/trak[1]/trak/trak"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), + ElementsAre("/trak[1]/trak[1]/trak[1]")); + } + { + credentio::BmffRange range; + range.set_xpath("/trak/trak/trak"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), + ElementsAre("/trak[1]/trak[1]/trak[1]")); + } + { + credentio::BmffRange range; + range.set_xpath("/trak/trak/trak[1]"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), + ElementsAre("/trak[1]/trak[1]/trak[1]")); + } + { + credentio::BmffRange range; + range.set_xpath("/trak/trak/abcd"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } + { + credentio::BmffRange range; + range.set_xpath("/trak"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), + ElementsAre("/trak[1]", "/trak[2]")); + } +} + +TEST(BoxMatcher, MatchLength) { + std::string uuid = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")); + std::string moov = Box("moov", "moov"); + std::string trak_trak1_trak1 = Box("trak", "abcd"); + std::string trak_trak1 = Box("trak", trak_trak1_trak1); + std::string trak_trak2 = BigBox("trak", "efgh"); + std::string trak1 = + BigBox("trak", absl::StrCat(trak_trak1, moov, trak_trak2)); + std::string trak2 = Box("trak", "ijkl"); + std::string contents = absl::StrCat(uuid, trak1, trak2); + riegeli::StringReader<> input(contents); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + { + credentio::BmffRange range; + range.set_xpath("/trak"); + range.set_length(68); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/trak[1]")); + } + { + credentio::BmffRange range; + range.set_xpath("/trak"); + range.set_length(12); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/trak[2]")); + } + { + credentio::BmffRange range; + range.set_xpath("/trak"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), + ElementsAre("/trak[1]", "/trak[2]")); + } +} + +TEST(BoxMatcher, MatchVersion) { + std::string uuid = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(2), BoxFlags(0), "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + { + credentio::BmffRange range; + range.set_xpath("/uuid"); + range.set_version(2); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + } + { + credentio::BmffRange range; + range.set_xpath("/uuid"); + range.set_version(1); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } + { + credentio::BmffRange range; + range.set_xpath("/uuid"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + } +} + +TEST(BoxMatcher, MatchFlagsDefaultExactMatch) { + std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), + kFlagsForOne, "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BmffRange range; + range.set_xpath("/uuid"); + range.set_flags(kFlagsForOne); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); +}; + +TEST(BoxMatcher, MatchFlagsExplicitlyExactMatch) { + std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), + kFlagsForOne, "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BmffRange range; + range.set_xpath("/uuid"); + range.set_flags(kFlagsForOne); + range.set_exact(true); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); +}; + +TEST(BoxMatcher, MatchFlagsNotMatched) { + std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), + kFlagsForOne, "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BmffRange range; + range.set_xpath("/uuid"); + range.set_flags(kFlagsForZero); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); +} + +TEST(BoxMatcher, MatchFlagsNotExactButMatched) { + credentio::BmffRange range; + range.set_xpath("/uuid"); + range.set_flags(kFlagsForZero); + range.set_exact(false); + { + std::string uuid = + Box("uuid", + UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), kFlagsForOne, "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + } + { + std::string uuid = + Box("uuid", + UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), kFlagsForTwo, "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + } +}; + +TEST(BoxMatcher, MatchFlagsNotExactNotMatched) { + std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), + kFlagsForOne, "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BmffRange range; + range.set_xpath("/uuid"); + range.set_flags(kFlagsForTwo); + range.set_exact(false); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); +}; + +TEST(BoxMatcher, MatchFlagsNotFullBoxNotMatched) { + std::string some_box = Box("some", "abcd"); + riegeli::StringReader<> input(some_box); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BmffRange range; + range.set_xpath("/some"); + range.set_flags(kFlagsForZero); + range.set_exact(false); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); +}; + +TEST(BoxMatcher, NoFlagsMatched) { + { + std::string uuid = + Box("uuid", + UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), kFlagsForOne, "")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + credentio::BmffRange range; + range.set_xpath("/uuid"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + }; +} + +TEST(BoxMatcher, MatchData) { + std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), + BoxFlags(0), "abcd")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + { + credentio::BmffRange range; + range.set_xpath("/uuid"); + auto data1 = range.add_data(); + data1->set_offset(4); + data1->set_value("uuid"); + auto data2 = range.add_data(); + data2->set_offset(28); + data2->set_value("abcd"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + } + { + credentio::BmffRange range; + range.set_xpath("/uuid"); + auto data1 = range.add_data(); + data1->set_offset(4); + data1->set_value("aaaa"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } + { + credentio::BmffRange range; + range.set_xpath("/uuid"); + auto data1 = range.add_data(); + data1->set_offset(10000); + data1->set_value("uuid"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } +} + +TEST(BoxMatcher, MismatchOneField) { + std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1), + kFlagsForOne, "abcd")); + riegeli::StringReader<> input(uuid); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + + credentio::BmffRange matched_range; + matched_range.set_xpath("/uuid"); + matched_range.set_length(32); + matched_range.set_version(1); + matched_range.set_flags(kFlagsForOne); + auto data1 = matched_range.add_data(); + data1->set_offset(4); + data1->set_value("uuid"); + auto data2 = matched_range.add_data(); + data2->set_offset(28); + data2->set_value("abcd"); + + credentio::BMFFBoxMatcher matcher(matched_range, &input); + ASSERT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + + { + credentio::BmffRange range = matched_range; + range.set_xpath("/uuid[1]"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]")); + } + { + credentio::BmffRange range = matched_range; + range.set_length(31); // length not matched. + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } + { + credentio::BmffRange range = matched_range; + range.set_version(2); // version not matched. + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } + { + credentio::BmffRange range = matched_range; + range.set_length(1); // length not matched. + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } + { + credentio::BmffRange range = matched_range; + range.set_flags(kFlagsForTwo); // flags not matched. + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } + { + credentio::BmffRange range = matched_range; + range.mutable_data(1)->set_value("abce"); // data not matched. + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty()); + } +} + +TEST(BoxMatcher, MatchedRanges) { + std::string uuid = Box( + "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")); + std::string moov = Box("moov", "moov"); // box_size = 12 + std::string trak1 = Box("trak", moov); // box_size = 20 + std::string trak2 = BigBox("trak", "ijkl"); // box_size = 20 + std::string contents = absl::StrCat(uuid, trak1, trak2); + riegeli::StringReader<> input(contents); + auto boxes_or = ReadBmffBoxHeaders(input); + ASSERT_TRUE(boxes_or.ok()); + auto boxes = *boxes_or; + // Single box matched. + { + credentio::BmffRange range; + range.set_xpath("/trak[1]"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxRanges(boxes, &matcher), + ElementsAre(credentio::BMFFBoxMatcher::Range{.offset = 28, + .length = 20})); + } + // Multiple boxes matched. + { + credentio::BmffRange range; + range.set_xpath("/trak"); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT( + MatchBmffBoxRanges(boxes, &matcher), + ElementsAre( + credentio::BMFFBoxMatcher::Range{.offset = 28, .length = 20}, + credentio::BMFFBoxMatcher::Range{.offset = 48, .length = 20})); + } + // Return subsets of the box. + { + credentio::BmffRange range; + range.set_xpath("/trak[1]"); + auto subset = range.add_subsets(); + subset->set_offset(12); + subset->set_length(1); + subset = range.add_subsets(); + subset->set_offset(16); + subset->set_length(2); + subset = range.add_subsets(); + subset->set_offset(18); + subset->set_length(0); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT( + MatchBmffBoxRanges(boxes, &matcher), + ElementsAre( + credentio::BMFFBoxMatcher::Range{.offset = 40, .length = 1}, + credentio::BMFFBoxMatcher::Range{.offset = 44, .length = 2}, + credentio::BMFFBoxMatcher::Range{.offset = 46, .length = 2})); + } + // The last entry may have a length of zero; this indicates that the remainder + // of the box from that relative byte offset onward is excluded. + { + credentio::BmffRange range; + range.set_xpath("/trak[1]"); + auto subset = range.add_subsets(); + subset->set_offset(18); + subset->set_length(0); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxRanges(boxes, &matcher), + ElementsAre(credentio::BMFFBoxMatcher::Range{.offset = 46, + .length = 2})); + } + // Relative byte offset plus length that exceeds the length of the box is + // allowed; bytes beyond the end of the box are never hashed. + { + credentio::BmffRange range; + range.set_xpath("/trak[1]"); + auto subset = range.add_subsets(); + subset->set_offset(18); + subset->set_length(100); + credentio::BMFFBoxMatcher matcher(range, &input); + EXPECT_THAT(MatchBmffBoxRanges(boxes, &matcher), + ElementsAre(credentio::BMFFBoxMatcher::Range{.offset = 46, + .length = 2})); + } +} + +} // namespace +} // namespace credentio
diff --git a/formats/bmff/constants.h b/formats/bmff/constants.h new file mode 100644 index 0000000..8ad9b7f --- /dev/null +++ b/formats/bmff/constants.h
@@ -0,0 +1,40 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_CONSTANTS_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +inline constexpr absl::string_view kBmffC2paBoxPurposeManifest = "manifest"; +inline constexpr absl::string_view kBmffC2paBoxPurposeOriginal = "original"; +inline constexpr absl::string_view kBmffC2paBoxPurposeMerkle = "merkle"; +inline constexpr absl::string_view kBmffC2paBoxPurposeUpdate = "update"; + +// The minimum size of a BMFF box header is 8 bytes, consisting of a 4-byte +// size and a 4-byte type. +constexpr int kBmffMinSizeofBoxHeader = 8; + +// The size of the user type in a UUID box. +constexpr int kBmffUserTypeSize = 16; + +inline constexpr absl::string_view kBmffComponentSubtypeVideo = "vide"; +inline constexpr absl::string_view kBmffComponentSubtypeSound = "soun"; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_CONSTANTS_H_
diff --git a/formats/bmff/extractor.cc b/formats/bmff/extractor.cc new file mode 100644 index 0000000..bbe8c73 --- /dev/null +++ b/formats/bmff/extractor.cc
@@ -0,0 +1,185 @@ +// 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 "formats/bmff/extractor.h" + +#include <cstdint> +#include <optional> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "constants/labels.h" +#include "formats/bmff/box_header.h" +#include "formats/byte_range.h" +#include "jumbf/utils.h" +#include "riegeli/bytes/reader.h" +#include "utils/riegeli.h" + +namespace credentio { +namespace { + +constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3); +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB + +absl::StatusOr<std::string> ReadPurpose(riegeli::Reader& contents) { + std::string purpose; + if (!ReadNullTerminatedString(contents, 20, purpose)) { + return contents.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof")); + } + return purpose; +} + +struct Result { + ByteRange location; + std::string manifest; +}; + +absl::StatusOr<Result> ExtractManifest(riegeli::Reader& input, + int64_t end_offset) { + Result result{ + .location{.offset = 0, .length = 0}, + .manifest = "", + }; + + if (!input.SupportsSize() || !input.Size().has_value()) { + return absl::InvalidArgumentError( + "manifest store not embedded: reader size cannot be determined"); + } + + uint64_t eof_offset = + end_offset < 0 ? *input.Size() : static_cast<uint64_t>(end_offset); + + absl::Status iteration_status = IterateOverBmffBoxes( + input, + [&input, &eof_offset, + &result](const BmffBoxHeader& header) -> absl::StatusOr<bool> { + // Validate that the end of the current box is within the end offset. + if (header.start + header.box_size > eof_offset) { + return absl::DataLossError("kUnexpectedEof; truncated BMFF box"); + } + uint64_t box_end = header.start + header.box_size; + + if (header.type == "mdat" || header.type == "moov") { + return false; // Terminate loop + } + + if (header.type != "uuid" || header.user_type != kC2paBmffBoxUuid) { + // Skip irrelevant box. + if (!input.Seek(box_end)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to seek to the end of the atom")); + } + return true; // Continue + } + + // C2PA box + if (!result.manifest.empty()) { + return absl::NotFoundError("Multiple manifest stores found"); + } + if (header.version != 0) { + return absl::InvalidArgumentError(absl::Substitute( + "unsupported C2PA box version ($0)", header.version)); + } + if (header.flags != kZeroFlags) { + return absl::InvalidArgumentError( + absl::Substitute("unsupported C2PA box flags ($0)", + absl::BytesToHexString(header.flags))); + } + + ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(input)); + if (purpose != "manifest") { + // Ignore non-manifest C2PA boxes. + if (!input.Seek(box_end)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to seek to the end of the atom")); + } + return true; // Continue + } + + // Skip the merkle offset. + if (!input.Skip(sizeof(uint64_t))) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; merkle offset")); + } + + if (box_end < input.pos()) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; payload")); + } + + uint64_t payload_size = box_end - input.pos(); + if (payload_size > kMaxPayloadSize) { + return absl::InvalidArgumentError(absl::Substitute( + "BMFF C2PA box is too large to extract ($0 > $1)", payload_size, + kMaxPayloadSize)); + } + result.location = {.offset = header.start, + .length = box_end - header.start}; + if (!input.Read(payload_size, result.manifest)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to read payload")); + } + + if (!input.Seek(box_end)) { + return input.StatusOrAnnotate( + absl::InternalError("failed to seek to the end of the atom")); + } + return true; // Continue + }); + if (!iteration_status.ok()) { + return absl::NotFoundError( + absl::StrCat("No manifest store found; ", iteration_status.message())); + } + if (result.manifest.empty()) { + return absl::NotFoundError("No manifest store found"); + } + return result; +} + +} // namespace + +absl::StatusOr<std::string> BmffExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + ABSL_ASSIGN_OR_RETURN(auto result, ExtractManifest(input, /*end_offset=*/-1)); + return result.manifest; +} + +absl::StatusOr<std::optional<ByteRange>> +BmffExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + auto result = ExtractManifest(input, options.end_offset); + if (!result.ok()) { + if (options.requires_c2pa) { + return result.status(); + } + return std::nullopt; + } + return (*result).location; +} + +bool BmffExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} + +} // namespace credentio
diff --git a/formats/bmff/extractor.h b/formats/bmff/extractor.h new file mode 100644 index 0000000..9b591e4 --- /dev/null +++ b/formats/bmff/extractor.h
@@ -0,0 +1,55 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_byte_info.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Extracts C2PA Manifest Store from an ISO BMFF based file (e.g., mp4). +class BmffExtractor : public FormatExtractor { + public: + BmffExtractor() = default; + ~BmffExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + // BMFF does not have a concept of boxes, so this always returns an error. + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("BMFF does not have a concept of boxes"); + } + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EXTRACTOR_H_
diff --git a/formats/bmff/extractor_test.cc b/formats/bmff/extractor_test.cc new file mode 100644 index 0000000..d3dc19a --- /dev/null +++ b/formats/bmff/extractor_test.cc
@@ -0,0 +1,299 @@ +// 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 "formats/bmff/extractor.h" + +#include <cstdint> +#include <limits> +#include <optional> +#include <string> +#include <vector> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/asset_box.h" +#include "formats/asset_byte_info.h" +#include "formats/bmff/test_utils.h" +#include "formats/byte_range.h" +#include "formats/extractor_result.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/jumbf_utils.h" +#include "testing/test_string_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::credentio_testing::BigBox; +using ::credentio_testing::Box; +using ::credentio_testing::BoxFlags; +using ::credentio_testing::BoxVersion; +using ::credentio_testing::C2paBoxPayload; +using ::credentio_testing::ManifestBigBox; +using ::credentio_testing::ManifestBox; +using ::credentio_testing::Uint32Str; +using ::credentio_testing::Uint64Str; +using ::credentio_testing::UuidBoxPayload; +using ::testing::Eq; +using ::testing::HasSubstr; + +constexpr int64_t kMaxChunkBytes = 10 * 1024 * 1024; // 2 MiB + +struct TestCase { + std::string name; + std::string contents; + absl::StatusOr<ExtractorResult> result; +}; + +class ExtractorTest : public testing::TestWithParam<TestCase> {}; + +INSTANTIATE_TEST_SUITE_P( + ExtractorTests, ExtractorTest, + testing::ValuesIn({ + TestCase{ + .name = "EmptyContent", + .contents = "", + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "C2paBox", + .contents = absl::StrCat(Box("blah", "irrelevant content"), + ManifestBox(/*merkle_offset=*/0, "foo")), + .result = ExtractorResult{.manifest_store = "foo", + .asset_byte_info = + {.manifest_store_location = + ByteRange{.offset = 26, + .length = 48}}}, + }, + TestCase{ + .name = "BigC2paBox", + .contents = absl::StrCat(Box("blah", "irrelevant content"), + ManifestBigBox(/*merkle_offset=*/0, + "foo")), + .result = ExtractorResult{.manifest_store = "foo", + .asset_byte_info = + {.manifest_store_location = + ByteRange{.offset = 26, + .length = 56}}}, + }, + TestCase{ + .name = "C2paBoxTooLarge", + .contents = absl::StrCat(Box("blah", "irrelevant content"), + ManifestBox(/*merkle_offset=*/0, + std::string(kMaxChunkBytes + 1, + 'Z'))), + .result = absl::NotFoundError("C2PA box is too large"), + }, + TestCase{ + .name = "NoC2paBox", + .contents = absl::StrCat(Box("blah", "irrelevant content"), + Box("asdf", "more irrelevant content"), + BigBox("zxcv", "whatever")), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "C2paBoxAfterMoovIgnored", + .contents = absl::StrCat(Box("blah", "irrelevant content"), + Box("moov", "also irrelevant"), + Box("uuid", absl::StrCat(kC2paBmffBoxUuid, + "foo"))), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "C2paBoxAfterMdatIgnored", + .contents = absl::StrCat(Box("blah", "irrelevant content"), + Box("mdat", "also irrelevant"), + Box("uuid", absl::StrCat(kC2paBmffBoxUuid, + "foo"))), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + // Quoting + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedded_2: + // "If there are multiple C2PA Manifest Stores present in an asset, + // they shall all be considered as invalid and the validation should + // treat this as if no manifests were located." + .name = "MultipleC2paBoxesIgnored", + .contents = absl::StrCat(ManifestBox(/*merkle_offset=*/0, "foo"), + ManifestBox(/*merkle_offset=*/0, "bar")), + .result = absl::NotFoundError("Multiple manifest stores found"), + }, + TestCase{ + .name = "NonC2paUuidBoxIgnored", + .contents = absl::StrCat(Box("uuid", + "abcdefghijklmnop not a C2PA box"), + ManifestBox(/*merkle_offset=*/0, "foo")), + .result = + ExtractorResult{ + .manifest_store = "foo", + .asset_byte_info = {.manifest_store_location = + ByteRange{ + .offset = 39, .length = 48}}}, + }, + TestCase{ + .name = "TruncatedBoxHeader", + .contents = "abcdefg", + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "TruncatedBoxPayload", + .contents = + []() { + std::string contents = Box("asdf", "payload"); + contents.resize(contents.size() - 1); + return contents; + }(), + .result = absl::NotFoundError("truncated BMFF box"), + }, + TestCase{ + .name = "TruncatedBoxPayloadHugeSize", + .contents = absl::StrCat( + Uint32Str(1), "asdf", + Uint64Str(std::numeric_limits<int64_t>::max()), "payload"), + .result = absl::NotFoundError("truncated BMFF box"), + }, + TestCase{ + .name = "ShortUuidBox", + .contents = Box("uuid", "foo"), + .result = absl::NotFoundError("kUnexpectedEof"), + }, + TestCase{ + .name = "InvalidBoxSize", + .contents = absl::StrCat(Uint32Str(2), "asdf", "payload"), + .result = absl::NotFoundError("kInvalidData"), + }, + TestCase{ + .name = "InvalidBigBoxSize", + .contents = absl::StrCat(Uint32Str(1), "asdf", Uint64Str(15), + "payload"), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "InvalidVersion", + .contents = Box("uuid", + UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1), + BoxFlags(0), "")), + .result = absl::NotFoundError("unsupported C2PA box version"), + }, + TestCase{ + .name = "InvalidFlags", + .contents = Box("uuid", + UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), + BoxFlags(1), "")), + .result = absl::NotFoundError("unsupported C2PA box flags"), + }, + TestCase{ + .name = "NonManifestC2paBoxIgnored", + .contents = absl::StrCat( + Box("uuid", C2paBoxPayload("merkle", "ignored data")), + ManifestBox(/*merkle_offset=*/0, "foo")), + .result = + ExtractorResult{ + .manifest_store = "foo", + .asset_byte_info = {.manifest_store_location = + ByteRange{ + .offset = 47, .length = 48}}}, + }, + TestCase{ + // Not enough bytes for the merkle offset. + .name = "TruncatedManifestBox", + .contents = Box("uuid", C2paBoxPayload("manifest", "asdfjkl")), + .result = absl::NotFoundError("kUnexpectedEof"), + }, + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +TEST_P(ExtractorTest, ExtractManifestStore) { + riegeli::StringReader<> input(GetParam().contents); + absl::StatusOr<std::string> result = + BmffExtractor().ExtractManifestStore(input); + + absl::StatusOr<ExtractorResult> expected_result = GetParam().result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, Eq(expected_result->manifest_store)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) { + std::string contents = "padding" + GetParam().contents; + riegeli::StringReader<> input(contents); + + ASSERT_TRUE(input.Seek(7)); + absl::StatusOr<std::string> result = + BmffExtractor().ExtractManifestStore(input); + + absl::StatusOr<ExtractorResult> expected_result = GetParam().result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, Eq(expected_result->manifest_store)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocation) { + riegeli::StringReader<> input(GetParam().contents); + absl::StatusOr<std::optional<ByteRange>> result = + BmffExtractor().ExtractManifestStoreLocation(input, + /*options=*/{}); + + absl::StatusOr<ExtractorResult> expected_result = GetParam().result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, + Eq(expected_result->asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxes) { + riegeli::StringReader<> input(GetParam().contents); + absl::StatusOr<std::vector<AssetBox>> result = + BmffExtractor().ExtractBoxes(input, + /*options=*/{}); + + // BMFF does not have a concept of boxes, calling this method should always + // fail. + EXPECT_THAT(result, StatusIs(absl::StatusCode::kUnimplemented, + "BMFF does not have a concept of boxes")); +} + +TEST(BmffExtractorTest, IsManifestStore) { + EXPECT_TRUE(BmffExtractor().MightBeC2paManifestStore( + CreateStartOfManifestStorePayload())); +} + +} // namespace +} // namespace credentio
diff --git a/formats/bmff/format.cc b/formats/bmff/format.cc new file mode 100644 index 0000000..2e54fd1 --- /dev/null +++ b/formats/bmff/format.cc
@@ -0,0 +1,36 @@ +// 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 "formats/bmff/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/bmff/assessor.h" +#include "formats/bmff/extractor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateBmffFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<BmffAssessor>(), + .extractor = std::make_unique<BmffExtractor>(), + .mime_types = {"video/mp4", "video/quicktime", "image/heic", "image/heif", + "image/avif", "audio/mp4"}, + }); +} + +} // namespace credentio
diff --git a/formats/bmff/format.h b/formats/bmff/format.h new file mode 100644 index 0000000..28a9432 --- /dev/null +++ b/formats/bmff/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateBmffFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_FORMAT_H_
diff --git a/formats/bmff/test_utils.h b/formats/bmff/test_utils.h new file mode 100644 index 0000000..2410736 --- /dev/null +++ b/formats/bmff/test_utils.h
@@ -0,0 +1,107 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_TEST_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_TEST_UTILS_H_ + +#include <cstdint> +#include <string> + +#include "absl/log/check.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "testing/test_string_utils.h" + +namespace credentio_testing { + +using ::credentio::kC2paBmffBoxUuid; + +inline constexpr absl::string_view kNul("\x00", 1); + +inline std::string Box(absl::string_view type, absl::string_view data) { + CHECK_EQ(type.size(), 4); + return absl::StrCat(Uint32Str(data.size() + 8), type, data); +} + +inline std::string BadBox(absl::string_view type, absl::string_view data, + uint32_t data_size) { + CHECK_EQ(type.size(), 4); + return absl::StrCat(Uint32Str(data_size), type, data); +} + +inline std::string BigBox(absl::string_view type, absl::string_view data) { + CHECK_EQ(type.size(), 4); + return absl::StrCat(Uint32Str(1), type, Uint64Str(data.size() + 16), data); +} + +inline std::string BoxVersion(uint8_t version) { return Uint8Str(version); } + +inline std::string BoxFlags(uint32_t flags) { + CHECK_EQ(flags & 0xff000000, 0); + return Uint32Str(flags).substr(1); +} + +// Returns the data payload of a UUID box, suitable for passing to Box() or +// BigBox(). +inline std::string UuidBoxPayload(absl::string_view uuid, + absl::string_view version, + absl::string_view flags, + absl::string_view data) { + CHECK_EQ(uuid.size(), 16); + CHECK_EQ(version.size(), 1); + CHECK_EQ(flags.size(), 3); + return absl::StrCat(uuid, version, flags, data); +} + +// Returns the data payload of a C2PA manifest box, suitable for passing to +// Box() or BigBox(). +inline std::string C2paBoxPayload(absl::string_view purpose, + absl::string_view data) { + return UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), + absl::StrCat(purpose, kNul, data)); +} + +// Returns the data payload of a well-formed C2PA manifest box (i.e., starting +// with an absolute offset to the first `merkle` C2PA box), suitable for passing +// to Box() or BigBox(). +inline std::string ManifestBoxPayload(uint64_t merkle_offset, + absl::string_view data) { + return C2paBoxPayload("manifest", + absl::StrCat(Uint64Str(merkle_offset), data)); +} + +// Returns a full C2PA manifest box encoded as a standard (small) Box. +inline std::string ManifestBox(uint64_t merkle_offset, absl::string_view data) { + return Box("uuid", ManifestBoxPayload(merkle_offset, data)); +} + +// Returns a full C2PA manifest box encoded as a BigBox. +inline std::string ManifestBigBox(uint64_t merkle_offset, + absl::string_view data) { + return BigBox("uuid", ManifestBoxPayload(merkle_offset, data)); +} + +inline std::string C2paUuidBox(absl::string_view data, + absl::string_view box_purpose = "manifest", + uint64_t merkle_offset = 0) { + return Box("uuid", + C2paBoxPayload(box_purpose, + absl::StrCat(Uint64Str(merkle_offset), data))); +} + +} // namespace credentio_testing + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_TEST_UTILS_H_
diff --git a/formats/bmff/xpath.cc b/formats/bmff/xpath.cc new file mode 100644 index 0000000..95d6735 --- /dev/null +++ b/formats/bmff/xpath.cc
@@ -0,0 +1,85 @@ +// 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 "formats/bmff/xpath.h" + +#include <string> +#include <vector> + +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" + +namespace credentio { +namespace { +// Returns true if the given path matches the pattern. Like, +// "/a[1]" matches "/a[1]"; +// "/a[1]" also matches "/a"; +// but "/a[1]" doesn't match "/a[2]". +// Similarly, "/a[1]/b[2]/c[3]" matches "/a/b/c", "/a[1]/b/c", "/a/b[2]/c", +// "/a/b/c[3]", etc; +// but "/a[2]/b[2]/c[3]" doesn't match "/a[1]/b/c". +// Note that while patterns like "/a/b/c" are expected, we don't expect to see +// paths like "/a/b/c" because positional indexes should always be applied in +// the path. +bool MatchXPath(absl::string_view path, absl::string_view pattern) { + if (path == pattern) { + return true; + } + std::vector<absl::string_view> terms_a = absl::StrSplit(path, '/'); + std::vector<absl::string_view> terms_b = absl::StrSplit(pattern, '/'); + if (terms_a.size() != terms_b.size()) { + return false; + } + for (int i = 0; i < terms_a.size(); ++i) { + if (terms_a[i] == terms_b[i]) { + continue; + } + if (absl::StrContains(terms_b[i], "[")) { + // Term b is a positioned element; requires exact match. + return false; + } + absl::string_view remainder = terms_a[i]; + if (absl::ConsumePrefix(&remainder, terms_b[i]) && + absl::StartsWith(remainder, "[")) { + continue; + } + return false; + } + return true; +}; +} // namespace + +bool XPathMatcher::Matches(absl::string_view path) const { + return MatchXPath(path, pattern_); +} + +std::string XPath::ToString() const { + std::string result; + for (const XPath* cursor = this; cursor != nullptr; + cursor = cursor->parent_) { + std::string element_name = + absl::StrCat("/", cursor->name_, + cursor->position_.has_value() + ? absl::StrCat("[", *cursor->position_, "]") + : ""); + result = absl::StrCat(element_name, result); + } + return result; +} + +} // namespace credentio
diff --git a/formats/bmff/xpath.h b/formats/bmff/xpath.h new file mode 100644 index 0000000..3882ebc --- /dev/null +++ b/formats/bmff/xpath.h
@@ -0,0 +1,64 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_XPATH_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_XPATH_H_ + +#include <optional> +#include <string> + +#include "absl/strings/string_view.h" + +namespace credentio { + +// An XPath represents a path from the root to a BMFF box, with the formatted +// string according to https://www.w3.org/TR/xpath-10/. +class XPath { + public: + // Constructs an XPath with a parent and a name. + XPath(const XPath* parent, std::string name) : parent_(parent), name_(name) {} + + // Constructs an XPath with a parent, a name, and a position. + XPath(const XPath* parent, std::string name, int position) + : parent_(parent), name_(name), position_(position) {} + + // Sets the position of the element for this path. + void SetPosition(int position) { position_ = position; } + // Returns the string representation of this full path. + std::string ToString() const; + + private: + // The parent of this path. + const XPath* parent_; + // The name of the element pointed by this path. + std::string name_; + // The position of the element for this path. The position represents that + // this path is the Nth child with tag name in its parent. + std::optional<int> position_ = std::nullopt; +}; + +// A util class to match a XPath. +class XPathMatcher { + public: + explicit XPathMatcher(absl::string_view pattern) : pattern_(pattern) {} + bool Matches(absl::string_view path) const; + + private: + std::string pattern_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_XPATH_H_
diff --git a/formats/bmff/xpath_test.cc b/formats/bmff/xpath_test.cc new file mode 100644 index 0000000..eda3852 --- /dev/null +++ b/formats/bmff/xpath_test.cc
@@ -0,0 +1,52 @@ +// 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 "formats/bmff/xpath.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::testing::Eq; + +class XPathTest : public ::testing::Test { + protected: + XPath a1_ = XPath(nullptr, "A", 1); + XPath a1_b1_ = XPath(&a1_, "B", 1); + XPath a1_b1_c1_ = XPath(&a1_b1_, "C", 1); + XPath a1_b1_c2_ = XPath(&a1_b1_, "C", 2); + XPath a_ = XPath(nullptr, "A"); + XPath a_b1_ = XPath(&a_, "B", 1); +}; + +TEST_F(XPathTest, ToString) { + EXPECT_THAT(a1_.ToString(), Eq("/A[1]")); + EXPECT_THAT(a1_b1_.ToString(), Eq("/A[1]/B[1]")); + EXPECT_THAT(a1_b1_c1_.ToString(), Eq("/A[1]/B[1]/C[1]")); + EXPECT_THAT(a1_b1_c2_.ToString(), Eq("/A[1]/B[1]/C[2]")); + EXPECT_THAT(a_.ToString(), Eq("/A")); + EXPECT_THAT(a_b1_.ToString(), Eq("/A/B[1]")); +} + +TEST_F(XPathTest, XPathMatcherMatches) { + XPathMatcher matcher("/moov/meta"); + EXPECT_TRUE(matcher.Matches("/moov[1]/meta[1]")); + EXPECT_FALSE(matcher.Matches("/[moo[1]/[met[1]")); +} + +} // namespace +} // namespace credentio
diff --git a/formats/byte_range.h b/formats/byte_range.h new file mode 100644 index 0000000..2ab0b58 --- /dev/null +++ b/formats/byte_range.h
@@ -0,0 +1,35 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BYTE_RANGE_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_BYTE_RANGE_H_ + +#include <cstdint> + +namespace credentio { + +// A range of bytes in an asset. +struct ByteRange { + uint64_t offset; + uint64_t length; + + bool operator==(const ByteRange& other) const { + return offset == other.offset && length == other.length; + } +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_BYTE_RANGE_H_
diff --git a/formats/core_registry.cc b/formats/core_registry.cc new file mode 100644 index 0000000..1a60c09 --- /dev/null +++ b/formats/core_registry.cc
@@ -0,0 +1,63 @@ +// 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 "formats/core_registry.h" + +#include <memory> +#include <utility> + +#include "absl/log/log.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/bmff/format.h" +#include "formats/format.h" +#include "formats/gif/format.h" +#include "formats/id3/format.h" +#include "formats/jpeg/format.h" +#include "formats/pdf/format.h" +#include "formats/png/format.h" +#include "formats/registry.h" +#include "formats/riff/format.h" +#include "formats/tiff/format.h" +#include "formats/zip/format.h" + +namespace credentio { + +std::unique_ptr<FormatRegistry> CreateCoreFormatRegistry() { + auto registry = std::make_unique<FormatRegistry>(); + + auto register_format = [&](absl::StatusOr<std::unique_ptr<Format>> format, + absl::string_view name) { + if (format.ok()) { + registry->Register(std::move(*format)); + } else { + LOG(WARNING) << "Failed to create and register format: " << name; + } + }; + + register_format(CreateJpegFormat(), "jpeg"); + register_format(CreatePngFormat(), "png"); + register_format(CreateBmffFormat(), "bmff"); + register_format(CreateTiffFormat(), "tiff"); + register_format(CreateRiffFormat(), "riff"); + register_format(CreateGifFormat(), "gif"); + register_format(CreatePdfFormat(), "pdf"); + register_format(CreateId3Format(), "id3"); + register_format(CreateZipFormat(), "zip"); + + return registry; +} + +} // namespace credentio
diff --git a/formats/core_registry.h b/formats/core_registry.h new file mode 100644 index 0000000..4d514e9 --- /dev/null +++ b/formats/core_registry.h
@@ -0,0 +1,29 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_CORE_REGISTRY_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_CORE_REGISTRY_H_ + +#include <memory> + +#include "formats/registry.h" + +namespace credentio { + +std::unique_ptr<FormatRegistry> CreateCoreFormatRegistry(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_CORE_REGISTRY_H_
diff --git a/formats/extractor.h b/formats/extractor.h new file mode 100644 index 0000000..5086875 --- /dev/null +++ b/formats/extractor.h
@@ -0,0 +1,82 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_H_ + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// FormatExtractor for finding the C2PA Manifest Store in an asset, without +// parsing or verifying the manifest store. +class FormatExtractor { + public: + virtual ~FormatExtractor() = default; + + struct ExtractOptions { + bool requires_c2pa = true; + int64_t end_offset = -1; + }; + + // Returns the extracted C2PA payload from the supplied asset if there is only + // one found. Returns a NotFoundError for 0 or 2+ C2PA payloads. Returns an + // error if there was a problem extracting the payload. + virtual absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const = 0; + + // Returns the location of the structure in the supplied asset that contains + // the C2PA Manifest Store. This includes format-specific wrapping, so for + // example in a JPEG it would cover the sequence of contiguous segments that + // contain the fragments of the manifest store. + // + // If requires_c2pa is true, then an error will be returned if the C2PA + // Manifest Store is not found. + virtual absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const = 0; + + // Returns the extracted boxes from the supplied asset. If requires_c2pa is + // true, then an error will be returned if the C2PA Manifest Store is not + // found. + virtual absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const = 0; + + // Lightweight check to determine if the payload may contain a C2PA + // Manifest Store. This payload is the data contained within defined sequence + // of bytes within an asset. + // Example: + // - For JPEG, this is the data contained within a JPEG segment minus the + // marker and segment size. + // - For BMFF, this is the data contained within a BMFF box minus the box + // header. + // - For PNG, this is the data contained within a PNG chunk minus the chunk + // length, type and CRC. + // - For RIFF, this is the data contained within a RIFF chunk minus the chunk + // length and type. + virtual bool MightBeC2paManifestStore(absl::string_view payload) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_H_
diff --git a/formats/extractor_result.h b/formats/extractor_result.h new file mode 100644 index 0000000..37923ff --- /dev/null +++ b/formats/extractor_result.h
@@ -0,0 +1,49 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_RESULT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_RESULT_H_ + +#include <ostream> +#include <string> + +#include "formats/asset_byte_info.h" + +namespace credentio { + +// Result of the extraction containing the extracted C2PA Manifest Store and +// any segments found in the asset. +struct ExtractorResult { + std::string manifest_store; + AssetByteInfo asset_byte_info; + + bool operator==(const ExtractorResult& other) const { + return manifest_store == other.manifest_store && + asset_byte_info == other.asset_byte_info; + } + bool operator!=(const ExtractorResult& other) const { + return !(*this == other); + } +}; + +inline std::ostream& operator<<(std::ostream& os, + const ExtractorResult& result) { + return os << "ExtractorResult{manifest_store: " << result.manifest_store + << ", asset_byte_info: " << result.asset_byte_info << "}"; +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_RESULT_H_
diff --git a/formats/format.h b/formats/format.h new file mode 100644 index 0000000..2852283 --- /dev/null +++ b/formats/format.h
@@ -0,0 +1,73 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_FORMAT_H_ + +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "formats/extractor.h" + +namespace credentio { + +struct FormatOptions { + std::unique_ptr<FormatAssessor> assessor; + std::unique_ptr<FormatExtractor> extractor; + std::vector<std::string> mime_types; +}; + +// Format is the entry point for all format-specific logic. +class Format { + public: + static absl::StatusOr<std::unique_ptr<Format>> Create(FormatOptions options) { + if (options.assessor == nullptr) { + return absl::InvalidArgumentError("FormatAssessor is required"); + } + if (options.extractor == nullptr) { + return absl::InvalidArgumentError("FormatExtractor is required"); + } + if (options.mime_types.empty()) { + return absl::InvalidArgumentError("At least one mime type is required"); + } + return std::unique_ptr<Format>(new Format(std::move(options))); + } + ~Format() = default; + + const FormatAssessor* assessor() const { return assessor_.get(); } + + const FormatExtractor* extractor() const { return extractor_.get(); } + + const std::vector<std::string>& mime_types() const { return mime_types_; } + + private: + explicit Format(FormatOptions options) + : assessor_(std::move(options.assessor)), + extractor_(std::move(options.extractor)), + mime_types_(std::move(options.mime_types)) {} + + const std::unique_ptr<FormatAssessor> assessor_; + const std::unique_ptr<FormatExtractor> extractor_; + const std::vector<std::string> mime_types_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_FORMAT_H_
diff --git a/formats/format_test.cc b/formats/format_test.cc new file mode 100644 index 0000000..f72fb76 --- /dev/null +++ b/formats/format_test.cc
@@ -0,0 +1,114 @@ +// 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 "formats/format.h" + +#include <memory> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/assessor.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +class FakeAssessor : public FormatAssessor { + public: + FakeAssessor() = default; + ~FakeAssessor() override = default; + + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override { + return absl::UnimplementedError("Not implemented"); + } +}; + +class FakeExtractor : public FormatExtractor { + public: + FakeExtractor() = default; + ~FakeExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override { + return absl::UnimplementedError("Not implemented"); + } + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("Not implemented"); + } + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("Not implemented"); + } + + bool MightBeC2paManifestStore(absl::string_view payload) const override { + return true; + } +}; + +TEST(FormatTest, FailsIfNoAssessor) { + EXPECT_THAT(Format::Create(FormatOptions{ + .extractor = std::make_unique<FakeExtractor>(), + .mime_types = {"jpeg"}, + }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("FormatAssessor is required"))); +} + +TEST(FormatTest, FailsIfNoExtractor) { + EXPECT_THAT(Format::Create(FormatOptions{ + .assessor = std::make_unique<FakeAssessor>(), + .mime_types = {"jpeg"}, + }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("FormatExtractor is required"))); +} + +TEST(FormatTest, FailsIfNoMimeTypes) { + EXPECT_THAT(Format::Create(FormatOptions{ + .assessor = std::make_unique<FakeAssessor>(), + .extractor = std::make_unique<FakeExtractor>(), + }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("At least one mime type is required"))); +} + +TEST(FormatTest, Succeeds) { + EXPECT_THAT(Format::Create(FormatOptions{ + .assessor = std::make_unique<FakeAssessor>(), + .extractor = std::make_unique<FakeExtractor>(), + .mime_types = {"jpeg"}, + }), + IsOk()); +} + +} // namespace +} // namespace credentio
diff --git a/formats/gif/BUILD b/formats/gif/BUILD new file mode 100644 index 0000000..19badc8 --- /dev/null +++ b/formats/gif/BUILD
@@ -0,0 +1,149 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":constants", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + deps = [ + ":constants", + ":reader", + ":test_builder", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "test_builder", + testonly = True, + srcs = ["test_builder.cc"], + hdrs = ["test_builder.h"], + deps = [ + ":constants", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = ["@abseil-cpp//absl/strings:string_view"], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":constants", + ":reader", + "//constants:labels", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":constants", + ":extractor", + ":test_builder", + "//formats:asset_box", + "//formats:asset_byte_info", + "//formats:byte_range", + "//formats:extractor_result", + "//testing:jumbf_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":constants", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/gif/assessor.cc b/formats/gif/assessor.cc new file mode 100644 index 0000000..c524b80 --- /dev/null +++ b/formats/gif/assessor.cc
@@ -0,0 +1,48 @@ +// 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 "formats/gif/assessor.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/gif/constants.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +absl::StatusOr<bool> GifAssessor::IsSupported(riegeli::Reader& input) const { + if (input.Size() < kMinGifSize) { + return false; + } + + int64_t starting_position = input.pos(); + + std::string payload; + if (!input.Read(kGifHeaderBlock.size(), payload)) { + return input.StatusOrAnnotate( + absl::InternalError("Failed to read GIF header block")); + } + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate( + absl::InternalError("Failed to seek to starting position")); + } + + return payload == kGifHeaderBlock; +} + +} // namespace credentio
diff --git a/formats/gif/assessor.h b/formats/gif/assessor.h new file mode 100644 index 0000000..7ee3c18 --- /dev/null +++ b/formats/gif/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the GIF image format. +class GifAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_ASSESSOR_H_
diff --git a/formats/gif/assessor_test.cc b/formats/gif/assessor_test.cc new file mode 100644 index 0000000..fea0f65 --- /dev/null +++ b/formats/gif/assessor_test.cc
@@ -0,0 +1,66 @@ +// 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 "formats/gif/assessor.h" + +#include <string> + +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; + +TEST(IsSupportedTest, FailureTooShort) { + std::string image = "test"; + riegeli::StringReader<> input(image); + + EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, FailureIncorrectHeader) { + std::string image = "this_is_not_a_gif"; + riegeli::StringReader<> input(image); + + EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, Succeeds) { + std::string image = "GIF89athis_is_a_gif"; + riegeli::StringReader<> input(image); + + EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, SucceedsAtOffset2) { + std::string image = "abGIF89athis_is_a_gif"; + riegeli::StringReader<> input(image); + + // Invalid at 0 + EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +} // namespace +} // namespace credentio
diff --git a/formats/gif/constants.h b/formats/gif/constants.h new file mode 100644 index 0000000..2bb4dab --- /dev/null +++ b/formats/gif/constants.h
@@ -0,0 +1,65 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_CONSTANTS_H_ + +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { + +// GIF format: https://giflib.sourceforge.net/whatsinagif/bits_and_bytes.html +// C2PA only supports GIF89a format: +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_gif_specific_handling +constexpr absl::string_view kGifHeaderBlock = "GIF89a"; + +// Header Block (6) + Logical Screen Descriptor (7) + Trailer (1) +constexpr int64_t kMinGifSize = 6 + 7 + 1; + +// Introducers (First Byte) +constexpr uint8_t kGifExtensionIntroducer = 0x21; +constexpr uint8_t kGifImageDescriptorIntroducer = 0x2C; +constexpr uint8_t kGifTrailerIntroducer = 0x3B; + +// Extension Label (Second Byte when introducer is 0x21) +constexpr uint8_t kGifExtensionPlainText = 0x01; +constexpr uint8_t kGifExtensionGraphicsControl = 0xF9; +constexpr uint8_t kGifExtensionComment = 0xFE; +constexpr uint8_t kGifExtensionApplication = 0xFF; + +// Corresponding C2PA Box Labels +constexpr absl::string_view kGifLogicalScreenDescriptorLabel = "LSD"; +constexpr absl::string_view kGifImageDescriptorLabel = "2C"; +constexpr absl::string_view kGifTrailerLabel = "3B"; +constexpr absl::string_view kGifImageDataLabel = "TBID"; +constexpr absl::string_view kGifExtensionPlainTextLabel = "2101"; +constexpr absl::string_view kGifExtensionGraphicsControlLabel = "21F9"; +constexpr absl::string_view kGifExtensionCommentLabel = "21FE"; +constexpr absl::string_view kGifExtensionApplicationLabel = "21FF"; +constexpr absl::string_view kGifAfterLabel = "c2pa.after"; + +// C2PA Block Information +// Embedding Information: +// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_embedding_manifests_into_gifs +constexpr uint8_t kGifC2paExtensionInfoSize = 0x0B; +constexpr absl::string_view kGifC2paIdentifier = "C2PA_GIF"; +constexpr uint16_t kGifC2paAuthenticationCode = 0x0100; +constexpr absl::string_view kGifC2paLabel = "C2PA"; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_CONSTANTS_H_
diff --git a/formats/gif/extractor.cc b/formats/gif/extractor.cc new file mode 100644 index 0000000..e74c306 --- /dev/null +++ b/formats/gif/extractor.cc
@@ -0,0 +1,202 @@ +// 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 "formats/gif/extractor.h" + +#include <algorithm> +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/gif/constants.h" +#include "formats/gif/reader.h" +#include "jumbf/utils.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +constexpr uint64_t kMaxPayloadSize = 10 * 1024 * 1024; // 10 MiB + +absl::StatusOr<std::string> DecodeDataBlocks(absl::string_view data) { + std::string result; + result.reserve(data.size()); + + for (size_t i = 0; i < data.size();) { + uint8_t block_size = data[i]; + i++; + if (block_size == 0) { + break; + } + if (i + block_size > data.size()) { + return absl::InvalidArgumentError("Invalid block size in GIF C2PA data"); + } + result.append(data.substr(i, block_size)); + i += block_size; + } + return result; +} + +} // namespace + +absl::StatusOr<std::string> GifExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + ABSL_ASSIGN_OR_RETURN( + std::optional<ByteRange> c2pa_location, + ExtractManifestStoreLocation(input, {.requires_c2pa = true})); + if (!c2pa_location.has_value()) { + return absl::NotFoundError("No manifest store found"); + } + + // The block starts with two markers identifying it as an application ext. + // Then there is a single byte identifying the size of the extension info. + // After that is the extension info itself and then starts the alternating + // size and data bytes for the manifest store. + int64_t manifest_data_offset = c2pa_location->offset + 14; + int64_t manifest_data_length = c2pa_location->length - 14; + + if (manifest_data_length < 0 || manifest_data_length > kMaxPayloadSize) { + return absl::InvalidArgumentError( + absl::StrCat("Manifest store is too large: ", manifest_data_length, + " > ", kMaxPayloadSize)); + } + + if (!input.Seek(manifest_data_offset)) { + return input.StatusOrAnnotate( + absl::InvalidArgumentError("Failed to seek to manifest data offset")); + } + std::string encoded_manifest_store; + if (!input.Read(manifest_data_length, encoded_manifest_store)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read manifest data from input")); + } + + // Data is stored as blocks of size-prefixed data. + return DecodeDataBlocks(encoded_manifest_store); +} + +absl::StatusOr<std::optional<ByteRange>> +GifExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + std::optional<GifBlock> c2pa_block = std::nullopt; + absl::Status iteration_status = IterateOverGifBlocks( + input, + [&options, &c2pa_block](const GifBlock& block) -> absl::StatusOr<bool> { + if (block.offset >= options.end_offset || + block.length > options.end_offset - block.offset) { + // We've gone past the declared end of the input. + return false; // stop processing + } + if (block.type == kGifImageDescriptorLabel) { + // The manifest store must be before the first image descriptor. + // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_embedding_manifests_into_gifs + return false; // stop processing + } + if (block.type == "C2PA") { + if (c2pa_block.has_value()) { + return absl::NotFoundError("Multiple manifest stores found"); + } + c2pa_block = std::move(block); + } + return true; + }, + options.end_offset); + ABSL_RETURN_IF_ERROR(iteration_status); + if (!c2pa_block.has_value()) { + if (options.requires_c2pa) { + return absl::NotFoundError("No manifest store found"); + } + return std::nullopt; + } + return ByteRange{.offset = c2pa_block->offset, .length = c2pa_block->length}; +} + +absl::StatusOr<std::vector<AssetBox>> GifExtractor::ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const { + if (!input.SupportsSize() || !input.Size().has_value()) { + return absl::InvalidArgumentError( + "Input does not support size or size is unknown"); + } + uint64_t end_offset = input.Size().value(); + if (options.end_offset >= 0) { + end_offset = + std::min(end_offset, static_cast<uint64_t>(options.end_offset)); + } + + std::vector<AssetBox> blocks; + bool c2pa_block_found = false; + absl::Status iteration_status = IterateOverGifBlocks( + input, + [&end_offset, &c2pa_block_found, + &blocks](const GifBlock& block) -> absl::StatusOr<bool> { + if (block.offset >= end_offset) { + // The box is past the declared end of the input. + return false; // stop processing + } + if (block.length > end_offset - block.offset) { + // The box extends past the declared end of the input. + if (block.type == "c2pa.after") { + // We can truncate the c2pa.after block to the end of the input. + blocks.push_back(AssetBox{ + .identifier = block.type, + .byte_range = {.offset = block.offset, + .length = end_offset - block.offset}, + }); + return false; + } + return absl::InvalidArgumentError( + "Block extends past the declared end of the input"); + } + if (block.type == "C2PA") { + if (c2pa_block_found) { + return absl::NotFoundError("Multiple manifest stores found"); + } + c2pa_block_found = true; + } + + blocks.push_back(AssetBox{ + .identifier = block.type, + .byte_range = {.offset = block.offset, .length = block.length}, + }); + return true; + }, + end_offset); + ABSL_RETURN_IF_ERROR(iteration_status); + if (!c2pa_block_found && options.requires_c2pa) { + return absl::NotFoundError("No manifest store found"); + } + return blocks; +} + +bool GifExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} + +} // namespace credentio
diff --git a/formats/gif/extractor.h b/formats/gif/extractor.h new file mode 100644 index 0000000..71afabb --- /dev/null +++ b/formats/gif/extractor.h
@@ -0,0 +1,53 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Implements the C2PA Manifest Store extractor for GIF files as described in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_png +class GifExtractor : public FormatExtractor { + public: + GifExtractor() = default; + ~GifExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override; + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_EXTRACTOR_H_
diff --git a/formats/gif/extractor_test.cc b/formats/gif/extractor_test.cc new file mode 100644 index 0000000..e879961 --- /dev/null +++ b/formats/gif/extractor_test.cc
@@ -0,0 +1,394 @@ +// 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 "formats/gif/extractor.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/asset_byte_info.h" +#include "formats/byte_range.h" +#include "formats/extractor_result.h" +#include "formats/gif/constants.h" +#include "formats/gif/test_builder.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/jumbf_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +class NoSizeStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return false; } +}; + +class SizeNotValuedStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return true; } + + protected: + std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; } +}; + +constexpr int64_t kMaxChunkBytes = 2 * 1048576; // 2MiB + +struct TestCase { + std::string name; + std::string contents; + absl::StatusOr<ExtractorResult> result; + uint32_t max_chunk_bytes = kMaxChunkBytes; +}; + +class ExtractorTest : public testing::TestWithParam<TestCase> {}; + +INSTANTIATE_TEST_SUITE_P( + ExtractorTests, ExtractorTest, + testing::ValuesIn({ + TestCase{ + .name = "Valid", + .contents = + []() { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, + kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", + "manifest_store"); + return builder.GetAsset(); + }(), + .result = + ExtractorResult{ + .manifest_store = "manifest_store", + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 13, + .length = 30}, + .boxes = {AssetBox{.identifier = "GIF89a", + .byte_range = {.offset = 0, + .length = 6}}, + AssetBox{.identifier = "LSD", + .byte_range = {.offset = 6, + .length = 7}}, + AssetBox{.identifier = "C2PA", + .byte_range = {.offset = 13, + .length = 30}}}, + }, + }, + }, + TestCase{ + .name = "NoC2pa", + .contents = + []() { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + return builder.GetAsset(); + }(), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "MultipleC2pa", + .contents = + []() { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock( + kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "first_store"); + builder.AddBlock( + kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "second_store"); + return builder.GetAsset(); + }(), + .result = absl::NotFoundError("Multiple manifest stores found"), + }, + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +TEST_P(ExtractorTest, Extract) { + const TestCase& param = GetParam(); + const absl::StatusOr<ExtractorResult>& expected = param.result; + riegeli::StringReader<> input(param.contents); + absl::StatusOr<std::string> result = + GifExtractor().ExtractManifestStore(input); + + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected->manifest_store)); + } else { + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) { + const TestCase& param = GetParam(); + const absl::StatusOr<ExtractorResult>& expected = param.result; + std::string contents = "padding" + param.contents; + riegeli::StringReader<> input(contents); + ASSERT_TRUE(input.Seek(7)); + absl::StatusOr<std::string> result = + GifExtractor().ExtractManifestStore(input); + + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected->manifest_store)); + } else { + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffixPadded) { + const TestCase& param = GetParam(); + const absl::StatusOr<ExtractorResult>& expected = param.result; + std::string contents = param.contents + "padding"; + riegeli::StringReader<> input(contents); + + absl::StatusOr<std::optional<ByteRange>> result = + GifExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(contents.size() - 7)}); + + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds( + expected->asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxesWithSuffixPadded) { + const TestCase& param = GetParam(); + const absl::StatusOr<ExtractorResult>& expected = param.result; + std::string contents = param.contents + "padding"; + riegeli::StringReader<> input(contents); + + absl::StatusOr<std::vector<AssetBox>> result = GifExtractor().ExtractBoxes( + input, {.end_offset = static_cast<int64_t>(contents.size()) - 7}); + + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected->asset_byte_info.boxes)); + } else { + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationRequiresC2pa) { + const TestCase& param = GetParam(); + const absl::StatusOr<ExtractorResult>& expected = param.result; + riegeli::StringReader<> input(param.contents); + absl::StatusOr<std::optional<ByteRange>> result = + GifExtractor().ExtractManifestStoreLocation(input, {}); + + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds( + expected->asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationDoesNotRequireC2pa) { + const TestCase& param = GetParam(); + const absl::StatusOr<ExtractorResult>& expected = param.result; + riegeli::StringReader<> input(param.contents); + absl::StatusOr<std::optional<ByteRange>> result = + GifExtractor().ExtractManifestStoreLocation(input, + {.requires_c2pa = false}); + + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds( + expected->asset_byte_info.manifest_store_location)); + } else if (expected.status().code() == absl::StatusCode::kNotFound && + expected.status().message() != "Multiple manifest stores found") { + EXPECT_THAT(result, IsOkAndHolds(std::nullopt)); + } else { + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxes) { + const TestCase& param = GetParam(); + const absl::StatusOr<ExtractorResult>& expected = param.result; + riegeli::StringReader<> input(param.contents); + absl::StatusOr<std::vector<AssetBox>> result = + GifExtractor().ExtractBoxes(input, {}); + + if (expected.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected->asset_byte_info.boxes)); + } else { + EXPECT_THAT(result, StatusIs(expected.status().code(), + HasSubstr(expected.status().message()))); + } +} + +TEST(GifExtractorTest, FailsWhenSizeNotSupported) { + std::string contents = "test"; + NoSizeStringReader input(contents); + + GifExtractor extractor; + EXPECT_THAT(extractor.ExtractBoxes(input, {}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Input does not support size"))); +} + +TEST(GifExtractorTest, FailsWhenSizeHasNoValue) { + std::string contents = "test"; + SizeNotValuedStringReader input(contents); + + GifExtractor extractor; + EXPECT_THAT(extractor.ExtractBoxes(input, {}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Input does not support size"))); +} + +TEST(GifExtractorTest, + ExtractManifestStoreLocationStopsWhenBlockExceedsEndOffset) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "manifest_store"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + // LSD is at [6, 13). Set end_offset to 10. + // It should stop at LSD and return NotFoundError because C2PA (at 13) is not + // reached. + absl::StatusOr<std::optional<ByteRange>> result = + GifExtractor().ExtractManifestStoreLocation(input, {.end_offset = 10}); + + EXPECT_THAT(result, StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found"))); +} + +TEST(GifExtractorTest, ExtractBoxesFailsWhenBlockExceedsEndOffset) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + // LSD is at [6, 13). Set end_offset to 10. + // It should fail with InvalidArgumentError because LSD extends past 10. + absl::StatusOr<std::vector<AssetBox>> result = + GifExtractor().ExtractBoxes(input, {.end_offset = 10}); + + EXPECT_THAT( + result, + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Block extends past the declared end of the input"))); +} + +TEST(GifExtractorTest, + ExtractManifestStoreLocationIgnoresC2paAfterImageDescriptor) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddImageDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "manifest_store"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + // It should stop at the image descriptor and not find the C2PA block. + absl::StatusOr<std::optional<ByteRange>> result = + GifExtractor().ExtractManifestStoreLocation(input, {}); + + EXPECT_THAT(result, StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found"))); +} + +class FakeGifExtractor : public GifExtractor { + public: + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override { + if (fake_location_.has_value()) { + return fake_location_; + } + return GifExtractor::ExtractManifestStoreLocation(input, options); + } + void SetFakeLocation(ByteRange loc) { fake_location_ = loc; } + + private: + mutable std::optional<ByteRange> fake_location_; +}; + +TEST(GifExtractorTest, ExtractManifestStoreFailsOnMalformedC2paData) { + std::string contents; + contents.resize(14, 'A'); // 14 bytes of padding + contents += '\005'; // block size 5 + contents += "abc"; // but only 3 bytes + + riegeli::StringReader<> input(contents); + + FakeGifExtractor extractor; + extractor.SetFakeLocation(ByteRange{.offset = 0, .length = 18}); + + absl::StatusOr<std::string> result = extractor.ExtractManifestStore(input); + + EXPECT_THAT(result, + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid block size in GIF C2PA data"))); +} + +TEST(GifExtractorTest, ExtractManifestStoreFailsOnOversizedData) { + std::string contents(10, 'A'); + riegeli::StringReader<> input(contents); + + FakeGifExtractor extractor; + extractor.SetFakeLocation( + ByteRange{.offset = 0, .length = 11 * 1024 * 1024 + 14}); + + absl::StatusOr<std::string> result = extractor.ExtractManifestStore(input); + + EXPECT_THAT(result, StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Manifest store is too large"))); +} + +TEST(GifExtractorTest, MightBeC2paManifestStore) { + EXPECT_TRUE(GifExtractor().MightBeC2paManifestStore( + CreateStartOfManifestStorePayload())); + EXPECT_FALSE(GifExtractor().MightBeC2paManifestStore("not a manifest store")); +} + +} // namespace +} // namespace credentio
diff --git a/formats/gif/format.cc b/formats/gif/format.cc new file mode 100644 index 0000000..60b9cfd --- /dev/null +++ b/formats/gif/format.cc
@@ -0,0 +1,35 @@ +// 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 "formats/gif/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/gif/assessor.h" +#include "formats/gif/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateGifFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<GifAssessor>(), + .extractor = std::make_unique<GifExtractor>(), + .mime_types = {"image/gif"}, + }); +} + +} // namespace credentio
diff --git a/formats/gif/format.h b/formats/gif/format.h new file mode 100644 index 0000000..e42c6ef --- /dev/null +++ b/formats/gif/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateGifFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_FORMAT_H_
diff --git a/formats/gif/reader.cc b/formats/gif/reader.cc new file mode 100644 index 0000000..37889ff --- /dev/null +++ b/formats/gif/reader.cc
@@ -0,0 +1,276 @@ +// 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 "formats/gif/reader.h" + +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "formats/gif/constants.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +struct ColorTable { + bool has_table; + uint8_t size; + + static ColorTable FromPacked(uint8_t packed) { + return { + .has_table = (packed & 0x80) != 0, + .size = static_cast<uint8_t>(packed & 0x07), + }; + } +}; + +absl::Status SeekToEndOfData(riegeli::Reader& input) { + uint8_t size = 0x00; + do { + if (!input.ReadByte(size)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read byte")); + } + if (!input.ok()) { + return input.status(); + } + + if (size > 0) { + if (!input.Skip(size)) { + return absl::InternalError("Failed to seek to offset"); + } + if (!input.ok()) { + return input.status(); + } + } + } while (size > 0); + + return absl::OkStatus(); +} + +absl::StatusOr<GifBlock> ReadHeader(riegeli::Reader& input) { + GifBlock block = {.offset = input.pos()}; + + std::string data; + if (!input.Read(kGifHeaderBlock.size(), data)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read GIF header block")); + } + if (data != kGifHeaderBlock) { + return absl::InvalidArgumentError("Invalid GIF header block"); + } + + block.length = data.size(); + block.type = data; + return std::move(block); +} + +absl::Status PopulateDescriptor(riegeli::Reader& input, + int64_t packed_byte_offset, GifBlock& block) { + std::string data; + if (!input.Read(block.length, data)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read descriptor")); + } + + ColorTable packed = ColorTable::FromPacked(data[packed_byte_offset]); + + if (packed.has_table) { + int64_t ct_length = 3 * (1 << (packed.size + 1)); + block.length += ct_length; + } + + return absl::OkStatus(); +} + +absl::Status PopulateLogicalDescriptor(riegeli::Reader& input, + GifBlock& block) { + block.length = 7; + block.type = std::string(kGifLogicalScreenDescriptorLabel); + return PopulateDescriptor(input, 4, block); +} + +absl::Status PopulateImageDescriptor(riegeli::Reader& input, GifBlock& block) { + // Backup one byte so we can read the introducer byte. + if (!input.Seek(input.pos() - 1)) { + return absl::InternalError("Failed to seek to offset"); + } + block.length = 10; + block.type = std::string(kGifImageDescriptorLabel); + return PopulateDescriptor(input, 9, block); +} + +absl::Status PopulateBlock(riegeli::Reader& input, GifBlock& block) { + ABSL_RETURN_IF_ERROR(SeekToEndOfData(input)); + block.length = input.pos() - block.offset; + return absl::OkStatus(); +} + +absl::Status PopulateApplicationExtension(riegeli::Reader& input, + GifBlock& block) { + int64_t next_size_offset = input.pos(); + + uint8_t extension_info_size = 0x00; + if (!input.ReadByte(extension_info_size)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read extension info size")); + } + + next_size_offset += 1 + extension_info_size; + + if (extension_info_size == kGifC2paExtensionInfoSize) { + std::string data; + if (!input.Read(kGifC2paIdentifier.size(), data)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read c2pa identifier")); + } + if (data == kGifC2paIdentifier) { + block.type = kGifC2paLabel; + } + } + + // Seek past the remaining Application Extension Information + if (!input.Seek(next_size_offset)) { + return absl::InternalError("Failed to seek to offset"); + } + + return PopulateBlock(input, block); +} + +} // namespace + +absl::Status IterateOverGifBlocks(riegeli::Reader& input, + GifBlockProcessor processor, + int64_t end_offset) { + if (!input.SupportsSize() || !input.Size().has_value()) { + return absl::InvalidArgumentError( + "Input does not support size or size is unknown"); + } + + uint64_t local_end_offset = + end_offset < 0 ? input.Size().value() : end_offset; + + ABSL_ASSIGN_OR_RETURN(auto header, ReadHeader(input)); + ABSL_ASSIGN_OR_RETURN(bool should_continue, processor(header)); + ABSL_RETURN_IF_ERROR(input.status()); + if (!should_continue) { + return absl::OkStatus(); + } + + if (input.pos() >= local_end_offset) { + return absl::OkStatus(); + } + + GifBlock logical_descriptor = {.offset = input.pos()}; + ABSL_RETURN_IF_ERROR(PopulateLogicalDescriptor(input, logical_descriptor)); + ABSL_ASSIGN_OR_RETURN(should_continue, processor(logical_descriptor)); + ABSL_RETURN_IF_ERROR(input.status()); + if (!should_continue) { + return absl::OkStatus(); + } + + if (!input.Seek(logical_descriptor.offset + logical_descriptor.length)) { + return input.StatusOrAnnotate(absl::InternalError( + "Failed to seek to offset after logical descriptor")); + } + + while (input.pos() < local_end_offset) { + GifBlock block = {.offset = input.pos()}; + + uint8_t introducer = 0x00; + if (!input.ReadByte(introducer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read introducer")); + } + + switch (introducer) { + case kGifExtensionIntroducer: { + // is extension + uint8_t extension_type = 0x00; + if (!input.ReadByte(extension_type)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read extension type")); + } + + switch (extension_type) { + case kGifExtensionPlainText: + block.type = std::string(kGifExtensionPlainTextLabel); + ABSL_RETURN_IF_ERROR(PopulateBlock(input, block)); + break; + case kGifExtensionGraphicsControl: + block.type = std::string(kGifExtensionGraphicsControlLabel); + ABSL_RETURN_IF_ERROR(PopulateBlock(input, block)); + break; + case kGifExtensionComment: + block.type = std::string(kGifExtensionCommentLabel); + ABSL_RETURN_IF_ERROR(PopulateBlock(input, block)); + break; + case kGifExtensionApplication: + block.type = std::string(kGifExtensionApplicationLabel); + ABSL_RETURN_IF_ERROR(PopulateApplicationExtension(input, block)); + break; + default: + return absl::UnimplementedError(absl::StrFormat( + "Extension Type not supported: %#04x", extension_type)); + } + break; + } + case kGifImageDescriptorIntroducer: { + ABSL_RETURN_IF_ERROR(PopulateImageDescriptor(input, block)); + break; + } + case kGifTrailerIntroducer: { + block.length = 1; + block.type = std::string(kGifTrailerLabel); + break; + } + default: + block.type = std::string(kGifImageDataLabel); + ABSL_RETURN_IF_ERROR(PopulateBlock(input, block)); + } + + ABSL_ASSIGN_OR_RETURN(should_continue, processor(block)); + ABSL_RETURN_IF_ERROR(input.status()); + if (!should_continue) { + return absl::OkStatus(); + } + + if (!input.Seek(block.offset + block.length)) { + return input.StatusOrAnnotate( + absl::InternalError("Failed to seek to offset")); + } + + if (block.type == kGifTrailerLabel) { + break; + } + } + + if (input.pos() < local_end_offset) { + GifBlock after_label = {.offset = input.pos(), + .length = local_end_offset - input.pos(), + .type = "c2pa.after"}; + return processor(after_label).status(); + } + + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/formats/gif/reader.h b/formats/gif/reader.h new file mode 100644 index 0000000..70e5bb1 --- /dev/null +++ b/formats/gif/reader.h
@@ -0,0 +1,51 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_READER_H_ + +#include <cstdint> +#include <string> + +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// GIF Reader for C2PA, holds onto a reference to a riegeli::Reader which +// must outlive the reader. +struct GifBlock { + uint64_t offset; + uint64_t length; + std::string type; + + bool operator==(const GifBlock& other) const { + return offset == other.offset && length == other.length && + type == other.type; + } +}; + +using GifBlockProcessor = + absl::FunctionRef<absl::StatusOr<bool>(const GifBlock&)>; + +absl::Status IterateOverGifBlocks(riegeli::Reader& input, + GifBlockProcessor processor, + int64_t end_offset = -1); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_READER_H_
diff --git a/formats/gif/reader_test.cc b/formats/gif/reader_test.cc new file mode 100644 index 0000000..e4ce30d --- /dev/null +++ b/formats/gif/reader_test.cc
@@ -0,0 +1,517 @@ +// 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 "formats/gif/reader.h" + +#include <optional> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "formats/gif/constants.h" +#include "formats/gif/test_builder.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +class NoSizeStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return false; } +}; + +class SizeNotValuedStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return true; } + + protected: + std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; } +}; + +absl::StatusOr<GifBlock> GetLastBlock(riegeli::Reader& input) { + GifBlock last_block; + if (auto status = IterateOverGifBlocks(input, + [&last_block](const GifBlock& block) { + last_block = block; + return true; + }); + !status.ok()) { + return status; + } + return last_block; +} + +TEST(GifReaderTest, ReadsHeader) { + TestGifBuilder builder; + builder.AddGifHeader(); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 0, + .length = 6, + .type = "GIF89a", + })); +} + +TEST(GifReaderTest, FailsOnInvalidHeader) { + std::string contents = "GIF87a"; + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(GifReaderTest, ReadsLogicalDescriptorWithoutColorTable) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 6, + .length = 7, + .type = "LSD", + })); +} + +TEST(GifReaderTest, ReadsLogicalDescriptorWithColorTable) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/true); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 6, + .length = 19, + .type = "LSD", + })); +} + +TEST(GifReaderTest, ReadsPlainTextExtension) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info", + "plain_text_data"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 28, + .type = "2101", + })); +} + +TEST(GifReaderTest, ReadsInvalidExtensionIntroducer) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, 0x0d); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("Extension Type not supported: 0x0d"))); +} + +TEST(GifReaderTest, ReadsGraphicsControlExtension) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionGraphicsControl, + "ext_info", "gce"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 16, + .type = "21F9", + })); +} + +TEST(GifReaderTest, ReadsComment) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionComment, "ext_info", + "comment"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 20, + .type = "21FE", + })); +} + +TEST(GifReaderTest, ReadsTrailer) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifTrailerIntroducer); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 1, + .type = "3B", + })); +} + +TEST(GifReaderTest, ReadsTableData) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(0x02, /*type=*/std::nullopt, /*info=*/std::nullopt, + "this_is_some_image_data"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 26, + .type = "TBID", + })); +} + +TEST(GifReaderTest, ReadsImageDescriptorWithoutColorTable) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddImageDescriptor(/*has_color_table=*/false); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 10, + .type = "2C", + })); +} + +TEST(GifReaderTest, ReadsImageDescriptorWithColorTable) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddImageDescriptor(/*has_color_table=*/true); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 22, + .type = "2C", + })); +} + +TEST(GifReaderTest, ReadsApplicationExtension) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + "ext_info", "data"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 17, + .type = "21FF", + })); +} + +TEST(GifReaderTest, ReadsC2paExtension) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication, + std::string(kGifC2paIdentifier) + "___", "manifest_store"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 13, + .length = 30, + .type = "C2PA", + })); +} + +TEST(GifReaderTest, ReadsPastTrailer) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifTrailerIntroducer); + std::string contents = builder.GetAsset() + "random_data_past_the_trailer"; + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{ + .offset = 14, + .length = 28, + .type = "c2pa.after", + })); +} + +TEST(GifReaderTest, FailsWhenSizeNotSupported) { + std::string contents = "test"; + NoSizeStringReader input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Input does not support size"))); +} + +TEST(GifReaderTest, FailsWhenSizeHasNoValue) { + std::string contents = "test"; + SizeNotValuedStringReader input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Input does not support size"))); +} + +TEST(GifReaderTest, StopsAtHeaderIfProcessorReturnsFalse) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + int call_count = 0; + EXPECT_THAT(IterateOverGifBlocks(input, + [&](const GifBlock& block) { + call_count++; + return false; + }), + IsOk()); + + EXPECT_EQ(call_count, 1); +} + +TEST(GifReaderTest, StopsAtLogicalDescriptorIfProcessorReturnsFalse) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info", + "plain_text_data"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + int call_count = 0; + EXPECT_THAT(IterateOverGifBlocks(input, + [&](const GifBlock& block) { + call_count++; + if (block.type == "LSD") { + return false; + } + return true; + }), + IsOk()); + + EXPECT_EQ(call_count, 2); +} + +TEST(GifReaderTest, StopsInsideLoopIfProcessorReturnsFalse) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info", + "plain_text_data"); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionComment, "ext_info", + "comment"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + int call_count = 0; + EXPECT_THAT( + IterateOverGifBlocks(input, + [&](const GifBlock& block) { + call_count++; + if (block.type == "2101") { // PlainText extension + return false; + } + return true; + }), + IsOk()); + + EXPECT_EQ(call_count, 3); // Header, LSD, PlainText +} + +TEST(GifReaderTest, FailsWhenReaderFailsDuringLogicalDescriptor) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, + [&](const GifBlock& block) { + if (block.type == "GIF89a") { + input.Fail( + absl::InternalError("forced read error")); + } + return true; + }), + StatusIs(absl::StatusCode::kInternal, HasSubstr("forced read error"))); +} + +TEST(GifReaderTest, FailsWhenSeekFailsAfterLogicalDescriptor) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, + [&](const GifBlock& block) { + if (block.type == "LSD") { + input.Fail( + absl::InternalError("forced seek error")); + } + return true; + }), + StatusIs(absl::StatusCode::kInternal, HasSubstr("forced seek error"))); +} + +TEST(GifReaderTest, FailsWhenSeekFailsInsideLoop) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info", + "plain_text_data"); + std::string contents = builder.GetAsset(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(IterateOverGifBlocks(input, + [&](const GifBlock& block) { + if (block.type == "2101") { + input.Fail(absl::InternalError( + "forced seek error inside loop")); + } + return true; + }), + StatusIs(absl::StatusCode::kInternal, + HasSubstr("forced seek error inside loop"))); +} + +TEST(GifReaderTest, FailsWhenSeekToEndOfDataFailsOnReadByte) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info", + "plain_text_data"); + std::string contents = builder.GetAsset(); + // Truncate the last byte of the block, which should be the 0x00 termination + // byte. + contents.pop_back(); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read byte"))); +} + +TEST(GifReaderTest, FailsWhenSeekToEndOfDataFailsOnSkip) { + TestGifBuilder builder; + builder.AddGifHeader(); + builder.AddLogicalDescriptor(/*has_color_table=*/false); + builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info", + "plain_text_data"); + std::string contents = builder.GetAsset(); + // Truncate the contents by 5 bytes (so skip will fail because we claim size + // is 15 but only 10 left). + for (int i = 0; i < 5; i++) { + contents.pop_back(); + } + + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }), + StatusIs(absl::StatusCode::kInternal, + HasSubstr("Failed to seek to offset"))); +} + +TEST(GifReaderTest, FailsOnHeaderTooShort) { + std::string contents = "GIF"; + + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read GIF header block"))); +} + +TEST(GifReaderTest, FailsOnDescriptorTooShort) { + TestGifBuilder builder; + builder.AddGifHeader(); + std::string contents = builder.GetAsset() + "123"; // Only 3 bytes for LSD + + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to read descriptor"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/gif/test_builder.cc b/formats/gif/test_builder.cc new file mode 100644 index 0000000..351e147 --- /dev/null +++ b/formats/gif/test_builder.cc
@@ -0,0 +1,102 @@ +// 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 "formats/gif/test_builder.h" + +#include <algorithm> +#include <cmath> +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "formats/gif/constants.h" + +namespace credentio { +namespace { + +std::string EncodeDataToBlocks(std::string data, + bool add_termination_block = true) { + std::vector<uint8_t> blocks; + blocks.reserve(data.size() + std::ceil(data.size() / 255.0) + 1); + + for (int64_t i = 0; i < data.size(); i += 255) { + int64_t block_size = std::min<int64_t>(255, data.size() - i); + blocks.push_back(static_cast<uint8_t>(block_size)); + blocks.insert(blocks.end(), data.begin() + i, + data.begin() + i + block_size); + } + if (add_termination_block) { + blocks.push_back(0); // Add a zero-length block to signify the end. + } + return std::string(blocks.begin(), blocks.end()); +} + +} // namespace + +void TestGifBuilder::AddGifHeader() { gif_data_ += "GIF89a"; } + +void TestGifBuilder::AddLogicalDescriptor(bool has_color_table) { + uint8_t packedField = has_color_table ? 0x91 : 0x00; + uint8_t backgroundColorIndex = has_color_table ? 0x11 : 0x00; + + std::vector<uint8_t> lsd = {1, 1, 2, 2, packedField, backgroundColorIndex, 0}; + gif_data_ += std::string(lsd.begin(), lsd.end()); + + if (has_color_table) { + std::vector<uint8_t> color_table = { + 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + }; + gif_data_ += std::string(color_table.begin(), color_table.end()); + } +} + +void TestGifBuilder::AddImageDescriptor(bool has_color_table) { + uint8_t packedField = has_color_table ? 0x91 : 0x00; + + std::vector<uint8_t> lsd = { + kGifImageDescriptorIntroducer, 1, 1, 1, 1, 1, 1, 1, 1, packedField}; + gif_data_ += std::string(lsd.begin(), lsd.end()); + + if (has_color_table) { + std::vector<uint8_t> color_table = { + 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + }; + gif_data_ += std::string(color_table.begin(), color_table.end()); + } +} + +void TestGifBuilder::AddBlock(uint8_t introducer, std::optional<uint8_t> type, + std::optional<std::string> info, + std::optional<std::string> data) { + gif_data_ += static_cast<char>(introducer); + + if (type.has_value()) { + gif_data_ += static_cast<char>(type.value()); + } + + if (info.has_value()) { + gif_data_ += + EncodeDataToBlocks(info.value(), /*add_termination_block=*/false); + } + + if (data.has_value()) { + gif_data_ += EncodeDataToBlocks(data.value()); + } +} + +std::string TestGifBuilder::GetAsset() { return gif_data_; } + +} // namespace credentio
diff --git a/formats/gif/test_builder.h b/formats/gif/test_builder.h new file mode 100644 index 0000000..288217f --- /dev/null +++ b/formats/gif/test_builder.h
@@ -0,0 +1,50 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_TEST_BUILDER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_TEST_BUILDER_H_ + +#include <cstdint> +#include <optional> +#include <string> + +namespace credentio { + +class TestGifBuilder { + public: + // Adds a GIF header block. + void AddGifHeader(); + + // Adds a GIF logical descriptor block. + void AddLogicalDescriptor(bool has_color_table); + + // Adds a GIF image descriptor block. + void AddImageDescriptor(bool has_color_table); + + // Adds a generic block to the GIF. + void AddBlock(uint8_t introducer, std::optional<uint8_t> type = std::nullopt, + std::optional<std::string> info = std::nullopt, + std::optional<std::string> data = std::nullopt); + + // Returns the built GIF. + std::string GetAsset(); + + private: + std::string gif_data_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_GIF_TEST_BUILDER_H_
diff --git a/formats/id3/BUILD b/formats/id3/BUILD new file mode 100644 index 0000000..90a715f --- /dev/null +++ b/formats/id3/BUILD
@@ -0,0 +1,158 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "id3", + srcs = ["id3.cc"], + hdrs = ["id3.h"], + deps = [ + ":constants", + "//utils:riegeli", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "id3_test", + srcs = ["id3_test.cc"], + deps = [ + ":id3", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":id3", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = ["@abseil-cpp//absl/strings:string_view"], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + deps = [ + ":id3", + ":reader", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":constants", + ":id3", + ":reader", + "//constants:labels", + "//formats:asset_byte_info", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":extractor", + "//formats:byte_range", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":constants", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/id3/assessor.cc b/formats/id3/assessor.cc new file mode 100644 index 0000000..ded3ee3 --- /dev/null +++ b/formats/id3/assessor.cc
@@ -0,0 +1,89 @@ +// 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 "formats/id3/assessor.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/id3/constants.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { +namespace { + +// The first 5 bytes of an ID3 v2.3 tag header in uint64_t format. +constexpr uint64_t kId3v23Header = + ((uint64_t)kId3FileIdentifierAndVersionV23[0] << 32) | + ((uint64_t)kId3FileIdentifierAndVersionV23[1] << 24) | + ((uint64_t)kId3FileIdentifierAndVersionV23[2] << 16) | + ((uint64_t)kId3FileIdentifierAndVersionV23[3] << 8) | + ((uint64_t)kId3FileIdentifierAndVersionV23[4]); + +// The first 5 bytes of an ID3 v2.4 tag header in uint64_t format. +constexpr uint64_t kId3v24Header = + ((uint64_t)kId3FileIdentifierAndVersionV24[0] << 32) | + ((uint64_t)kId3FileIdentifierAndVersionV24[1] << 24) | + ((uint64_t)kId3FileIdentifierAndVersionV24[2] << 16) | + ((uint64_t)kId3FileIdentifierAndVersionV24[3] << 8) | + ((uint64_t)kId3FileIdentifierAndVersionV24[4]); + +// The first 2 bytes of an MPEG Version 2 Layer 3 frame header, with CRC +// protection enabled. +constexpr uint64_t kMp3MpegFrameHeader = 0xFFF3ULL; + +// The FLAC stream marker "fLaC". +constexpr uint32_t kFlacMarker = 0x664C6143; + +// Returns true if the `header` starts with the ID3 v2.3 or v2.4 identifier. +// +// For a MP3 file that contains ID3 tags, the ID3 tags can be prepended to the +// audio data. +bool StartsWithId3Header(uint64_t header) { + // Compare the first 5 bytes. + return (header >> 24) == kId3v23Header || (header >> 24) == kId3v24Header; +} + +// Returns true if the `header` starts with a MPEG frame, indicating a MP3 file. +bool StartsWithMp3MpegFrameHeader(uint64_t header) { + // Compare the first 15 bits, ignoring the protection bit. + return (header >> 49) == (kMp3MpegFrameHeader >> 1); +} + +// Returns true if the `header` starts with FLAC marker. +bool StartsWithFlacMarker(uint64_t header) { + // Compare the first 4 bytes. + return (header >> 32) == kFlacMarker; +} +} // namespace + +absl::StatusOr<bool> Id3Assessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + uint64_t header; + if (!riegeli::ReadBigEndian<uint64_t>(input, header)) { + return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof")); + } + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to starting position")); + } + + return StartsWithId3Header(header) || StartsWithMp3MpegFrameHeader(header) || + StartsWithFlacMarker(header); +} +} // namespace credentio
diff --git a/formats/id3/assessor.h b/formats/id3/assessor.h new file mode 100644 index 0000000..a79d30e --- /dev/null +++ b/formats/id3/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the ID3-compatible audio format. +class Id3Assessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_ASSESSOR_H_
diff --git a/formats/id3/assessor_test.cc b/formats/id3/assessor_test.cc new file mode 100644 index 0000000..9c6c517 --- /dev/null +++ b/formats/id3/assessor_test.cc
@@ -0,0 +1,92 @@ +// 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 "formats/id3/assessor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(IsSupportedTest, TooFewBytes) { + std::string audio = "\xFF"; + riegeli::StringReader<> input(audio); + + EXPECT_THAT( + Id3Assessor().IsSupported(input), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); +} + +TEST(IsSupportedTest, FalseForInvalidBeginning) { + std::string audio = "this_is_not_a_mp3"; + riegeli::StringReader<> input(audio); + + EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, ValidId3v23Header) { + std::string audio("ID3\x03\x00\xFF\xFF\xFF", 8); + riegeli::StringReader<> input(audio); + + EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidId3v24Header) { + std::string audio("ID3\x04\x00\xFF\xFF\xFF", 8); + riegeli::StringReader<> input(audio); + + EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidMp3MpegFrameHeader) { + std::string audio = "\xFF\xF2\xFF\xFF\xFF\xFF\xFF\xFF"; + riegeli::StringReader<> input(audio); + + EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidFlacHeader) { + std::string audio("fLaC\x00\x00\x00\x22", 8); + riegeli::StringReader<> input(audio); + + EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidStartingBytesAtOffset2) { + std::string audio = "ab\xFF\xF3\xFF\xFF\xFF\xFF\xFF\xFF"; + riegeli::StringReader<> input(audio); + + // Invalid at 0 + EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +} // namespace +} // namespace credentio
diff --git a/formats/id3/constants.h b/formats/id3/constants.h new file mode 100644 index 0000000..0178f33 --- /dev/null +++ b/formats/id3/constants.h
@@ -0,0 +1,39 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_CONSTANTS_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +// The first five bytes of an ID3 v2.3 tag header. See +// https://id3.org/id3v2.3.0#ID3v2_header +constexpr char kId3FileIdentifierAndVersionV23[] = {'I', 'D', '3', '\x03', + '\x00'}; +// The first five bytes of an ID3 v2.4 tag header. See +// https://id3.org/id3v2.4.0-structure +constexpr char kId3FileIdentifierAndVersionV24[] = {'I', 'D', '3', '\x04', + '\x00'}; + +// A deprecated MIME type still used by c2pa-rs SDK. +constexpr absl::string_view kGeobFrameMimeTypeDeprecated = + "application/x-c2pa-manifest-store"; +constexpr absl::string_view kGeobFrameMimeType = "application/c2pa"; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_CONSTANTS_H_
diff --git a/formats/id3/extractor.cc b/formats/id3/extractor.cc new file mode 100644 index 0000000..8273cb2 --- /dev/null +++ b/formats/id3/extractor.cc
@@ -0,0 +1,116 @@ +// 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 "formats/id3/extractor.h" + +#include <optional> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/byte_range.h" +#include "formats/id3/constants.h" +#include "formats/id3/id3.h" +#include "formats/id3/reader.h" +#include "jumbf/utils.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +struct C2paManifestStore { + ByteRange location; + std::string content; +}; + +bool ContainsC2paManifestStore(const GeobFrame& geob) { + return geob.mime_type == kGeobFrameMimeType || + geob.mime_type == kGeobFrameMimeTypeDeprecated; +} + +absl::StatusOr<C2paManifestStore> ExtractManifestStoreInternal( + riegeli::Reader& input) { + C2paManifestStore manifest_store; + + auto status = IterateOverId3Frames( + input, [&](const Id3Frame& frame) -> absl::StatusOr<bool> { + if (frame.id == "GEOB") { + ABSL_ASSIGN_OR_RETURN(const GeobFrame geob, + GeobFrame::Parse(input, frame.data_offset(), + frame.data_length())); + if (ContainsC2paManifestStore(geob)) { + if (!manifest_store.content.empty()) { + return absl::NotFoundError("Multiple manifest stores found"); + } + manifest_store.location = { + .offset = frame.data_offset() + frame.data_length() - + geob.encapsulated_object.size(), + .length = geob.encapsulated_object.size(), + }; + manifest_store.content = std::move(geob.encapsulated_object); + } + } + return true; + }); + + if (absl::IsNotFound(status.status())) { + if (status.status().message() != "Multiple manifest stores found") { + return absl::NotFoundError("No manifest store found"); + } + return status.status(); + } + if (!status.ok()) { + return status.status(); + } + + if (manifest_store.content.empty()) { + return absl::NotFoundError("No manifest store found"); + } + return manifest_store; +} +} // namespace + +absl::StatusOr<std::string> Id3Extractor::ExtractManifestStore( + riegeli::Reader& input) const { + ABSL_ASSIGN_OR_RETURN(auto manifest_store, + ExtractManifestStoreInternal(input)); + return manifest_store.content; +} + +absl::StatusOr<std::optional<ByteRange>> +Id3Extractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + auto manifest_store = ExtractManifestStoreInternal(input); + + if (!manifest_store.ok()) { + if (options.requires_c2pa) { + return manifest_store.status(); + } + return std::nullopt; + } + return manifest_store->location; +} + +bool Id3Extractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} +} // namespace credentio
diff --git a/formats/id3/extractor.h b/formats/id3/extractor.h new file mode 100644 index 0000000..f90aba5 --- /dev/null +++ b/formats/id3/extractor.h
@@ -0,0 +1,55 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_byte_info.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Implements the C2PA Manifest Store extractor for ID3v2 compatible files as +// described in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_id3 +class Id3Extractor : public FormatExtractor { + public: + Id3Extractor() = default; + ~Id3Extractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("ID3 tag does not have a concept of boxes"); + } + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_EXTRACTOR_H_
diff --git a/formats/id3/extractor_test.cc b/formats/id3/extractor_test.cc new file mode 100644 index 0000000..3331874 --- /dev/null +++ b/formats/id3/extractor_test.cc
@@ -0,0 +1,186 @@ +// 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 "formats/id3/extractor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "formats/byte_range.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Eq; +using ::testing::Field; +using ::testing::HasSubstr; +using ::testing::Optional; + +#ifndef ASSERT_OK_AND_ASSIGN +#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y +#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) + +#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ + ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) + +#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ + auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ + ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ + ::absl_testing::IsOk()); \ + lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) +#endif + +constexpr unsigned char kId3TagWithC2paManifestStore[] = { + 'I', 'D', '3', // ID3 file identifier + 0x03, 0x00, // Version 2.3 + 0x00, // Flags + 0x00, 0x00, 0x00, 0x3E, // Tag size = 62 (52 + 10) + 'G', 'E', 'O', 'B', // Frame ID + 0x00, 0x00, 0x00, 0x34, // Frame Size = 52 + 0x00, 0x00, // Frame flags + 0x01, // Text encoding [UTF-16] + // MIME type + 'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'c', '2', 'p', + 'a', 0x00, + // File name [UTF-16LE] + 0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00, + // Content description [UTF-16LE] + 0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00, + // Encapsulated object + 'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'}; + +constexpr unsigned char kId3TagWithMultipleC2paManifestStores[] = { + 'I', 'D', '3', // ID3 file identifier + 0x03, 0x00, // Version 2.3 + 0x00, // Flags + 0x00, 0x00, 0x00, 0x7C, // Tag size = 124 (52 + 10 + 52 + 10) + // Frame 1 + 'G', 'E', 'O', 'B', // Frame ID + 0x00, 0x00, 0x00, 0x34, // Frame Size = 52 + 0x00, 0x00, // Frame flags + 0x01, // Text encoding [UTF-16] + // MIME type + 'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'c', '2', 'p', + 'a', 0x00, + // File name [UTF-16LE] + 0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00, + // Content description [UTF-16LE] + 0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00, + // Encapsulated object + 'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e', + // Frame 2 + 'G', 'E', 'O', 'B', // Frame ID + 0x00, 0x00, 0x00, 0x34, // Frame Size = 52 + 0x00, 0x00, // Frame flags + 0x01, // Text encoding [UTF-16] + // MIME type + 'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'c', '2', 'p', + 'a', 0x00, + // File name [UTF-16LE] + 0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00, + // Content description [UTF-16LE] + 0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00, + // Encapsulated object + 'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'}; + +constexpr unsigned char kId3TagWithC2paManifestStoreDeprecatedMimeType[] = { + 'I', 'D', '3', // ID3 file identifier + 0x03, 0x00, // Version 2.3 + 0x00, // Flags + 0x00, 0x00, 0x00, 0x4F, // Tag size = 79 (69 + 10) + 'G', 'E', 'O', 'B', // Frame ID + 0x00, 0x00, 0x00, 0x45, // Frame Size = 69 + 0x00, 0x00, // Frame flags + 0x01, // Text encoding [UTF-16] + // MIME type + 'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'x', '-', 'c', + '2', 'p', 'a', '-', 'm', 'a', 'n', 'i', 'f', 'e', 's', 't', '-', 's', 't', + 'o', 'r', 'e', 0x00, + // File name [UTF-16LE] + 0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00, + // Content description [UTF-16LE] + 0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00, + // Encapsulated object + 'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'}; + +TEST(Id3ExtractorTest, + ExtractManifestStoreFromGeobFrameWithDeprecatedMimeType) { + std::string mp3_file_contents = + std::string(reinterpret_cast<const char*>( + kId3TagWithC2paManifestStoreDeprecatedMimeType), + sizeof(kId3TagWithC2paManifestStoreDeprecatedMimeType)); + riegeli::StringReader<> input(mp3_file_contents); + Id3Extractor extractor; + auto result = extractor.ExtractManifestStore(input); + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, HasSubstr("manifest store")); +} + +TEST(Id3ExtractorTest, ExtractManifestStore) { + std::string mp3_file_contents = + std::string(reinterpret_cast<const char*>(kId3TagWithC2paManifestStore), + sizeof(kId3TagWithC2paManifestStore)); + riegeli::StringReader<> input(mp3_file_contents); + Id3Extractor extractor; + auto result = extractor.ExtractManifestStore(input); + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, HasSubstr("manifest store")); +} + +TEST(Id3ExtractorTest, + ExtractManifestStoreThrowsErrorIfMultipleC2paManifestStoresFound) { + std::string mp3_file_contents = std::string( + reinterpret_cast<const char*>(kId3TagWithMultipleC2paManifestStores), + sizeof(kId3TagWithMultipleC2paManifestStores)); + riegeli::StringReader<> input(mp3_file_contents); + Id3Extractor extractor; + + EXPECT_THAT(extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("Multiple manifest stores found"))); +} + +TEST(Id3ExtractorTest, ExtractManifestStoreLocation) { + std::string mp3_file_contents = + std::string(reinterpret_cast<const char*>(kId3TagWithC2paManifestStore), + sizeof(kId3TagWithC2paManifestStore)); + riegeli::StringReader<> input(mp3_file_contents); + Id3Extractor extractor; + ASSERT_OK_AND_ASSIGN( + auto manifest_store_location, + extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true})); + + EXPECT_THAT(manifest_store_location, + Optional(AllOf(Field(&ByteRange::offset, Eq(58)), + Field(&ByteRange::length, Eq(14))))); +} + +TEST(Id3ExtractorTest, ExtractBoxesUnimplemented) { + riegeli::StringReader<> input("test_content"); + Id3Extractor extractor; + EXPECT_THAT(extractor.ExtractBoxes(input, {.requires_c2pa = true}), + StatusIs(absl::StatusCode::kUnimplemented)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/id3/format.cc b/formats/id3/format.cc new file mode 100644 index 0000000..54a1db1 --- /dev/null +++ b/formats/id3/format.cc
@@ -0,0 +1,35 @@ +// 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 "formats/id3/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/id3/assessor.h" +#include "formats/id3/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateId3Format() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<Id3Assessor>(), + .extractor = std::make_unique<Id3Extractor>(), + .mime_types = {"audio/mpeg", "audio/flac"}, + }); +} + +} // namespace credentio
diff --git a/formats/id3/format.h b/formats/id3/format.h new file mode 100644 index 0000000..6f95556 --- /dev/null +++ b/formats/id3/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateId3Format(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_FORMAT_H_
diff --git a/formats/id3/id3.cc b/formats/id3/id3.cc new file mode 100644 index 0000000..7cf2cee --- /dev/null +++ b/formats/id3/id3.cc
@@ -0,0 +1,338 @@ +// 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 "formats/id3/id3.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "formats/id3/constants.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" +#include "utils/riegeli.h" + +namespace credentio { +namespace { + +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB + +constexpr int kUtf16BomSize = 2; + +enum class TextEncoding : uint8_t { + // Text terminated with 0x00. + kIso8859_1 = 0x00, + + // UTF-16 encoded Unicode with BOM. + // Text terminated with 0x00 0x00. + kUnicode = 0x01, + + // UTF-16BE encoded Unicode without BOM. + // Text terminated with 0x00 0x00. + kUtf16Be = 0x02, + + // UTF-8 encoded Unicode. + // Text terminated with 0x00. + kUtf8 = 0x03, +}; + +absl::StatusOr<TextEncoding> ReadTextEncoding(riegeli::Reader& input) { + uint8_t text_encoding_byte; + if (!input.ReadByte(text_encoding_byte)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read text encoding byte")); + } + if (text_encoding_byte <= 0x03) { + return static_cast<TextEncoding>(text_encoding_byte); + } + return absl::FailedPreconditionError( + absl::StrCat("Invalid text encoding value: ", text_encoding_byte)); +} + +absl::StatusOr<std::string> ReadUtf8Text(riegeli::Reader& input, + uint64_t max_length) { + std::string output; + if (!ReadNullTerminatedString(input, max_length, output)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read utf-8 text")); + } + return output; +} + +absl::Status SkipTerminatedUnicodeText(riegeli::Reader& input, + uint64_t max_length, bool read_bom) { + uint64_t read_size = 0; + if (read_bom) { + read_size = kUtf16BomSize; + if (!input.Skip(kUtf16BomSize)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to skip UTF-16 BOM")); + } + } + + uint16_t wide_char; + while (read_size < max_length) { + read_size += 2; + if (!riegeli::ReadBigEndian<uint16_t>(input, wide_char)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read UTF-16 Character")); + } + if (wide_char == 0) { + break; + } + } + return absl::OkStatus(); +} + +absl::Status SkipTerminatedText(riegeli::Reader& input, uint64_t max_length, + TextEncoding text_encoding) { + switch (text_encoding) { + case TextEncoding::kIso8859_1: + // ISO-8859-1 uses the same encoding as UTF-8 for the ASCII subset. + return ReadUtf8Text(input, max_length).status(); + case TextEncoding::kUnicode: + return SkipTerminatedUnicodeText(input, max_length, /*read_bom=*/true); + case TextEncoding::kUtf16Be: + return SkipTerminatedUnicodeText(input, max_length, /*read_bom=*/false); + case TextEncoding::kUtf8: + return ReadUtf8Text(input, max_length).status(); + default: + return absl::UnimplementedError( + absl::StrCat("Unsupported text encoding: ", text_encoding)); + } +} + +// Computes the ID3v2.3.0 tag size from a 4-byte syncsafe integer string. +// IDv2.3.0 uses syncsafe integer strings for the sizes. +absl::StatusOr<uint32_t> GetSizeFromSyncsafeBytes( + absl::string_view syncsafe_bytes) { + if (syncsafe_bytes.length() != 4) { + return absl::InvalidArgumentError("Size should be 4 bytes"); + } + // Cast the characters to unsigned 8-bit integers (bytes) + uint8_t byte1 = static_cast<uint8_t>(syncsafe_bytes[0]) & 0b01111111; + uint8_t byte2 = static_cast<uint8_t>(syncsafe_bytes[1]) & 0b01111111; + uint8_t byte3 = static_cast<uint8_t>(syncsafe_bytes[2]) & 0b01111111; + uint8_t byte4 = static_cast<uint8_t>(syncsafe_bytes[3]) & 0b01111111; + + // Combine the 7-bit chunks from each byte + uint32_t size = (byte1 << 21) | (byte2 << 14) | (byte3 << 7) | byte4; + + return size; +} +} // namespace + +absl::StatusOr<GeobFrame> GeobFrame::Parse(riegeli::Reader& input, + uint64_t data_offset, + uint64_t data_length) { + if (!input.Seek(data_offset)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to start of data")); + } + GeobFrame frame; + const uint64_t end_offset = data_offset + data_length; + + ABSL_ASSIGN_OR_RETURN(auto text_encoding, ReadTextEncoding(input)); + if (input.pos() > end_offset) { + return absl::DataLossError( + "Data structure exceeded specified offset boundary limit."); + } + + // MIME type is always ISO-8859-1 encoded. + ABSL_ASSIGN_OR_RETURN( + frame.mime_type, + ReadUtf8Text(input, /*max_length=*/end_offset - input.pos())); + if (input.pos() > end_offset) { + return absl::DataLossError( + "Data structure exceeded specified offset boundary limit."); + } + + // Skip filename. + ABSL_RETURN_IF_ERROR(SkipTerminatedText( + input, /*max_length=*/end_offset - input.pos(), text_encoding)); + if (input.pos() > end_offset) { + return absl::DataLossError( + "Data structure exceeded specified offset boundary limit."); + } + + // Skip content description. + ABSL_RETURN_IF_ERROR(SkipTerminatedText( + input, /*max_length=*/end_offset - input.pos(), text_encoding)); + if (input.pos() > end_offset) { + return absl::DataLossError( + "Data structure exceeded specified offset boundary limit."); + } + // Read the encapsulated object as raw bytes. + uint64_t payload_size = end_offset - input.pos(); + if (payload_size > kMaxPayloadSize) { + return absl::InvalidArgumentError( + "Encapsulated object size exceeds maximum payload size"); + } + if (!input.Read(payload_size, frame.encapsulated_object)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read encapsulated object")); + } + + return frame; +} + +absl::StatusOr<Id3Header> Id3Header::Parse(riegeli::Reader& input) { + Id3Header id3_header; + std::string identifier_and_version; + if (!input.Read(5, identifier_and_version)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read id and version")); + } + if (identifier_and_version == + std::string(kId3FileIdentifierAndVersionV23, 5)) { + id3_header.major_version = 3; + } else if (identifier_and_version == + std::string(kId3FileIdentifierAndVersionV24, 5)) { + id3_header.major_version = 4; + } else { + return absl::NotFoundError("ID3v2 tag not found"); + } + // Read the flag byte + uint8_t flag_byte; + if (!input.ReadByte(flag_byte)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read flag byte")); + } + // Check that the flag is valid + // Only bits 5-7 should be set. + if ((flag_byte & 0b00011111) != 0) { + return absl::InvalidArgumentError("invalid ID3v2 flag byte"); + } + // Check bit 7 for the presence of the unsynchronisation bit + id3_header.use_unsynchronisation = (flag_byte & 0b10000000) != 0; + // Check bit 6 for the presence of the extended header bit + id3_header.has_extended_header = (flag_byte & 0b01000000) != 0; + // Check bit 5 for the presence of the experimental indicator bit + id3_header.is_experimental = (flag_byte & 0b00100000) != 0; + + std::string syncsafe_bytes; + if (!input.Read(4, syncsafe_bytes)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read syncsafe bytes")); + } + ABSL_ASSIGN_OR_RETURN(uint32_t size, + GetSizeFromSyncsafeBytes(syncsafe_bytes)); + if (size == 0) { + return absl::InvalidArgumentError("no frame inside the ID3v2 tag"); + } + // `size` is the size of the tag, not including the header but including the + // extended header if present. + id3_header.tag_size = size; + + // Process the extended header if present. + if (id3_header.has_extended_header) { + // Read the size of the extended header, excluding the size field itself. + // Header size field is 4 bytes. + // The first 3 bytes should be zero. + // The last byte should be 0x0A (ten) or 0x06 (six). + if (!riegeli::ReadBigEndian<uint32_t>(input, + id3_header.extended_header_size)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed extracting the extended header size")); + } + if (id3_header.extended_header_size != 10 && + id3_header.extended_header_size != 6) { + return absl::InvalidArgumentError("invalid extended header size value"); + } + if (size == id3_header.extended_header_size + 4) { + // If the size of the extended header is the same as the size of the tag, + // it means that there are no frames inside the tag. + return absl::InvalidArgumentError("no frame inside the ID3v2 tag"); + } + if (!input.Read(2, id3_header.extended_flags)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed extracting the extended flags")); + } + if (!riegeli::ReadBigEndian<uint32_t>(input, id3_header.padding_size)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed extracting the padding size")); + } + if (id3_header.extended_header_size == 10) { + if (!input.Read(4, id3_header.total_frame_crc)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed extracting the total frame crc")); + } + } + } + return id3_header; +} + +absl::Cord Id3Header::ToCord() const { + absl::Cord output; + output.Append(absl::string_view("ID3", 3)); + if (major_version == 4) { + output.Append(absl::string_view("\x04\x00", 2)); // Version 2.4 + } else { + output.Append(absl::string_view("\x03\x00", 2)); // Version 2.3 + } + + // Flags + char flags = 0; + if (use_unsynchronisation) { + flags |= 1 << 7; + } + if (has_extended_header) { + flags |= 1 << 6; + } + if (is_experimental) { + flags |= 1 << 5; + } + output.Append(absl::string_view(&flags, 1)); + + char tag_size_bytes[4]; + tag_size_bytes[0] = (tag_size >> 21) & 0x7F; + tag_size_bytes[1] = (tag_size >> 14) & 0x7F; + tag_size_bytes[2] = (tag_size >> 7) & 0x7F; + tag_size_bytes[3] = tag_size & 0x7F; + output.Append(absl::string_view(tag_size_bytes, 4)); + + if (has_extended_header) { + // Append the size of the extended header (4 bytes, big-endian). + char extended_header_size_bytes[4]; + extended_header_size_bytes[0] = (extended_header_size >> 24) & 0xFF; + extended_header_size_bytes[1] = (extended_header_size >> 16) & 0xFF; + extended_header_size_bytes[2] = (extended_header_size >> 8) & 0xFF; + extended_header_size_bytes[3] = extended_header_size & 0xFF; + output.Append(absl::string_view(extended_header_size_bytes, 4)); + // Append the extended flags (2 bytes). + output.Append(extended_flags); + // Append the padding size (4 bytes, big-endian). + char padding_size_bytes[4]; + padding_size_bytes[0] = (padding_size >> 24) & 0xFF; + padding_size_bytes[1] = (padding_size >> 16) & 0xFF; + padding_size_bytes[2] = (padding_size >> 8) & 0xFF; + padding_size_bytes[3] = padding_size & 0xFF; + output.Append(absl::string_view(padding_size_bytes, 4)); + + if (extended_header_size == 10) { + // Append the total frame CRC (4 bytes). + output.Append(total_frame_crc); + } + } + + return output; +} + +} // namespace credentio
diff --git a/formats/id3/id3.h b/formats/id3/id3.h new file mode 100644 index 0000000..1b96a07 --- /dev/null +++ b/formats/id3/id3.h
@@ -0,0 +1,96 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_ID3_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_ID3_H_ + +#include <cstdint> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Represents the header of an ID3 tag. +struct Id3Header { + // Whether or not unsynchronisation is used. + bool use_unsynchronisation; + // Whether or not the extended header is present. + bool has_extended_header; + // Whether or not the experimental indicator is set. + bool is_experimental; + // The size of the ID3 tag, excluding the size of the header but including the + // size of the extended header if present. + uint32_t tag_size; + + // The size of the extended header, excluding the size field itself. Only + // relevant if `has_extended_header` is true. + uint32_t extended_header_size; + // Extended flags in the extended header. Only relevant if + // `has_extended_header` is true. + std::string extended_flags; + // The size of the padding added to the end of the ID3 tag. Only relevant if + // `has_extended_header` is true. + uint32_t padding_size; + // The total frame CRC in the extended header. Only relevant if + // `has_extended_header` is true. + std::string total_frame_crc; + + // The major version of the tag (e.g., 3 for ID3v2.3, 4 for ID3v2.4). + uint8_t major_version = 3; + + // Parses the ID3 header from the given input. + static absl::StatusOr<Id3Header> Parse(riegeli::Reader& input); + + // Returns the ID3 header encoded as an `absl::Cord`. + absl::Cord ToCord() const; +}; + +// Represents a raw ID3 frame read from an MP3 file. +struct Id3Frame { + // The offset of the ID3 frame from the beginning of the MP3 file. + uint64_t offset; + // The size of the ID3 frame, including the 10-byte header. + uint64_t length; + // The 4-character ID of the frame (e.g., "GEOB"). + std::string id; + + // Returns the offset of the data section within the frame, relative to + // the beginning of the MP3 file. The data section starts immediately after + // the 10-byte ID3 frame header. + uint64_t data_offset() const { return offset + 10; } + // Returns the length of the data section within the frame. + uint64_t data_length() const { return length - 10; } + + bool operator==(const Id3Frame& other) const { + return offset == other.offset && length == other.length && id == other.id; + } +}; + +struct GeobFrame { + std::string mime_type; + std::string encapsulated_object; + + // Parses a GEOB frame from the given input. + static absl::StatusOr<GeobFrame> Parse(riegeli::Reader& input, + uint64_t data_offset, + uint64_t data_length); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_ID3_H_
diff --git a/formats/id3/id3_test.cc b/formats/id3/id3_test.cc new file mode 100644 index 0000000..dacab97 --- /dev/null +++ b/formats/id3/id3_test.cc
@@ -0,0 +1,475 @@ +// 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 "formats/id3/id3.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(GeobFrameTest, ParseIso8859_1) { + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + 'f', 'i', 'l', 'e', 0x00, // Filename + 'd', 'e', 's', 'c', 0x00, // Content description + 'o', 'b', 'j', 'e', 'c', 't' // Encapsulated object + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, + /*data_length=*/geob_frame_contents.size()); + ASSERT_THAT(geob_or, IsOk()); + GeobFrame geob = *geob_or; + + EXPECT_EQ(geob.mime_type, "text/plain"); + EXPECT_EQ(geob.encapsulated_object, "object"); +} + +TEST(GeobFrameTest, ParseUtf16LeBom) { + const char geob_frame[] = { + 0x01, // Text encoding [UTF-16] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + '\xff', '\xfe', // BOM + 'f', 0x00, 0x00, 0x00, // Filename + '\xff', '\xfe', // BOM + 'd', 0x00, 0x00, 0x00, // Content description + 'o', 'b', 'j', 'e', 'c', 't' // Encapsulated object + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, + /*data_length=*/geob_frame_contents.size()); + ASSERT_THAT(geob_or, IsOk()); + GeobFrame geob = *geob_or; + + EXPECT_EQ(geob.mime_type, "text/plain"); + EXPECT_EQ(geob.encapsulated_object, "object"); +} + +TEST(GeobFrameTest, ParseUtf16BeBom) { + const char geob_frame[] = { + 0x01, // Text encoding [UTF-16] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + '\xfe', '\xff', // BOM + 0x00, 'f', 0x00, 0x00, // Filename + '\xfe', '\xff', // BOM + 0x00, 'd', 0x00, 0x00, // Content description + 'o', 'b', 'j', 'e', 'c', 't' // Encapsulated object + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, + /*data_length=*/geob_frame_contents.size()); + ASSERT_THAT(geob_or, IsOk()); + GeobFrame geob = *geob_or; + + EXPECT_EQ(geob.mime_type, "text/plain"); + EXPECT_EQ(geob.encapsulated_object, "object"); +} + +TEST(GeobFrameTest, ParseUtf16BeNoBom) { + const char geob_frame[] = { + 0x02, // Text encoding [UTF-16BE] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + 0x00, 'f', 0x00, 0x00, // Filename + 0x00, 'd', 0x00, 0x00, // Content description + 'o', 'b', 'j', 'e', 'c', 't' // Encapsulated object + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, + /*data_length=*/geob_frame_contents.size()); + ASSERT_THAT(geob_or, IsOk()); + GeobFrame geob = *geob_or; + + EXPECT_EQ(geob.mime_type, "text/plain"); + EXPECT_EQ(geob.encapsulated_object, "object"); +} + +TEST(GeobFrameTest, ParseUtf8) { + const char geob_frame[] = { + 0x03, // Text encoding [UTF-8] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + 'f', 'i', 'l', 'e', 0x00, // Filename + 'd', 'e', 's', 'c', 0x00, // Content description + 'o', 'b', 'j', 'e', 'c', 't' // Encapsulated object + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, + /*data_length=*/geob_frame_contents.size()); + ASSERT_THAT(geob_or, IsOk()); + GeobFrame geob = *geob_or; + + EXPECT_EQ(geob.mime_type, "text/plain"); + EXPECT_EQ(geob.encapsulated_object, "object"); +} + +TEST(GeobFrameTest, ParseInvalidTextEncodingFails) { + const char geob_frame[] = { + 0x04, // Invalid text encoding + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + + EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, + /*data_length=*/geob_frame_contents.size()), + StatusIs(absl::StatusCode::kFailedPrecondition, + HasSubstr("Invalid text encoding value"))); +} + +TEST(GeobFrameTest, ParseZeroLengthExceedingsBoundsFails) { + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + + EXPECT_THAT( + GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/0), + StatusIs( + absl::StatusCode::kDataLoss, + HasSubstr( + "Data structure exceeded specified offset boundary limit."))); +} + +TEST(GeobFrameTest, ParseMimeTypeNotTerminatedFails) { + // MIME type is not null-terminated within the frame data, but there is a + // null byte immediately after the frame data. + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 't', 'e', 'x', 't', '/', + 'p', 'l', 'a', 'i', 'n', // MIME type (10 bytes, no null) + 0x00 // Null byte outside the frame + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + // data_length is 11 (1 byte encoding + 10 bytes MIME type). + // The trailing null byte is at index 11, which is outside the frame. + EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/11), + StatusIs(absl::StatusCode::kDataLoss, + "Failed to read utf-8 text; at byte 1")); +} + +TEST(GeobFrameTest, ParseFilenameNotTerminatedFails) { + // Filename is not null-terminated within the frame data, but there is a + // null byte immediately after the frame data. + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + 'f', 'i', 'l', 'e', // Filename (4 bytes, no null) + 0x00 // Null byte outside the frame + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + // data_length is 1 + 11 + 4 = 16. + // The trailing null byte is at index 16, which is outside the frame. + EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/16), + StatusIs(absl::StatusCode::kDataLoss, + "Failed to read utf-8 text; at byte 12")); +} + +TEST(GeobFrameTest, ParseDescriptionNotTerminatedFails) { + // Content description is not null-terminated within the frame data, but there + // is a null byte immediately after the frame data. + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + 'f', 'i', 'l', 'e', 0x00, // Filename + 'd', 'e', 's', 'c', // Content description (4 bytes, no null) + 0x00 // Null byte outside the frame + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + // data_length is 1 + 11 + 5 + 4 = 21. + // The trailing null byte is at index 21, which is outside the frame. + EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/21), + StatusIs(absl::StatusCode::kDataLoss, + "Failed to read utf-8 text; at byte 17")); +} + +TEST(GeobFrameTest, ParseDescriptionMissingFails) { + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 'a', 0x00, // MIME type + 'f', 'i', 'l', 'e', 0x00, // Filename + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + // data_length is 8 (1 + 2 + 5). + // Description is missing. + EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/8), + StatusIs(absl::StatusCode::kDataLoss, + "Failed to read utf-8 text; at byte 8")); +} + +TEST(GeobFrameTest, ParseFilenameMissingFails) { + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 'a', + 0x00, // MIME type + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + // data_length is 3 (1 + 2). + // Filename is missing. + EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/3), + StatusIs(absl::StatusCode::kDataLoss, + "Failed to read utf-8 text; at byte 3")); +} + +TEST(GeobFrameTest, ParseDescriptionEmptySucceeds) { + const char geob_frame[] = { + 0x00, // Text encoding [ISO-8859-1] + 't', 'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n', + 0x00, // MIME type + 'f', 'i', 'l', 'e', 0x00, // Filename + 0x00, // Content description (empty) + }; + auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame)); + riegeli::StringReader<> input(geob_frame_contents); + auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0, + /*data_length=*/geob_frame_contents.size()); + ASSERT_THAT(geob_or, IsOk()); + GeobFrame geob = *geob_or; + + EXPECT_EQ(geob.mime_type, "text/plain"); + EXPECT_EQ(geob.encapsulated_object, ""); +} + +TEST(Id3HeaderTest, ParseId3HeaderNoExtendedHeader) { + const char id3_header[] = { + 'I', 'D', '3', // ID3 identifier + 0x03, 0x00, // Version 2.3 + 0x00, // Flags + 0x00, 0x00, 0x00, 127, // Tag size + }; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + auto header_or = Id3Header::Parse(input); + ASSERT_THAT(header_or, IsOk()); + Id3Header header = *header_or; + + EXPECT_FALSE(header.use_unsynchronisation); + EXPECT_FALSE(header.has_extended_header); + EXPECT_FALSE(header.is_experimental); + EXPECT_EQ(header.tag_size, 127); +} + +TEST(Id3HeaderTest, ParseId3HeaderWithValidExtendedHeader) { + const char id3_header[] = { + 'I', 'D', '3', // ID3 identifier + 0x03, 0x00, // Version 2.3 + 0x40, // Flags (extended header) + 0x00, 0x00, 0x00, 127, // Tag size + 0x00, 0x00, 0x00, 6, // Extended header size + 0x00, 0x00, // Extended flags + 0x00, 0x00, 0x00, 0x00, // Padding size + }; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + auto header_or = Id3Header::Parse(input); + ASSERT_THAT(header_or, IsOk()); + Id3Header header = *header_or; + + EXPECT_FALSE(header.use_unsynchronisation); + EXPECT_TRUE(header.has_extended_header); + EXPECT_FALSE(header.is_experimental); + EXPECT_EQ(header.tag_size, 127); + EXPECT_EQ(header.extended_header_size, 6); + EXPECT_EQ(header.extended_flags, std::string("\0\0", 2)); + EXPECT_EQ(header.padding_size, 0); +} + +TEST(Id3HeaderTest, ParseId3HeaderInvalidIdentifierFails) { + const char id3_header[] = { + 'I', 'D', '4', // Invalid ID3 identifier + 0x03, 0x00, // Version 2.3 + 0x00, // Flags + 0x00, 0x00, 0x00, 127, // Tag size + }; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + EXPECT_THAT(Id3Header::Parse(input), + StatusIs(absl::StatusCode::kNotFound, "ID3v2 tag not found")); +} + +TEST(Id3HeaderTest, ParseId3HeaderInvalidVersionFails) { + const char id3_header[] = { + 'I', 'D', '3', // ID3 identifier + 0x03, 0x01, // Invalid version + 0x00, // Flags + 0x00, 0x00, 0x00, 127, // Tag size + }; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + EXPECT_THAT( + Id3Header::Parse(input), + StatusIs(absl::StatusCode::kNotFound, HasSubstr("ID3v2 tag not found"))); +} + +TEST(Id3HeaderTest, ParseId3HeaderInvalidFlagFails) { + const char id3_header[] = { + 'I', 'D', '3', // ID3 identifier + 0x03, 0x00, // Version 2.3 + 0x01, // Invalid flags + 0x00, 0x00, 0x00, 127, // Tag size + }; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + EXPECT_THAT( + Id3Header::Parse(input), + StatusIs(absl::StatusCode::kInvalidArgument, "invalid ID3v2 flag byte")); +} + +TEST(Id3HeaderTest, ParseId3HeaderZeroSizeFails) { + const char id3_header[] = { + 'I', 'D', '3', // ID3 identifier + 0x03, 0x00, // Version 2.3 + 0x00, // Flags + 0x00, 0x00, 0x00, 0x00, // Tag size = 0 + }; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + EXPECT_THAT(Id3Header::Parse(input), + StatusIs(absl::StatusCode::kInvalidArgument, + "no frame inside the ID3v2 tag")); +} + +TEST(Id3HeaderTest, ParseId3HeaderInvalidExtendedHeaderSizeFails) { + const char id3_header[] = { + 'I', 'D', '3', // ID3 identifier + 0x03, 0x00, // Version 2.3 + 0x40, // Flags (extended header) + 0x00, 0x00, 0x00, 127, // Tag size + 0x00, 0x00, 0x00, 7, // Invalid extended header size + 0x00, 0x00, // Extended flags + 0x00, 0x00, 0x00, 0x00, // Padding size + }; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + EXPECT_THAT(Id3Header::Parse(input), + StatusIs(absl::StatusCode::kInvalidArgument, + "invalid extended header size value")); +} + +TEST(Id3HeaderTest, ParseId3HeaderNoFrameWithExtendedHeader) { + constexpr char id3_header[] = { + 'I', 'D', '3', // ID3 identifier + 0x03, 0x00, // Version 2.3 + 0x40, // Flags (extended header) + 0x00, 0x00, 0x00, 0x0e, // Tag size + 0x00, 0x00, 0x00, 0x0a, // Extended header size = 10 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + auto id3_header_contents = std::string(id3_header, sizeof(id3_header)); + riegeli::StringReader<> input(id3_header_contents); + EXPECT_THAT(Id3Header::Parse(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("no frame inside the ID3v2 tag"))); +} + +TEST(Id3HeaderTest, EncodeId3HeaderNoExtendedHeader) { + Id3Header header = { + .use_unsynchronisation = false, + .has_extended_header = false, + .is_experimental = false, + .tag_size = 127, + }; + + EXPECT_EQ(header.ToCord(), + absl::string_view("ID3\x03\x00\x00\x00\x00\x00\x7F", 10)); +} + +TEST(Id3HeaderTest, EncodeId3HeaderWithExtendedHeader6Bytes) { + Id3Header header = { + .use_unsynchronisation = false, + .has_extended_header = true, + .is_experimental = false, + .tag_size = 127, + .extended_header_size = 6, + .extended_flags = std::string("\0\0", 2), + .padding_size = 0, + }; + + EXPECT_EQ( + header.ToCord(), + absl::string_view( + "ID3\x03\x00\x40\x00\x00\x00\x7F\x00\x00\x00\x06\x00\x00\x00\x00" + "\x00\x00", + 20)); +} + +TEST(Id3HeaderTest, EncodeId3HeaderWithExtendedHeader10Bytes) { + Id3Header header = { + .use_unsynchronisation = false, + .has_extended_header = true, + .is_experimental = false, + .tag_size = 127, + .extended_header_size = 10, + .extended_flags = std::string("\0\0", 2), + .padding_size = 0, + .total_frame_crc = "\x01\x02\x03\x04", + }; + + EXPECT_EQ( + header.ToCord(), + absl::string_view( + "ID3\x03\x00\x40\x00\x00\x00\x7F\x00\x00\x00\x0A\x00\x00\x00\x00" + "\x00\x00\x01\x02\x03\x04", + 24)); +} + +TEST(Id3HeaderTest, EncodeId3HeaderWithAllFlags) { + Id3Header header = { + .use_unsynchronisation = true, + .has_extended_header = true, + .is_experimental = true, + .tag_size = 127, + .extended_header_size = 6, + .extended_flags = std::string("\0\0", 2), + .padding_size = 0, + }; + + EXPECT_EQ( + header.ToCord(), + absl::string_view( + "ID3\x03\x00\xE0\x00\x00\x00\x7F\x00\x00\x00\x06\x00\x00\x00\x00" + "\x00\x00", + 20)); +} +} // namespace +} // namespace credentio
diff --git a/formats/id3/reader.cc b/formats/id3/reader.cc new file mode 100644 index 0000000..d5e9d5a --- /dev/null +++ b/formats/id3/reader.cc
@@ -0,0 +1,126 @@ +// 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 "formats/id3/reader.h" + +#include <sys/types.h> + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/id3/id3.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { + +constexpr uint32_t kId3TagHeaderSize = 10; +constexpr uint32_t kId3FrameHeaderSize = 10; + +absl::StatusOr<Id3Header> IterateOverId3Frames(riegeli::Reader& reader, + Id3FrameProcessor processor) { + if (!reader.SupportsSize() || !reader.Size().has_value()) { + return absl::InvalidArgumentError("reader does not support size"); + } + + uint64_t beginning_offset = reader.pos(); + ABSL_ASSIGN_OR_RETURN(Id3Header id3_header, Id3Header::Parse(reader)); + uint64_t id3_tag_end_offset = + beginning_offset + kId3TagHeaderSize + id3_header.tag_size; + + if (id3_tag_end_offset > reader.Size().value()) { + return absl::InvalidArgumentError("tag end offset exceeds input size"); + } + + uint64_t current_offset = reader.pos(); + + while (current_offset + kId3FrameHeaderSize < id3_tag_end_offset) { + // Peek the next frame ID to see if the ID is valid. + uint32_t frame_id; + if (!reader.Seek(current_offset)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to seek to current offset")); + } + if (!riegeli::ReadBigEndian<uint32_t>(reader, frame_id)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read frame ID for peeking")); + } + + // Check if the frame ID is all NULL bytes. This indicates the start of + // padding at the end of the ID3v2 tag. + if (frame_id == 0) { + break; + } + + if (!reader.Seek(current_offset)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to seek back to current offset")); + } + + Id3Frame frame; + frame.offset = current_offset; + // Read the ID3 frame header, which is 10 bytes. + if (!reader.Read(4, frame.id)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read frame ID")); + } + uint32_t size; + if (!riegeli::ReadBigEndian<uint32_t>(reader, size)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read frame size")); + } + if (id3_header.major_version == 4) { + if ((size & 0x80808080) != 0) { + return reader.StatusOrAnnotate(absl::DataLossError( + "Invalid synchsafe integer in ID3v2.4 frame size")); + } + size = ((size >> 24) & 0x7f) << 21 | ((size >> 16) & 0x7f) << 14 | + ((size >> 8) & 0x7f) << 7 | (size & 0x7f); + } + std::string flags; + if (!reader.Read(2, flags)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read frame flags")); + } + const uint64_t remaining_tag_size = + id3_tag_end_offset - current_offset - kId3FrameHeaderSize; + if (size > remaining_tag_size) { + return reader.StatusOrAnnotate( + absl::DataLossError("ID3 frame size exceeds remaining tag size")); + } + + frame.length = static_cast<uint64_t>(size) + kId3FrameHeaderSize; + + ABSL_ASSIGN_OR_RETURN(bool continue_iteration, processor(frame)); + if (!continue_iteration) { + break; + } + + // Seek to the end of the frame for the next iteration. + if (!reader.Seek(frame.offset + frame.length)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to seek to end of frame")); + } + current_offset = reader.pos(); + } + + return id3_header; +} + +} // namespace credentio
diff --git a/formats/id3/reader.h b/formats/id3/reader.h new file mode 100644 index 0000000..68981e7 --- /dev/null +++ b/formats/id3/reader.h
@@ -0,0 +1,42 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ID3_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_READER_H_ + +#include <cstdint> + +#include "absl/base/nullability.h" +#include "absl/functional/function_ref.h" +#include "absl/status/statusor.h" +#include "formats/id3/id3.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Callback function for IterateOverId3Frames. +// Returns true to continue iteration, false to stop, or an error to abort. +using Id3FrameProcessor = + absl::FunctionRef<absl::StatusOr<bool>(const Id3Frame&)>; + +// Iterates over all ID3 frames in the given reader, calling the given function +// for each frame. Returns the ID3 header on success. +// Returns NotFoundError if no ID3 tag is found. +absl::StatusOr<Id3Header> IterateOverId3Frames(riegeli::Reader& reader, + Id3FrameProcessor processor); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ID3_READER_H_
diff --git a/formats/id3/reader_test.cc b/formats/id3/reader_test.cc new file mode 100644 index 0000000..1e98844 --- /dev/null +++ b/formats/id3/reader_test.cc
@@ -0,0 +1,180 @@ +// 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 "formats/id3/reader.h" + +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "formats/id3/id3.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(Id3ReaderTest, TagEndOffsetExceedsInputSize) { + constexpr char mp3_data[] = {'I', 'D', '3', '\x03', '\x00', + '\x00', '\x7f', '\x7f', '\x7f', '\x7f'}; + auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data)); + riegeli::StringReader<> input(mp3_file_contents); + + EXPECT_THAT(IterateOverId3Frames(input, [](const Id3Frame&) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("tag end offset exceeds input size"))); +} + +class NonSizeSupportingStringReader : public riegeli::StringReader<> { + public: + using riegeli::StringReader<>::StringReader; + bool SupportsSize() override { return false; } +}; + +TEST(Id3ReaderTest, ReaderDoesNotSupportSize) { + NonSizeSupportingStringReader non_size_input("dummy data"); + + EXPECT_THAT(IterateOverId3Frames(non_size_input, + [](const Id3Frame&) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("reader does not support size"))); +} + +TEST(Id3ReaderTest, EarlyExit) { + constexpr char mp3_data[] = { + 'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00', + '\x2c', // Tag size = 44 + '\x00', '\x00', '\x00', '\x0a', // Extended header size = 10 + '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x00', + // A 15-byte COMM frame + 'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', + '\x01', '\x02', '\x03', '\x04', '\x05', + // A 15-byte GEOB frame + 'G', 'E', 'O', 'B', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', + '\x05', '\x04', '\x03', '\x02', '\x01'}; + auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data)); + riegeli::StringReader<> input(mp3_file_contents); + + std::vector<Id3Frame> frames; + EXPECT_THAT(IterateOverId3Frames(input, + [&](const Id3Frame& frame) { + frames.push_back(frame); + return false; + }), + IsOk()); + + EXPECT_THAT(frames, testing::ElementsAre(Id3Frame{ + .offset = 24, + .length = 15, + .id = "COMM", + })); +} + +TEST(Id3ReaderTest, ReadsAllFrames) { + constexpr char mp3_data[] = { + 'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00', + '\x2c', // Tag size = 44 + '\x00', '\x00', '\x00', '\x0a', // Extended header size = 10 + '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x00', + // A 15-byte COMM frame + 'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', + '\x01', '\x02', '\x03', '\x04', '\x05', + // A 15-byte GEOB frame + 'G', 'E', 'O', 'B', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', + '\x05', '\x04', '\x03', '\x02', '\x01'}; + auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data)); + riegeli::StringReader<> input(mp3_file_contents); + + std::vector<Id3Frame> frames; + auto status = IterateOverId3Frames(input, [&](const Id3Frame& frame) { + frames.push_back(frame); + return true; + }); + ASSERT_THAT(status, IsOk()); + ASSERT_TRUE(status.value().has_extended_header); + + EXPECT_THAT(frames, testing::UnorderedElementsAre( + Id3Frame{ + .offset = 24, + .length = 15, + .id = "COMM", + }, + Id3Frame{ + .offset = 39, + .length = 15, + .id = "GEOB", + })); +} + +TEST(Id3ReaderTest, FrameSizeExceedsRemainingTagSize) { + constexpr char mp3_data[] = { + 'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00', + '\x22', // Tag size = 34 (syncsafe 0x22) + '\x00', '\x00', '\x00', '\x0a', // Extended header size = 10 + '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x00', + // Frame: size = 0xFFFFFFF6 + 'F', 'I', 'D', 'S', '\xFF', '\xFF', '\xFF', '\xF6', '\x00', '\x00', + // Padding to satisfy the tag size (10 bytes) + '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x00'}; + auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data)); + riegeli::StringReader<> input(mp3_file_contents); + + int call_count = 0; + auto status = IterateOverId3Frames(input, [&](const Id3Frame& frame) { + call_count++; + return true; + }); + + EXPECT_THAT(status, + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("ID3 frame size exceeds remaining tag size"))); + EXPECT_EQ(call_count, 0); +} + +TEST(Id3ReaderTest, FrameLengthOneTerminates) { + constexpr char mp3_data[] = { + 'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00', + '\x22', // Tag size = 34 + '\x00', '\x00', '\x00', '\x0a', // Extended header size = 10 + '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x00', + // Frame: size = 0xFFFFFFF7 + 'F', 'I', 'D', 'S', '\xFF', '\xFF', '\xFF', '\xF7', '\x00', '\x00', + // Padding + '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', + '\x00'}; + auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data)); + riegeli::StringReader<> input(mp3_file_contents); + + auto status = + IterateOverId3Frames(input, [](const Id3Frame& frame) { return true; }); + + EXPECT_THAT(status, + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("ID3 frame size exceeds remaining tag size"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/BUILD b/formats/jpeg/BUILD new file mode 100644 index 0000000..982f3a0 --- /dev/null +++ b/formats/jpeg/BUILD
@@ -0,0 +1,195 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":box_header", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + deps = [ + ":reader", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "box_header", + srcs = ["box_header.cc"], + hdrs = ["box_header.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "box_header_test", + srcs = ["box_header_test.cc"], + deps = [ + ":box_header", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:str_format", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "c2pa_segment_handler", + srcs = ["c2pa_segment_handler.cc"], + hdrs = ["c2pa_segment_handler.h"], + deps = [ + ":reader", + "//constants:labels", + "//jumbf:box", + "//jumbf:constants", + "//jumbf:parse", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "c2pa_segment_handler_test", + srcs = ["c2pa_segment_handler_test.cc"], + deps = [ + ":c2pa_segment_handler", + ":reader", + "//jumbf:constants", + "@abseil-cpp//absl/container:btree", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":c2pa_segment_handler", + ":reader", + "//constants:labels", + "//formats:asset_box", + "//formats:asset_byte_info", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "//utils:byte_readers", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":extractor", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor_result", + "//formats/jpeg/testing:app_segment_creator", + "//formats/jpeg/testing:jpeg_creator", + "//formats/jpeg/testing:jumbf_creator", + "//testing:jumbf_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/jpeg/assessor.cc b/formats/jpeg/assessor.cc new file mode 100644 index 0000000..dc22e7f --- /dev/null +++ b/formats/jpeg/assessor.cc
@@ -0,0 +1,50 @@ +// 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 "formats/jpeg/assessor.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { + +namespace { + +// JPEG Marker which indicates the start of a JPEG file. +const uint16_t kJpegStartingBytes = 0xffd8; + +} // namespace + +absl::StatusOr<bool> JpegAssessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + uint16_t marker; + if (!riegeli::ReadBigEndian<uint16_t>(input, marker)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; marker")); + } + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; position")); + } + + return marker == kJpegStartingBytes; +} + +} // namespace credentio
diff --git a/formats/jpeg/assessor.h b/formats/jpeg/assessor.h new file mode 100644 index 0000000..8dbc395 --- /dev/null +++ b/formats/jpeg/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the JPEG image format. +class JpegAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_ASSESSOR_H_
diff --git a/formats/jpeg/assessor_test.cc b/formats/jpeg/assessor_test.cc new file mode 100644 index 0000000..75251a6 --- /dev/null +++ b/formats/jpeg/assessor_test.cc
@@ -0,0 +1,71 @@ +// 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 "formats/jpeg/assessor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(IsSupportedTest, ErrorTooFewBytes) { + std::string image = "\xab"; + riegeli::StringReader<> input(image); + EXPECT_THAT( + JpegAssessor().IsSupported(input), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); + EXPECT_EQ(input.pos(), 0); +} + +TEST(IsSupportedTest, FalseForInvalidBeginning) { + std::string image = "\xab\xcd"; + riegeli::StringReader<> input(image); + EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); +} + +TEST(IsSupportedTest, ValidStartingBytes) { + std::string image = "\xff\xd8"; + riegeli::StringReader<> input(image); + EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 0); +} + +TEST(IsSupportedTest, ValidStartingBytesAtOffset2) { + std::string image = "ab\xff\xd8"; + riegeli::StringReader<> input(image); + + // Invalid at 0 + EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/box_header.cc b/formats/jpeg/box_header.cc new file mode 100644 index 0000000..9e924db --- /dev/null +++ b/formats/jpeg/box_header.cc
@@ -0,0 +1,139 @@ +// 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 "formats/jpeg/box_header.h" + +#include <cstdint> +#include <ostream> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { +namespace { + +const uint32_t kJpegStartMarker = 0xffd8; +const uint32_t kJpegEndMarker = 0xffd9; + +// Converts a JPEG marker to a human readable label. +// Sourced from Table B.1 of ISO/IEC 10918-1:1994 +std::string ConvertMarkerToLabel(uint16_t marker) { + if (0xff00 != (marker & 0xff00) || marker == 0xffff) { + // Markers are always 0xFFXX + // 0xFFFF is not a valid JPEG marker + return absl::StrFormat("%04x", marker); + } + if (0x00c0 == (marker & 0x00f0)) { + switch (marker) { + case 0xffc4: + return "DHT"; // Define Huffman Table(s) + case 0xffc8: + return "JPG"; // Reserved for JPG extensions + case 0xffcc: + return "DAC"; // Define arithmetic coding conditioning(s) + default: + // Start Of Frame markers labeled numerically + return absl::StrFormat("SOF%d", (marker & 0x000f)); + } + } + if (0x00d0 == (marker & 0x00f0)) { + switch (marker) { + case 0xffd8: + return "SOI"; // Start of Image + case 0xffd9: + return "EOI"; // End of Image + case 0xffda: + return "SOS"; // Start of Scan + case 0xffdb: + return "DQT"; // Define Quantization Table(s) + case 0xffdc: + return "DNL"; // Define Number of Lines + case 0xffdd: + return "DRI"; // Define Restart Interval + case 0xffde: + return "DHP"; // Define Hierarchical Progression + case 0xffdf: + return "EXP"; // Expand reference component(s) + default: + // Restart with modulo 8 markers labeled numerically + return absl::StrFormat("RST%d", (marker & 0x000f)); + } + } + if (0x00e0 == (marker & 0x00f0)) { + // Reserved for APP markers labeled numerically + return absl::StrFormat("APP%d", (marker & 0x000f)); + } + if (0x00f0 == (marker & 0x00f0)) { + switch (marker) { + case 0xfffe: + return "COM"; // Comment + default: + // Reserved for JPEG extensions labeled numerically + return absl::StrFormat("JPG%d", (marker & 0x000f)); + } + } + if (marker == 0xff01) { + return "TEM"; // For temporary private use in arithmetic coding + } + return "RES"; // Reserved +} +} // namespace + +std::string JpegBoxHeader::label() const { + return ConvertMarkerToLabel(this->type); +} + +absl::StatusOr<JpegBoxHeader> ConsumeJpegBoxHeader(riegeli::Reader& input) { + JpegBoxHeader b = {.offset = input.pos()}; + if (!riegeli::ReadBigEndian<uint16_t>(input, b.type)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read type")); + } + + if (0xff00 != (b.type & 0xff00) || b.type == 0xffff) { + // Invalid marker. + return absl::InvalidArgumentError( + absl::StrFormat("Invalid JPEG marker: %d", b.type)); + } + + if (b.type == kJpegStartMarker || b.type == kJpegEndMarker || + b.type == 0xff01 || (b.type >= 0xffd0 && b.type <= 0xffd7)) { + // No payload. + b.size = sizeof(uint16_t); + return b; + } + + uint16_t chunk_size; + if (!riegeli::ReadBigEndian<uint16_t>(input, chunk_size)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read size")); + } + if (chunk_size < 2) { + return absl::InvalidArgumentError( + absl::StrFormat("Invalid JPEG segment size: %d", chunk_size)); + } + b.size = chunk_size + sizeof(b.type); + return b; +} + +void PrintTo(const JpegBoxHeader& x, ::std::ostream* os) { + *os << absl::StrCat("{type: ", x.type, ", offset: ", x.offset, + ", size: ", x.size, ", label: \"", x.label(), "\"}"); +} + +} // namespace credentio
diff --git a/formats/jpeg/box_header.h b/formats/jpeg/box_header.h new file mode 100644 index 0000000..1d2506e --- /dev/null +++ b/formats/jpeg/box_header.h
@@ -0,0 +1,42 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_BOX_HEADER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_BOX_HEADER_H_ + +#include <cstdint> +#include <iosfwd> +#include <string> + +#include "absl/status/statusor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +struct JpegBoxHeader { + uint16_t type; // The marker of the box. + uint64_t offset; // The offset of the box in the file. + uint64_t size; // The size of the box (including the marker). + std::string label() const; // The human-readable box type (marker). + + friend bool operator==(const JpegBoxHeader&, const JpegBoxHeader&) = default; +}; + +void PrintTo(const JpegBoxHeader& x, ::std::ostream* os); + +// Reads and returns the box header. +absl::StatusOr<JpegBoxHeader> ConsumeJpegBoxHeader(riegeli::Reader& input); +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_BOX_HEADER_H_
diff --git a/formats/jpeg/box_header_test.cc b/formats/jpeg/box_header_test.cc new file mode 100644 index 0000000..77ffdf9 --- /dev/null +++ b/formats/jpeg/box_header_test.cc
@@ -0,0 +1,167 @@ +// 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 "formats/jpeg/box_header.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_format.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::Range; +using ::testing::TestParamInfo; + +using BoxHeaderTest = testing::TestWithParam<uint32_t>; + +INSTANTIATE_TEST_SUITE_P(MarkerTests, BoxHeaderTest, + Range(static_cast<uint32_t>(0x0000feff), + static_cast<uint32_t>(0x00010000)), + [](const TestParamInfo<uint32_t>& info) { + return absl::StrFormat( + "%04x", static_cast<uint16_t>(info.param)); + }); + +TEST_P(BoxHeaderTest, ConsumeJpegBoxHeaderTest) { + std::string data("\0\0\0\4\1\1", 6); + data[0] = static_cast<uint8_t>((GetParam() & 0x0000ff00) >> 8); + data[1] = static_cast<uint8_t>((GetParam() & 0x000000ff)); + + riegeli::StringReader<> input(data); + + if (GetParam() < 0xff00 || GetParam() == 0xffff) { + // Invalid marker. + EXPECT_THAT(ConsumeJpegBoxHeader(input), + StatusIs(absl::StatusCode::kInvalidArgument)); + return; + } + + auto header_or = ConsumeJpegBoxHeader(input); + ASSERT_THAT(header_or, IsOk()); + + JpegBoxHeader header = *header_or; + EXPECT_EQ(header.offset, 0); + EXPECT_EQ(header.type, GetParam()); + if (GetParam() == 0xff01 || (GetParam() >= 0xffd0 && GetParam() <= 0xffd9)) { + EXPECT_EQ(header.size, 2); + } else { + EXPECT_EQ(header.size, 6); // Length + 2 for the marker + } +} + +TEST(BoxHeaderTest, ConsumeJpegBoxHeaderEmptyInput) { + std::string data(""); + riegeli::StringReader<> input(data); + EXPECT_THAT(ConsumeJpegBoxHeader(input), + StatusIs(absl::StatusCode::kDataLoss, + ::testing::HasSubstr("Failed to read type"))); +} + +TEST(BoxHeaderTest, ConsumeJpegBoxHeaderShortInputForSize) { + std::string data("\xff\xe1\x00", 3); + riegeli::StringReader<> input(data); + EXPECT_THAT(ConsumeJpegBoxHeader(input), + StatusIs(absl::StatusCode::kDataLoss, + ::testing::HasSubstr("Failed to read size"))); +} + +TEST_P(BoxHeaderTest, ConvertMarkerToLabelTest) { + uint16_t marker = static_cast<uint16_t>(GetParam() & 0x0000ffff); + JpegBoxHeader header = {.type = marker}; + + switch (marker) { + case 0xfeff: + EXPECT_EQ(header.label(), "feff"); + return; + case 0xff01: + EXPECT_EQ(header.label(), "TEM"); + return; + case 0xffc4: + EXPECT_EQ(header.label(), "DHT"); + return; + case 0xffc8: + EXPECT_EQ(header.label(), "JPG"); + return; + case 0xffcc: + EXPECT_EQ(header.label(), "DAC"); + return; + case 0xffd8: + EXPECT_EQ(header.label(), "SOI"); + return; + case 0xffd9: + EXPECT_EQ(header.label(), "EOI"); + return; + case 0xffda: + EXPECT_EQ(header.label(), "SOS"); + return; + case 0xffdb: + EXPECT_EQ(header.label(), "DQT"); + return; + case 0xffdc: + EXPECT_EQ(header.label(), "DNL"); + return; + case 0xffdd: + EXPECT_EQ(header.label(), "DRI"); + return; + case 0xffde: + EXPECT_EQ(header.label(), "DHP"); + return; + case 0xffdf: + EXPECT_EQ(header.label(), "EXP"); + return; + case 0xfffe: + EXPECT_EQ(header.label(), "COM"); + return; + case 0xffff: + EXPECT_EQ(header.label(), "ffff"); + return; + } + + if ((marker & 0x00f0) == 0x00c0) { + EXPECT_EQ(header.label(), absl::StrFormat("SOF%d", (marker & 0x000f))); + return; + } + if (marker >= 0xffd0 && marker <= 0xffd7) { + EXPECT_EQ(header.label(), absl::StrFormat("RST%d", (marker & 0x000f))); + return; + } + if ((marker & 0x00f0) == 0x00e0) { + EXPECT_EQ(header.label(), absl::StrFormat("APP%d", (marker & 0x000f))); + return; + } + if (marker >= 0xfff0 && marker <= 0xfffd) { + EXPECT_EQ(header.label(), absl::StrFormat("JPG%d", (marker & 0x000f))); + return; + } + EXPECT_EQ(header.label(), "RES"); +} + +TEST(BoxHeaderTest, PrintToJpegBoxHeader) { + JpegBoxHeader header = {.type = 0xffe1, .offset = 10, .size = 20}; + EXPECT_EQ(testing::PrintToString(header), + "{type: 65505, offset: 10, size: 20, label: \"APP1\"}"); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/c2pa_segment_handler.cc b/formats/jpeg/c2pa_segment_handler.cc new file mode 100644 index 0000000..45f8779 --- /dev/null +++ b/formats/jpeg/c2pa_segment_handler.cc
@@ -0,0 +1,246 @@ +// 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 "formats/jpeg/c2pa_segment_handler.h" + +#include <cstddef> +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "absl/types/span.h" +#include "constants/labels.h" +#include "formats/jpeg/reader.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/parse.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB +constexpr size_t kMaxSegmentsPerId = 1000; + +bool IsValidNextSegment(const JpegSegment& prev_segment, + const JpegSegment& curr_segment) { + if (!prev_segment.app11_info.has_value() || + !curr_segment.app11_info.has_value()) { + // Missing APP11 information, invalid. + return false; + } + if (!prev_segment.app11_info->jumbf_data.has_value() || + !curr_segment.app11_info->jumbf_data.has_value()) { + // Segments do not contain JUMBF data, invalid. + return false; + } + + // C2PA Spec requires sequence numbers to be increasing. + if (prev_segment.app11_info->sequence_number >= + curr_segment.app11_info->sequence_number) { + // Sequence number is not increasing, invalid. + return false; + } + + // C2PA Spec requires segments to be contiguous. + if ((prev_segment.offset + prev_segment.length) != curr_segment.offset) { + // Segments are not contiguous, invalid. + return false; + } + + // All segments must have the same lbox and xlbox. + if (prev_segment.app11_info->jumbf_data->lbox != + curr_segment.app11_info->jumbf_data->lbox) { + // Lbox is different, invalid. + return false; + } + if (prev_segment.app11_info->jumbf_data->xlbox != + curr_segment.app11_info->jumbf_data->xlbox) { + // Xlbox is different, invalid. + return false; + } + + return true; +} + +absl::StatusOr<std::string> ExtractCompletePayload( + riegeli::Reader& input, absl::Span<const JpegSegment> segments) { + if (segments.empty() || !segments.front().app11_info.has_value() || + !segments.front().app11_info->jumbf_data.has_value()) { + return ""; + } + uint64_t expected_payload_size = + segments.front().app11_info->jumbf_data->lbox == 1 + ? segments.front().app11_info->jumbf_data->xlbox + : segments.front().app11_info->jumbf_data->lbox; + if (expected_payload_size == 0 || expected_payload_size > kMaxPayloadSize) { + return absl::InvalidArgumentError(absl::Substitute( + "Declared JUMBF payload size is invalid ($0)", expected_payload_size)); + } + + std::string payload; + for (const JpegSegment& segment : segments) { + if (!segment.app11_info.has_value() || + !segment.app11_info->jumbf_data.has_value()) { + // Segment does not contain JUMBF data within an APP11 segment, skip it. + continue; + } + + uint64_t payload_offset = segment.app11_info->payload_offset; + uint64_t payload_length = segment.app11_info->payload_length; + if (!payload.empty()) { + // This is not the first segment, so skip the JUMBF header data + payload_offset = segment.app11_info->jumbf_data->payload_offset; + payload_length = segment.app11_info->jumbf_data->payload_length; + } + + if (payload.size() + payload_length > kMaxPayloadSize) { + return absl::InvalidArgumentError( + absl::Substitute("JPEG segment is too large to extract ($0 > $1)", + payload.size() + payload_length, kMaxPayloadSize)); + } + + if (!input.Seek(payload_offset) || input.pos() != payload_offset) { + return absl::InvalidArgumentError("Failed to seek to payload offset"); + } + + std::string segment_payload; + if (!input.Read(payload_length, segment_payload)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read payload")); + } + payload.append(segment_payload); + } + + if (payload.size() != expected_payload_size) { + // Incomplete payload, return empty string. + return ""; + } + + return payload; +} + +bool IsC2paManifestStore(absl::string_view payload) { + absl::StatusOr<jumbf::SuperBox> box = + jumbf::ConsumeSuperBox(&payload, /*recursion_limit=*/0); + if (!box.ok()) { + // If we can't read a JUMBF Superbox, it's not a C2PA Manifest Store, + // ignore. + return false; + } + + if (box->description.type_uuid != kManifestStoreUuid) { + // JUMBF Superbox is not a C2PA Manifest Store, ignore. + return false; + }; + + if (!box->description.requestable) { + // Manifest store must be requestable. + return false; + } + + return box->description.label == kManifestStoreLabel; +} + +} // namespace + +void C2paSegmentHandler::RecordSegment(JpegSegment segment) { + if (!segment.app11_info.has_value() || + !segment.app11_info->jumbf_data.has_value()) { + // Segment does not contain APP11 information, skip it. + return; + } + + const JpegApp11Segment& app11_info = *segment.app11_info; + const JpegJumbfData& jumbf_data = *app11_info.jumbf_data; + + if (ignored_segment_ids_.contains(app11_info.segment_id)) { + // Already ignored this segment ID, skip it. + return; + } + + if (app11_info.segment_id == 0) { + // Invalid segment id, skip it. + return; + } + + if (jumbf_data.tbox != jumbf::kSuperBoxType) { + // Segment is not a JUMBF Superbox, skip it. + return; + } + + auto [it, inserted] = segment_map_.try_emplace(app11_info.segment_id); + if (inserted) { + // First segment for this segment ID, record it. + it->second.push_back(std::move(segment)); + } else { + // Segment ID already exists. + if (it->second.size() >= kMaxSegmentsPerId || + !IsValidNextSegment(it->second.back(), segment)) { + // Exceeded max segments per ID or invalid continuing segment, remove from + // map and ignore. + segment_map_.erase(it); + ignored_segment_ids_.insert(app11_info.segment_id); + } else { + // Valid continuing segment, record it. + it->second.push_back(std::move(segment)); + } + } +} + +absl::StatusOr<C2paSegmentHandler::Segment> C2paSegmentHandler::GetC2paSegment( + riegeli::Reader& input) const { + C2paSegmentHandler::Segment result; + int64_t c2pa_count = 0; + + for (const auto& [segment_id, segments] : segment_map_) { + if (segments.empty()) { + continue; + } + ABSL_ASSIGN_OR_RETURN(std::string payload, + ExtractCompletePayload(input, segments)); + if (payload.empty()) { + // Incomplete payload, ignore. + continue; + } + + if (IsC2paManifestStore(payload)) { + ++c2pa_count; + if (c2pa_count > 1) { + return absl::NotFoundError("Multiple manifest stores found"); + } + + result.manifest_store = std::move(payload); + result.segment = JpegSegment{ + .offset = segments.front().offset, + .marker_label = "C2PA", + .length = (segments.back().offset - segments.front().offset) + + segments.back().length, + }; + } + } + + if (c2pa_count == 0) { + return absl::NotFoundError("No manifest store found"); + } + return std::move(result); +} + +} // namespace credentio
diff --git a/formats/jpeg/c2pa_segment_handler.h b/formats/jpeg/c2pa_segment_handler.h new file mode 100644 index 0000000..728332d --- /dev/null +++ b/formats/jpeg/c2pa_segment_handler.h
@@ -0,0 +1,60 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_C2PA_SEGMENT_HANDLER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_C2PA_SEGMENT_HANDLER_H_ + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/status/statusor.h" +#include "formats/jpeg/reader.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +class C2paSegmentHandler { + public: + struct Segment { + JpegSegment segment; + std::string manifest_store; + }; + + void RecordSegment(JpegSegment segment); + + absl::StatusOr<C2paSegmentHandler::Segment> GetC2paSegment( + riegeli::Reader& input) const; + + protected: + const absl::flat_hash_map<uint16_t, std::vector<JpegSegment>>& GetSegmentMap() + const { + return segment_map_; + } + + const absl::flat_hash_set<uint16_t>& GetIgnoredSegmentIds() const { + return ignored_segment_ids_; + } + + private: + absl::flat_hash_set<uint16_t> ignored_segment_ids_; + absl::flat_hash_map<uint16_t, std::vector<JpegSegment>> segment_map_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_C2PA_SEGMENT_HANDLER_H_
diff --git a/formats/jpeg/c2pa_segment_handler_test.cc b/formats/jpeg/c2pa_segment_handler_test.cc new file mode 100644 index 0000000..afecd4a --- /dev/null +++ b/formats/jpeg/c2pa_segment_handler_test.cc
@@ -0,0 +1,408 @@ +// 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 "formats/jpeg/c2pa_segment_handler.h" + +#include <cstdint> +#include <vector> + +#include "absl/container/btree_set.h" +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "formats/jpeg/reader.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/constants.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::SizeIs; + +class TestC2paSegmentHandler : public C2paSegmentHandler { + public: + std::vector<std::vector<JpegSegment>> AllRecorded() const { + absl::btree_set<uint16_t> segment_ids; + for (const auto& [segment_id, segments] : GetSegmentMap()) { + segment_ids.insert(segment_id); + } + + std::vector<std::vector<JpegSegment>> result; + for (uint16_t segment_id : segment_ids) { + result.push_back(GetSegmentMap().at(segment_id)); + } + return result; + } + + absl::btree_set<uint16_t> AllIgnored() const { + return absl::btree_set<uint16_t>(GetIgnoredSegmentIds().begin(), + GetIgnoredSegmentIds().end()); + } +}; + +TEST(RecordSegmentTest, DoesNotRecordSegmentWithoutApp11Info) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), IsEmpty()); +} + +TEST(RecordSegmentTest, DoesNotRecordSegmentIdZero) { + TestC2paSegmentHandler handler; + handler.RecordSegment( + JpegSegment{.app11_info = JpegApp11Segment{.segment_id = 0}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), IsEmpty()); +} + +TEST(RecordSegmentTest, DoesNotRecordSegmentWithoutJumbfData) { + TestC2paSegmentHandler handler; + handler.RecordSegment( + JpegSegment{.app11_info = JpegApp11Segment{.segment_id = 1}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), IsEmpty()); +} + +TEST(RecordSegmentTest, DoesNotRecordSegmentIncorrectTbox) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .app11_info = JpegApp11Segment{ + .segment_id = 1, .jumbf_data = JpegJumbfData{.tbox = 0x1234}}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), IsEmpty()); +} + +TEST(RecordSegmentTest, RecordSegment) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .app11_info = JpegApp11Segment{ + .segment_id = 1, + .jumbf_data = JpegJumbfData{.tbox = jumbf::kSuperBoxType}}}); + EXPECT_THAT( + handler.AllRecorded(), + ElementsAre(ElementsAre(JpegSegment{ + .app11_info = JpegApp11Segment{ + .segment_id = 1, + .jumbf_data = JpegJumbfData{.tbox = jumbf::kSuperBoxType}}}))); + EXPECT_THAT(handler.AllIgnored(), IsEmpty()); +} + +TEST(RecordSegmentTest, DoesNotRecordNextSegmentIncorrectSequence) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .tbox = jumbf::kSuperBoxType, + }}}); + handler.RecordSegment(JpegSegment{ + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .tbox = jumbf::kSuperBoxType, + }}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), ElementsAre(1)); +} + +TEST(RecordSegmentTest, DoesNotRecordNextSegmentNotContiguous) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .offset = 100, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .tbox = jumbf::kSuperBoxType, + }}}); + handler.RecordSegment(JpegSegment{ + .offset = 200, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 2, + .jumbf_data = JpegJumbfData{ + .tbox = jumbf::kSuperBoxType, + }}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), ElementsAre(1)); +} + +TEST(RecordSegmentTest, DoesNotRecordNextSegmentDifferentLbox) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .offset = 100, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + }}}); + handler.RecordSegment(JpegSegment{ + .offset = 110, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 2, + .jumbf_data = JpegJumbfData{ + .lbox = 20, + .tbox = jumbf::kSuperBoxType, + }}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), ElementsAre(1)); +} + +TEST(RecordSegmentTest, DoesNotRecordNextSegmentDifferentXlbox) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .offset = 100, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .lbox = 1, + .tbox = jumbf::kSuperBoxType, + .xlbox = 10, + }}}); + handler.RecordSegment(JpegSegment{ + .offset = 110, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 2, + .jumbf_data = JpegJumbfData{ + .lbox = 1, + .tbox = jumbf::kSuperBoxType, + .xlbox = 20, + }}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), ElementsAre(1)); +} + +TEST(RecordSegmentTest, RecordsNextSegment) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .offset = 100, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + handler.RecordSegment(JpegSegment{ + .offset = 110, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 2, + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + EXPECT_THAT(handler.AllRecorded(), + ElementsAre(ElementsAre( + JpegSegment{ + .offset = 100, + .length = 10, + .app11_info = + JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = + JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}, + }, + JpegSegment{ + .offset = 110, + .length = 10, + .app11_info = + JpegApp11Segment{.segment_id = 1, + .sequence_number = 2, + .jumbf_data = + JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}, + }))); + EXPECT_THAT(handler.AllIgnored(), IsEmpty()); +} + +TEST(RecordSegmentTest, DoesNotRecordIgnoredSegment) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .offset = 100, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + handler.RecordSegment(JpegSegment{ + .offset = 110, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), ElementsAre(1)); + + handler.RecordSegment(JpegSegment{ + .offset = 210, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), ElementsAre(1)); +} + +TEST(RecordSegmentTest, DoesNotRecordMoreThanMaxSegmentsPerId) { + TestC2paSegmentHandler handler; + for (int i = 1; i <= 1000; ++i) { + handler.RecordSegment(JpegSegment{ + .offset = static_cast<uint64_t>((i - 1) * 10), + .length = 10, + .app11_info = + JpegApp11Segment{.segment_id = 1, + .sequence_number = static_cast<uint32_t>(i), + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + } + EXPECT_THAT(handler.AllRecorded(), SizeIs(1)); + EXPECT_THAT(handler.AllRecorded().front(), SizeIs(1000)); + EXPECT_THAT(handler.AllIgnored(), IsEmpty()); + + handler.RecordSegment(JpegSegment{ + .offset = 10000, + .length = 10, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1001, + .jumbf_data = JpegJumbfData{ + .lbox = 10, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + EXPECT_THAT(handler.AllRecorded(), IsEmpty()); + EXPECT_THAT(handler.AllIgnored(), ElementsAre(1)); +} + +TEST(GetC2paSegmentTest, RejectsDeclaredPayloadSizeTooLarge) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .offset = 0, + .length = 100, + .app11_info = JpegApp11Segment{ + .segment_id = 1, + .sequence_number = 1, + .payload_offset = 0, + .payload_length = 10, + .jumbf_data = JpegJumbfData{ + .lbox = 1024 * 1024 * 10 + 1, // > kMaxPayloadSize (10 MiB) + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + std::string data(10, 'A'); + riegeli::StringReader input(data); + EXPECT_THAT(handler.GetC2paSegment(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Declared JUMBF payload size is invalid"))); +} + +TEST(GetC2paSegmentTest, RejectsDeclaredPayloadSizeZero) { + TestC2paSegmentHandler handler; + handler.RecordSegment(JpegSegment{ + .offset = 0, + .length = 100, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .payload_offset = 0, + .payload_length = 10, + .jumbf_data = JpegJumbfData{ + .lbox = 0, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + }}}); + std::string data(10, 'A'); + riegeli::StringReader input(data); + EXPECT_THAT(handler.GetC2paSegment(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Declared JUMBF payload size is invalid"))); +} + +TEST(GetC2paSegmentTest, RejectsCumulativePayloadSizeExceedingMax) { + TestC2paSegmentHandler handler; + const uint64_t kSixMiB = 6 * 1024 * 1024; + const uint64_t kFiveMiB = 5 * 1024 * 1024; + const uint64_t kTenMiB = 10 * 1024 * 1024; + handler.RecordSegment(JpegSegment{ + .offset = 0, + .length = 100, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 1, + .payload_offset = 0, + .payload_length = kSixMiB, + .jumbf_data = JpegJumbfData{ + .lbox = kTenMiB, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + .payload_offset = 0, + .payload_length = kSixMiB, + }}}); + handler.RecordSegment(JpegSegment{ + .offset = 100, + .length = 100, + .app11_info = JpegApp11Segment{.segment_id = 1, + .sequence_number = 2, + .payload_offset = kSixMiB, + .payload_length = kFiveMiB, + .jumbf_data = JpegJumbfData{ + .lbox = kTenMiB, + .tbox = jumbf::kSuperBoxType, + .xlbox = 0, + .payload_offset = kSixMiB, + .payload_length = kFiveMiB, + }}}); + std::string data(kSixMiB, 'A'); + riegeli::StringReader input(data); + EXPECT_THAT(handler.GetC2paSegment(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("JPEG segment is too large to extract"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/extractor.cc b/formats/jpeg/extractor.cc new file mode 100644 index 0000000..1176b6f --- /dev/null +++ b/formats/jpeg/extractor.cc
@@ -0,0 +1,180 @@ +// 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 "formats/jpeg/extractor.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/jpeg/c2pa_segment_handler.h" +#include "formats/jpeg/reader.h" +#include "jumbf/utils.h" +#include "riegeli/bytes/reader.h" +#include "utils/byte_readers.h" + +namespace credentio { +namespace { + +// APP11 Marker and APP11 JPEG Extension Type values are sourced from +// Section A.4 JPEG XT boxes of the ISO/IEC 18477-3:2023 +const uint16_t kApp11ExtensionType = 0x4a50; + +} // namespace + +absl::StatusOr<std::string> JpegExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + C2paSegmentHandler segment_handler; + + ABSL_RETURN_IF_ERROR( + IterateOverJpegSegments(input, [&segment_handler](JpegSegment segment) { + segment_handler.RecordSegment(std::move(segment)); + return true; + })); + + ABSL_ASSIGN_OR_RETURN(auto c2pa_segment, + segment_handler.GetC2paSegment(input)); + + return std::move(c2pa_segment.manifest_store); +} + +absl::StatusOr<std::optional<ByteRange>> +JpegExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + C2paSegmentHandler c2pa_handler; + ABSL_RETURN_IF_ERROR(IterateOverJpegSegments( + input, + [&c2pa_handler, &options, &input](JpegSegment segment) { + if (options.end_offset >= 0 && + (segment.offset + segment.length) > options.end_offset) { + // This segment starts or ends beyond the window, ignore, stop reading + // and move the input's position to the declared end. + if (!input.Seek(options.end_offset)) { + return absl::StatusOr<bool>(input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to end offset"))); + } + return absl::StatusOr<bool>(false); // stop iteration + } + + c2pa_handler.RecordSegment(std::move(segment)); + return absl::StatusOr<bool>(true); + }, + options.end_offset)); + + absl::StatusOr<C2paSegmentHandler::Segment> c2pa_segment = + c2pa_handler.GetC2paSegment(input); + if (!c2pa_segment.ok()) { + if (options.requires_c2pa) { + return c2pa_segment.status(); + } + return std::nullopt; + } + + return ByteRange{ + .offset = c2pa_segment->segment.offset, + .length = c2pa_segment->segment.length, + }; +} + +absl::StatusOr<std::vector<AssetBox>> JpegExtractor::ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const { + C2paSegmentHandler c2pa_handler; + std::vector<AssetBox> boxes; + ABSL_RETURN_IF_ERROR(IterateOverJpegSegments( + input, + [&boxes, &c2pa_handler](JpegSegment segment) { + boxes.push_back(AssetBox{ + .identifier = segment.marker_label, + .byte_range = {.offset = segment.offset, .length = segment.length}, + }); + + c2pa_handler.RecordSegment(std::move(segment)); + return true; + }, + options.end_offset)); + + absl::StatusOr<C2paSegmentHandler::Segment> c2pa_segment = + c2pa_handler.GetC2paSegment(input); + if (!c2pa_segment.ok()) { + if (options.requires_c2pa) { + return c2pa_segment.status(); + } + return boxes; + } + + uint64_t c2pa_start = c2pa_segment->segment.offset; + uint64_t c2pa_end = c2pa_start + c2pa_segment->segment.length; + + std::vector<AssetBox> merged_boxes; + merged_boxes.reserve(boxes.size()); + for (const auto& box : boxes) { + uint64_t box_start = box.byte_range.offset; + uint64_t box_end = box_start + box.byte_range.length; + + if (box_end <= c2pa_start || box_start >= c2pa_end) { + // Box is before or after the C2PA segment, add it. + merged_boxes.push_back(std::move(box)); + continue; + } + + if (box_end == c2pa_end) { + // Box is the last C2PA Segment, add the box + merged_boxes.push_back(AssetBox{ + .identifier = "C2PA", + .byte_range = {.offset = c2pa_start, + .length = c2pa_segment->segment.length}, + }); + continue; + } + + // Box is inside the C2PA segment, skip it. + } + return merged_boxes; +} + +bool JpegExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + absl::StatusOr<uint16_t> extension_type = ConsumeUint<uint16_t>(&payload); + if (!extension_type.ok() || *extension_type != kApp11ExtensionType) { + return false; + } + + // Segment ID + auto iter_status = SkipBytes<uint16_t>(&payload); + if (!iter_status.ok()) { + return false; + } + + // Sequence Number + iter_status = SkipBytes<uint32_t>(&payload); + if (!iter_status.ok()) { + return false; + } + + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} + +} // namespace credentio
diff --git a/formats/jpeg/extractor.h b/formats/jpeg/extractor.h new file mode 100644 index 0000000..eee77d3 --- /dev/null +++ b/formats/jpeg/extractor.h
@@ -0,0 +1,52 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_byte_info.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_jpeg_specific_handling +// for more details. +class JpegExtractor : public FormatExtractor { + public: + ~JpegExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override; + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EXTRACTOR_H_
diff --git a/formats/jpeg/extractor_test.cc b/formats/jpeg/extractor_test.cc new file mode 100644 index 0000000..df119ff --- /dev/null +++ b/formats/jpeg/extractor_test.cc
@@ -0,0 +1,739 @@ +// 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 "formats/jpeg/extractor.h" + +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor_result.h" +#include "formats/jpeg/testing/app_segment_creator.h" +#include "formats/jpeg/testing/jpeg_creator.h" +#include "formats/jpeg/testing/jumbf_creator.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/jumbf_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::Eq; +using ::testing::HasSubstr; + +#ifndef ASSERT_OK_AND_ASSIGN +#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y +#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) + +#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ + ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) + +#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ + auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ + ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ + ::absl_testing::IsOk()); \ + lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) +#endif + +std::vector<AssetBox> CreateBoxes( + std::vector<std::pair<std::string, uint32_t>> expected) { + uint64_t offset = 0; + std::vector<AssetBox> boxes; + for (const auto& name_and_size : expected) { + boxes.push_back(AssetBox{ + .identifier = name_and_size.first, + .byte_range = {.offset = offset, .length = name_and_size.second}, + }); + offset += name_and_size.second; + } + return boxes; +} + +struct PayloadInput { + std::string payload; + JumbfCreatorParams jumbf_creator_params; + AppSegmentParams app_segment_params; +}; + +struct TestInput { + std::string name; + std::vector<PayloadInput> payload = {}; + JpegParams jpeg_params = {}; + void (*modify_jumbf_boxes)(std::vector<AppSegmentParams>*) = nullptr; + void (*modify_app_segments)(JpegParams*) = nullptr; + + absl::StatusOr<ExtractorResult> expected = + absl::InvalidArgumentError("Unsupported hard binding type"); +}; + +class ExtractorTest : public testing::TestWithParam<TestInput> { + private: + std::vector<AppSegmentParams> PopulateJumbfBoxes(TestInput input) { + std::vector<AppSegmentParams> jumbf_boxes; + jumbf_boxes.reserve(input.payload.size()); + + for (const auto& payload : input.payload) { + JumbfCreatorParams jumbf_params = payload.jumbf_creator_params; + jumbf_params.payload = payload.payload; + + AppSegmentParams app_segment_params = payload.app_segment_params; + app_segment_params.payload = CreateJumbf(jumbf_params); + + jumbf_boxes.push_back(app_segment_params); + } + if (input.modify_jumbf_boxes != nullptr) { + input.modify_jumbf_boxes(&jumbf_boxes); + } + return jumbf_boxes; + } + + absl::StatusOr<JpegParams> PopulateAppSegments( + TestInput input, std::vector<AppSegmentParams> jumbf_boxes) { + JpegParams result = input.jpeg_params; + + result.payload.clear(); + result.payload.reserve(jumbf_boxes.size()); + + uint16_t en = 0; + for (auto& jumbf_box : jumbf_boxes) { + if (!jumbf_box.en.has_value()) { + jumbf_box.en = ++en; + } + + ABSL_ASSIGN_OR_RETURN(auto segments, CreateAppSegments(jumbf_box)); + for (const auto& segment : segments) { + result.payload.push_back(segment); + } + } + + if (input.modify_app_segments != nullptr) { + input.modify_app_segments(&result); + } + return result; + } + + protected: + absl::StatusOr<std::string> CreateImage(TestInput input) { + ABSL_ASSIGN_OR_RETURN( + JpegParams params, + PopulateAppSegments(input, PopulateJumbfBoxes(input))); + return CreateJpeg(params); + } +}; + +TEST_P(ExtractorTest, Extract) { + ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); + riegeli::StringReader input(image); + + absl::StatusOr<std::string> extractor_result = + JpegExtractor().ExtractManifestStore(input); + + if (GetParam().expected.ok()) { + ASSERT_OK_AND_ASSIGN(const std::string& manifest_store, extractor_result); + ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); + EXPECT_THAT(manifest_store, Eq(expected.manifest_store)); + } else { + EXPECT_THAT(extractor_result, + StatusIs(GetParam().expected.status().code(), + HasSubstr(GetParam().expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) { + ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); + std::string contents = "padding" + image; + riegeli::StringReader input(contents); + + ASSERT_TRUE(input.Seek(7)); + absl::StatusOr<std::string> extractor_result = + JpegExtractor().ExtractManifestStore(input); + + if (GetParam().expected.ok()) { + ASSERT_OK_AND_ASSIGN(const std::string& manifest_store, extractor_result); + ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); + EXPECT_THAT(manifest_store, Eq(expected.manifest_store)); + } else { + EXPECT_THAT(extractor_result, + StatusIs(GetParam().expected.status().code(), + HasSubstr(GetParam().expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocation) { + ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); + riegeli::StringReader input(image); + + absl::StatusOr<std::optional<ByteRange>> result = + JpegExtractor().ExtractManifestStoreLocation(input, {}); + + if (GetParam().expected.ok()) { + ASSERT_OK_AND_ASSIGN(auto manifest_store_location, result); + ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); + EXPECT_THAT(manifest_store_location, + Eq(expected.asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, + StatusIs(GetParam().expected.status().code(), + HasSubstr(GetParam().expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxes) { + ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); + riegeli::StringReader input(image); + + absl::StatusOr<std::vector<AssetBox>> result = + JpegExtractor().ExtractBoxes(input, {}); + + if (GetParam().expected.ok()) { + ASSERT_OK_AND_ASSIGN(const auto& boxes, result); + ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); + EXPECT_THAT(boxes, Eq(expected.asset_byte_info.boxes)); + } else { + EXPECT_THAT(result, + StatusIs(GetParam().expected.status().code(), + HasSubstr(GetParam().expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffix) { + ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); + std::string contents = image + "suffix"; + riegeli::StringReader input(contents); + + absl::StatusOr<std::optional<ByteRange>> result = + JpegExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(contents.size() - 6)}); + + if (GetParam().expected.ok()) { + ASSERT_OK_AND_ASSIGN(auto manifest_store_location, result); + ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); + EXPECT_THAT(manifest_store_location, + Eq(expected.asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, + StatusIs(GetParam().expected.status().code(), + HasSubstr(GetParam().expected.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxesWithSuffix) { + ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam())); + std::string contents = image + "suffix"; + riegeli::StringReader input(contents); + + absl::StatusOr<std::vector<AssetBox>> result = JpegExtractor().ExtractBoxes( + input, {.end_offset = static_cast<int64_t>(contents.size()) - 6}); + + if (GetParam().expected.ok()) { + ASSERT_OK_AND_ASSIGN(const auto& boxes, result); + ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected); + EXPECT_THAT(boxes, Eq(expected.asset_byte_info.boxes)); + } else { + EXPECT_THAT(result, + StatusIs(GetParam().expected.status().code(), + HasSubstr(GetParam().expected.status().message()))); + } +} + +INSTANTIATE_TEST_SUITE_P( + ImageEncoding, ExtractorTest, + testing::Values<TestInput>( + TestInput{ + .name = "InvalidBeginning", + .payload = {}, + .jpeg_params = {.start_marker = 0x0102}, + .expected = absl::InvalidArgumentError("Invalid JPEG marker: 258"), + }, + TestInput{ + .name = "InvalidEnding", + .payload = {}, + .jpeg_params = {.end_markers = 0x0304}, + .expected = absl::InvalidArgumentError("Invalid JPEG marker: 772"), + }, + TestInput{ + .name = "NoSegments", + .payload = {}, + .jpeg_params = {}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "NoApp11Segments", + .payload = {{.payload = "test", + .app_segment_params = {.marker = 0xffe2}}}, + .jpeg_params = {}, + .expected = absl::NotFoundError("No manifest store found"), + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +INSTANTIATE_TEST_SUITE_P( + Segments, ExtractorTest, + testing::Values<TestInput>( + TestInput{ + .name = "OnlyApp2Segment", + .payload = {{ + .payload = "test", + .app_segment_params = {.marker = 0xffe2}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "IncorrectJpegExtensionType", + .payload = {{ + .payload = "test", + .app_segment_params = {.cl = 0101}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "InvalidSegmentId", + .payload = {{ + .payload = "test", + .app_segment_params = {.en = 0x0000}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "SegmentsOutOfOrder", + .payload = + { + { + .payload = + "This is a much longer string of test to ensure " + "that there is enough data to expect a C2PA " + "manifest store within the segment payload.", + .app_segment_params = {.max_size = 150}, + }, + }, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 2); + + auto temp = params->payload[0]; + params->payload[0] = params->payload[1]; + params->payload[1] = temp; + }, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "IncompleteSegment", + .payload = + { + { + .payload = "longer test", + .app_segment_params = {.max_size = 50}, + }, + }, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 2); + params->payload.pop_back(); + }, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "SplitSegment", + .payload = + { + { + .payload = "longer test", + .app_segment_params = {.max_size = 50}, + }, + { + .payload = "test", + .app_segment_params = {.marker = 0xffe6}, + }, + }, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 3); + auto temp = params->payload[1]; + params->payload[1] = params->payload[2]; + params->payload[2] = temp; + }, + .expected = absl::NotFoundError("No manifest store found"), + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +INSTANTIATE_TEST_SUITE_P( + JumbfBoxes, ExtractorTest, + testing::Values<TestInput>( + TestInput{ + .name = "TooFewBytesToDetermineIfC2pa", + .payload = {{.payload = "test"}}, + .modify_jumbf_boxes = + [](std::vector<AppSegmentParams>* params) { + ASSERT_EQ(params->size(), 1); + params->at(0).payload = params->at(0).payload.substr(0, 30); + }, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "IncorrectSuperBoxTbox", + .payload = {{ + .payload = "test", + .jumbf_creator_params = {.super_box_identifier = 0x12345678}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "InvalidUuidHigh", + .payload = {{ + .payload = "test", + .jumbf_creator_params = {.uuid_high = 0x0000000000000000}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "InvalidUuidLow", + .payload = {{ + .payload = "test", + .jumbf_creator_params = {.uuid_low = 0x0000000000000000}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "InvalidToggles", + .payload = {{ + .payload = "test", + .jumbf_creator_params = {.toggles = 0b00110000}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "InvalidLabel", + .payload = {{ + .payload = "test", + .jumbf_creator_params = {.label = "demo"}, + }}, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "ForceSizeIntoXlBox", + .payload = {{ + .payload = "test", + .jumbf_creator_params = {.force_xlbox = true}, + }}, + .expected = + ExtractorResult{ + .manifest_store = CreateJumbf({.payload = "test", + .force_xlbox = true}), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 2, + .length = 78}, + .boxes = CreateBoxes({ + {"SOI", /*size=*/2}, + {"C2PA", /*size=*/78}, + {"EOI", /*size=*/2}, + }), + }, + }, + }, + TestInput{ + .name = "IncorrectLboxSize", + .payload = {{ + .payload = "longer test", + .app_segment_params = {.max_size = 50}, + }}, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 2); + params->payload.pop_back(); + }, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "IncorrectXlboxSize", + .payload = {{ + .payload = "This is a much longer string of test to ensure " + "that there is enough data to expect a C2PA " + "manifest store within the segment payload.", + .jumbf_creator_params = {.force_xlbox = true}, + .app_segment_params = {.max_size = 100}, + }}, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 3); + params->payload.pop_back(); + }, + .expected = absl::NotFoundError("No manifest store found"), + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +INSTANTIATE_TEST_SUITE_P( + C2paManifestStore, ExtractorTest, + testing::Values<TestInput>( + TestInput{ + .name = "OnlyManifestStore", + .payload = {{.payload = "test"}}, + .expected = + ExtractorResult{ + .manifest_store = CreateJumbf({.payload = "test"}), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 2, + .length = 62}, + .boxes = CreateBoxes({ + {"SOI", /*size=*/2}, + {"C2PA", /*size=*/62}, + {"EOI", /*size=*/2}, + }), + }, + }, + }, + TestInput{ + .name = "SandwichedManifestStore", + .payload = + { + { + .payload = "app3", + .app_segment_params = {.marker = 0xffe3}, + }, + {.payload = "test"}, + { + .payload = "app6", + .app_segment_params = {.marker = 0xffe6}, + }, + }, + .expected = + ExtractorResult{ + .manifest_store = CreateJumbf({.payload = "test"}), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 64, + .length = 62}, + .boxes = CreateBoxes({ + {"SOI", /*size=*/2}, + {"APP3", /*size=*/62}, + {"C2PA", /*size=*/62}, + {"APP6", /*size=*/62}, + {"EOI", /*size=*/2}, + }), + }, + }, + }, + TestInput{ + .name = "ManifestStoreAcrossTwoSegments", + .payload = + { + { + .payload = "longer test", + .app_segment_params = {.max_size = 50}, + }, + }, + .expected = + ExtractorResult{ + .manifest_store = CreateJumbf({.payload = "longer test"}), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 2, + .length = 89}, + .boxes = CreateBoxes({ + {"SOI", /*size=*/2}, + {"C2PA", /*size=*/89}, + {"EOI", /*size=*/2}, + }), + }, + }, + }, + TestInput{ + .name = "ManifestStoreAcrossTwoSegmentsDifferentTbox", + .payload = + { + { + .payload = "longer test", + .app_segment_params = {.max_size = 50}, + }, + }, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 2); + // lowest byte of tbox is at 19, normally 0x62, change it + params->payload[1][19] = 0x69; + }, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "ManifestStoreAcrossTwoSegmentsDifferentLbox", + .payload = + { + { + .payload = "longer test", + .app_segment_params = {.max_size = 50}, + }, + }, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 2); + // lowest byte of lbox is at 15, normally 0x39, change it + params->payload[1][15] = 0x47; + }, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "ManifestStoreAcrossTwoSegmentsDifferentXlbox", + .payload = + { + { + .payload = "longer test", + .jumbf_creator_params = {.force_xlbox = true}, + .app_segment_params = {.max_size = 60}, + }, + }, + .modify_app_segments = + [](JpegParams* params) { + ASSERT_EQ(params->payload.size(), 2); + // lowest byte of xlbox is at 27, normally 0x49, change it + params->payload[1][27] = 0x57; + }, + .expected = absl::NotFoundError("No manifest store found"), + }, + TestInput{ + .name = "TwoManifestStores", + .payload = + { + {.payload = "store 1"}, + {.payload = "store 2"}, + }, + .expected = absl::NotFoundError("Multiple manifest stores found"), + }, + TestInput{ + .name = "TwoManifestStoresThenOthers", + .payload = + {{.payload = "store 1"}, + {.payload = "store 2"}, + {.payload = "app8", .app_segment_params = {.marker = 0xffe8}}, + {.payload = "app9", .app_segment_params = {.marker = 0xffe9}}}, + .expected = absl::NotFoundError("Multiple manifest stores found"), + }, + // If there are multiple manifest stores, but one of them is invalid + // JUMBF we should still return the valid one. + TestInput{ + .name = "TwoManifestStoresOneInvalid", + .payload = + { + {.payload = "store 1"}, + { + .payload = "store 2", + .app_segment_params = {.max_size = 50}, + }, + }, + .modify_app_segments = + [](JpegParams* params) { + // Remove the last segment of the second manifest store to + // invalidate it + ASSERT_EQ(params->payload.size(), 3); + params->payload.pop_back(); + }, + .expected = + ExtractorResult{ + .manifest_store = CreateJumbf({.payload = "store 1"}), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 2, + .length = 65}, + .boxes = CreateBoxes({ + {"SOI", /*size=*/2}, + {"C2PA", /*size=*/65}, + {"APP11", /*size=*/52}, + {"EOI", /*size=*/2}, + }), + }, + }, + }, + TestInput{ + .name = "MultiSegmentC2paAndMultiSegmentMiscApp11", + .payload = + { + { + .payload = + "This is a much longer string of test to ensure " + "that there is enough data to expect a C2PA " + "manifest store within the segment payload.", + .app_segment_params = {.max_size = 100}, + }, + { + .payload = + "This is a much longer string of test to ensure " + "that there is enough data to expect a C2PA " + "manifest store within the segment payload.", + .app_segment_params = {.cl = 0x1111, .max_size = 100}, + }, + }, + .expected = + ExtractorResult{ + .manifest_store = CreateJumbf( + {.payload = + "This is a much longer string of test to ensure " + "that there is enough data to expect a C2PA " + "manifest store within the segment payload."}), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 2, + .length = 230}, + .boxes = CreateBoxes({ + {"SOI", /*size=*/2}, + {"C2PA", /*size=*/230}, + {"APP11", /*size=*/102}, + {"APP11", /*size=*/102}, + {"APP11", /*size=*/26}, + {"EOI", /*size=*/2}, + }), + }, + }, + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +TEST(JpegExtractorTest, IsManifestStorePayloadMissingJpegElements) { + EXPECT_FALSE(JpegExtractor().MightBeC2paManifestStore( + CreateStartOfManifestStorePayload())); +} + +TEST(JpegExtractorTest, IsManifestStoreIncorrectExtensionType) { + EXPECT_FALSE(JpegExtractor().MightBeC2paManifestStore( + CreateStartOfManifestStorePayload( + {0x12, 0x34, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01}))); +} + +TEST(JpegExtractorTest, IsManifestStore) { + EXPECT_TRUE(JpegExtractor().MightBeC2paManifestStore( + CreateStartOfManifestStorePayload( + {0x4a, 0x50, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01}))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/format.cc b/formats/jpeg/format.cc new file mode 100644 index 0000000..6fca0f0 --- /dev/null +++ b/formats/jpeg/format.cc
@@ -0,0 +1,35 @@ +// 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 "formats/jpeg/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/jpeg/assessor.h" +#include "formats/jpeg/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateJpegFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<JpegAssessor>(), + .extractor = std::make_unique<JpegExtractor>(), + .mime_types = {"image/jpeg"}, + }); +} + +} // namespace credentio
diff --git a/formats/jpeg/format.h b/formats/jpeg/format.h new file mode 100644 index 0000000..e27f39f --- /dev/null +++ b/formats/jpeg/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateJpegFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_FORMAT_H_
diff --git a/formats/jpeg/reader.cc b/formats/jpeg/reader.cc new file mode 100644 index 0000000..1c17dd5 --- /dev/null +++ b/formats/jpeg/reader.cc
@@ -0,0 +1,279 @@ +// 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 "formats/jpeg/reader.h" + +#include <algorithm> +#include <cstdint> +#include <optional> +#include <ostream> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "formats/jpeg/box_header.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { +namespace { + +constexpr uint16_t kApp11ExtensionType = 0x4a50; + +// Returns the start of the next chunk following the scan data that starts at +// `offset`. +absl::StatusOr<int64_t> FindEndOfScan(riegeli::Reader& input, int64_t offset) { + if (!input.Seek(offset)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + uint8_t first = 0; + uint8_t second = 0; + while (true) { + if (first == 0xff && second != 0) { + break; + } + first = second; + if (!input.ReadByte(second)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read byte")); + } + } + return input.pos() - 2; // Back up to start of chunk header. +} + +absl::Status PopulateJumbfData(riegeli::Reader& reader, JpegSegment& segment) { + if (!segment.app11_info.has_value()) { + // Will never happen as it's always called from PopulateApp11Info + return absl::InvalidArgumentError( + "APP11 info not populated, cannot populate JUMBf data"); + } + + uint64_t header_size = 8; // lbox + tbox + if (segment.app11_info->payload_length < header_size) { + // Not enough bytes for JUMBf header. + return absl::OkStatus(); + } + + segment.app11_info->jumbf_data = JpegJumbfData{}; + if (!riegeli::ReadBigEndian<uint32_t>(reader, + segment.app11_info->jumbf_data->lbox)) { + return reader.StatusOrAnnotate(absl::DataLossError("Failed to read lbox")); + } + if (!riegeli::ReadBigEndian<uint32_t>(reader, + segment.app11_info->jumbf_data->tbox)) { + return reader.StatusOrAnnotate(absl::DataLossError("Failed to read tbox")); + } + if (segment.app11_info->jumbf_data->lbox == 1) { + header_size += sizeof(uint64_t); + + if (segment.app11_info->payload_length < header_size) { + return absl::InvalidArgumentError( + "JUMBf box payload too short to contain xlbox"); + } + if (!riegeli::ReadBigEndian<uint64_t>( + reader, segment.app11_info->jumbf_data->xlbox)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read xlbox")); + } + } + + uint64_t jumbf_size = segment.app11_info->jumbf_data->lbox == 1 + ? segment.app11_info->jumbf_data->xlbox + : segment.app11_info->jumbf_data->lbox; + if (jumbf_size < header_size) { + return absl::InvalidArgumentError("JUMBf box payload too short"); + } + + segment.app11_info->jumbf_data->payload_offset = + segment.app11_info->payload_offset + header_size; + segment.app11_info->jumbf_data->payload_length = + segment.app11_info->payload_length - header_size; + return absl::OkStatus(); +} + +absl::Status PopulateApp11Info(riegeli::Reader& reader, JpegSegment& segment) { + // APP11 segment payload must be at least 8 bytes long + if (segment.payload_length < 8) { + // Return OK without populating app11_info, treating it as non-C2PA. + return absl::OkStatus(); + } + + // Seek to the start of the APP11 header. + if (!reader.Seek(segment.payload_offset)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + segment.app11_info = JpegApp11Segment{}; + if (!riegeli::ReadBigEndian<uint16_t>(reader, + segment.app11_info->extension_type)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read extension type")); + } + if (!riegeli::ReadBigEndian<uint16_t>(reader, + segment.app11_info->segment_id)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read segment ID")); + } + if (!riegeli::ReadBigEndian<uint32_t>(reader, + segment.app11_info->sequence_number)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read sequence number")); + } + + segment.app11_info->payload_offset = segment.payload_offset + 8; + segment.app11_info->payload_length = segment.payload_length - 8; + + if (segment.app11_info->extension_type == kApp11ExtensionType) { + return PopulateJumbfData(reader, segment); + } + return absl::OkStatus(); +} + +} // namespace + +absl::Status IterateOverJpegSegments(riegeli::Reader& reader, + JpegSegmentProcessor processor, + int64_t end_offset) { + uint64_t current_offset = reader.pos(); + uint64_t actual_end_offset = + end_offset == -1 + ? reader.Size().value_or(0) + : std::min(static_cast<uint64_t>(reader.Size().value_or(0)), + static_cast<uint64_t>(end_offset)); + + while (current_offset < actual_end_offset) { + if (!reader.Seek(current_offset)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + JpegBoxHeader box_header; + if (auto result = ConsumeJpegBoxHeader(reader); result.ok()) { + box_header = *result; + } else { + return result.status(); + } + + JpegSegment segment = { + .offset = box_header.offset, + .marker = box_header.type, + .marker_label = box_header.label(), + .length = box_header.size, + .payload_offset = box_header.size < 4 + ? box_header.offset + box_header.size + : box_header.offset + 4, + .payload_length = box_header.size < 4 ? 0 : box_header.size - 4, + }; + + if (segment.marker_label == "SOS" || + absl::StartsWith(segment.marker_label, "RST")) { + // Chunk is followed by indefinite-length image data, which is + // included in the box content for hashing purposes. + uint64_t next_chunk_start = 0; + if (auto result = FindEndOfScan(reader, segment.offset + segment.length); + result.ok()) { + next_chunk_start = *result; + } else { + return result.status(); + } + segment.length = next_chunk_start - segment.offset; + segment.payload_length = next_chunk_start - segment.payload_offset; + // Return to the start of the payload. + if (!reader.Seek(segment.payload_offset)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + } + + if (segment.marker_label == "APP11") { + ABSL_RETURN_IF_ERROR(PopulateApp11Info(reader, segment)); + } + + if (segment.length > actual_end_offset - segment.offset && + segment.payload_length > 0) { + // This box is truncated. + return absl::InvalidArgumentError("truncated JPEG box"); + } + current_offset = segment.offset + segment.length; + + bool continue_processing = true; + if (auto result = processor(segment); result.ok()) { + continue_processing = *result; + } else { + return result.status(); + } + if (!continue_processing) { + return absl::OkStatus(); + } + + if (segment.marker_label == "EOI") { + break; + } + } + + if (current_offset < actual_end_offset) { + // End of first image, any additional data is just a `c2pa.after` segment. + JpegSegment after_segment = + JpegSegment{.offset = current_offset, + .marker = 0x0000, + .marker_label = "c2pa.after", + .length = actual_end_offset - current_offset, + .payload_offset = 0, + .payload_length = 0}; + return processor(after_segment).status(); + } + + return absl::OkStatus(); +} + +void PrintTo(const JpegJumbfData& x, ::std::ostream* os) { + *os << absl::StrCat("{lbox: ", x.lbox, ", tbox: ", x.tbox, + ", xlbox: ", x.xlbox, + ", payload_offset: ", x.payload_offset, + ", payload_length: ", x.payload_length, "}"); +} + +void PrintTo(const JpegApp11Segment& x, ::std::ostream* os) { + *os << "{extension_type: " << x.extension_type + << ", segment_id: " << x.segment_id + << ", sequence_number: " << x.sequence_number + << ", payload_offset: " << x.payload_offset + << ", payload_length: " << x.payload_length << ", jumbf_data: "; + if (x.jumbf_data.has_value()) { + PrintTo(*x.jumbf_data, os); + } else { + *os << "nullopt"; + } + *os << "}"; +} + +void PrintTo(const JpegSegment& x, ::std::ostream* os) { + *os << "{offset: " << x.offset << ", marker: " << x.marker + << ", marker_label: \"" << x.marker_label << "\"" + << ", length: " << x.length << ", payload_offset: " << x.payload_offset + << ", payload_length: " << x.payload_length << ", app11_info: "; + if (x.app11_info.has_value()) { + PrintTo(*x.app11_info, os); + } else { + *os << "nullopt"; + } + *os << "}"; +} + +} // namespace credentio
diff --git a/formats/jpeg/reader.h b/formats/jpeg/reader.h new file mode 100644 index 0000000..09672ff --- /dev/null +++ b/formats/jpeg/reader.h
@@ -0,0 +1,93 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_READER_H_ + +#include <cstdint> +#include <iosfwd> +#include <optional> +#include <string> + +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +struct JpegJumbfData { + // Offset for this data is contained within + // `JpegApp11Segment::payload_offset`. + uint32_t lbox; + uint32_t tbox; + uint64_t xlbox; + + // Offset and length of the data contained in the JUMBF segment. + uint64_t payload_offset; + uint64_t payload_length; + + friend bool operator==(const JpegJumbfData&, const JpegJumbfData&) = default; +}; + +void PrintTo(const JpegJumbfData& x, ::std::ostream* os); + +struct JpegApp11Segment { + uint16_t extension_type; + uint16_t segment_id; + uint32_t sequence_number; + + // Offset and length of the data contained in the APP11 segment. + uint64_t payload_offset; + uint64_t payload_length; + + std::optional<JpegJumbfData> jumbf_data; + + friend bool operator==(const JpegApp11Segment&, + const JpegApp11Segment&) = default; +}; + +void PrintTo(const JpegApp11Segment& x, ::std::ostream* os); + +struct JpegSegment { + uint64_t offset; + uint16_t marker; // Marker code for the segment. + std::string marker_label; // Human-readable label for the marker. + uint64_t length; + + // Offset and length of the data contained in the segment. + uint64_t payload_offset; + uint64_t payload_length; + + std::optional<JpegApp11Segment> app11_info; + + friend bool operator==(const JpegSegment&, const JpegSegment&) = default; +}; + +void PrintTo(const JpegSegment& x, ::std::ostream* os); + +using JpegSegmentProcessor = + absl::FunctionRef<absl::StatusOr<bool>(const JpegSegment&)>; + +// Iterates over all JPEG segments in the given reader, calling the given +// function for each segment. The method will NOT seek to position 0 in the +// reader before iterating, it will start from the current position. +absl::Status IterateOverJpegSegments(riegeli::Reader& reader, + JpegSegmentProcessor processor, + int64_t end_offset = -1); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_READER_H_
diff --git a/formats/jpeg/reader_test.cc b/formats/jpeg/reader_test.cc new file mode 100644 index 0000000..312e58b --- /dev/null +++ b/formats/jpeg/reader_test.cc
@@ -0,0 +1,434 @@ +// 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 "formats/jpeg/reader.h" + +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::PrintToString; +using ::testing::SizeIs; + +class MarkerTest : public testing::TestWithParam<uint16_t> {}; + +INSTANTIATE_TEST_SUITE_P(MarkerBasedTests, MarkerTest, + ::testing::Range(uint16_t{0xff00}, uint16_t{0xffff}), + [](const testing::TestParamInfo<uint16_t>& marker) { + return absl::StrFormat("%04x", marker.param); + }); + +TEST_P(MarkerTest, HandlesMarker) { + std::string data("\0\0\0\4\1\1\2\2\1\1\1\1\1\1\1\1", 16); + data[0] = static_cast<uint8_t>((GetParam() & 0x0000ff00) >> 8); + data[1] = static_cast<uint8_t>((GetParam() & 0x000000ff)); + data[6] = 0xff; + data[7] = 0xff; + + riegeli::StringReader<> input(data); + + JpegSegment segment; + bool called = false; + EXPECT_THAT( + IterateOverJpegSegments(input, + [&segment, &called](const JpegSegment& chunk) { + segment = std::move(chunk); + called = true; + return false; // stop after first + }), + IsOk()); + ASSERT_TRUE(called); + + EXPECT_EQ(segment.marker, GetParam()); + + EXPECT_EQ(segment.offset, 0); + + if (GetParam() == 0xff01 || GetParam() == 0xffd8 || GetParam() == 0xffd9) { + // No payload. + EXPECT_EQ(segment.length, 2); + EXPECT_EQ(segment.payload_offset, 2); + EXPECT_EQ(segment.payload_length, 0); + return; + } + + EXPECT_EQ(segment.length, 6); + + if ((GetParam() >= 0xffd0 && GetParam() <= 0xffd7)) { + // No defined length, so length bytes are part of payload + EXPECT_EQ(segment.payload_offset, 2); + EXPECT_EQ(segment.payload_length, 4); + return; + } + + EXPECT_EQ(segment.payload_offset, 4); + EXPECT_EQ(segment.payload_length, 2); +} + +TEST(ReaderTest, IterateOverMultipleSegments) { + // SOI (2 bytes), APP0 (6 bytes with 2 bytes payload), EOI (2 bytes) + std::string data("\xff\xd8\xff\xe0\x00\x04\x01\x02\xff\xd9", 10); + riegeli::StringReader<> input(data); + + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; // continue + }), + IsOk()); + + // SOI is processed, APP0 is processed, EOI is processed (and breaks the loop) + ASSERT_EQ(segments.size(), 3); + EXPECT_EQ(segments[0].marker, 0xffd8); + EXPECT_EQ(segments[0].marker_label, "SOI"); + EXPECT_EQ(segments[1].marker, 0xffe0); + EXPECT_EQ(segments[1].marker_label, "APP0"); + EXPECT_EQ(segments[2].marker, 0xffd9); + EXPECT_EQ(segments[2].marker_label, "EOI"); +} + +TEST(ReaderTest, ParsesValidApp11Segment) { + std::string data( + "\xff\xd8" // SOI + "\xff\xeb\x00\x12" // APP11, length 18 + "\x4a\x50\x00\x01\x00\x00\x00\x01" // APP11 header (type 0x4a50, id 1, + // seq 1) + "\x00\x00\x00\x08\x6a\x75\x6d\x62" // JUMBF header (lbox 8, tbox 'jumb') + "\xff\xd9", // EOI + 24); + riegeli::StringReader<> input(data); + + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + IsOk()); + + ASSERT_EQ(segments.size(), 3); + EXPECT_EQ(segments[1].marker, 0xffeb); + EXPECT_EQ(segments[1].marker_label, "APP11"); + ASSERT_TRUE(segments[1].app11_info.has_value()); + EXPECT_EQ(segments[1].app11_info->extension_type, 0x4a50); + EXPECT_EQ(segments[1].app11_info->segment_id, 1); + EXPECT_EQ(segments[1].app11_info->sequence_number, 1); + ASSERT_TRUE(segments[1].app11_info->jumbf_data.has_value()); + EXPECT_EQ(segments[1].app11_info->jumbf_data->lbox, 8); + EXPECT_EQ(segments[1].app11_info->jumbf_data->tbox, 0x6a756d62); + EXPECT_EQ(segments[1].app11_info->jumbf_data->xlbox, 0); +} + +TEST(ReaderTest, HandlesTruncatedBox) { + // SOI (2 bytes), APP0 (18 bytes total: marker 2, length 2, payload 14) + std::string data( + "\xff\xd8" + "\xff\xe0\x00\x10\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d" + "\x0e", + 20); + riegeli::StringReader<> input(data); + + // We pass end_offset = 15, which truncates the APP0 box. + EXPECT_THAT( + IterateOverJpegSegments( + input, [](const JpegSegment& chunk) { return true; }, 15), + StatusIs(absl::StatusCode::kInvalidArgument, "truncated JPEG box")); +} + +TEST(ReaderTest, ParsesSosWithByteStuffing) { + std::string data( + "\xff\xd8" // SOI + "\xff\xda\x00\x06\xaa\xbb\xcc\xdd" // SOS header (size 8) + "\x11\x22\xff\x00\x33\x44" // Scan data (6 bytes, containing stuffed + // \xff\x00) + "\xff\xd9", // EOI + 18); + riegeli::StringReader<> input(data); + + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + IsOk()); + + ASSERT_EQ(segments.size(), 3); + EXPECT_EQ(segments[1].marker, 0xffda); + EXPECT_EQ(segments[1].marker_label, "SOS"); + EXPECT_EQ(segments[1].offset, 2); + EXPECT_EQ(segments[1].length, 14); // 8 (header) + 6 (scan data) + EXPECT_EQ(segments[1].payload_offset, 6); + EXPECT_EQ(segments[1].payload_length, 10); + + EXPECT_EQ(segments[2].marker, 0xffd9); + EXPECT_EQ(segments[2].marker_label, "EOI"); + EXPECT_EQ(segments[2].offset, 16); +} + +TEST(ReaderTest, ParsesC2paAfterSegment) { + std::string data( + "\xff\xd8" // SOI + "\xff\xd9" // EOI + "\x01\x02\x03\x04", // Extra data + 8); + riegeli::StringReader<> input(data); + + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + IsOk()); + + ASSERT_EQ(segments.size(), 3); + EXPECT_EQ(segments[0].marker, 0xffd8); + EXPECT_EQ(segments[1].marker, 0xffd9); + + EXPECT_EQ(segments[2].marker, 0x0000); + EXPECT_EQ(segments[2].marker_label, "c2pa.after"); + EXPECT_EQ(segments[2].offset, 4); + EXPECT_EQ(segments[2].length, 4); + EXPECT_EQ(segments[2].payload_offset, 0); + EXPECT_EQ(segments[2].payload_length, 0); +} + +TEST(ReaderTest, PropagatesProcessorError) { + std::string data("\xff\xd8\xff\xe0\x00\x04\x01\x02\xff\xd9", 10); + riegeli::StringReader<> input(data); + + EXPECT_THAT( + IterateOverJpegSegments(input, + [](const JpegSegment& chunk) { + return absl::InternalError("test error"); + }), + StatusIs(absl::StatusCode::kInternal, "test error")); +} + +TEST(ReaderTest, PrintToJpegJumbfData) { + JpegJumbfData data = {.lbox = 1, + .tbox = 2, + .xlbox = 3, + .payload_offset = 4, + .payload_length = 5}; + EXPECT_EQ( + PrintToString(data), + "{lbox: 1, tbox: 2, xlbox: 3, payload_offset: 4, payload_length: 5}"); +} + +TEST(ReaderTest, PrintToJpegApp11Segment) { + JpegApp11Segment segment = { + .extension_type = 1, + .segment_id = 2, + .sequence_number = 3, + .payload_offset = 4, + .payload_length = 5, + .jumbf_data = JpegJumbfData{.lbox = 10, + .tbox = 20, + .xlbox = 30, + .payload_offset = 40, + .payload_length = 50}}; + + EXPECT_EQ(PrintToString(segment), + "{extension_type: 1, segment_id: 2, sequence_number: 3, " + "payload_offset: 4, payload_length: 5, jumbf_data: {lbox: 10, " + "tbox: 20, xlbox: 30, payload_offset: 40, payload_length: 50}}"); + + JpegApp11Segment segment_no_jumbf = {.extension_type = 1, + .segment_id = 2, + .sequence_number = 3, + .payload_offset = 4, + .payload_length = 5, + .jumbf_data = std::nullopt}; + EXPECT_EQ(PrintToString(segment_no_jumbf), + "{extension_type: 1, segment_id: 2, sequence_number: 3, " + "payload_offset: 4, payload_length: 5, jumbf_data: nullopt}"); +} + +TEST(ReaderTest, PrintToJpegSegment) { + JpegSegment segment = { + .offset = 1, + .marker = 2, + .marker_label = "LABEL", + .length = 3, + .payload_offset = 4, + .payload_length = 5, + .app11_info = JpegApp11Segment{.extension_type = 10, + .segment_id = 20, + .sequence_number = 30, + .payload_offset = 40, + .payload_length = 50, + .jumbf_data = std::nullopt}}; + + EXPECT_EQ(PrintToString(segment), + "{offset: 1, marker: 2, marker_label: \"LABEL\", length: 3, " + "payload_offset: 4, payload_length: 5, app11_info: " + "{extension_type: 10, segment_id: 20, sequence_number: 30, " + "payload_offset: 40, payload_length: 50, jumbf_data: nullopt}}"); + + JpegSegment segment_no_app11 = {.offset = 1, + .marker = 2, + .marker_label = "LABEL", + .length = 3, + .payload_offset = 4, + .payload_length = 5, + .app11_info = std::nullopt}; + EXPECT_EQ(PrintToString(segment_no_app11), + "{offset: 1, marker: 2, marker_label: \"LABEL\", length: 3, " + "payload_offset: 4, payload_length: 5, app11_info: nullopt}"); +} + +TEST(ReaderTest, MalformedSegmentSize) { + std::string data("\xff\xd8\xff\xe0\x00\x00\xff\xd9", 8); + riegeli::StringReader<> input(data); + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid JPEG segment size: 0"))); +} + +TEST(ReaderTest, App11SegmentShortLengthDesynchronization) { + std::string data( + "\xff\xd8" + "\xff\xeb\x00\x04\x00\x00" + "\xff\xeb\x00\x12\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x08\x6a\x75" + "\x6d\x62" + "\xff\xd9", + 30); + riegeli::StringReader<> input(data); + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + IsOk()); + ASSERT_EQ(segments.size(), 4); + EXPECT_EQ(segments[0].marker_label, "SOI"); + EXPECT_EQ(segments[1].marker, 0xffeb); + EXPECT_EQ(segments[1].length, 6); + EXPECT_FALSE(segments[1].app11_info.has_value()); + EXPECT_EQ(segments[2].marker, 0xffeb); + EXPECT_EQ(segments[2].offset, 8); + EXPECT_EQ(segments[2].length, 20); + EXPECT_TRUE(segments[2].app11_info.has_value()); + EXPECT_EQ(segments[3].marker_label, "EOI"); +} + +TEST(ReaderTest, App11JumbfBoxXlboxTooShort) { + std::string data( + "\xff\xd8" + "\xff\xeb\x00\x1c\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x6a\x75" + "\x6d\x62\x00\x00\x00\x00\x00\x00\x00\x0a\x00\x00" + "\xff\xd9", + 34); + riegeli::StringReader<> input(data); + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("JUMBf box payload too short"))); +} + +TEST(ReaderTest, App11JumbfBoxLboxReserved) { + std::string data( + "\xff\xd8" + "\xff\xeb\x00\x12\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x05\x6a\x75" + "\x6d\x62" + "\xff\xd9", + 24); + riegeli::StringReader<> input(data); + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("JUMBf box payload too short"))); +} + +TEST(ReaderTest, App11JumbfBoxPayloadLengthTooShort) { + // SOI (2 bytes) + // APP11 (16 bytes) + // - header (4 bytes) + // - extension info (8 bytes) + // - JUMBF header (4 bytes) * Invalid: JUMBF needs a minimum of 8 bytes + // EOI (2 bytes) + std::string data( + "\xff\xd8" + "\xff\xeb\x00\x0e\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x08" + "\xff\xd9", + 20); + riegeli::StringReader<> input(data); + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + IsOk()); + EXPECT_THAT(segments, SizeIs(3)); + EXPECT_EQ(segments[0].marker_label, "SOI"); + EXPECT_EQ(segments[1].marker_label, "APP11"); + EXPECT_TRUE(segments[1].app11_info.has_value()); + EXPECT_FALSE(segments[1].app11_info->jumbf_data.has_value()); + EXPECT_EQ(segments[2].marker_label, "EOI"); +} + +TEST(ReaderTest, PayloadLengthDoesNotUnderflowOnShortSegment) { + // SOI (2 bytes: \xff\xd8) followed by EOI (\xff\xd9) + std::string data("\xff\xd8\xff\xd9", 4); + riegeli::StringReader<> input(data); + std::vector<JpegSegment> segments; + EXPECT_THAT(IterateOverJpegSegments(input, + [&segments](const JpegSegment& chunk) { + segments.push_back(chunk); + return true; + }), + IsOk()); + ASSERT_GE(segments.size(), 1); + EXPECT_EQ(segments[0].marker, 0xffd8); + EXPECT_EQ(segments[0].payload_length, 0); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/testing/BUILD b/formats/jpeg/testing/BUILD new file mode 100644 index 0000000..671f516 --- /dev/null +++ b/formats/jpeg/testing/BUILD
@@ -0,0 +1,64 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package( + default_testonly = True, + default_visibility = ["//visibility:public"], +) + +cc_library( + name = "jumbf_creator", + srcs = ["jumbf_creator.cc"], + hdrs = ["jumbf_creator.h"], + deps = [ + "//utils:byte_writers", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "app_segment_creator", + srcs = ["app_segment_creator.cc"], + hdrs = ["app_segment_creator.h"], + deps = [ + "//utils:byte_readers", + "//utils:byte_writers", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "jpeg_creator", + srcs = ["jpeg_creator.cc"], + hdrs = ["jpeg_creator.h"], + deps = ["//utils:byte_writers"], +) + +cc_test( + name = "app_segment_creator_test", + srcs = ["app_segment_creator_test.cc"], + deps = [ + ":app_segment_creator", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +)
diff --git a/formats/jpeg/testing/app_segment_creator.cc b/formats/jpeg/testing/app_segment_creator.cc new file mode 100644 index 0000000..6536640 --- /dev/null +++ b/formats/jpeg/testing/app_segment_creator.cc
@@ -0,0 +1,112 @@ +// 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 "formats/jpeg/testing/app_segment_creator.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "utils/byte_readers.h" +#include "utils/byte_writers.h" + +namespace credentio { + +absl::StatusOr<std::vector<std::string>> CreateAppSegments( + AppSegmentParams params) { + absl::string_view payload(params.payload); + + std::vector<std::string> segments; + + ABSL_ASSIGN_OR_RETURN(uint32_t lbox, ConsumeUint<uint32_t>(&payload)); + ABSL_ASSIGN_OR_RETURN(uint32_t tbox, ConsumeUint<uint32_t>(&payload)); + uint64_t xlbox = 0; + if (lbox == 1) { + ABSL_ASSIGN_OR_RETURN(xlbox, ConsumeUint<uint64_t>(&payload)); + } + + if (!params.en.has_value()) { + params.en = 1; + } + + uint32_t z = params.starting_z; + uint16_t base_le = sizeof(uint16_t) + // le + sizeof(uint16_t) + // cl + sizeof(uint16_t) + // en + sizeof(uint32_t) + // z + sizeof(uint32_t) + // lbox + sizeof(uint32_t) + // tbox + (lbox == 1 ? sizeof(uint64_t) : 0); // xlbox + + if (params.max_size <= base_le) { + return absl::InvalidArgumentError( + "AppSegmentParams max_size must be strictly greater than base_le."); + } + + uint16_t max_segment_size = params.max_size - base_le; + while (!payload.empty()) { + std::vector<uint8_t> bytes; + // The maximum size of the segment is params.max_size. The bytes vector + // also includes the 2-byte marker. + bytes.reserve(sizeof(uint16_t) + params.max_size); + + // Add Marker + WriteUint16NetworkOrder(params.marker, &bytes); + + uint16_t le = + base_le + (payload.length() < max_segment_size ? payload.length() + : max_segment_size); + // Add Size + WriteUint16NetworkOrder(le, &bytes); + + // Add Extension Type + WriteUint16NetworkOrder(params.cl, &bytes); + + // Add Identifier + WriteUint16NetworkOrder(*params.en, &bytes); + + // Add Sequence Number + WriteUint32NetworkOrder(z, &bytes); + + // Add LBox + WriteUint32NetworkOrder(lbox, &bytes); + + // Add TBox + WriteUint32NetworkOrder(tbox, &bytes); + + // Maybe Add XLBox + if (lbox == 1) { + WriteUint64NetworkOrder(xlbox, &bytes); + } + + for (int i = 0; i < max_segment_size && !payload.empty(); ++i) { + // Will Always Contain at least one byte + ABSL_ASSIGN_OR_RETURN(auto byte, ConsumeUint<uint8_t>(&payload)); + bytes.push_back(byte); + } + + z = params.z_incrementer(z); + + segments.push_back(std::string(bytes.begin(), bytes.end())); + } + + return segments; +} + +} // namespace credentio
diff --git a/formats/jpeg/testing/app_segment_creator.h b/formats/jpeg/testing/app_segment_creator.h new file mode 100644 index 0000000..684213e --- /dev/null +++ b/formats/jpeg/testing/app_segment_creator.h
@@ -0,0 +1,53 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_APP_SEGMENT_CREATOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_APP_SEGMENT_CREATOR_H_ + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" + +namespace credentio { + +struct AppSegmentParams { + uint16_t marker = 0xffeb; // APP11 Marker + uint16_t cl = 0x4a50; // APP11 JPEG Extension Indicator + std::optional<uint16_t> en = std::nullopt; // En: Instance Number + + // Maximum size of the segment in bytes + uint32_t max_size = 65535; + + // Z is the sequence number of the segment starting at the speficied value and + // using the z_incrementer function to increment + uint32_t starting_z = 1; + uint32_t (*z_incrementer)(uint32_t) = [](uint32_t z) { return z + 1; }; + + // The data to be embedded, potentially split into multiple segments + std::string payload; +}; + +// Creates Jpeg APP segments they are APP11 segments by default but can be +// overridden by setting the various fields within the AppSegmentParams +// See ISO/IEC 18477-3:2023 for the specification of APP segments +absl::StatusOr<std::vector<std::string>> CreateAppSegments( + AppSegmentParams params); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_APP_SEGMENT_CREATOR_H_
diff --git a/formats/jpeg/testing/app_segment_creator_test.cc b/formats/jpeg/testing/app_segment_creator_test.cc new file mode 100644 index 0000000..2250aee --- /dev/null +++ b/formats/jpeg/testing/app_segment_creator_test.cc
@@ -0,0 +1,56 @@ +// 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 "formats/jpeg/testing/app_segment_creator.h" + +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; + +TEST(AppSegmentCreatorTest, CreateAppSegmentsSuccess) { + AppSegmentParams params; + // 4 bytes lbox (10), 4 bytes tbox (0x4a554d42), 2 bytes data + params.payload = std::string("\x00\x00\x00\x0a\x4a\x55\x4d\x42\x01\x02", 10); + params.max_size = 65535; + + auto segments_or = CreateAppSegments(params); + ASSERT_THAT(segments_or, IsOk()); + EXPECT_EQ(segments_or.value().size(), 1); +} + +TEST(AppSegmentCreatorTest, MaxSizeTooSmallReturnsError) { + AppSegmentParams params; + // 4 bytes lbox (10), 4 bytes tbox (0x4a554d42), 1 byte data + params.payload = std::string("\x00\x00\x00\x0a\x4a\x55\x4d\x42\x01", 9); + // base_le is 18 when lbox != 1. Setting max_size to 18 should trigger an + // error. + params.max_size = 18; + + EXPECT_THAT(CreateAppSegments(params), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/jpeg/testing/jpeg_creator.cc b/formats/jpeg/testing/jpeg_creator.cc new file mode 100644 index 0000000..0eff735 --- /dev/null +++ b/formats/jpeg/testing/jpeg_creator.cc
@@ -0,0 +1,42 @@ +// 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 "formats/jpeg/testing/jpeg_creator.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "utils/byte_writers.h" + +namespace credentio { + +std::string CreateJpeg(const JpegParams& params) { + std::vector<uint8_t> jpeg; + + WriteUint16NetworkOrder(params.start_marker, &jpeg); + + for (const auto& p : params.payload) { + for (const auto& c : p) { + jpeg.push_back(c); + } + } + + WriteUint16NetworkOrder(params.end_markers, &jpeg); + + return std::string(jpeg.begin(), jpeg.end()); +} + +} // namespace credentio
diff --git a/formats/jpeg/testing/jpeg_creator.h b/formats/jpeg/testing/jpeg_creator.h new file mode 100644 index 0000000..0e55f0e --- /dev/null +++ b/formats/jpeg/testing/jpeg_creator.h
@@ -0,0 +1,36 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JPEG_CREATOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JPEG_CREATOR_H_ + +#include <cstdint> +#include <string> +#include <vector> + +namespace credentio { + +struct JpegParams { + uint16_t start_marker = 0xffd8; + uint16_t end_markers = 0xffd9; + + std::vector<std::string> payload; +}; + +std::string CreateJpeg(const JpegParams& params); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JPEG_CREATOR_H_
diff --git a/formats/jpeg/testing/jumbf_creator.cc b/formats/jpeg/testing/jumbf_creator.cc new file mode 100644 index 0000000..62d68d8 --- /dev/null +++ b/formats/jpeg/testing/jumbf_creator.cc
@@ -0,0 +1,157 @@ +// 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 "formats/jpeg/testing/jumbf_creator.h" + +#include <sys/stat.h> + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/strings/string_view.h" +#include "utils/byte_writers.h" + +namespace credentio { +namespace { + +std::string CreateJumbfDescriptionBox(const JumbfCreatorParams& params) { + std::vector<uint8_t> box; + + // 4 bytes for Lbox + // 4 bytes for Tbox + // Maybe 8 bytes for XLBox (if forced) + // 8 bytes for UUID High + // 8 bytes for UUID Low + // 1 byte for Toggles + // X bytes for Label + // 1 byte for 0x00 terminator + // --- + // 26 + label.length() + 8? + uint32_t size = 26 + params.label.length() + (params.force_xlbox ? 8 : 0); + box.reserve(size); + + uint32_t lbox = size; + std::optional<uint64_t> xlbox = std::nullopt; + if (params.force_xlbox) { + lbox = 1; + xlbox = static_cast<uint64_t>(size); + } + + // Lbox + WriteUint32NetworkOrder(lbox, &box); + + // Tbox + WriteUint32NetworkOrder(params.description_box_identifier, &box); + + // Xlbox + if (xlbox.has_value()) { + WriteUint64NetworkOrder(*xlbox, &box); + } + + // Type + WriteUint64NetworkOrder(params.uuid_high, &box); + WriteUint64NetworkOrder(params.uuid_low, &box); + + // Toggles + box.push_back(params.toggles); + + // Label + for (const auto& c : params.label) { + box.push_back(c); + } + box.push_back(params.label_terminator); // End of label + + return std::string(box.begin(), box.end()); +} + +std::string CreateJumbfContentBox(absl::string_view payload) { + std::vector<uint8_t> box; + + // 4 bytes for Lbox + // 4 bytes for Tbox + // --- + // 8 + payload.length() + uint32_t size = 8 + payload.size(); + box.reserve(size); + + // Lbox + WriteUint32NetworkOrder(size, &box); + + // Tbox?? + box.push_back('a'); + box.push_back('a'); + box.push_back('a'); + box.push_back('a'); + + // Payload + for (const auto& c : payload) { + box.push_back(c); + } + + return std::string(box.begin(), box.end()); +} +} // namespace + +std::string CreateJumbf(const JumbfCreatorParams& params) { + std::string description_box = CreateJumbfDescriptionBox(params); + std::string content_box = CreateJumbfContentBox(params.payload); + + std::vector<uint8_t> box; + + // 4 bytes for Lbox + // 4 bytes for Tbox + // Maybe 8 bytes for XLBox (if forced) + // Description Box + // Content Box + // --- + // 26 + label.length() + 8? + uint32_t size = 8 + (params.force_xlbox ? 8 : 0) + description_box.size() + + content_box.size(); + box.reserve(size); + + uint32_t lbox = size; + std::optional<uint64_t> xlbox = std::nullopt; + if (params.force_xlbox) { + lbox = 1; + xlbox = static_cast<uint64_t>(size); + } + + // Lbox + WriteUint32NetworkOrder(lbox, &box); + + // Tbox + WriteUint32NetworkOrder(params.super_box_identifier, &box); + + // Xlbox + if (xlbox.has_value()) { + WriteUint64NetworkOrder(*xlbox, &box); + } + + // Description Box + for (const auto& c : description_box) { + box.push_back(c); + } + + // Content Box + for (const auto& c : content_box) { + box.push_back(c); + } + + return std::string(box.begin(), box.end()); +} + +} // namespace credentio
diff --git a/formats/jpeg/testing/jumbf_creator.h b/formats/jpeg/testing/jumbf_creator.h new file mode 100644 index 0000000..9a2c546 --- /dev/null +++ b/formats/jpeg/testing/jumbf_creator.h
@@ -0,0 +1,41 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JUMBF_CREATOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JUMBF_CREATOR_H_ + +#include <cstdint> +#include <string> + +namespace credentio { + +struct JumbfCreatorParams { + uint32_t super_box_identifier = 'jumb'; + uint32_t description_box_identifier = 'jumd'; + uint64_t uuid_high = 0x6332706100110010; + uint64_t uuid_low = 0x800000AA00389B71; + std::string label = "c2pa"; + std::string payload; + uint8_t toggles = 0b00000011; + uint8_t label_terminator = 0x00; + bool force_xlbox = false; +}; + +// Creates a Jpeg JUMBF box, see ISO/IEC 19566-5:2023 +std::string CreateJumbf(const JumbfCreatorParams& params); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JUMBF_CREATOR_H_
diff --git a/formats/pdf/BUILD b/formats/pdf/BUILD new file mode 100644 index 0000000..df588d5 --- /dev/null +++ b/formats/pdf/BUILD
@@ -0,0 +1,184 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "objects", + hdrs = ["objects.h"], + deps = [ + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + ], +) + +cc_library( + name = "object_reader", + srcs = ["object_reader.cc"], + hdrs = ["object_reader.h"], + deps = [ + ":objects", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@re2", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "object_reader_test", + srcs = ["object_reader_test.cc"], + deps = [ + ":object_reader", + ":objects", + "@abseil-cpp//absl/functional:any_invocable", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":object_reader", + ":objects", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:btree", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/memory", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@re2", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + deps = [ + ":objects", + ":reader", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":constants", + ":objects", + ":reader", + "//constants:labels", + "//formats:asset_byte_info", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":extractor", + "//formats:byte_range", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":constants", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + ":constants", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/pdf/assessor.cc b/formats/pdf/assessor.cc new file mode 100644 index 0000000..a065205 --- /dev/null +++ b/formats/pdf/assessor.cc
@@ -0,0 +1,44 @@ +// 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 "formats/pdf/assessor.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/pdf/constants.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +absl::StatusOr<bool> PdfAssessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + std::string file_header; + if (!input.Read(kPdfFileHeader.size(), file_header)) { + input.Seek(starting_position); + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; header")); + } + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek")); + } + + return file_header == kPdfFileHeader; +} + +} // namespace credentio
diff --git a/formats/pdf/assessor.h b/formats/pdf/assessor.h new file mode 100644 index 0000000..a0d806b --- /dev/null +++ b/formats/pdf/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the PDF document format. +class PdfAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_ASSESSOR_H_
diff --git a/formats/pdf/assessor_test.cc b/formats/pdf/assessor_test.cc new file mode 100644 index 0000000..8f659ec --- /dev/null +++ b/formats/pdf/assessor_test.cc
@@ -0,0 +1,76 @@ +// 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 "formats/pdf/assessor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "formats/pdf/constants.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(IsSupportedTest, ErrorTooFewBytes) { + std::string document = "\xab"; + riegeli::StringReader<> input(document); + + EXPECT_THAT( + PdfAssessor().IsSupported(input), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); + EXPECT_EQ(input.pos(), 0); +} + +TEST(IsSupportedTest, FalseForIncorrectHeader) { + std::string document = "not_a_pdf_file"; + riegeli::StringReader<> input(document); + + EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, ValidStartingBytes) { + std::string document = + absl::StrCat(kPdfFileHeader, "1.5", "this_is_a_pdf_file"); + riegeli::StringReader<> input(document); + + EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidStartingBytesAtOffset2) { + std::string document = + absl::StrCat("\xab\xcd", kPdfFileHeader, "1.5", "this_is_a_pdf_file"); + riegeli::StringReader<> input(document); + + // Invalid at 0 + EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/constants.h b/formats/pdf/constants.h new file mode 100644 index 0000000..537a55a --- /dev/null +++ b/formats/pdf/constants.h
@@ -0,0 +1,54 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_CONSTANTS_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +// PDF format, see ISO 32000-2 Section 7.5.2 File header. +constexpr absl::string_view kPdfFileHeader = "%PDF-"; + +// File trailer dictionary constants. +constexpr absl::string_view kPdfRootKey = "Root"; +constexpr absl::string_view kPdfSizeKey = "Size"; + +// Document catalog dictionary constants. +constexpr absl::string_view kPdfAfKey = "AF"; +constexpr absl::string_view kPdfNamesKey = "Names"; +constexpr absl::string_view kPdfEmbeddedFilesKey = "EmbeddedFiles"; + +// File specification dictionary constants. +constexpr absl::string_view kPdfTypeKey = "Type"; +constexpr absl::string_view kPdfFileSpecType = "Filespec"; +constexpr absl::string_view kPdfSubtypeKey = "Subtype"; +constexpr absl::string_view kPdfAfRelationshipKey = "AFRelationship"; +constexpr absl::string_view kPdfFKey = "F"; +constexpr absl::string_view kPdfUfKey = "UF"; +constexpr absl::string_view kPdfEfKey = "EF"; +constexpr absl::string_view kPdfParamsKey = "Params"; +constexpr absl::string_view kPdfModDateKey = "ModDate"; + +// C2PA Manifest file spec constants. See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_adding_the_manifest_to_a_pdf. +constexpr absl::string_view kPdfC2paManifestMimeType = "application/c2pa"; +constexpr absl::string_view kPdfC2paManifestAfRelationship = "C2PA_Manifest"; +constexpr absl::string_view kPdfC2paEmbeddedFileName = + "content_credential.c2pa"; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_CONSTANTS_H_
diff --git a/formats/pdf/extractor.cc b/formats/pdf/extractor.cc new file mode 100644 index 0000000..b2e0a9d --- /dev/null +++ b/formats/pdf/extractor.cc
@@ -0,0 +1,290 @@ +// 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 "formats/pdf/extractor.h" + +#include <optional> +#include <string> +#include <utility> +#include <variant> + +#include "absl/algorithm/container.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/byte_range.h" +#include "formats/pdf/constants.h" +#include "formats/pdf/objects.h" +#include "formats/pdf/reader.h" +#include "jumbf/utils.h" +#if defined(__GNUC__) || defined(__clang__) +#include <cxxabi.h> // IWYU pragma: keep + +#include <cstdlib> +#include <memory> +#endif +#include "absl/status/status_macros.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +std::string Demangle(const char* mangled) { +#if defined(__GNUC__) || defined(__clang__) + int status = 0; + std::unique_ptr<char, void (*)(void*)> res{ + abi::__cxa_demangle(mangled, nullptr, nullptr, &status), std::free}; + return (status == 0) ? res.get() : mangled; +#else + return mangled; +#endif +} + +struct C2paManifestStore { + ByteRange location; + std::string content; +}; + +// Checks if `file_spec_dict` is a file spec dictionary for an embedded C2PA +// file stream. +absl::StatusOr<bool> IsC2paManifestFileSpec( + const Object::Dictionary& file_spec_dict) { + const auto af_relationship = FindObjectInDictionary<Object::Name>( + file_spec_dict, kPdfAfRelationshipKey); + if (!af_relationship.ok()) { + if (absl::IsNotFound(af_relationship.status())) { + return false; + } + return af_relationship.status(); + } + return af_relationship->value == kPdfC2paManifestAfRelationship; +} + +// Checks that the embedded file stream subtype is as expected. +absl::StatusOr<bool> IsC2paManifestSubtype( + const IndirectObject& embedded_file_obj) { + if (!embedded_file_obj.stream_dictionary.has_value()) { + return false; + } + const auto subtype = FindObjectInDictionary<Object::Name>( + *embedded_file_obj.stream_dictionary, kPdfSubtypeKey); + if (!subtype.ok()) { + // Optionally check the subtype if it exists as it is only required in C2PA + // v2.2 onward. + if (absl::IsNotFound(subtype.status())) { + return true; + } + return subtype.status(); + } + return subtype->value == kPdfC2paManifestMimeType; +} + +// Gets the indirect reference to the embedded file stream from the file spec +// dictionary under the '/EF/F' key. +absl::StatusOr<Object::IndirectReference> GetEmbeddedFileRef( + const Object::Dictionary& file_spec_dict) { + ABSL_ASSIGN_OR_RETURN( + const auto ef_dict, + FindObjectInDictionary<Object::Dictionary>(file_spec_dict, kPdfEfKey)); + + return FindObjectInDictionary<Object::IndirectReference>(ef_dict, kPdfFKey); +} + +// Gets the array of embedded file name and indirect reference pairs from the +// catalog dictionary under the '/Names/EmbeddedFiles/Names' key. +absl::StatusOr<Object::Array> GetEmbeddedFilesNameTreeArray( + const Object::Dictionary& catalog_dict, PdfReader& reader) { + if (auto it = catalog_dict.entries.find(kPdfNamesKey); + it != catalog_dict.entries.end()) { + // /Catalog/Names + Object::Dictionary names_dict; + if (std::holds_alternative<Object::IndirectReference>(it->second.value)) { + const auto& names_ref = + std::get<Object::IndirectReference>(it->second.value); + ABSL_ASSIGN_OR_RETURN(const auto names_obj, + reader.GetObject(names_ref.object_number, + names_ref.generation_number)); + ABSL_ASSIGN_OR_RETURN(names_dict, + Object::As<Object::Dictionary>(names_obj.object)); + } else if (std::holds_alternative<Object::Dictionary>(it->second.value)) { + names_dict = std::get<Object::Dictionary>(it->second.value); + } else { + return absl::InvalidArgumentError( + absl::StrCat("Unexpected object type found. Expected " + "IndirectReference or Dictionary, but got ", + Demangle(typeid(it->second.value).name()))); + } + // /Catalog/Names/EmbeddedFiles + auto name_tree_it = names_dict.entries.find(kPdfEmbeddedFilesKey); + if (name_tree_it == names_dict.entries.end()) { + return absl::NotFoundError("No '/EmbeddedFiles' dictionary found."); + } + Object::Dictionary name_tree_dict; + if (std::holds_alternative<Object::IndirectReference>( + name_tree_it->second.value)) { + const auto& name_tree_ref = + std::get<Object::IndirectReference>(name_tree_it->second.value); + ABSL_ASSIGN_OR_RETURN(const auto name_tree_obj, + reader.GetObject(name_tree_ref.object_number, + name_tree_ref.generation_number)); + ABSL_ASSIGN_OR_RETURN( + name_tree_dict, Object::As<Object::Dictionary>(name_tree_obj.object)); + } else if (std::holds_alternative<Object::Dictionary>( + name_tree_it->second.value)) { + name_tree_dict = std::get<Object::Dictionary>(name_tree_it->second.value); + } else { + return absl::InvalidArgumentError( + absl::StrCat("Unexpected object type found. Expected " + "IndirectReference or Dictionary, but got ", + Demangle(typeid(name_tree_it->second.value).name()))); + } + // /Catalog/Names/EmbeddedFiles/Names + return FindObjectInDictionary<Object::Array>(name_tree_dict, kPdfNamesKey); + } else { + return absl::NotFoundError( + "No '/Names' dictionary found in the document catalog."); + } +} + +absl::StatusOr<C2paManifestStore> ExtractManifestStoreInternal( + riegeli::Reader& input) { + ABSL_ASSIGN_OR_RETURN(auto reader, PdfReader::Create(&input)); + + // Locate the active manifest store in the file trailer dictionary. + const Object::Dictionary& file_trailer_dict = + reader->file_trailer_dictionary(); + + ABSL_ASSIGN_OR_RETURN(const auto root_ref, + FindObjectInDictionary<Object::IndirectReference>( + file_trailer_dict, kPdfRootKey)); + + ABSL_ASSIGN_OR_RETURN( + auto root_obj, + reader->GetObject(root_ref.object_number, root_ref.generation_number)); + ABSL_ASSIGN_OR_RETURN(auto catalog_dict, + Object::As<Object::Dictionary>(root_obj.object)); + + const auto& file_spec_array = + FindObjectInDictionary<Object::Array>(catalog_dict, kPdfAfKey); + if (absl::IsNotFound(file_spec_array.status())) { + // Early return if the document catalog dictionary does not contain the + // required '/AF' entry for C2PA manifest extraction. + return absl::NotFoundError("No manifest store found."); + } + ABSL_RETURN_IF_ERROR(file_spec_array.status()); + + ABSL_ASSIGN_OR_RETURN(auto name_tree_array, + GetEmbeddedFilesNameTreeArray(catalog_dict, *reader)); + + for (const auto& file_spec_obj : file_spec_array->objects) { + Object::Dictionary file_spec_dict; + Object::IndirectReference file_spec_ref; + if (std::holds_alternative<Object::IndirectReference>( + file_spec_obj.value)) { + // C2PA manifest file spec is expected to be referenced indirectly from + // the '/AF' array. + file_spec_ref = std::get<Object::IndirectReference>(file_spec_obj.value); + ABSL_ASSIGN_OR_RETURN(auto file_spec_dict_obj, + reader->GetObject(file_spec_ref.object_number, + file_spec_ref.generation_number)); + ABSL_ASSIGN_OR_RETURN(file_spec_dict, Object::As<Object::Dictionary>( + file_spec_dict_obj.object)); + } else { + // Not an indirect reference, continue searching. + continue; + } + + ABSL_ASSIGN_OR_RETURN(auto is_c2pa_manifest_file_spec, + IsC2paManifestFileSpec(file_spec_dict)); + if (!is_c2pa_manifest_file_spec) { + continue; + } + ABSL_ASSIGN_OR_RETURN(auto embedded_file_ref, + GetEmbeddedFileRef(file_spec_dict)); + + // The C2PA manifest file spec object should also be referenced from the + // '/EmbeddedFiles' NameTree. + if (absl::c_any_of(name_tree_array.objects, [&](const auto& name_tree_obj) { + return std::holds_alternative<Object::IndirectReference>( + name_tree_obj.value) && + std::get<Object::IndirectReference>(name_tree_obj.value) + .object_number == file_spec_ref.object_number && + std::get<Object::IndirectReference>(name_tree_obj.value) + .generation_number == file_spec_ref.generation_number; + })) { + // The embedded file reference was found in the NameTree. + // Proceed with extraction. + } else { + // The embedded file reference was NOT found in the NameTree. + continue; + } + + ABSL_ASSIGN_OR_RETURN( + auto embedded_file_obj, + reader->GetObject(embedded_file_ref.object_number, + embedded_file_ref.generation_number)); + ABSL_ASSIGN_OR_RETURN(auto is_c2pa_manifest_subtype, + IsC2paManifestSubtype(embedded_file_obj)); + if (!is_c2pa_manifest_subtype) { + continue; + } + ABSL_ASSIGN_OR_RETURN(auto embedded_file_stream, + Object::As<Object::Stream>(embedded_file_obj.object)); + ABSL_ASSIGN_OR_RETURN( + auto embedded_file_offset, + reader->GetObjectOffset(embedded_file_ref.object_number, + embedded_file_ref.generation_number)); + return C2paManifestStore{ + .location = {.offset = embedded_file_offset, + .length = input.pos() - embedded_file_offset}, + .content = embedded_file_stream.raw_value}; + } + return absl::NotFoundError("No manifest store found."); +} +} // namespace + +absl::StatusOr<std::string> PdfExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + ABSL_ASSIGN_OR_RETURN(auto c2pa_manifest_store, + ExtractManifestStoreInternal(input)); + return c2pa_manifest_store.content; +} + +absl::StatusOr<std::optional<ByteRange>> +PdfExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + auto c2pa_manifest_store = ExtractManifestStoreInternal(input); + + // Extraction failed but C2PA is required, return an error. + if (!c2pa_manifest_store.ok() && options.requires_c2pa) { + return absl::NotFoundError("No manifest store found"); + } + // Extraction failed but C2PA is not required, return nullopt. + if (!c2pa_manifest_store.ok() && !options.requires_c2pa) { + return std::nullopt; + } + // Extraction succeeded, return the location. + return c2pa_manifest_store->location; +} + +bool PdfExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} +} // namespace credentio
diff --git a/formats/pdf/extractor.h b/formats/pdf/extractor.h new file mode 100644 index 0000000..839d28a --- /dev/null +++ b/formats/pdf/extractor.h
@@ -0,0 +1,54 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_byte_info.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Implements the C2PA Manifest Store extractor for PDF files as described in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_pdfs +class PdfExtractor : public FormatExtractor { + public: + PdfExtractor() = default; + ~PdfExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("PDF does not have a concept of boxes"); + } + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_EXTRACTOR_H_
diff --git a/formats/pdf/extractor_test.cc b/formats/pdf/extractor_test.cc new file mode 100644 index 0000000..60ded2f --- /dev/null +++ b/formats/pdf/extractor_test.cc
@@ -0,0 +1,670 @@ +// 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 "formats/pdf/extractor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "formats/byte_range.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::Optional; + +#ifndef ASSERT_OK_AND_ASSIGN +#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y +#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) + +#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ + ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) + +#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ + auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ + ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ + ::absl_testing::IsOk()); \ + lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) +#endif + +constexpr absl::string_view kTestPdfContent = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNoC2paSubtype = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000155 00000 n \n" + "0000000238 00000 n \n" + "0000000298 00000 n \n" + "0000000338 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "397\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNoEmbeddedFilesRef = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(not_a_c2pa_manifest.txt) 1 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNamesNotDict = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names [1 0 R]>>\n" + "endobj\n" + "xref\n" + "0 5\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "trailer\n" + "<</Root 4 0 R>>\n" + "startxref\n" + "328\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentEmbeddedFilesDirect = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names <</EmbeddedFiles <</Names " + "[(content_credential.c2pa) 3 0 R]>> >> >>\n" + "endobj\n" + "xref\n" + "0 5\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "trailer\n" + "<</Root 4 0 R>>\n" + "startxref\n" + "386\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNoAfRelationship = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentDifferentAfRelationship = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /Other /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNonIndirectAf = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [123 ] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentInvalidEmbeddedFilesType = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 12345>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNamesDictNotDict = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "12345 \n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNoEmbeddedFiles = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</OtherFiles___ 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +constexpr absl::string_view kTestPdfContentNamesObjectNotFound = + "%PDF-1.7\n" + "%%%%%\n" + "1 0 obj\n" + "<</Length 11>>\n" + "stream\n" + "test_stream\n" + "endstream\n" + "endobj\n" + "2 0 obj\n" + "<</Length 33 /Subtype /application#2fc2pa>>\n" + "stream\n" + "test_c2pa_manifest_raw_bytestream\n" + "endstream\n" + "endobj\n" + "3 0 obj\n" + "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n" + "endobj\n" + "4 0 obj\n" + "<</Names [(content_credential.c2pa) 3 0 R]>>\n" + "endobj\n" + "5 0 obj\n" + "<</EmbeddedFiles 4 0 R>>\n" + "endobj\n" + "6 0 obj\n" + "<</AF [3 0 R] /Type /Catalog /Names 9 0 R>>\n" + "endobj\n" + "xref\n" + "0 7\n" + "0000000000 65535 f \n" + "0000000015 00000 n \n" + "0000000074 00000 n \n" + "0000000184 00000 n \n" + "0000000267 00000 n \n" + "0000000327 00000 n \n" + "0000000367 00000 n \n" + "trailer\n" + "<</Root 6 0 R>>\n" + "startxref\n" + "426\n" + "%%EOF"; + +TEST(PdfExtractorTest, ExtractManifestStoreV22EmbeddedFilesDirect) { + riegeli::StringReader<> input(kTestPdfContentEmbeddedFilesDirect); + PdfExtractor extractor; + ASSERT_OK_AND_ASSIGN(auto manifest_store, + extractor.ExtractManifestStore(input)); + EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream"); +} + +TEST(PdfExtractorTest, ExtractManifestStoreV22) { + riegeli::StringReader<> input(kTestPdfContent); + PdfExtractor extractor; + ASSERT_OK_AND_ASSIGN(auto manifest_store, + extractor.ExtractManifestStore(input)); + EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream"); +} + +TEST(PdfExtractorTest, ExtractManifestStoreNoSubtype) { + riegeli::StringReader<> input(kTestPdfContentNoC2paSubtype); + PdfExtractor extractor; + ASSERT_OK_AND_ASSIGN(auto manifest_store, + extractor.ExtractManifestStore(input)); + EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream"); +} + +TEST(PdfExtractorTest, + ExtractManifestStoreThrowNotFoundErrorIfNoEmbeddedFilesRef) { + riegeli::StringReader<> input(kTestPdfContentNoEmbeddedFilesRef); + PdfExtractor extractor; + EXPECT_THAT(extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(PdfExtractorTest, ExtractManifestStoreLocation) { + riegeli::StringReader<> input(kTestPdfContent); + PdfExtractor extractor; + ASSERT_OK_AND_ASSIGN( + auto manifest_store_location, + extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true})); + EXPECT_THAT(manifest_store_location, + Optional(ByteRange{.offset = 74, .length = 109})); +} + +TEST(PdfExtractorTest, ExtractManifestStoreLocationNoSubtype) { + riegeli::StringReader<> input(kTestPdfContentNoC2paSubtype); + PdfExtractor extractor; + ASSERT_OK_AND_ASSIGN( + auto manifest_store_location, + extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true})); + EXPECT_THAT(manifest_store_location, + Optional(ByteRange{.offset = 74, .length = 80})); +} + +TEST(PdfExtractorTest, ExtractManifestStoreFailsWithInvalidNamesType) { + riegeli::StringReader<> input(kTestPdfContentNamesNotDict); + PdfExtractor extractor; + EXPECT_THAT( + extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + ::testing::AllOf(HasSubstr("Unexpected object type found."), + HasSubstr("credentio::Object::")))); +} + +TEST(PdfExtractorTest, ExtractBoxesUnimplemented) { + riegeli::StringReader<> input("test_content"); + PdfExtractor extractor; + EXPECT_THAT(extractor.ExtractBoxes(input, {.requires_c2pa = true}), + StatusIs(absl::StatusCode::kUnimplemented)); +} + +TEST(PdfExtractorTest, + ExtractManifestStoreThrowsNotFoundErrorIfNoAfRelationship) { + riegeli::StringReader<> input(kTestPdfContentNoAfRelationship); + PdfExtractor extractor; + EXPECT_THAT(extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found."))); +} + +TEST(PdfExtractorTest, + ExtractManifestStoreThrowsNotFoundErrorIfDifferentAfRelationship) { + riegeli::StringReader<> input(kTestPdfContentDifferentAfRelationship); + PdfExtractor extractor; + EXPECT_THAT(extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found."))); +} + +TEST(PdfExtractorTest, ExtractManifestStoreThrowsNotFoundErrorIfNonIndirectAf) { + riegeli::StringReader<> input(kTestPdfContentNonIndirectAf); + PdfExtractor extractor; + EXPECT_THAT(extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found."))); +} + +TEST(PdfExtractorTest, ExtractManifestStoreFailsWithInvalidEmbeddedFilesType) { + riegeli::StringReader<> input(kTestPdfContentInvalidEmbeddedFilesType); + PdfExtractor extractor; + EXPECT_THAT( + extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + ::testing::AllOf(HasSubstr("Unexpected object type found."), + HasSubstr("credentio::Object::")))); +} + +TEST(PdfExtractorTest, ExtractManifestStoreFailsIfNamesObjectNotDict) { + riegeli::StringReader<> input(kTestPdfContentNamesDictNotDict); + PdfExtractor extractor; + EXPECT_THAT( + extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Object is not a credentio::Object::Dictionary"))); +} + +TEST(PdfExtractorTest, + ExtractManifestStoreThrowsNotFoundErrorIfNoEmbeddedFiles) { + riegeli::StringReader<> input(kTestPdfContentNoEmbeddedFiles); + PdfExtractor extractor; + EXPECT_THAT(extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No '/EmbeddedFiles' dictionary found."))); +} + +TEST(PdfExtractorTest, ExtractManifestStoreFailsIfNamesObjectNotFound) { + riegeli::StringReader<> input(kTestPdfContentNamesObjectNotFound); + PdfExtractor extractor; + EXPECT_THAT(extractor.ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No offset found for object number: 9"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/format.cc b/formats/pdf/format.cc new file mode 100644 index 0000000..b69a3f4 --- /dev/null +++ b/formats/pdf/format.cc
@@ -0,0 +1,35 @@ +// 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 "formats/pdf/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/pdf/assessor.h" +#include "formats/pdf/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreatePdfFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<PdfAssessor>(), + .extractor = std::make_unique<PdfExtractor>(), + .mime_types = {"application/pdf"}, + }); +} + +} // namespace credentio
diff --git a/formats/pdf/format.h b/formats/pdf/format.h new file mode 100644 index 0000000..5f39106 --- /dev/null +++ b/formats/pdf/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreatePdfFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_FORMAT_H_
diff --git a/formats/pdf/object_reader.cc b/formats/pdf/object_reader.cc new file mode 100644 index 0000000..a92ec79 --- /dev/null +++ b/formats/pdf/object_reader.cc
@@ -0,0 +1,673 @@ +// 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 "formats/pdf/object_reader.h" + +#include <cstdint> +#include <string> +#include <utility> +#include <variant> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/match.h" +#include "absl/strings/numbers.h" +#include "absl/strings/str_cat.h" +#include "formats/pdf/objects.h" +#include "re2/re2.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB +constexpr uint64_t kMaxStringTokenLength = 1024 * 1024 * 10; // 10 MiB +constexpr uint64_t kMaxNameTokenLength = 4096; +constexpr uint64_t kMaxNumberTokenLength = 128; +constexpr int kMaxObjectRecursionDepth = 100; + +// PDF White-space characters. See ISO 32000-2 Section 7.2.3 Table 1. +constexpr bool IsPdfWhiteSpace(uint8_t c) { + return c == 0x00 || c == 0x09 || c == 0x0a || c == 0x0c || c == 0x0d || + c == 0x20; +} +// PDF Delimiter characters. See ISO 32000-2 Section 7.2.3 Table 2. +constexpr bool IsPdfDelimiter(uint8_t c) { + return c == 0x28 || c == 0x29 || c == 0x3c || c == 0x3e || c == 0x5b || + c == 0x5d || c == 0x7b || c == 0x7d || c == 0x2f || c == 0x25; +} + +static constexpr LazyRE2 kIndirectReferencePattern = { + R"(^[+-]?(\d+)\s+(\d+)\s+R[^a-zA-Z])"}; + +// Reads either an integer or a real number from the given input stream at the +// current position. +absl::Status ReadNumber(riegeli::Reader& input, Object& obj) { + uint8_t token = 0x00; + std::string buffer; + + while (true) { + if (buffer.size() >= kMaxNumberTokenLength) { + return absl::InvalidArgumentError( + absl::StrCat("Number token exceeds maximum allowed length: ", + kMaxNumberTokenLength)); + } + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + // A number can be terminated by any white-space character or delimiter. + if (IsPdfWhiteSpace(token) || IsPdfDelimiter(token)) { + if (!input.Seek(input.pos() - 1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + break; + } + buffer += token; + } + if (absl::StrContains(buffer, '.')) { + obj.value = Object::RealNumber{.raw_value = std::move(buffer)}; + } else { + int64_t value; + if (!absl::SimpleAtoi(buffer, &value)) { + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse integer. Invalid value: ", buffer)); + } + obj.value = Object::Integer{.value = std::move(value)}; + } + return absl::OkStatus(); +} + +absl::Status ReadBoolean(riegeli::Reader& input, Object& obj) { + std::string buffer; + if (!input.Read(5, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (buffer.substr(0, 4) == "true") { + obj.value = Object::Boolean{.value = true}; + if (!input.Seek(input.pos() - 1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + } else if (buffer == "false") { + obj.value = Object::Boolean{.value = false}; + } else { + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse boolean. Invalid value: ", buffer)); + } + return absl::OkStatus(); +} + +absl::Status ReadLiteralString(riegeli::Reader& input, Object& obj) { + uint8_t token = 0x00; + std::string buffer; + int32_t parentheses_count = 1; + + // Consume the beginning delimiter. + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token != '(') { + return absl::InvalidArgumentError( + absl::StrCat("Invalid delimiter. Expected '(' but got 0x", + absl::Hex(token), " at offset ", input.pos() - 1)); + } + + while (true) { + if (buffer.size() >= kMaxStringTokenLength) { + return absl::InvalidArgumentError( + absl::StrCat("Literal string exceeds maximum allowed length: ", + kMaxStringTokenLength)); + } + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token == '(') { + parentheses_count++; + } + if (token == ')' && --parentheses_count == 0) { + break; + } + buffer += token; + } + obj.value = Object::LiteralString{.raw_value = std::move(buffer)}; + return absl::OkStatus(); +} + +absl::Status ReadHexadecimalString(riegeli::Reader& input, Object& obj) { + uint8_t token = 0x00; + std::string buffer; + + // Consume the beginning delimiter. + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token != '<') { + return absl::InvalidArgumentError( + absl::StrCat("Invalid delimiter. Expected '<' but got 0x", + absl::Hex(token), " at offset ", input.pos() - 1)); + } + + while (true) { + if (buffer.size() >= kMaxStringTokenLength) { + return absl::InvalidArgumentError( + absl::StrCat("Hexadecimal string exceeds maximum allowed length: ", + kMaxStringTokenLength)); + } + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token == '>') { + break; + } + buffer += token; + } + obj.value = Object::HexadecimalString{.raw_value = std::move(buffer)}; + return absl::OkStatus(); +} + +absl::Status ReadName(riegeli::Reader& input, Object& obj) { + uint8_t token = 0x00; + std::string buffer; + + // Consume the beginning delimiter. + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token != '/') { + return absl::InvalidArgumentError( + absl::StrCat("Invalid delimiter. Expected '/' but got 0x", + absl::Hex(token), " at offset ", input.pos() - 1)); + } + + while (true) { + if (buffer.size() >= kMaxNameTokenLength) { + return absl::InvalidArgumentError(absl::StrCat( + "Name token exceeds maximum allowed length: ", kMaxNameTokenLength)); + } + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + // A name can be terminated by any white-space character or delimiter. + if (IsPdfWhiteSpace(token) || IsPdfDelimiter(token)) { + if (!input.Seek(input.pos() - 1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + break; + } + if (token == '#') { + std::string hex_str(2, '\0'); + if (!input.Read(2, hex_str)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read hex string")); + } + if (!absl::ascii_isxdigit(hex_str[0]) || + !absl::ascii_isxdigit(hex_str[1])) { + return absl::InvalidArgumentError("Invalid hex string"); + } + int hex_val = 0; + if (!absl::SimpleHexAtoi(hex_str, &hex_val)) { + return absl::InvalidArgumentError("Invalid hex string"); + } + token = hex_val; + } + buffer += token; + } + obj.value = Object::Name{.value = std::move(buffer)}; + return absl::OkStatus(); +} + +absl::Status ReadArray(riegeli::Reader& input, Object& obj, int depth) { + uint8_t token = 0x00; + Object::Array array; + + // Consume the beginning delimiter. + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token != '[') { + return absl::InvalidArgumentError( + absl::StrCat("Invalid delimiter. Expected '[' but got '", + absl::Hex(token), "' at offset ", input.pos() - 1)); + } + + while (true) { + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token == ']') { + break; + } + Object sub_obj; + ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos() - 1, sub_obj, depth)); + array.objects.push_back(std::move(sub_obj)); + } + obj.value = std::move(array); + + return absl::OkStatus(); +} + +absl::Status ReadNull(riegeli::Reader& input, Object& obj) { + std::string buffer; + if (!input.Read(4, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (buffer == "null") { + obj.value = Object::Null{}; + } else { + return absl::InvalidArgumentError(absl::StrCat("Invalid value: ", buffer)); + } + return absl::OkStatus(); +} + +absl::Status ReadDictionary(riegeli::Reader& input, Object& obj, int depth) { + std::string buffer; + Object::Dictionary dictionary; + + if (!input.Read(2, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (buffer != "<<") { + return absl::InvalidArgumentError(absl::StrCat( + "Invalid delimiter. Expected '<<' but got '", buffer, "'")); + } + + while (true) { + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.Read(2, buffer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read buffer")); + } + if (buffer == ">>") { + break; + } + if (!input.Seek(input.pos() - 2)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + Object name_obj; + ABSL_RETURN_IF_ERROR(ReadName(input, name_obj)); + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + Object value_obj; + ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), value_obj, depth)); + ABSL_ASSIGN_OR_RETURN(auto name, Object::As<Object::Name>(name_obj)); + dictionary.entries.insert({name.value, std::move(value_obj)}); + } + obj.value = std::move(dictionary); + + return absl::OkStatus(); +} + +absl::Status ReadStream(riegeli::Reader& input, int64_t length, Object& obj) { + std::string buffer; + Object::Stream stream; + + if (!input.Read(6, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (buffer != "stream") { + return absl::InvalidArgumentError(absl::StrCat( + "Malformed keyword. Expected 'stream' but got '", buffer, "'")); + } + if (!input.Read(2, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + // The spec requires "stream" keyword to be followed by a CARRIAGE RETURN and + // a LINE FEED, or just a LINE FEED. + if (buffer == "\r\n") { // CARRIAGE RETURN + LINE FEED + // Do nothing. + } else if (buffer[0] == '\n') { // LINE FEED + // Seek back to the end of the LINE FEED. + if (!input.Seek(input.pos() - 1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + } else { + return absl::InvalidArgumentError(absl::StrCat( + "Malformed stream. Expected '\r\n' or '\n' but got '", buffer, "'")); + } + if (length > kMaxPayloadSize) { + return absl::InvalidArgumentError( + absl::StrCat("Stream length exceeds maximum allowed size: ", length, + " > ", kMaxPayloadSize)); + } + if (!input.Read(length, stream.raw_value)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + obj.value = std::move(stream); + // Skip over the end-of-line marker. + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.Read(9, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (buffer != "endstream") { + return absl::InvalidArgumentError(absl::StrCat( + "Malformed keyword. Expected 'endstream' but got '", buffer, "'")); + } + return absl::OkStatus(); +} +} // namespace + +absl::Status ReadObject(riegeli::Reader& input, int64_t obj_offset, Object& obj, + int depth) { + if (depth > kMaxObjectRecursionDepth) { + return absl::InvalidArgumentError("Max nesting depth exceeded."); + } + if (!input.Seek(obj_offset)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + uint8_t token = 0x00; + + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (!input.Seek(input.pos() - 1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + switch (token) { + case '+': + case '-': + case '.': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': { // Numbers or indirect reference + std::string buffer(20, '\0'); + // Read more bytes to check if this is an indirect reference. + if (!input.Read(20, buffer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read buffer")); + } + if (!input.Seek(input.pos() - 20)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + if (uint32_t obj_num, gen_num; RE2::PartialMatch( + buffer, *kIndirectReferencePattern, &obj_num, &gen_num)) { + obj.value = Object::IndirectReference{.object_number = obj_num, + .generation_number = gen_num}; + auto pos = buffer.find('R'); + // This should never happen. + if (pos == std::string::npos) { + return absl::InternalError( + "Failed to locate keyword 'R' in indirect reference buffer."); + } + if (!input.Seek(input.pos() + pos + 1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + break; + } + ABSL_RETURN_IF_ERROR(ReadNumber(input, obj)); + break; + } + case 't': + case 'f': { // Boolean + ABSL_RETURN_IF_ERROR(ReadBoolean(input, obj)); + break; + } + case '(': { // Literal string + ABSL_RETURN_IF_ERROR(ReadLiteralString(input, obj)); + break; + } + case '<': { // Hexadecimal string or Dictionary + // Read one more byte to determine the object type. + std::string buffer(2, '\0'); + if (!input.Read(2, buffer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read buffer")); + } + if (!input.Seek(input.pos() - 2)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + if (buffer == "<<") { // Dictionary + ABSL_RETURN_IF_ERROR(ReadDictionary(input, obj, depth + 1)); + } else { + // Hexadecimal string + ABSL_RETURN_IF_ERROR(ReadHexadecimalString(input, obj)); + } + break; + } + case '/': { // Name + ABSL_RETURN_IF_ERROR(ReadName(input, obj)); + break; + } + case '[': { // Array + ABSL_RETURN_IF_ERROR(ReadArray(input, obj, depth + 1)); + break; + } + case 'n': { // Null + ABSL_RETURN_IF_ERROR(ReadNull(input, obj)); + break; + } + case 's': { // Stream + std::string buffer(6, '\0'); + if (!input.Read(6, buffer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read buffer")); + } + if (!input.Seek(input.pos() - 6)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + if (buffer == "stream") { + return absl::InvalidArgumentError( + "Stream must reside in an indirect object. Call " + "ReadIndirectObject() instead."); + } else { + return absl::InvalidArgumentError(absl::StrCat( + "Malformed keyword. Expected 'stream' but got '", buffer, "'")); + } + break; + } + default: + return absl::InvalidArgumentError( + absl::StrCat("Unsupported delimiter: 0x", absl::Hex(token), + " at offset ", input.pos())); + } + return absl::OkStatus(); +} + +absl::Status ReadIndirectObject( + riegeli::Reader& input, int64_t obj_offset, + absl::AnyInvocable< + absl::StatusOr<IndirectObject>(const Object::IndirectReference&)> + indirect_reference_resolver, + IndirectObject& obj) { + if (!input.Seek(obj_offset)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + uint8_t token = 0x00; + std::string buffer; + + // Read the object number. + while (true) { + if (buffer.size() >= kMaxNumberTokenLength) { + return absl::InvalidArgumentError( + absl::StrCat("Object number token exceeds maximum allowed length: ", + kMaxNumberTokenLength)); + } + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (IsPdfWhiteSpace(token)) { + break; + } + absl::StrAppend(&buffer, std::string(1, token)); + } + bool success = absl::SimpleAtoi(buffer, &obj.object_number); + if (!success) { + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse object number. Invalid value: ", buffer)); + } + + // Read the generation number. + buffer.clear(); + while (true) { + if (buffer.size() >= kMaxNumberTokenLength) { + return absl::InvalidArgumentError(absl::StrCat( + "Generation number token exceeds maximum allowed length: ", + kMaxNumberTokenLength)); + } + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (IsPdfWhiteSpace(token)) { + break; + } + absl::StrAppend(&buffer, std::string(1, token)); + } + success = absl::SimpleAtoi(buffer, &obj.generation_number); + if (!success) { + return absl::InvalidArgumentError(absl::StrCat( + "Failed to parse generation number. Invalid value: ", buffer)); + } + + // Read the enclosed object. + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.Read(3, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (buffer != "obj") { + return absl::InvalidArgumentError(absl::StrCat( + "Malformed keyword. Expected 'obj' but got '", buffer, "'")); + } + Object temp_obj; + ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), temp_obj)); + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.Read(6, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (!input.Seek(input.pos() - 6)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + if (buffer == "stream") { + // Maker indicates that this is a stream object, hence the object preceding + // it must be the stream dictionary. + ABSL_ASSIGN_OR_RETURN(obj.stream_dictionary, + Object::As<Object::Dictionary>(temp_obj)); + + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + // Determine the length of the stream. + int64_t length = -1; + if (const auto it = obj.stream_dictionary->entries.find("Length"); + it != obj.stream_dictionary->entries.end()) { + if (std::holds_alternative<Object::IndirectReference>(it->second.value)) { + ABSL_ASSIGN_OR_RETURN( + auto indirect_ref, + Object::As<Object::IndirectReference>(it->second)); + // Save the current input stream position, as the + // `indirect_reference_resolver` might change it. + auto pos = input.pos(); + ABSL_ASSIGN_OR_RETURN(auto indirect_length_obj, + indirect_reference_resolver(indirect_ref)); + + ABSL_ASSIGN_OR_RETURN(auto length_obj, Object::As<Object::Integer>( + indirect_length_obj.object)); + length = length_obj.value; + // Seek back to the saved position to continue reading the stream. + if (!input.Seek(pos)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + } else { + ABSL_ASSIGN_OR_RETURN(auto length_obj, + Object::As<Object::Integer>(it->second)); + length = length_obj.value; + } + } else { + return absl::DataLossError( + "Stream dictionary does not contain a 'Length' entry."); + } + ABSL_RETURN_IF_ERROR(ReadStream(input, length, obj.object)); + // Skip over the end-of-object marker. + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.Read(6, buffer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read buffer")); + } + } else { + obj.object = std::move(temp_obj); + } + if (buffer != "endobj") { + return absl::InvalidArgumentError(absl::StrCat( + "Malformed keyword. Expected 'endobj' but got '", buffer, "'")); + } + return absl::OkStatus(); +} + +absl::Status SkipOverWhiteSpaces(riegeli::Reader& input) { + uint8_t token = 0x00; + while (true) { + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token == '%') { + // It's a comment, skip until EOL or EOF. + while (true) { + if (!input.ReadByte(token)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read beginning delimiter")); + } + if (token == 0x0a || token == 0x0d) { // LF or CR + break; + } + } + continue; + } + if (!IsPdfWhiteSpace(token)) { + // Seek back to the last non-whitespace character. + if (!input.Seek(input.pos() - 1)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + break; + } + } + return absl::OkStatus(); +} +} // namespace credentio
diff --git a/formats/pdf/object_reader.h b/formats/pdf/object_reader.h new file mode 100644 index 0000000..f196fb9 --- /dev/null +++ b/formats/pdf/object_reader.h
@@ -0,0 +1,49 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_READER_H_ + +#include <cstdint> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/pdf/objects.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Reads a PDF object from the given input stream at the given offset. +absl::Status ReadObject(riegeli::Reader& input, int64_t obj_offset, Object& obj, + int depth = 0); + +// Reads a PDF indirect object from the given input stream at the given offset. +// `indirect_reference_resolver` is used to resolve any indirect references +// encountered during the reading process. +absl::Status ReadIndirectObject( + riegeli::Reader& input, int64_t obj_offset, + absl::AnyInvocable< + absl::StatusOr<IndirectObject>(const Object::IndirectReference&)> + indirect_reference_resolver, + IndirectObject& obj); + +// Reads and skips over white-space characters and comments until the first +// non-whitespace, non-comment character is encountered. +absl::Status SkipOverWhiteSpaces(riegeli::Reader& input); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_READER_H_
diff --git a/formats/pdf/object_reader_test.cc b/formats/pdf/object_reader_test.cc new file mode 100644 index 0000000..97b21b8 --- /dev/null +++ b/formats/pdf/object_reader_test.cc
@@ -0,0 +1,772 @@ +// 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 "formats/pdf/object_reader.h" + +#include <optional> +#include <string> +#include <utility> + +#include "absl/functional/any_invocable.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "formats/pdf/objects.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::Pair; +using ::testing::UnorderedElementsAre; +using ::testing::VariantWith; + +// This is used to pad the end of the test input because an object should never +// be terminated by EOF per PDF spec. +constexpr absl::string_view kContentPadding = " %%EOF"; + +absl::AnyInvocable< + absl::StatusOr<IndirectObject>(const Object::IndirectReference&)> +MockResolver(const IndirectObject& obj = IndirectObject()) { + return [obj](const Object::IndirectReference& ref) { return obj; }; +} + +TEST(ReadObjectTest, ReadValidBoolean) { + std::string content = absl::StrCat("true false", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Boolean>(Object::Boolean{.value = true})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Boolean>(Object::Boolean{.value = false})); +} + +TEST(ReadObjectTest, ReadInvalidBooleanFailure) { + std::string content = absl::StrCat("fake", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT( + ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to parse boolean. Invalid value: fake"))); +} + +TEST(ReadObjectTest, ReadValidInteger) { + std::string content = + absl::StrCat("123 43445 +17 -98 0", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 123})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 43445})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 17})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = -98})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 0})); +} + +TEST(ReadObjectTest, ReadInvalidIntegerFailure) { + std::string content = absl::StrCat("-123+123", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT( + ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to parse integer. Invalid value: -123+123"))); +} + +TEST(ReadObjectTest, ReadDelimiterTerminatedInteger) { + std::string content = absl::StrCat("123(", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 123})); +} + +TEST(ReadObjectTest, ReadWhiteSpaceTerminatedInteger) { + std::string content = absl::StrCat("123\t", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 123})); +} + +TEST(ReadObjectTest, ReadRealNumberRawValue) { + std::string content = + absl::StrCat("34.5 -3.62 +123.6 4. -.002 0.0", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( + Object::RealNumber{.raw_value = "34.5"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( + Object::RealNumber{.raw_value = "-3.62"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( + Object::RealNumber{.raw_value = "+123.6"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( + Object::RealNumber{.raw_value = "4."})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( + Object::RealNumber{.raw_value = "-.002"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>( + Object::RealNumber{.raw_value = "0.0"})); +} + +TEST(ReadObjectTest, ReadLiteralStringRawValue) { + std::string content = R"((This is a string) +(Strings can contain newlines +and such.) +(Strings can contain balanced parentheses () +and special characters ( * ! & } ^ %and so on) .) +(The following is an empty string .) +() +(It has zero (0) length.))"; + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::LiteralString>( + Object::LiteralString{.raw_value = R"(This is a string)"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::LiteralString>(Object::LiteralString{ + .raw_value = R"(Strings can contain newlines +and such.)"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::LiteralString>(Object::LiteralString{ + .raw_value = R"(Strings can contain balanced parentheses () +and special characters ( * ! & } ^ %and so on) .)"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::LiteralString>(Object::LiteralString{ + .raw_value = R"(The following is an empty string .)"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>( + Object::LiteralString{.raw_value = R"()"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::LiteralString>(Object::LiteralString{ + .raw_value = R"(It has zero (0) length.)"})); +} + +TEST(ReadObjectTest, ReadHexadecimalStringRawValue) { + std::string content = "<4E6F762073686D6F7A206B6120706F702E> <901FA3> <901FA>"; + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::HexadecimalString>(Object::HexadecimalString{ + .raw_value = "4E6F762073686D6F7A206B6120706F702E"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>( + Object::HexadecimalString{.raw_value = "901FA3"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>( + Object::HexadecimalString{.raw_value = "901FA"})); +} + +TEST(ReadObjectTest, ReadName) { + std::string content = absl::StrCat( + "/Name1 /ASomewhatLongerName /A;Name_With-Various***Characters? /1.2 " + "/$$ " + "/@pattern /.notdef /Lime#20Green /paired#28#29parentheses " + "/The_Key_of_F#23_Minor /A#42", + kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "Name1"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::Name>( + Object::Name{.value = "ASomewhatLongerName"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{ + .value = "A;Name_With-Various***Characters?"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "1.2"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "$$"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "@pattern"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = ".notdef"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "Lime Green"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::Name>( + Object::Name{.value = "paired()parentheses"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::Name>( + Object::Name{.value = "The_Key_of_F#_Minor"})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "AB"})); +} + +TEST(ReadObjectTest, ReadDelimiterTerminatedName) { + std::string content = absl::StrCat("/Name]", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "Name"})); +} + +TEST(ReadObjectTest, ReadWhiteSpaceTerminatedName) { + std::string content = absl::StrCat("/Name\n", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "Name"})); +} + +TEST(ReadObjectTest, ReadArray) { + std::string content = + absl::StrCat("[549 3.14 false (Ralph) /SomeName]", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT( + obj.value, + VariantWith<Object::Array>(Object::Array{ + .objects = { + Object{.value = Object::Integer{.value = 549}}, + Object{.value = Object::RealNumber{.raw_value = "3.14"}}, + Object{.value = Object::Boolean{.value = false}}, + Object{.value = Object::LiteralString{.raw_value = "Ralph"}}, + Object{.value = Object::Name{.value = "SomeName"}}}})); +} + +TEST(ReadObjectTest, ReadArrayNested) { + std::string content = absl::StrCat("[549 [3.14]]", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT( + obj.value, + VariantWith<Object::Array>(Object::Array{ + .objects = { + Object{.value = Object::Integer{.value = 549}}, + Object{ + .value = Object::Array{.objects = {Object{ + .value = + Object::RealNumber{ + .raw_value = "3.14"}}}}, + }}})); +} + +TEST(ReadObjectTest, ReadArrayWithComments) { + std::string content = + absl::StrCat("[549 %comment \n 3.14 % comment \n]", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT( + obj.value, + VariantWith<Object::Array>(Object::Array{ + .objects = { + Object{.value = Object::Integer{.value = 549}}, + Object{.value = Object::RealNumber{.raw_value = "3.14"}}}})); +} + +TEST(ReadObjectTest, ReadDictionary) { + std::string content = + absl::StrCat("<</Type /Example /Subtype /Table>>", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + + Object expected_obj; + Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>(); + dict.entries = {{"Type", Object{.value = Object::Name{.value = "Example"}}}, + {"Subtype", Object{.value = Object::Name{.value = "Table"}}}}; + EXPECT_EQ(obj, expected_obj); +} + +TEST(ReadObjectTest, ReadDictionaryWithComments) { + std::string content = absl::StrCat( + "<</Type /Example %comment \n /Subtype /Table>>", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + + Object expected_obj; + Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>(); + dict.entries = {{"Type", Object{.value = Object::Name{.value = "Example"}}}, + {"Subtype", Object{.value = Object::Name{.value = "Table"}}}}; + EXPECT_EQ(obj, expected_obj); +} + +TEST(ReadObjectTest, CommentsTreatedAsDelimiters) { + std::string content = absl::StrCat(R"(/abc%comment (/%) blah blah blah + 123)", + kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "abc"})); + + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 123})); +} + +TEST(ReadObjectTest, ReadDictionaryEmpty) { + std::string content = absl::StrCat("<<>>", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + + Object expected_obj = Object{.value = Object::Dictionary{}}; + EXPECT_EQ(obj, expected_obj); +} + +TEST(ReadObjectTest, ReadDictionaryNested) { + std::string content = + absl::StrCat("<</Subdictionary << /Item1 0.4 >> >>", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + + Object expected_obj; + Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>(); + dict.entries = { + {"Subdictionary", + Object{.value = Object::Dictionary{ + .entries = {{"Item1", Object{.value = Object::RealNumber{ + .raw_value = "0.4"}}}}}}}}; + EXPECT_EQ(obj, expected_obj); +} + +TEST(ReadObjectTest, ReadValidNull) { + std::string content = "null"; + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, VariantWith<Object::Null>(Object::Null{})); +} + +TEST(ReadObjectTest, ReadInvalidNullFailure) { + std::string content = "nULL"; + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT(ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid value: nULL"))); +} + +TEST(ReadObjectTest, ReadIndirectReference) { + std::string content = absl::StrCat("123 321 R", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::IndirectReference>(Object::IndirectReference{ + .object_number = 123, .generation_number = 321})); +} + +TEST(ReadObjectTest, IndirectReferencePatternMatchesBeginningOfString) { + std::string content = absl::StrCat("123 /Root 1 2 R", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, /*obj_offset=*/0, obj), IsOk()); + // "123 /Root 1 2 R ...." should not be matched as an indirect reference, the + // preceding integer should be read instead. + EXPECT_THAT(obj.value, + VariantWith<Object::Integer>(Object::Integer{.value = 123})); + ASSERT_THAT(ReadObject(input, /*obj_offset=*/10, obj), IsOk()); + // "1 2 R ..." should be matched as an indirect reference. + EXPECT_THAT(obj.value, + VariantWith<Object::IndirectReference>(Object::IndirectReference{ + .object_number = 1, .generation_number = 2})); +} + +TEST(ReadObjectTest, ReadIndirectReferenceAdvanceInputStreamPositionCorrectly) { + std::string content = absl::StrCat("123 321 R /Name", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::IndirectReference>(Object::IndirectReference{ + .object_number = 123, .generation_number = 321})); + ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk()); + EXPECT_THAT(obj.value, + VariantWith<Object::Name>(Object::Name{.value = "Name"})); +} + +TEST(ReadObjectTest, ReadStreamNotAllowed) { + std::string content = absl::StrCat("stream endstream", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT( + ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Stream must reside in an indirect object. Call " + "ReadIndirectObject() instead."))); +} + +TEST(ReadObjectTest, ReadMalformedStreamKeywordFailure) { + std::string content = absl::StrCat("steam endstream", kContentPadding); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT( + ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Malformed keyword. Expected 'stream' but got"))); +} + +TEST(ReadIndirectObjectTest, ReadIndirectObject) { + std::string content = R"(12 0 obj +(Brillig) +endobj)"; + riegeli::StringReader<> input(content); + IndirectObject obj; + ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), + IsOk()); + EXPECT_EQ(obj.object_number, 12); + EXPECT_EQ(obj.generation_number, 0); + EXPECT_THAT(obj.object.value, + VariantWith<Object::LiteralString>( + Object::LiteralString{.raw_value = "Brillig"})); +} + +TEST(ReadIndirectObjectTest, ReadInvalidObjectNumberFailure) { + std::string content = "1+2 0 obj <A0E2FA> endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT( + ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to parse object number. Invalid value: 1+2"))); +} + +TEST(ReadIndirectObjectTest, ReadInvalidGenerationNumberFailure) { + std::string content = "12 0.0 obj <A0E2FA> endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT( + ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to parse generation number. Invalid value: 0.0"))); +} + +TEST(ReadIndirectObjectTest, ReadMalformedKeywordFailure) { + std::string content = "12 0 R <A0E2FA> endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Malformed keyword. Expected 'obj' but got"))); +} + +TEST(ReadIndirectObjectTest, ReadMalformedKeywordFailure2) { + std::string content = "12 0 obj <A0E2FA> objend"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT( + ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Malformed keyword. Expected 'endobj' but got"))); +} + +TEST(ReadIndirectObjectTest, ReadStreamWithCrlfMarker) { + std::string content = + "1 2 obj <</Length 10>> stream\r\n1234567890\r\nendstream endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), + IsOk()); + EXPECT_EQ(obj.object_number, 1); + EXPECT_EQ(obj.generation_number, 2); + EXPECT_THAT(obj.stream_dictionary.value().entries, + UnorderedElementsAre(Pair( + "Length", Object{.value = Object::Integer{.value = 10}}))); + EXPECT_THAT(obj.object.value, VariantWith<Object::Stream>( + Object::Stream{.raw_value = "1234567890"})); +} + +TEST(ReadIndirectObjectTest, ReadStreamWithLfMarker) { + std::string content = + "1 2 obj <</Length 10>> stream\n1234567890\nendstream endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), + IsOk()); + EXPECT_EQ(obj.object_number, 1); + EXPECT_EQ(obj.generation_number, 2); + EXPECT_THAT(obj.stream_dictionary.value().entries, + UnorderedElementsAre(Pair( + "Length", Object{.value = Object::Integer{.value = 10}}))); + EXPECT_THAT(obj.object.value, VariantWith<Object::Stream>( + Object::Stream{.raw_value = "1234567890"})); +} + +TEST(ReadIndirectObjectTest, ReadStreamWithCrMarkerFailure) { + std::string content = + "1 2 obj <</Length 10>> stream\r1234567890\rendstream endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT( + ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Malformed stream. Expected '\r\n' or '\n' but got"))); +} + +TEST(ReadIndirectObjectTest, ReadStreamUnspecifiedLengthFailure) { + std::string content = "1 2 obj <<>> stream\r\n1234567890\r\nendstream endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT( + ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs( + absl::StatusCode::kDataLoss, + HasSubstr("Stream dictionary does not contain a 'Length' entry."))); +} + +TEST(ReadIndirectObjectTest, ReadStreamByChunks) { + std::string content = + "1 2 obj <</Length 50>> " + "stream\r\n12345678901234567890123456789012345678901234567890\r\nendstrea" + "m endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj), + IsOk()); + EXPECT_EQ(obj.object_number, 1); + EXPECT_EQ(obj.generation_number, 2); + EXPECT_THAT(obj.stream_dictionary.value().entries, + UnorderedElementsAre(Pair( + "Length", Object{.value = Object::Integer{.value = 50}}))); + EXPECT_THAT( + obj.object.value, + VariantWith<Object::Stream>(Object::Stream{ + .raw_value = "12345678901234567890123456789012345678901234567890"})); +} + +TEST(ReadIndirectObjectTest, ReadStreamOversizedLengthFails) { + std::string content = + "1 2 obj <</Length 11000000>> " + "stream\r\n123\r\nendstream endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT( + ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Stream length exceeds maximum allowed size"))); +} + +TEST(ReadIndirectObjectTest, IndirectReferenceResolved) { + std::string content = + "1 2 obj <</Length 3 4 R>> stream\r\n1234567890\r\nendstream endobj"; + riegeli::StringReader<> input(content); + auto mock_resolver = + MockResolver({.object_number = 3, + .generation_number = 4, + .object = Object{.value = Object::Integer{.value = 10}}}); + IndirectObject obj; + ASSERT_THAT( + ReadIndirectObject(input, input.pos(), std::move(mock_resolver), obj), + IsOk()); + EXPECT_EQ(obj.object_number, 1); + EXPECT_EQ(obj.generation_number, 2); + EXPECT_THAT( + obj.stream_dictionary.value().entries, + UnorderedElementsAre(Pair( + "Length", Object{.value = Object::IndirectReference{ + .object_number = 3, .generation_number = 4}}))); + EXPECT_THAT(obj.object.value, VariantWith<Object::Stream>( + Object::Stream{.raw_value = "1234567890"})); +} + +TEST(ReadIndirectObjectTest, ReadStreamMalformedEndstreamKeywordFailure) { + std::string content = + "1 2 obj <</Length 10>> stream\r\n1234567890\r\nendstram endobj"; + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT( + ReadIndirectObject(input, input.pos(), MockResolver(), obj), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "Malformed keyword. Expected 'endstream' but got 'endstram '"))); +} + +TEST(SkipOverWhiteSpacesTest, SkipsOverLeadingWhiteSpaces) { + std::string content = + absl::StrCat("\x09\x0a\x0c\x0d\x20", "abc", kContentPadding); + riegeli::StringReader<> input(content); + + EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk()); + EXPECT_EQ(input.pos(), 5); +} + +TEST(SkipOverWhiteSpacesTest, NoLeadingWhiteSpaces) { + std::string content = absl::StrCat("abc", kContentPadding); + riegeli::StringReader<> input(content); + + EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk()); + EXPECT_EQ(input.pos(), 0); +} + +TEST(SkipOverWhiteSpacesTest, OnlyWhiteSpacesFailure) { + std::string content = "\t\n\r "; + riegeli::StringReader<> input(content); + + EXPECT_THAT(SkipOverWhiteSpaces(input), + StatusIs(absl::StatusCode::kDataLoss)); +} + +TEST(SkipOverWhiteSpacesTest, EmptyInputFailure) { + std::string content = ""; + riegeli::StringReader<> input(content); + + EXPECT_THAT(SkipOverWhiteSpaces(input), + StatusIs(absl::StatusCode::kDataLoss)); +} + +TEST(SkipOverWhiteSpacesTest, SkipsOverComments) { + std::string content = "% comment\nabc"; + riegeli::StringReader<> input(content); + + EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk()); + EXPECT_EQ(input.pos(), 10); +} + +TEST(SkipOverWhiteSpacesTest, SkipsOverMultipleComments) { + std::string content = "% comment 1\n% comment 2\nabc"; + riegeli::StringReader<> input(content); + + EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk()); + EXPECT_EQ(input.pos(), 24); +} + +TEST(SkipOverWhiteSpacesTest, SkipsOverCommentsMixedWithWhitespaces) { + std::string content = " % comment \n abc"; + riegeli::StringReader<> input(content); + + EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk()); + EXPECT_EQ(input.pos(), 15); +} + +TEST(ReadObjectTest, DeeplyNestedArrayFails) { + std::string content(102, '['); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT(ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Max nesting depth exceeded"))); +} + +TEST(ReadObjectTest, DeeplyNestedDictionaryFails) { + std::string content; + for (int i = 0; i < 102; ++i) { + content += "<</K "; + } + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT(ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Max nesting depth exceeded"))); +} + +TEST(ReadObjectTest, UnboundedNumberTokenExceedsLimitFails) { + std::string content = std::string(130, '1'); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT(ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("exceeds maximum allowed length"))); +} + +TEST(ReadObjectTest, UnboundedLiteralStringExceedsLimitFails) { + std::string content = "(" + std::string(1024 * 1024 * 10 + 5, 'a'); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT(ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("exceeds maximum allowed length"))); +} + +TEST(ReadObjectTest, UnboundedHexadecimalStringExceedsLimitFails) { + std::string content = "<" + std::string(1024 * 1024 * 10 + 5, 'a'); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT(ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("exceeds maximum allowed length"))); +} + +TEST(ReadObjectTest, UnboundedNameTokenExceedsLimitFails) { + std::string content = "/" + std::string(4100, 'a'); + riegeli::StringReader<> input(content); + Object obj; + EXPECT_THAT(ReadObject(input, input.pos(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("exceeds maximum allowed length"))); +} + +TEST(ReadIndirectObjectTest, UnboundedObjectNumberExceedsLimitFails) { + std::string content = std::string(130, '1'); + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT(ReadIndirectObject(input, 0, MockResolver(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("exceeds maximum allowed length"))); +} + +TEST(ReadIndirectObjectTest, UnboundedGenerationNumberExceedsLimitFails) { + std::string content = "1 " + std::string(130, '1'); + riegeli::StringReader<> input(content); + IndirectObject obj; + EXPECT_THAT(ReadIndirectObject(input, 0, MockResolver(), obj), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("exceeds maximum allowed length"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/pdf/objects.h b/formats/pdf/objects.h new file mode 100644 index 0000000..4b19df5 --- /dev/null +++ b/formats/pdf/objects.h
@@ -0,0 +1,277 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECTS_H_ + +#include <cstdint> +#include <cstdlib> +#include <memory> +#include <optional> +#include <ostream> +#include <sstream> +#include <string> +#include <variant> +#include <vector> + +#ifdef __GNUG__ +#include <cxxabi.h> // IWYU pragma: keep +#endif + +#include "absl/container/flat_hash_map.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/strings/substitute.h" + +namespace credentio { + +namespace internal { +inline std::string Demangle(const char* mangled) { +#ifdef __GNUG__ + int status = 0; + std::unique_ptr<char, void (*)(void*)> res{ + abi::__cxa_demangle(mangled, nullptr, nullptr, &status), std::free}; + return (status == 0) ? res.get() : mangled; +#else + return mangled; +#endif +} + +template <typename T> +std::string Stringify(const T& val) { + std::ostringstream ss; + ss << val; + return ss.str(); +} +} // namespace internal + +struct Object; + +// Represents any PDF object. +// Uses std::variant to hold one of the possible PDF object types. +// See ISO 32000-2 Section 7.3.2 through 7.3.9 for more details. +struct Object { + // Represents a PDF boolean object. + struct Boolean { + bool value; + friend bool operator==(const Boolean&, const Boolean&) = default; + friend std::ostream& operator<<(std::ostream& os, const Boolean& b) { + return os << (b.value ? "true" : "false"); + } + }; + + // Represents a PDF integer object. + struct Integer { + int64_t value; + friend bool operator==(const Integer&, const Integer&) = default; + friend std::ostream& operator<<(std::ostream& os, const Integer& i) { + return os << i.value; + } + }; + + // Represents a PDF real number object. + struct RealNumber { + std::string raw_value; + friend bool operator==(const RealNumber&, const RealNumber&) = default; + friend std::ostream& operator<<(std::ostream& os, const RealNumber& r) { + return os << r.raw_value; + } + }; + + // Represents a PDF literal string object. + struct LiteralString { + std::string raw_value; + friend bool operator==(const LiteralString&, + const LiteralString&) = default; + friend std::ostream& operator<<(std::ostream& os, const LiteralString& s) { + return os << "(" << s.raw_value << ")"; + } + }; + + // Represents a PDF hexadecimal string object. + struct HexadecimalString { + std::string raw_value; + friend bool operator==(const HexadecimalString&, + const HexadecimalString&) = default; + friend std::ostream& operator<<(std::ostream& os, + const HexadecimalString& s) { + return os << "<" << s.raw_value << ">"; + } + }; + + // Represents a PDF name object. + struct Name { + std::string value; + friend bool operator==(const Name&, const Name&) = default; + friend std::ostream& operator<<(std::ostream& os, const Name& n) { + return os << "/" << n.value; + } + }; + + // Represents a PDF array object. + struct Array { + std::vector<Object> objects; + friend bool operator==(const Array&, const Array&); + friend std::ostream& operator<<(std::ostream& os, const Array& a); + }; + + // Represents a PDF dictionary object. + struct Dictionary { + absl::flat_hash_map<std::string, Object> entries; + friend bool operator==(const Dictionary&, const Dictionary&); + friend std::ostream& operator<<(std::ostream& os, const Dictionary& d); + }; + + // Represents a PDF stream object. + struct Stream { + std::string raw_value; + friend bool operator==(const Stream&, const Stream&) = default; + friend std::ostream& operator<<(std::ostream& os, const Stream& s) { + return os << "stream\n" << s.raw_value << "\nendstream"; + } + }; + + // Represents a PDF null object. + struct Null { + friend bool operator==(const Null&, const Null&) = default; + friend std::ostream& operator<<(std::ostream& os, const Null& n) { + return os << "null"; + } + }; + + // Represents a reference to a PDF indirect object. + struct IndirectReference { + uint32_t object_number; + uint32_t generation_number; + friend bool operator==(const IndirectReference&, + const IndirectReference&) = default; + friend auto operator<=>(const IndirectReference&, + const IndirectReference&) = default; + friend std::ostream& operator<<(std::ostream& os, + const IndirectReference& r) { + return os << r.object_number << " " << r.generation_number << " R"; + } + }; + + std::variant<Boolean, Integer, RealNumber, LiteralString, HexadecimalString, + Name, Array, Dictionary, Stream, Null, IndirectReference> + value; + + friend bool operator==(const Object& lhs, const Object& rhs) { + return lhs.value == rhs.value; + } + friend std::ostream& operator<<(std::ostream& os, const Object& obj) { + std::visit([&os](const auto& alternative) { os << alternative; }, + obj.value); + return os; + } + + // Returns a copy of the value of type `T` held by `obj.value`. Returns an + // `InvalidArgumentError` if `obj.value` is not of type `T`. + template <typename T> + static absl::StatusOr<T> As(const Object& obj) { + if (!std::holds_alternative<T>(obj.value)) { + constexpr size_t kMaxErrorStringLen = 256; + std::string str = internal::Stringify(obj); + if (str.size() > kMaxErrorStringLen) { + str = absl::StrCat(absl::string_view(str).substr(0, kMaxErrorStringLen), + "..."); + } + return absl::InvalidArgumentError(absl::StrCat( + "Object is not a ", internal::Demangle(typeid(T).name()), ": ", str)); + } + return std::get<T>(obj.value); + } +}; + +// Define these after Object is fully defined because they use Object. +inline bool operator==(const Object::Array& lhs, const Object::Array& rhs) { + return lhs.objects == rhs.objects; +} + +inline bool operator==(const Object::Dictionary& lhs, + const Object::Dictionary& rhs) { + return lhs.entries == rhs.entries; +} + +inline std::ostream& operator<<(std::ostream& os, const Object::Array& a) { + static thread_local int depth = 0; + if (depth >= 100) { + return os << "[...]"; + } + ++depth; + os << "["; + for (size_t i = 0; i < a.objects.size(); ++i) { + if (i > 0) os << " "; + os << a.objects[i]; + } + os << "]"; + --depth; + return os; +} + +inline std::ostream& operator<<(std::ostream& os, const Object::Dictionary& d) { + static thread_local int depth = 0; + if (depth >= 100) { + return os << "<<...>>"; + } + ++depth; + os << "<<"; + for (const auto& [key, val] : d.entries) { + os << "/" << key << " " << val; + } + os << ">>"; + --depth; + return os; +} + +// Represents a PDF indirect object. +// See ISO 32000-2 Section 7.3.10 for more details. +struct IndirectObject { + uint32_t object_number; + uint32_t generation_number; + + // Required if `object` is a stream. + std::optional<Object::Dictionary> stream_dictionary; + + Object object; + + friend bool operator==(const IndirectObject&, + const IndirectObject&) = default; + friend std::ostream& operator<<(std::ostream& os, const IndirectObject& obj) { + os << obj.object_number << " " << obj.generation_number << " obj\n"; + if (obj.stream_dictionary.has_value()) { + os << *obj.stream_dictionary << "\n"; + } + os << obj.object << "\nendobj"; + return os; + } +}; + +template <typename T> +absl::StatusOr<T> FindObjectInDictionary(const Object::Dictionary& dict, + absl::string_view key) { + const auto it = dict.entries.find(key); + if (it == dict.entries.end()) { + return absl::NotFoundError( + absl::Substitute("Dictionary does not contain a '$0' entry.", key)); + } + return Object::As<T>(it->second); +} +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECTS_H_
diff --git a/formats/pdf/reader.cc b/formats/pdf/reader.cc new file mode 100644 index 0000000..dbe3973 --- /dev/null +++ b/formats/pdf/reader.cc
@@ -0,0 +1,289 @@ +// 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 "formats/pdf/reader.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/container/btree_map.h" +#include "absl/container/btree_set.h" +#include "absl/container/flat_hash_set.h" +#include "absl/memory/memory.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/substitute.h" +#include "formats/pdf/object_reader.h" +#include "formats/pdf/objects.h" +#include "re2/re2.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +// Matches the cross-reference table offset in the trailer. See ISO 32000-2 +// Section 7.5.5 +static constexpr LazyRE2 kXrefOffsetPattern = {R"(startxref\s(\d+)\s%%EOF)"}; + +// Matches a single cross-reference entry in the cross-reference table. See ISO +// 32000-2 Section 7.5.4 +static constexpr LazyRE2 kXrefEntryPattern = { + R"(^(\d{10}) (\d{5}) ([nf])(?: \r| \n|\r\n))"}; + +// Reads the first cross-reference table offset from the file trailer. +absl::StatusOr<uint64_t> GetFirstXrefTableOffset(riegeli::Reader& input) { + // Scan the last 30 bytes for the cross-reference table offset. This allows + // for the "startxref" keyword and an offset value large enough for a + // multi-terabyte file. + if (!input.Seek(input.Size().value_or(30) - 30)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + std::string buffer; + if (!input.Read(30, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + uint64_t offset; + if (!RE2::PartialMatch(buffer, *kXrefOffsetPattern, &offset)) { + return absl::InvalidArgumentError( + absl::StrCat("Failed to find cross-reference table offset in the " + "following payload: ", + buffer)); + } + return offset; +} + +// Reads the offset to the next cross-reference table from the file trailer. +// Returns `std::nullopt` if the file trailer dictionary does not contain a +// "Prev" entry. +absl::StatusOr<std::optional<uint64_t>> GetPrevXrefTableOffset( + const Object::Dictionary& file_trailer_dict) { + if (const auto it = file_trailer_dict.entries.find("Prev"); + it != file_trailer_dict.entries.end()) { + ABSL_ASSIGN_OR_RETURN(const auto prev_xref_offset, + Object::As<Object::Integer>(it->second)); + return prev_xref_offset.value; + } + return std::nullopt; +} + +// Reads the standard cross-reference table and the file trailer from the input +// stream at the given offset. +absl::Status ReadXrefTableAndTrailer( + riegeli::Reader& input, uint64_t xref_offset, + absl::btree_map<Object::IndirectReference, uint64_t>& xref_table, + absl::btree_set<Object::IndirectReference>& free_entries, + Object::Dictionary& file_trailer_dict) { + if (!input.Seek(xref_offset)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + std::string buffer; + if (!input.Read(4, buffer)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer")); + } + if (buffer != "xref") { + return absl::InvalidArgumentError( + absl::StrCat("Malformed cross-reference table. Expected 'xref' but got " + "'", + buffer, "'")); + } + while (true) { + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + if (!input.Read(7, buffer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read buffer")); + } + if (buffer == "trailer") { + break; + } + if (!input.Seek(input.pos() - 7)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + Object int_obj; + ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), int_obj)); + ABSL_ASSIGN_OR_RETURN(const auto obj_num, + Object::As<Object::Integer>(int_obj)); + + ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), int_obj)); + ABSL_ASSIGN_OR_RETURN(const auto obj_count, + Object::As<Object::Integer>(int_obj)); + + ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input)); + + for (uint32_t i = 0; i < obj_count.value; ++i) { + // Each cross-reference entry is expected to be exactly 20 bytes long. + if (!input.Read(20, buffer)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read buffer")); + } + uint64_t obj_offset; + uint32_t gen_num; + std::string type; + if (RE2::PartialMatch(buffer, *kXrefEntryPattern, &obj_offset, &gen_num, + &type)) { + auto xref_entry_key = Object::IndirectReference{ + .object_number = static_cast<uint32_t>(obj_num.value + i), + .generation_number = gen_num}; + if (type == "n") { // 'n' represents an in-use entry. + xref_table.insert({xref_entry_key, obj_offset}); + } else { // 'f' represents a free entry. + free_entries.insert(xref_entry_key); + } + } else { + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse cross-reference table entry. Invalid " + "value: ", + buffer)); + } + } + } + Object dict_obj; + ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), dict_obj)); + ABSL_ASSIGN_OR_RETURN(file_trailer_dict, + Object::As<Object::Dictionary>(dict_obj)); + + return absl::OkStatus(); +} +} // namespace + +absl::StatusOr<std::unique_ptr<PdfReader>> PdfReader::Create( + riegeli::Reader* absl_nonnull input) { + if (input == nullptr) { + return absl::InvalidArgumentError("input cannot be null"); + } + absl::flat_hash_set<uint64_t> xref_offsets_visited; + ABSL_ASSIGN_OR_RETURN(uint64_t xref_offset, GetFirstXrefTableOffset(*input)); + xref_offsets_visited.insert(xref_offset); + + absl::btree_map<Object::IndirectReference, uint64_t> xref_table; + absl::btree_set<Object::IndirectReference> free_entries; + Object::Dictionary file_trailer_dict; + ABSL_RETURN_IF_ERROR(ReadXrefTableAndTrailer( + *input, xref_offset, xref_table, free_entries, file_trailer_dict)); + + ABSL_ASSIGN_OR_RETURN(std::optional<uint64_t> prev_xref_offset, + GetPrevXrefTableOffset(file_trailer_dict)); + while (prev_xref_offset.has_value()) { + if (xref_offsets_visited.contains(*prev_xref_offset)) { + return absl::InvalidArgumentError( + "Initialization failed: Circular cross-reference table detected."); + } + xref_offsets_visited.insert(*prev_xref_offset); + + absl::btree_map<Object::IndirectReference, uint64_t> prev_xref_table; + absl::btree_set<Object::IndirectReference> prev_free_entries; + Object::Dictionary prev_file_trailer_dict; + ABSL_RETURN_IF_ERROR( + ReadXrefTableAndTrailer(*input, *prev_xref_offset, prev_xref_table, + prev_free_entries, prev_file_trailer_dict)); + ABSL_ASSIGN_OR_RETURN(prev_xref_offset, + GetPrevXrefTableOffset(prev_file_trailer_dict)); + + xref_table.merge(prev_xref_table); + if (!prev_xref_table.empty()) { + return absl::InvalidArgumentError( + "Initialization failed: Collision detected in cross-reference " + "table for in-use entries."); + } + free_entries.merge(prev_free_entries); + if (!prev_free_entries.empty()) { + return absl::InvalidArgumentError( + "Initialization failed: Collision detected in cross-reference " + "table for free entries."); + } + } + auto reader = absl::WrapUnique(new PdfReader(input, std::move(xref_table), + std::move(free_entries), + std::move(file_trailer_dict))); + return reader; +} + +bool PdfReader::HasNext() { return current_it_ != xref_table_.end(); } + +absl::StatusOr<IndirectObject> PdfReader::Next() { + if (!HasNext()) { + return absl::OutOfRangeError("No more objects to read."); + } + IndirectObject obj; + if (absl::Status status = ReadIndirectObject( + input_, current_it_->second, + [this](const Object::IndirectReference& ref) { + return this->GetObject(ref.object_number, ref.generation_number); + }, + obj); + !status.ok()) { + return status; + } + ++current_it_; + return obj; +} + +absl::StatusOr<uint64_t> PdfReader::GetObjectOffset( + uint32_t object_number, uint32_t generation_number) { + auto it = xref_table_.find(Object::IndirectReference{ + .object_number = object_number, .generation_number = generation_number}); + if (it == xref_table_.end()) { + return absl::NotFoundError(absl::Substitute( + "No offset found for object number: $0, generation number: $1", + object_number, generation_number)); + } + return it->second; +} + +absl::StatusOr<IndirectObject> PdfReader::GetObject( + uint32_t object_number, uint32_t generation_number) { + Object::IndirectReference ref{.object_number = object_number, + .generation_number = generation_number}; + if (active_objects_.contains(ref)) { + return absl::InvalidArgumentError( + absl::StrCat("Circular reference detected for object: ", object_number, + " ", generation_number)); + } + + struct ActiveObjectGuard { + absl::btree_set<Object::IndirectReference>& active_objects; + Object::IndirectReference ref; + ~ActiveObjectGuard() { active_objects.erase(ref); } + }; + active_objects_.insert(ref); + ActiveObjectGuard guard{active_objects_, ref}; + + ABSL_ASSIGN_OR_RETURN(uint64_t offset, + GetObjectOffset(object_number, generation_number)); + IndirectObject obj; + if (absl::Status status = ReadIndirectObject( + input_, offset, + [this](const Object::IndirectReference& ref) { + return this->GetObject(ref.object_number, ref.generation_number); + }, + obj); + !status.ok()) { + return status; + } + return obj; +} + +} // namespace credentio
diff --git a/formats/pdf/reader.h b/formats/pdf/reader.h new file mode 100644 index 0000000..db1e4b5 --- /dev/null +++ b/formats/pdf/reader.h
@@ -0,0 +1,97 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PDF_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_READER_H_ + +#include <cstdint> +#include <memory> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/container/btree_map.h" +#include "absl/container/btree_set.h" +#include "absl/status/statusor.h" +#include "formats/pdf/objects.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// PDF reader for C2PA, holds onto a reference to a `riegeli::Reader` which +// must outlive the reader. +class PdfReader { + public: + // Creates and initializes a PdfReader. + static absl::StatusOr<std::unique_ptr<PdfReader>> Create( + riegeli::Reader* absl_nonnull input); + + // Returns true if there is a next PDF object to read. + bool HasNext(); + + // Reads the next PDF object from the input. + absl::StatusOr<IndirectObject> Next(); + + // Returns the file trailer dictionary. + const Object::Dictionary& file_trailer_dictionary() const { + return file_trailer_dictionary_; + } + + // Returns the ordered set of free object entries in the cross-reference + // table. + const absl::btree_set<Object::IndirectReference>& free_entries() const { + return free_entries_; + } + + // Returns the byte offset of the PDF object with the given object number and + // generation number. If the object is not found, returns a NOT_FOUND error. + absl::StatusOr<uint64_t> GetObjectOffset(uint32_t object_number, + uint32_t generation_number); + + // Returns the PDF object with the given object number and generation number. + // If the object is not found, returns a NOT_FOUND error. + absl::StatusOr<IndirectObject> GetObject(uint32_t object_number, + uint32_t generation_number); + + private: + explicit PdfReader( + riegeli::Reader* absl_nonnull input, + absl::btree_map<Object::IndirectReference, uint64_t>&& xref_table, + absl::btree_set<Object::IndirectReference>&& free_entries, + Object::Dictionary&& file_trailer_dict) + : input_(*input), + xref_table_(std::move(xref_table)), + current_it_(xref_table_.begin()), + free_entries_(std::move(free_entries)), + file_trailer_dictionary_(std::move(file_trailer_dict)) {} + + riegeli::Reader& input_; + + // Cross-reference table mapping in-use object references to their byte + // offsets in the input stream. + const absl::btree_map<Object::IndirectReference, uint64_t> xref_table_; + absl::btree_map<Object::IndirectReference, uint64_t>::const_iterator + current_it_; + + // Set of free object entries in the cross-reference table. + const absl::btree_set<Object::IndirectReference> free_entries_; + + // File trailer dictionary. + const Object::Dictionary file_trailer_dictionary_; + + // Active objects during indirect reference resolution to detect cycles. + absl::btree_set<Object::IndirectReference> active_objects_; +}; +} // namespace credentio +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PDF_READER_H_
diff --git a/formats/pdf/reader_test.cc b/formats/pdf/reader_test.cc new file mode 100644 index 0000000..75dbbb7 --- /dev/null +++ b/formats/pdf/reader_test.cc
@@ -0,0 +1,684 @@ +// 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 "formats/pdf/reader.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_replace.h" +#include "absl/strings/string_view.h" +#include "formats/pdf/objects.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::HasSubstr; +using ::testing::Pair; +using ::testing::UnorderedElementsAre; + +#ifndef ASSERT_OK_AND_ASSIGN +#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y +#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) + +#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ + ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) + +#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ + auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ + ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ + ::absl_testing::IsOk()); \ + lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) +#endif + +Object CreateName(absl::string_view value) { + return Object{.value = Object::Name{.value = std::string(value)}}; +} + +Object CreateInteger(int64_t value) { + return Object{.value = Object::Integer{.value = value}}; +} + +Object CreateRealNumber(absl::string_view value) { + return Object{.value = Object::RealNumber{.raw_value = std::string(value)}}; +} + +Object CreateIndirectRef(uint32_t object_number, uint32_t generation_number) { + return Object{.value = Object::IndirectReference{ + .object_number = object_number, + .generation_number = generation_number}}; +} + +Object CreateArray(std::vector<Object> values) { + return Object{.value = Object::Array{.objects = std::move(values)}}; +} + +Object CreateStream(absl::string_view raw_value) { + return Object{.value = Object::Stream{.raw_value = std::string(raw_value)}}; +} + +Object CreateDictionary(absl::flat_hash_map<std::string, Object> entries) { + return Object{.value = Object::Dictionary{.entries = std::move(entries)}}; +} + +std::string GetTestPdfContent() { + std::string content = R"(%PDF-1.7 +%%%%% +1 0 obj +/test_name +endobj +2 0 obj +123.456 +endobj +3 0 obj +<</Type /Catalog>> +endobj +xref +0 4 +0000000000 65535 f@ +0000000015 00000 n@ +0000000041 00000 n@ +0000000064 00000 n@ +trailer +<</Root 3 0 R /Size 3>> +startxref +98 +%%EOF)"; + return absl::StrReplaceAll(content, {{"@", " "}}); +} + +TEST(PdfReaderTest, ReadsAllObjects) { + std::string content = GetTestPdfContent(); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + std::vector<IndirectObject> objects; + while (reader->HasNext()) { + ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); + objects.push_back(std::move(obj)); + } + + EXPECT_THAT( + objects, + ElementsAre(IndirectObject{.object_number = 1, + .generation_number = 0, + .object = CreateName("test_name")}, + IndirectObject{.object_number = 2, + .generation_number = 0, + .object = CreateRealNumber("123.456")}, + IndirectObject{.object_number = 3, + .generation_number = 0, + .object = CreateDictionary( + {{"Type", CreateName("Catalog")}})})); +} + +TEST(PdfReaderTest, FailsWhenReadingPastAllObjects) { + std::string content = GetTestPdfContent(); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + int64_t count = 0; + while (reader->HasNext()) { + ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); + ++count; + } + + EXPECT_EQ(count, 3); + + EXPECT_THAT(reader->Next(), StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("No more objects to read"))); +} + +TEST(PdfReaderTest, GetFileTrailerDictionary) { + std::string content = GetTestPdfContent(); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + auto file_trailer_dict = reader->file_trailer_dictionary(); + EXPECT_THAT(file_trailer_dict.entries, + UnorderedElementsAre(Pair("Root", CreateIndirectRef(3, 0)), + Pair("Size", CreateInteger(3)))); +} + +TEST(PdfReaderTest, FreeEntries) { + std::string content = R"(%PDF-1.7 +xref +0 8 +0000000003 65535 f@ +0000000015 00000 n@ +0000000081 00000 n@ +0000000005 00000 f@ +0000000331 00000 n@ +0000000000 00000 f@ +0000000409 00000 n@ +0000000555 00000 n@ +trailer +<<>> +startxref +9 +%%EOF)"; + content = absl::StrReplaceAll(content, {{"@", " "}}); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + EXPECT_THAT(reader->free_entries(), + ElementsAre(Object::IndirectReference{.object_number = 0, + .generation_number = 65535}, + Object::IndirectReference{.object_number = 3, + .generation_number = 0}, + Object::IndirectReference{.object_number = 5, + .generation_number = 0})); +} + +TEST(PdfReaderTest, GetObjectOffset) { + std::string content = GetTestPdfContent(); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + EXPECT_THAT( + reader->GetObjectOffset(/*object_number=*/2, /*generation_number=*/0), + IsOkAndHolds(41)); +} + +TEST(PdfReaderTest, GetNonExistentObjectOffsetFailure) { + std::string content = GetTestPdfContent(); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + EXPECT_THAT( + reader->GetObjectOffset(/*object_number=*/10, /*generation_number=*/0), + StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found"))); +} + +TEST(PdfReaderTest, GetSpecificObject) { + std::string content = GetTestPdfContent(); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + ASSERT_OK_AND_ASSIGN(auto obj, reader->GetObject(2, 0)); + EXPECT_THAT(obj, (IndirectObject{.object_number = 2, + .generation_number = 0, + .object = CreateRealNumber("123.456")})); +} + +TEST(PdfReaderTest, GetNonExistentObjectFailure) { + std::string content = GetTestPdfContent(); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + EXPECT_THAT(reader->GetObject(10, 0), StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No offset found"))); +} + +TEST(PdfReaderTest, ShortFileCausesInitializationFailure) { + std::string content = "file is too short"; + riegeli::StringReader<> input(content); + EXPECT_THAT(PdfReader::Create(&input), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Failed to seek to offset"))); +} + +TEST(PdfReaderTest, MissingXrefOffsetCausesInitializationFailure) { + std::string content = R"(%PDF-1.7 +xref +0 1 +0000000000 65536 f +startxref +%%EOF)"; + riegeli::StringReader<> input(content); + EXPECT_THAT(PdfReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to find cross-reference table offset " + "in the following payload"))); +} + +TEST(PdfReaderTest, InvalidXrefOffsetCausesInitializationFailure) { + std::string content = R"(%PDF-1.7 +xref +0 1 +0000000000 65536 f +startxref +2 +%%EOF)"; + riegeli::StringReader<> input(content); + EXPECT_THAT( + PdfReader::Create(&input), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "Malformed cross-reference table. Expected 'xref' but got"))); +} + +TEST(PdfReaderTest, MalformedXrefTableCausesInitializationFailure) { + std::string content = R"(%PDF-1.7 +xref +0 2 +0000000000 65536 f +0000000 00001 n +startxref +9 +%%EOF)"; + riegeli::StringReader<> input(content); + EXPECT_THAT( + PdfReader::Create(&input), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr( + "Failed to parse cross-reference table entry. Invalid value"))); +} + +TEST(PdfReaderTest, InvalidPrevXrefOffsetTypeCausesInitializationFailure) { + std::string content = R"(%PDF-1.7 +xref +0 1 +0000000000 65535 f@ +trailer +<</Prev /NotAnInteger /Size 1>> +startxref +9 +%%EOF)"; + content = absl::StrReplaceAll(content, {{"@", " "}}); + riegeli::StringReader<> input(content); + EXPECT_THAT( + PdfReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Object is not a credentio::Object::Integer"))); +} + +TEST(PdfReaderTest, ContainingPreviousXrefTable) { + std::string content = R"(%PDF-1.7 +1 0 obj +<< /Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< /Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< /Type /Page +/Parent 2 0 R +/MediaBox [0 0 100 100] +/Contents 4 0 R +>> +endobj +4 0 obj +<< /Length 35 >> +stream +BT /F1 12 Tf 35 50 Td (Hello) Tj ET +endstream +endobj +xref +0 5 +0000000000 65535 f@ +0000000009 00000 n@ +0000000058 00000 n@ +0000000115 00000 n@ +0000000202 00000 n@ +trailer +<< /Size 5 +/Root 1 0 R +>> +startxref +287 +%%EOF +5 0 obj +<< /Type /Catalog +/Pages 6 0 R +>> +endobj +6 0 obj +<< /Type /Pages +/Kids [7 0 R 3 0 R] +/Count 2 +>> +endobj +7 0 obj +<< /Type /Page +/Parent 6 0 R +/MediaBox [0 0 50 50] +/Contents 8 0 R +>> +endobj +8 0 obj +<< /Length 35 >> +stream +BT /F1 12 Tf 10 20 Td (Hello) Tj ET +endstream +endobj +xref +5 4 +0000000450 00000 n@ +0000000499 00000 n@ +0000000563 00000 n@ +0000000648 00000 n@ +trailer +<< /Size 9 +/Root 5 0 R +/Prev 287 +>> +startxref +733 +%%EOF)"; + // Replace all @ with space to avoid presubmit failing trailing whitespace. + content = absl::StrReplaceAll(content, {{"@", " "}}); + + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + + std::vector<IndirectObject> objects; + while (reader->HasNext()) { + ASSERT_OK_AND_ASSIGN(auto obj, reader->Next()); + objects.push_back(std::move(obj)); + } + + ASSERT_THAT( + objects, + ElementsAre( + IndirectObject{.object_number = 1, + .generation_number = 0, + .object = CreateDictionary({ + {"Type", CreateName("Catalog")}, + {"Pages", CreateIndirectRef(2, 0)}, + })}, + IndirectObject{.object_number = 2, + .generation_number = 0, + .object = CreateDictionary({ + {"Kids", CreateArray({CreateIndirectRef(3, 0)})}, + {"Type", CreateName("Pages")}, + {"Count", CreateInteger(1)}, + })}, + IndirectObject{.object_number = 3, + .generation_number = 0, + .object = CreateDictionary({ + {"Parent", CreateIndirectRef(2, 0)}, + {"Type", CreateName("Page")}, + {"MediaBox", CreateArray({ + CreateInteger(0), + CreateInteger(0), + CreateInteger(100), + CreateInteger(100), + })}, + {"Contents", CreateIndirectRef(4, 0)}, + })}, + IndirectObject{ + .object_number = 4, + .generation_number = 0, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Length", CreateInteger(35)}}}, + .object = CreateStream("BT /F1 12 Tf 35 50 Td (Hello) Tj ET")}, + IndirectObject{.object_number = 5, + .generation_number = 0, + .object = CreateDictionary({ + {"Type", CreateName("Catalog")}, + {"Pages", CreateIndirectRef(6, 0)}, + })}, + IndirectObject{.object_number = 6, + .generation_number = 0, + .object = CreateDictionary({ + {"Kids", CreateArray({CreateIndirectRef(7, 0), + CreateIndirectRef(3, 0)})}, + {"Type", CreateName("Pages")}, + {"Count", CreateInteger(2)}, + })}, + IndirectObject{.object_number = 7, + .generation_number = 0, + .object = CreateDictionary({ + {"Parent", CreateIndirectRef(6, 0)}, + {"Type", CreateName("Page")}, + {"MediaBox", CreateArray({ + CreateInteger(0), + CreateInteger(0), + CreateInteger(50), + CreateInteger(50), + })}, + {"Contents", CreateIndirectRef(8, 0)}, + })}, + IndirectObject{ + .object_number = 8, + .generation_number = 0, + .stream_dictionary = + Object::Dictionary{ + .entries = {{"Length", CreateInteger(35)}}}, + .object = CreateStream("BT /F1 12 Tf 10 20 Td (Hello) Tj ET")})); +} + +riegeli::Reader* GetNullReader() { return nullptr; } + +TEST(PdfReaderTest, NullInputFailure) +__attribute__((no_sanitize("nullability"))) { + EXPECT_THAT(PdfReader::Create(GetNullReader()), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("input cannot be null"))); +} + +TEST(PdfReaderTest, CircularXrefTableFailure) { + std::string content = R"(%PDF-1.7 +1 0 obj +<< /Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< /Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< /Type /Page +/Parent 2 0 R +/MediaBox [0 0 100 100] +/Contents 4 0 R +>> +endobj +4 0 obj +<< /Length 35 >> +stream +BT /F1 12 Tf 35 50 Td (Hello) Tj ET +endstream +endobj +xref +0 5 +0000000000 65535 f@ +0000000009 00000 n@ +0000000058 00000 n@ +0000000115 00000 n@ +0000000202 00000 n@ +trailer +<< /Size 5 +/Root 1 0 R +/Prev 743 +>> +startxref +287 +%%EOF +5 0 obj +<< /Type /Catalog +/Pages 6 0 R +>> +endobj +6 0 obj +<< /Type /Pages +/Kids [7 0 R 3 0 R] +/Count 2 +>> +endobj +7 0 obj +<< /Type /Page +/Parent 6 0 R +/MediaBox [0 0 50 50] +/Contents 8 0 R +>> +endobj +8 0 obj +<< /Length 35 >> +stream +BT /F1 12 Tf 10 20 Td (Hello) Tj ET +endstream +endobj +xref +5 4 +0000000460 00000 n@ +0000000509 00000 n@ +0000000573 00000 n@ +0000000658 00000 n@ +trailer +<< /Size 9 +/Root 5 0 R +/Prev 287 +>> +startxref +743 +%%EOF)"; + content = absl::StrReplaceAll(content, {{"@", " "}}); + riegeli::StringReader<> input(content); + EXPECT_THAT(PdfReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Circular cross-reference table detected"))); +} + +TEST(PdfReaderTest, CollisionInUseEntriesFailure) { + std::string content = R"(%PDF-1.7 +1 0 obj +<< /Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< /Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< /Type /Page +/Parent 2 0 R +/MediaBox [0 0 100 100] +/Contents 4 0 R +>> +endobj +4 0 obj +<< /Length 35 >> +stream +BT /F1 12 Tf 35 50 Td (Hello) Tj ET +endstream +endobj +xref +0 5 +0000000000 65535 f@ +0000000009 00000 n@ +0000000058 00000 n@ +0000000115 00000 n@ +0000000202 00000 n@ +trailer +<< /Size 5 +/Root 1 0 R +>> +startxref +287 +%%EOF +5 0 obj +<< /Type /Catalog +/Pages 6 0 R +>> +endobj +6 0 obj +<< /Type /Pages +/Kids [7 0 R 3 0 R] +/Count 2 +>> +endobj +7 0 obj +<< /Type /Page +/Parent 6 0 R +/MediaBox [0 0 50 50] +/Contents 8 0 R +>> +endobj +8 0 obj +<< /Length 35 >> +stream +BT /F1 12 Tf 10 20 Td (Hello) Tj ET +endstream +endobj +xref +1 1 +0000000450 00000 n@ +5 4 +0000000450 00000 n@ +0000000499 00000 n@ +0000000563 00000 n@ +0000000648 00000 n@ +trailer +<< /Size 9 +/Root 5 0 R +/Prev 287 +>> +startxref +733 +%%EOF)"; + content = absl::StrReplaceAll(content, {{"@", " "}}); + riegeli::StringReader<> input(content); + EXPECT_THAT(PdfReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Collision detected in cross-reference table " + "for in-use entries"))); +} + +TEST(PdfReaderTest, CyclicStreamLengthFailure) { + std::string content = R"(%PDF-1.7 +%%%%% +1 0 obj +<< /Length 1 0 R >> +stream +abc +endstream +endobj +xref +0 2 +0000000000 65535 f@ +0000000015 00000 n@ +trailer +<< /Size 2 >> +startxref +71 +%%EOF)"; + content = absl::StrReplaceAll(content, {{"@", " "}}); + riegeli::StringReader<> input(content); + ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input)); + EXPECT_THAT(reader->GetObject(1, 0), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Circular reference detected"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/png/BUILD b/formats/png/BUILD new file mode 100644 index 0000000..493902a --- /dev/null +++ b/formats/png/BUILD
@@ -0,0 +1,177 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":constants", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + deps = [ + ":reader", + ":test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "test_utils", + testonly = True, + srcs = ["test_utils.cc"], + hdrs = ["test_utils.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "crc", + srcs = ["crc.cc"], + hdrs = ["crc.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "crc_test", + srcs = ["crc_test.cc"], + deps = [ + ":crc", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":constants", + ":crc", + ":reader", + "//constants:labels", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":extractor", + ":test_utils", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor_result", + "//testing:jumbf_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":constants", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + ":constants", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/png/assessor.cc b/formats/png/assessor.cc new file mode 100644 index 0000000..0373ffa --- /dev/null +++ b/formats/png/assessor.cc
@@ -0,0 +1,43 @@ +// 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 "formats/png/assessor.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/png/constants.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +absl::StatusOr<bool> PngAssessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + std::string header; + if (!input.Read(kPngHeader.size(), header)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; header")); + } + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek")); + } + + return header == kPngHeader; +} + +} // namespace credentio
diff --git a/formats/png/assessor.h b/formats/png/assessor.h new file mode 100644 index 0000000..d65ab13 --- /dev/null +++ b/formats/png/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the PNG image format. +class PngAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_ASSESSOR_H_
diff --git a/formats/png/assessor_test.cc b/formats/png/assessor_test.cc new file mode 100644 index 0000000..4f30e38 --- /dev/null +++ b/formats/png/assessor_test.cc
@@ -0,0 +1,70 @@ +// 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 "formats/png/assessor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "formats/png/constants.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(IsSupportedTest, FailureTooShort) { + std::string image = "test"; + riegeli::StringReader<> input(image); + EXPECT_THAT( + PngAssessor().IsSupported(input), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); +} + +TEST(IsSupportedTest, FailureIncorrectHeader) { + std::string image = "this_is_not_a_png"; + riegeli::StringReader<> input(image); + EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, Succeeds) { + std::string image = absl::StrCat(kPngHeader, "this_is_a_png"); + riegeli::StringReader<> input(image); + EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, SucceedsAtOffset2) { + std::string image = absl::StrCat("ab", kPngHeader, "this_is_a_png"); + riegeli::StringReader<> input(image); + + // Invalid at 0 + EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +} // namespace +} // namespace credentio
diff --git a/formats/png/constants.h b/formats/png/constants.h new file mode 100644 index 0000000..9f0ffa9 --- /dev/null +++ b/formats/png/constants.h
@@ -0,0 +1,43 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_CONSTANTS_H_ + +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { + +// PNG format, see https://www.w3.org/TR/2003/REC-PNG-20031110/ Section 5.2 +constexpr absl::string_view kPngHeader = "\x89PNG\x0d\x0a\x1a\x0a"; + +constexpr absl::string_view kPngChunkTypeHeader = "PNGh"; +constexpr absl::string_view kPngChunkTypeEnd = "IEND"; +constexpr absl::string_view kPngChunkTypeC2pa = "caBX"; +constexpr absl::string_view kPngChunkTypeIdat = "IDAT"; +constexpr absl::string_view kPngChunkTypeExif = "eXIf"; +constexpr uint32_t kPngChunkTypeExifNum = 'eXIf'; + +const int64_t kPngChunkLengthSize = sizeof(uint32_t); +const int64_t kPngCrcSize = sizeof(uint32_t); + +const int64_t kPngMinimumAssetSize = + kPngHeader.size() + kPngChunkTypeEnd.size() + kPngCrcSize; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_CONSTANTS_H_
diff --git a/formats/png/crc.cc b/formats/png/crc.cc new file mode 100644 index 0000000..fb504fd --- /dev/null +++ b/formats/png/crc.cc
@@ -0,0 +1,62 @@ +// 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 "formats/png/crc.h" + +#include <array> +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { +namespace { + +// Precomputed CRC-32 table (IEEE 802.3 polynomial: 0xedb88320) +constexpr std::array<uint32_t, 256> MakeCrcTable() { + std::array<uint32_t, 256> table = {}; + for (uint32_t i = 0; i < 256; ++i) { + uint32_t c = i; + for (int j = 0; j < 8; ++j) { + if (c & 1) { + c = 0xedb88320L ^ (c >> 1); + } else { + c = c >> 1; + } + } + table[i] = c; + } + return table; +} + +static constexpr std::array<uint32_t, 256> kCrcTable = MakeCrcTable(); + +uint32_t UpdateCrc(uint32_t crc, absl::string_view data) { + uint32_t c = crc; + for (char byte : data) { + c = kCrcTable[(c ^ static_cast<uint8_t>(byte)) & 0xff] ^ (c >> 8); + } + return c; +} + +} // namespace + +uint32_t PngChunkCrc(absl::string_view type, absl::string_view data) { + uint32_t crc = 0xffffffffL; + crc = UpdateCrc(crc, type); + crc = UpdateCrc(crc, data); + return crc ^ 0xffffffffL; +} + +} // namespace credentio
diff --git a/formats/png/crc.h b/formats/png/crc.h new file mode 100644 index 0000000..956525b --- /dev/null +++ b/formats/png/crc.h
@@ -0,0 +1,31 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_CRC_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_CRC_H_ + +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { + +// Returns the CRC of a PNG chunk as described in +// https://www.w3.org/TR/png/#5CRC-algorithm. +uint32_t PngChunkCrc(absl::string_view type, absl::string_view data); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_CRC_H_
diff --git a/formats/png/crc_test.cc b/formats/png/crc_test.cc new file mode 100644 index 0000000..f0ff977 --- /dev/null +++ b/formats/png/crc_test.cc
@@ -0,0 +1,35 @@ +// 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 "formats/png/crc.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::testing::Eq; + +TEST(PngChunkCrcTest, ComputesCrcCorrectly) { + EXPECT_THAT(PngChunkCrc("abcd", "data1"), Eq(0x870ce600)); + EXPECT_THAT(PngChunkCrc("dcba", "data1"), Eq(0xf78b39d6)); + EXPECT_THAT(PngChunkCrc("dcba", "data2"), Eq(0x6e82686c)); + EXPECT_THAT(PngChunkCrc("IDAT", "more data"), Eq(0x6b6f3927)); + EXPECT_THAT(PngChunkCrc("IEND", ""), Eq(0xae426082)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/png/extractor.cc b/formats/png/extractor.cc new file mode 100644 index 0000000..1e4d8a4 --- /dev/null +++ b/formats/png/extractor.cc
@@ -0,0 +1,253 @@ +// 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 "formats/png/extractor.h" + +#include <sys/types.h> + +#include <algorithm> +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "constants/labels.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/png/constants.h" +#include "formats/png/crc.h" +#include "formats/png/reader.h" +#include "jumbf/utils.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { + +namespace { +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB + +absl::StatusOr<std::string> ValidateCrcAndReturnPayload(riegeli::Reader& input, + PngChunk chunk, + int64_t end_offset) { + if (chunk.data_length > kMaxPayloadSize) { + return absl::InvalidArgumentError( + absl::Substitute("PNG C2PA chunk is too large to extract ($0 > $1)", + chunk.data_length, kMaxPayloadSize)); + } + + std::string payload; + if (!input.Read(chunk.data_length, payload)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read payload")); + } + + uint32_t extracted_crc; + if (!riegeli::ReadBigEndian<uint32_t>(input, extracted_crc)) { + return input.StatusOrAnnotate(absl::DataLossError("Failed to read CRC")); + } + + uint32_t computed_crc = PngChunkCrc(chunk.type, payload); + if (extracted_crc != computed_crc) { + return absl::InvalidArgumentError( + absl::Substitute("chunk CRC 0x$0 does not match computed CRC 0x$1", + absl::Hex(extracted_crc), absl::Hex(computed_crc))); + } + + return std::move(payload); +} + +absl::Status ValidateAssetSize(riegeli::Reader& input, int64_t end_offset) { + int64_t asset_size = + (end_offset < 0 ? input.Size().value_or(input.pos()) : end_offset) - + input.pos(); + if (asset_size < kPngMinimumAssetSize) { + // The asset is too small, we can't possibly have a manifest store. + return absl::NotFoundError("No manifest store found"); + } + return absl::OkStatus(); +} + +absl::Status ValidateWithinAssetWindow(const PngChunk& chunk, + int64_t end_offset) { + // If we are iterating over the entire asset, return early. + if (end_offset < 0) { + return absl::OkStatus(); + } + + if (chunk.type == "c2pa.after") { + return chunk.offset >= end_offset ? absl::AbortedError("asset-window-end") + : absl::OkStatus(); + } + + if (chunk.offset >= end_offset) { + // Chunk starts at or past the end of the asset window, early return. + return absl::AbortedError("asset-window-end"); + } + + if (chunk.offset + chunk.length > end_offset) { + // Chunk starts within the asset window, but extends beyond, return error. + return absl::InvalidArgumentError( + "PNG chunk extends beyond the end of the file"); + } + + // Chunk starts within the asset window and does not extend beyond it. + return absl::OkStatus(); +} + +} // namespace + +absl::StatusOr<std::string> PngExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + std::optional<std::string> result = std::nullopt; + absl::Status iteration_status = IterateOverPngChunks( + input, [&result, &input](const PngChunk& chunk) -> absl::Status { + if (chunk.type != kPngChunkTypeC2pa) { + return absl::OkStatus(); + } + + if (result.has_value()) { + return absl::NotFoundError("Multiple manifest stores found"); + } + + ABSL_ASSIGN_OR_RETURN( + result, ValidateCrcAndReturnPayload(input, chunk, + input.Size().value_or(0))); + return absl::OkStatus(); + }); + ABSL_RETURN_IF_ERROR(iteration_status); + if (result.has_value()) { + return *result; + } + return absl::NotFoundError("No manifest store found"); +} + +absl::StatusOr<std::optional<ByteRange>> +PngExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + std::optional<ByteRange> result = std::nullopt; + + if (!ValidateAssetSize(input, options.end_offset).ok()) { + if (options.requires_c2pa) { + return absl::NotFoundError("No manifest store found"); + } + return result; + } + + absl::Status iteration_status = IterateOverPngChunks( + input, + [&result, &options, &input](const PngChunk& chunk) -> absl::Status { + ABSL_RETURN_IF_ERROR( + ValidateWithinAssetWindow(chunk, options.end_offset)); + + if (chunk.type != kPngChunkTypeC2pa) { + return absl::OkStatus(); + } + if (result.has_value()) { + return absl::NotFoundError("Multiple manifest stores found"); + } + + // Ensure the C2PA chunk is valid before using it. + ABSL_RETURN_IF_ERROR( + ValidateCrcAndReturnPayload(input, chunk, input.Size().value_or(0)) + .status()); + + result = {.offset = chunk.offset, .length = chunk.length}; + + return absl::OkStatus(); + }); + if (iteration_status.code() != absl::StatusCode::kAborted) { + // Ignore the aborted error, it means we've reached the end of the asset + // window in a valid manner. + ABSL_RETURN_IF_ERROR(iteration_status); + } + + if (options.requires_c2pa && !result.has_value()) { + return absl::NotFoundError("No manifest store found"); + } + return result; +} + +absl::StatusOr<std::vector<AssetBox>> PngExtractor::ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const { + std::vector<AssetBox> result; + + if (!ValidateAssetSize(input, options.end_offset).ok()) { + if (options.requires_c2pa) { + return absl::NotFoundError("No manifest store found"); + } + return result; + } + + int64_t c2pa_chunks_found = 0; + absl::Status iteration_status = IterateOverPngChunks( + input, + [&result, &options, &c2pa_chunks_found, + &input](const PngChunk& chunk) -> absl::Status { + ABSL_RETURN_IF_ERROR( + ValidateWithinAssetWindow(chunk, options.end_offset)); + + std::string identifier = chunk.type; + if (chunk.type == kPngChunkTypeC2pa) { + c2pa_chunks_found++; + identifier = "C2PA"; + + if (c2pa_chunks_found > 1) { + return absl::NotFoundError("Multiple manifest stores found"); + } + // Ensure the C2PA chunk is valid. + if (auto payload_or = + ValidateCrcAndReturnPayload(input, chunk, options.end_offset); + !payload_or.ok()) { + return payload_or.status(); + } + } + + uint64_t length = chunk.length; + if (identifier == "c2pa.after" && options.end_offset >= 0) { + length = std::min( + length, static_cast<uint64_t>(options.end_offset - chunk.offset)); + } + result.push_back(AssetBox{ + .identifier = std::move(identifier), + .byte_range = {.offset = chunk.offset, .length = length}, + }); + return absl::OkStatus(); + }); + if (iteration_status.code() != absl::StatusCode::kAborted) { + // Ignore the aborted error, it means we've reached the end of the asset + // window in a valid manner. + ABSL_RETURN_IF_ERROR(iteration_status); + } + if (options.requires_c2pa && c2pa_chunks_found == 0) { + return absl::NotFoundError("No manifest store found"); + } + return result; +} + +bool PngExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} + +} // namespace credentio
diff --git a/formats/png/extractor.h b/formats/png/extractor.h new file mode 100644 index 0000000..edf652c --- /dev/null +++ b/formats/png/extractor.h
@@ -0,0 +1,53 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Implements the C2PA Manifest Store extractor for PNG files as described in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_png +class PngExtractor : public FormatExtractor { + public: + PngExtractor() = default; + ~PngExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override; + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_EXTRACTOR_H_
diff --git a/formats/png/extractor_test.cc b/formats/png/extractor_test.cc new file mode 100644 index 0000000..faff925 --- /dev/null +++ b/formats/png/extractor_test.cc
@@ -0,0 +1,419 @@ +// 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 "formats/png/extractor.h" + +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor_result.h" +#include "formats/png/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/jumbf_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::IsEmpty; + +constexpr int64_t kMaxChunkBytes = 2 * 1048576; // 2MiB + +std::vector<AssetBox> CreateBoxes( + std::vector<std::pair<std::string, uint32_t>> expected) { + uint64_t offset = 0; + std::vector<AssetBox> boxes; + for (const auto& name_and_size : expected) { + boxes.push_back(AssetBox{ + .identifier = name_and_size.first, + .byte_range = {.offset = offset, .length = name_and_size.second}, + }); + offset += name_and_size.second; + } + return boxes; +} + +struct TestCase { + std::string name; + std::string contents; + absl::StatusOr<ExtractorResult> result; + uint32_t max_chunk_bytes = kMaxChunkBytes; +}; + +class ExtractorTest : public testing::TestWithParam<TestCase> {}; + +INSTANTIATE_TEST_SUITE_P( + ExtractorTests, ExtractorTest, + testing::ValuesIn({ + TestCase{ + .name = "EmptyContent", + .contents = "", + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "NoPngMarker", + .contents = "this_is_clearly_not_a_png", + .result = absl::InvalidArgumentError( + "input does not start with PNG marker"), + }, + TestCase{ + .name = "OnlyPngMarker", + .contents = + []() { + absl::StatusOr<std::string> png = + CreatePng({}, /*add_header=*/true, /*add_end=*/false); + CHECK_OK(png); + return *png; + }(), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "EmptyPng", + .contents = + []() { + absl::StatusOr<std::string> png = CreatePng({}); + CHECK_OK(png); + return *png; + }(), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "NonC2paInvalidCrcIsIgnored", + .contents = + []() { + absl::StatusOr<std::string> png = + CreatePng({{.type = 'IEND', .payload = ""}}, + /*add_header=*/true, /*add_end=*/false); + CHECK_OK(png); + return *png; + }(), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "C2paFound", + .contents = + []() { + absl::StatusOr<std::string> png = CreatePng({ + {.type = 'IDAT', .payload = "ignored IDAT chunk"}, + {.type = 'caBX', + .payload = "A C2PA manifest store", + .crc = 0xdb50c4b5}, + }); + CHECK_OK(png); + return *png; + }(), + .result = + ExtractorResult{ + .manifest_store = "A C2PA manifest store", + .asset_byte_info = + { + .manifest_store_location = {.offset = 38, + .length = 33}, + .boxes = CreateBoxes({{"PNGh", /*size=*/8}, + {"IDAT", /*size=*/30}, + {"C2PA", /*size=*/33}, + {"IEND", /*size=*/12}}), + }, + }, + }, + TestCase{ + .name = "TruncatedChunk", + .contents = + []() { + absl::StatusOr<std::string> data = CreatePng( + {{.type = 'IDAT', .payload = "ignored IDAT chunk"}}, + /*add_header=*/true, /*add_end=*/false); + CHECK_OK(data); + data->resize(data->size() - 1); + return *data; + }(), + .result = absl::InvalidArgumentError( + "PNG chunk extends beyond the end of the file"), + }, + TestCase{ + .name = "C2paInvalidCrc", + .contents = + []() { + absl::StatusOr<std::string> png = CreatePng( + {{.type = 'caBX', .payload = "", .crc = 0x11111111}}); + CHECK_OK(png); + return *png; + }(), + .result = absl::InvalidArgumentError("does not match computed CRC"), + }, + TestCase{ + .name = "C2paChunkTooLarge", + .contents = + []() { + absl::StatusOr<std::string> png = CreatePng({ + {.type = 'caBX', + .payload = std::string(10 * 1024 * 1024 + 1, 'a')}, + }); + CHECK_OK(png); + return *png; + }(), + .result = absl::InvalidArgumentError( + "PNG C2PA chunk is too large to extract"), + }, + TestCase{ + .name = "MultipleC2paChunks", + .contents = + []() { + absl::StatusOr<std::string> png = CreatePng({ + {.type = 'caBX', .payload = "", .crc = 0xe63dd2a7}, + {.type = 'caBX', .payload = "", .crc = 0xe63dd2a7}, + }); + CHECK_OK(png); + return *png; + }(), + .result = absl::NotFoundError("Multiple manifest stores found"), + }, + TestCase{ + .name = "LargeIDATChunk", + .contents = + []() { + absl::StatusOr<std::string> png = CreatePng({ + {.type = 'IDAT', + .payload = "this_is_a_large_idat_chunk", + .crc = 2230102072}, + }); + CHECK_OK(png); + return *png; + }(), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "ContainsAdditionalData", + .contents = + []() { + absl::StatusOr<std::string> png = CreatePng({ + {.type = 'IDAT', .payload = "ignored IDAT chunk"}, + {.type = 'caBX', + .payload = "A C2PA manifest store", + .crc = 0xdb50c4b5}, + }); + CHECK_OK(png); + return *png + *png; + }(), + .result = + ExtractorResult{ + .manifest_store = "A C2PA manifest store", + .asset_byte_info = + { + .manifest_store_location = {.offset = 38, + .length = 33}, + .boxes = CreateBoxes({{"PNGh", /*size=*/8}, + {"IDAT", /*size=*/30}, + {"C2PA", /*size=*/33}, + {"IEND", /*size=*/12}, + {"c2pa.after", /*size=*/83}}), + }, + }, + }, + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +TEST_P(ExtractorTest, Extract) { + const TestCase& test_case = GetParam(); + riegeli::StringReader<> input(test_case.contents); + absl::StatusOr<std::string> result = + PngExtractor().ExtractManifestStore(input); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected_result->manifest_store)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) { + const TestCase& test_case = GetParam(); + std::string contents = "padding" + test_case.contents; + riegeli::StringReader<> input(contents); + + ASSERT_TRUE(input.Seek(7)); + absl::StatusOr<std::string> result = + PngExtractor().ExtractManifestStore(input); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected_result->manifest_store)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffixPadded) { + const TestCase& test_case = GetParam(); + std::string contents = test_case.contents + "padding"; + riegeli::StringReader<> input(contents); + + absl::StatusOr<std::optional<ByteRange>> result = + PngExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(contents.size()) - 7}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + EXPECT_THAT( + result, + IsOkAndHolds(expected_result->asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxesWithSuffixPadded) { + const TestCase& test_case = GetParam(); + std::string contents = test_case.contents + "padding"; + riegeli::StringReader<> input(contents); + + absl::StatusOr<std::vector<AssetBox>> result = PngExtractor().ExtractBoxes( + input, {.end_offset = static_cast<int64_t>(contents.size()) - 7}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected_result->asset_byte_info.boxes)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationRequiresC2pa) { + const TestCase& test_case = GetParam(); + riegeli::StringReader<> input(test_case.contents); + + absl::StatusOr<std::optional<ByteRange>> result = + PngExtractor().ExtractManifestStoreLocation(input, {}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + EXPECT_THAT( + result, + IsOkAndHolds(expected_result->asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationDoesNotRequireC2pa) { + const TestCase& test_case = GetParam(); + riegeli::StringReader<> input(test_case.contents); + + absl::StatusOr<std::optional<ByteRange>> result = + PngExtractor().ExtractManifestStoreLocation(input, + {.requires_c2pa = false}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + EXPECT_THAT( + result, + IsOkAndHolds(expected_result->asset_byte_info.manifest_store_location)); + } else if (expected_result.status().code() == absl::StatusCode::kNotFound && + expected_result.status().message() != + "Multiple manifest stores found") { + EXPECT_THAT(result, IsOkAndHolds(std::nullopt)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxes) { + const TestCase& test_case = GetParam(); + riegeli::StringReader<> input(test_case.contents); + + absl::StatusOr<std::vector<AssetBox>> result = + PngExtractor().ExtractBoxes(input, {}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + EXPECT_THAT(result, IsOkAndHolds(expected_result->asset_byte_info.boxes)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST(PngExtractorTest, IsManifestStore) { + EXPECT_TRUE(credentio::PngExtractor().MightBeC2paManifestStore( + credentio::CreateStartOfManifestStorePayload())); +} + +TEST(PngExtractorTest, ExtractManifestStoreLocationWithEndOffsetBeforeC2pa) { + absl::StatusOr<std::string> png = CreatePng({ + {.type = 'IDAT', .payload = "ignored IDAT chunk"}, + {.type = 'caBX', .payload = "A C2PA manifest store", .crc = 0xdb50c4b5}, + }); + ASSERT_THAT(png, ::absl_testing::IsOk()); + riegeli::StringReader<> input(*png); + + // Set end_offset to 38 (right before caBX). + // This should cause caBX to be considered past the asset window, + // triggering the abort early return in ValidateWithinAssetWindow. + auto result = PngExtractor().ExtractManifestStoreLocation( + input, {.requires_c2pa = false, .end_offset = 38}); + EXPECT_THAT(result, IsOkAndHolds(std::nullopt)); +} + +TEST(PngExtractorTest, ExtractBoxesDoesNotRequireC2paAndAssetTooSmall) { + riegeli::StringReader<> input(""); + auto result = PngExtractor().ExtractBoxes(input, {.requires_c2pa = false}); + EXPECT_THAT(result, IsOkAndHolds(IsEmpty())); +} + +TEST(PngExtractorTest, + ExtractManifestStoreLocationAssetJustTooSmallDoesNotRequireC2pa) { + // kPngMinimumAssetSize is 16. We use 15 bytes. + std::string contents = std::string( + "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x00\x00\x00\x00", 15); + riegeli::StringReader<> input(contents); + auto result = PngExtractor().ExtractManifestStoreLocation( + input, {.requires_c2pa = false}); + EXPECT_THAT(result, IsOkAndHolds(std::nullopt)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/png/format.cc b/formats/png/format.cc new file mode 100644 index 0000000..477ace1 --- /dev/null +++ b/formats/png/format.cc
@@ -0,0 +1,35 @@ +// 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 "formats/png/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/png/assessor.h" +#include "formats/png/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreatePngFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<PngAssessor>(), + .extractor = std::make_unique<PngExtractor>(), + .mime_types = {"image/png"}, + }); +} + +} // namespace credentio
diff --git a/formats/png/format.h b/formats/png/format.h new file mode 100644 index 0000000..b5e0235 --- /dev/null +++ b/formats/png/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreatePngFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_FORMAT_H_
diff --git a/formats/png/reader.cc b/formats/png/reader.cc new file mode 100644 index 0000000..0e3a3b9 --- /dev/null +++ b/formats/png/reader.cc
@@ -0,0 +1,146 @@ +// 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 "formats/png/reader.h" + +#include <sys/types.h> + +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/str_cat.h" +#include "formats/png/constants.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { + +namespace { + +absl::StatusOr<PngChunk> ReadHeader(riegeli::Reader& reader) { + uint64_t start_offset = reader.pos(); + std::string header; + if (!reader.Read(kPngHeader.size(), header)) { + return reader.StatusOrAnnotate( + absl::InvalidArgumentError("Failed to read header")); + } + if (header != kPngHeader) { + return absl::InvalidArgumentError("input does not start with PNG marker"); + } + return PngChunk{ + .offset = start_offset, + .length = kPngHeader.size(), + .type = kPngChunkTypeHeader.data(), + .data_length = static_cast<uint32_t>(kPngHeader.size()), + }; +} + +bool ChunkGoesPastEndOffset(uint64_t chunk_start, uint64_t chunk_length, + uint64_t end_offset) { + if (end_offset < chunk_length) { + // Chunk is larger than the file size + return true; + } + if (chunk_start > (end_offset - chunk_length)) { + // Chunk starts too far into the file to read the entire chunk. + return true; + } + return false; +} + +absl::StatusOr<PngChunk> ReadChunk(riegeli::Reader& reader, + int64_t end_offset) { + PngChunk chunk{.offset = reader.pos()}; + if (!riegeli::ReadBigEndian<uint32_t>(reader, chunk.data_length)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read chunk data length")); + } + chunk.length = sizeof(uint32_t) // Chunk length + + sizeof(uint32_t) // Chunk type + + static_cast<uint64_t>(chunk.data_length) // Chunk data + + sizeof(uint32_t); // CRC + if (ChunkGoesPastEndOffset(chunk.offset, chunk.length, end_offset)) { + return absl::InvalidArgumentError( + "PNG chunk extends beyond the end of the file"); + } + if (!reader.Read(4, chunk.type)) { + return reader.StatusOrAnnotate( + absl::DataLossError("Failed to read chunk type")); + } + for (char c : chunk.type) { + if (!absl::ascii_isalpha(c)) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid PNG chunk type name: ", chunk.type)); + } + } + return std::move(chunk); +} + +} // namespace + +absl::Status IterateOverPngChunks(riegeli::Reader& reader, + PngChunkProcessor processor) { + if (!reader.SupportsSize() || !reader.Size().has_value()) { + return absl::InvalidArgumentError( + "PNG reader does not support size, cannot iterate"); + } + uint64_t end_offset = *reader.Size(); + if (kPngMinimumAssetSize > end_offset - reader.pos()) { + // Asset size is smaller than the minimum size needed, just return. + return absl::OkStatus(); + } + + ABSL_ASSIGN_OR_RETURN(PngChunk header, ReadHeader(reader)); + + ABSL_RETURN_IF_ERROR(processor(header)); + uint64_t last_chunk_offset = header.offset; + while (reader.pos() < end_offset) { + ABSL_ASSIGN_OR_RETURN(PngChunk chunk, ReadChunk(reader, end_offset)); + + if (chunk.offset <= last_chunk_offset) { + return reader.StatusOrAnnotate( + absl::InvalidArgumentError("PNG chunk offset did not advance")); + } + last_chunk_offset = chunk.offset; + + ABSL_RETURN_IF_ERROR(processor(chunk)); + if (!reader.Seek(chunk.offset + chunk.length)) { + return reader.StatusOrAnnotate( + absl::InvalidArgumentError("Failed to seek to next chunk")); + } + + if (chunk.type == kPngChunkTypeEnd) { + break; + } + } + + if (reader.pos() < end_offset) { + PngChunk chunk{ + .offset = reader.pos(), + .length = end_offset - reader.pos(), + .type = "c2pa.after", + .data_length = 0, + }; + ABSL_RETURN_IF_ERROR(processor(chunk)); + } + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/formats/png/reader.h b/formats/png/reader.h new file mode 100644 index 0000000..7d4494a --- /dev/null +++ b/formats/png/reader.h
@@ -0,0 +1,53 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_READER_H_ + +#include <cstdint> +#include <string> + +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +struct PngChunk { + uint64_t offset; + uint64_t length; + + std::string type; + uint32_t data_length; +}; + +inline bool operator==(const PngChunk& lhs, const PngChunk& rhs) { + return lhs.offset == rhs.offset && lhs.length == rhs.length && + lhs.type == rhs.type && lhs.data_length == rhs.data_length; +} + +// The function should return an OK status to continue iteration, or a non-OK +// status to abort iteration. +using PngChunkProcessor = absl::FunctionRef<absl::Status(const PngChunk&)>; + +// Iterates over all PNG Chunks in the given reader, calling the given function +// for each box. The method will seek to position 0 in the reader before +// iterating. +absl::Status IterateOverPngChunks(riegeli::Reader& reader, + PngChunkProcessor processor); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_READER_H_
diff --git a/formats/png/reader_test.cc b/formats/png/reader_test.cc new file mode 100644 index 0000000..9a1dde2 --- /dev/null +++ b/formats/png/reader_test.cc
@@ -0,0 +1,122 @@ +// 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 "formats/png/reader.h" + +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "formats/png/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; +using ::testing::IsEmpty; + +absl::StatusOr<std::vector<PngChunk>> GetChunks(riegeli::Reader& reader) { + std::vector<PngChunk> chunks; + ABSL_RETURN_IF_ERROR(IterateOverPngChunks(reader, [&](const PngChunk& chunk) { + chunks.push_back(std::move(chunk)); + return absl::OkStatus(); + })); + return chunks; +} + +absl::StatusOr<std::vector<PngChunk>> GetChunksFromContents( + absl::string_view contents) { + riegeli::StringReader reader(contents); + return GetChunks(reader); +} + +using PngReaderTest = testing::Test; + +TEST_F(PngReaderTest, NoData) { + EXPECT_THAT(GetChunksFromContents(""), IsOkAndHolds(IsEmpty())); +} + +TEST_F(PngReaderTest, InvalidHeader) { + std::string content = "abcdefghijklmnopqrstuvwxyz"; + + EXPECT_THAT(GetChunksFromContents(content), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("input does not start with PNG marker"))); +} + +TEST_F(PngReaderTest, ClosedReader) { + std::string content = "abcdefghijklmnopqrstuvwxyz"; + + riegeli::StringReader reader(content); + EXPECT_TRUE(reader.Close()); + + EXPECT_THAT( + GetChunks(reader), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("PNG reader does not support size, cannot iterate"))); +} + +TEST_F(PngReaderTest, ChunkExtendsBeyondEndOfFile) { + auto content_or = CreatePng({{.type = 'IDAT', .payload = "a"}}); + if (!content_or.ok()) { + FAIL() << "Failed to create PNG: " << content_or.status(); + } + std::string content = *content_or; + content[11] = '\xff'; // Marks the IDAT chunk as 255 bytes long. + + EXPECT_THAT( + GetChunksFromContents(content), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("PNG chunk extends beyond the end of the file"))); +} + +TEST_F(PngReaderTest, ChunkLengthOverflowPrevention) { + auto content_or = CreatePng({{.type = 'IDAT', .payload = "a"}}); + ASSERT_TRUE(content_or.ok()); + std::string content = *content_or; + content[8] = '\xff'; + content[9] = '\xff'; + content[10] = '\xff'; + content[11] = '\xf4'; // data_length = 0xfffffff4 (-12 in 32-bit) + + EXPECT_THAT( + GetChunksFromContents(content), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("PNG chunk extends beyond the end of the file"))); +} + +TEST_F(PngReaderTest, InvalidChunkType) { + auto content_or = CreatePng({{.type = 'C2PA', .payload = "a"}}); + ASSERT_TRUE(content_or.ok()); + + EXPECT_THAT(GetChunksFromContents(*content_or), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid PNG chunk type name: C2PA"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/png/test_utils.cc b/formats/png/test_utils.cc new file mode 100644 index 0000000..f652bd9 --- /dev/null +++ b/formats/png/test_utils.cc
@@ -0,0 +1,85 @@ +// 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 "formats/png/test_utils.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/types/span.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +absl::Status WritePngHeader(riegeli::Writer& buffer) { + const std::string header = "\x89PNG\x0d\x0a\x1a\x0a"; + if (!buffer.Write(header)) { + return buffer.StatusOrAnnotate( + absl::InternalError("Failed to write PNG header")); + } + return absl::OkStatus(); +} + +absl::Status WritePngChunk(riegeli::Writer& buffer, const Chunk& chunk) { + if (!riegeli::WriteBigEndian<uint32_t>(chunk.payload.size(), buffer)) { + return buffer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk size")); + } + if (!riegeli::WriteBigEndian<uint32_t>(chunk.type, buffer)) { + return buffer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk type")); + } + if (!chunk.payload.empty()) { + if (!buffer.Write(chunk.payload)) { + return buffer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk payload")); + } + } + if (!riegeli::WriteBigEndian<uint32_t>(chunk.crc.value_or(0), buffer)) { + return buffer.StatusOrAnnotate( + absl::InternalError("Failed to write chunk CRC")); + } + return absl::OkStatus(); +} + +absl::Status WritePngEnd(riegeli::Writer& buffer) { + return WritePngChunk(buffer, Chunk{.type = 'IEND', .payload = ""}); +} + +absl::StatusOr<std::string> CreatePng(absl::Span<const Chunk> chunks, + bool add_header, bool add_end) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + if (add_header) { + ABSL_RETURN_IF_ERROR(WritePngHeader(writer)); + } + for (const Chunk& chunk : chunks) { + ABSL_RETURN_IF_ERROR(WritePngChunk(writer, chunk)); + } + if (add_end) { + ABSL_RETURN_IF_ERROR(WritePngEnd(writer)); + } + if (!writer.Close()) { + return writer.status(); + } + return buffer; +} + +} // namespace credentio
diff --git a/formats/png/test_utils.h b/formats/png/test_utils.h new file mode 100644 index 0000000..302c0e5 --- /dev/null +++ b/formats/png/test_utils.h
@@ -0,0 +1,51 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_TEST_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_TEST_UTILS_H_ + +#include <cstdint> +#include <optional> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/types/span.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// A PNG chunk. +struct Chunk { + // Chunk type (e.g., 'IDAT' or 'caBX'). + uint32_t type; + std::string payload; + // If set, the CRC will be set to this value. + std::optional<uint32_t> crc = std::nullopt; +}; + +// Creates a PNG image from the given chunks. +absl::StatusOr<std::string> CreatePng(absl::Span<const Chunk> chunks, + bool add_header = true, + bool add_end = true); + +// Lower-level functions. +absl::Status WritePngHeader(riegeli::Writer& buffer); +absl::Status WritePngChunk(riegeli::Writer& buffer, const Chunk& chunk); +absl::Status WritePngEnd(riegeli::Writer& buffer); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_PNG_TEST_UTILS_H_
diff --git a/formats/registry.cc b/formats/registry.cc new file mode 100644 index 0000000..5f4ad97 --- /dev/null +++ b/formats/registry.cc
@@ -0,0 +1,93 @@ +// 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 "formats/registry.h" + +#include <memory> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "formats/format.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +void FormatRegistry::Register(std::unique_ptr<Format> format) { + for (const auto& media_type : format->mime_types()) { + format_for_type_[media_type].push_back(format.get()); + } + formats_.push_back(std::move(format)); +} + +absl::StatusOr<const Format*> FormatRegistry::GetFormat( + absl::string_view media_type) const { + auto it = format_for_type_.find(media_type); + if (it == format_for_type_.end()) { + return absl::UnimplementedError( + absl::StrCat("Unsupported media type: ", media_type)); + } + if (it->second.empty()) { + return absl::UnimplementedError( + absl::StrCat("Unsupported media type: ", media_type)); + } + if (it->second.size() == 1) { + return it->second[0]; + } + return absl::UnimplementedError( + absl::StrCat("Multiple formats support media type: ", media_type)); +} + +absl::StatusOr<const Format*> FormatRegistry::GetFormat( + riegeli::Reader& asset) const { + for (const std::unique_ptr<Format>& format : formats_) { + absl::StatusOr<bool> is_supported = format->assessor()->IsSupported(asset); + if (is_supported.ok() && *is_supported) { + // Asset is supported by this format. + return format.get(); + } + // Errors are treated as unsupported by the format. + } + return absl::UnimplementedError("No applicable Format found"); +} + +absl::StatusOr<const Format*> FormatRegistry::GetFormat( + absl::string_view media_type, riegeli::Reader& asset) const { + auto it = format_for_type_.find(media_type); + if (it == format_for_type_.end()) { + return absl::UnimplementedError( + absl::StrCat("Unsupported media type: ", media_type)); + } + if (it->second.empty()) { + return absl::UnimplementedError( + absl::StrCat("Unsupported media type: ", media_type)); + } + if (it->second.size() == 1) { + return it->second[0]; + } + for (const Format* format : it->second) { + absl::StatusOr<bool> is_supported = format->assessor()->IsSupported(asset); + if (is_supported.ok() && *is_supported) { + // Asset is supported by this format. + return format; + } + // Errors are treated as unsupported by the format. + } + return absl::UnimplementedError("No applicable Format found"); +} + +} // namespace credentio
diff --git a/formats/registry.h b/formats/registry.h new file mode 100644 index 0000000..df17f47 --- /dev/null +++ b/formats/registry.h
@@ -0,0 +1,52 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_REGISTRY_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_REGISTRY_H_ + +#include <memory> +#include <string> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/format.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +class FormatRegistry { + public: + // Takes ownership of the format. + void Register(std::unique_ptr<Format> format); + + absl::StatusOr<const Format*> GetFormat(absl::string_view media_type) const; + + absl::StatusOr<const Format*> GetFormat(riegeli::Reader& asset) const; + + absl::StatusOr<const Format*> GetFormat(absl::string_view media_type, + riegeli::Reader& asset) const; + + private: + // Formats. + std::vector<std::unique_ptr<Format>> formats_; + // Map from media type to format. + absl::flat_hash_map<std::string, std::vector<const Format*>> format_for_type_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_REGISTRY_H_
diff --git a/formats/registry_test.cc b/formats/registry_test.cc new file mode 100644 index 0000000..16137d2 --- /dev/null +++ b/formats/registry_test.cc
@@ -0,0 +1,237 @@ +// 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 "formats/registry.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/assessor.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor.h" +#include "formats/format.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +class ErrorAssessor : public FormatAssessor { + public: + ErrorAssessor() = default; + ~ErrorAssessor() override = default; + + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override { + return absl::UnimplementedError("Not implemented"); + } +}; + +class PassAssessor : public FormatAssessor { + public: + PassAssessor() = default; + ~PassAssessor() override = default; + + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override { + return true; + } +}; + +class FailAssessor : public FormatAssessor { + public: + FailAssessor() = default; + ~FailAssessor() override = default; + + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override { + return false; + } +}; + +class FakeExtractor : public FormatExtractor { + public: + FakeExtractor() = default; + ~FakeExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override { + return absl::UnimplementedError("Not implemented"); + } + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("Not implemented"); + } + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("Not implemented"); + } + + bool MightBeC2paManifestStore(absl::string_view payload) const override { + return true; + } +}; + +class FormatRegistryTest : public ::testing::Test { + protected: + void SetUp() override { registry_ = std::make_unique<FormatRegistry>(); } + + void RegisterErrorFormat(std::vector<std::string> mime_types) { + auto format = Format::Create(FormatOptions{ + .assessor = std::make_unique<ErrorAssessor>(), + .extractor = std::make_unique<FakeExtractor>(), + .mime_types = std::move(mime_types), + }); + ABSL_ASSERT_OK(format); + registry_->Register(*std::move(format)); + } + + void RegisterSuccessFormat(std::vector<std::string> mime_types) { + auto format = Format::Create(FormatOptions{ + .assessor = std::make_unique<PassAssessor>(), + .extractor = std::make_unique<FakeExtractor>(), + .mime_types = std::move(mime_types), + }); + ABSL_ASSERT_OK(format); + registry_->Register(*std::move(format)); + } + + void RegisterFailFormat(std::vector<std::string> mime_types) { + auto format = Format::Create(FormatOptions{ + .assessor = std::make_unique<FailAssessor>(), + .extractor = std::make_unique<FakeExtractor>(), + .mime_types = std::move(mime_types), + }); + ABSL_ASSERT_OK(format); + registry_->Register(*std::move(format)); + } + + std::unique_ptr<FormatRegistry> registry_; +}; + +TEST_F(FormatRegistryTest, MediaTypeNotFound) { + EXPECT_THAT(registry_->GetFormat("jpeg"), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("Unsupported media type: jpeg"))); +} + +TEST_F(FormatRegistryTest, MediaTypeWithSingleFormat) { + RegisterSuccessFormat({"jpeg"}); + + EXPECT_THAT(registry_->GetFormat("jpeg"), IsOkAndHolds(testing::NotNull())); +} + +TEST_F(FormatRegistryTest, MediaTypeWithMultipleFormats) { + RegisterSuccessFormat({"jpeg"}); + RegisterSuccessFormat({"jpeg"}); + + EXPECT_THAT(registry_->GetFormat("jpeg"), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("Multiple formats support media type: jpeg"))); +} + +TEST_F(FormatRegistryTest, AssetNotSupported) { + RegisterFailFormat({"jpeg"}); + + std::string asset = "asset"; + riegeli::StringReader<> input(asset); + + EXPECT_THAT(registry_->GetFormat(input), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("No applicable Format found"))); +} + +TEST_F(FormatRegistryTest, AssetSupported) { + RegisterSuccessFormat({"jpeg"}); + + std::string asset = "asset"; + riegeli::StringReader<> input(asset); + + EXPECT_THAT(registry_->GetFormat(input), IsOkAndHolds(testing::NotNull())); +} + +TEST_F(FormatRegistryTest, AssetFailsThenSupports) { + RegisterFailFormat({"other"}); + RegisterSuccessFormat({"jpeg"}); + + std::string asset = "asset"; + riegeli::StringReader<> input(asset); + + EXPECT_THAT(registry_->GetFormat(input), IsOkAndHolds(testing::NotNull())); +} + +TEST_F(FormatRegistryTest, MediaTypeAndAssetNotSupported) { + RegisterSuccessFormat({"other"}); + + std::string asset = "asset"; + riegeli::StringReader<> input(asset); + + EXPECT_THAT(registry_->GetFormat("jpeg", input), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("Unsupported media type: jpeg"))); +} + +TEST_F(FormatRegistryTest, MediaTypeAndAssetSingleFormat) { + RegisterSuccessFormat({"other"}); + RegisterFailFormat({"jpeg"}); + + std::string asset = "asset"; + riegeli::StringReader<> input(asset); + + EXPECT_THAT(registry_->GetFormat("jpeg", input), + IsOkAndHolds(testing::NotNull())); +} + +TEST_F(FormatRegistryTest, MediaTypeAndAssetMultipleFormat) { + RegisterSuccessFormat({"other"}); + RegisterFailFormat({"jpeg"}); + RegisterSuccessFormat({"jpeg"}); + + std::string asset = "asset"; + riegeli::StringReader<> input(asset); + + EXPECT_THAT(registry_->GetFormat("jpeg", input), + IsOkAndHolds(testing::NotNull())); +} + +TEST_F(FormatRegistryTest, MediaTypeAndAssetNoApplicableFormats) { + RegisterSuccessFormat({"other"}); + RegisterFailFormat({"jpeg"}); + RegisterFailFormat({"jpeg"}); + + std::string asset = "asset"; + riegeli::StringReader<> input(asset); + + EXPECT_THAT(registry_->GetFormat("jpeg", input), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("No applicable Format found"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/riff/BUILD b/formats/riff/BUILD new file mode 100644 index 0000000..df9c29a --- /dev/null +++ b/formats/riff/BUILD
@@ -0,0 +1,181 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "chunk_header", + hdrs = ["chunk_header.h"], + deps = [ + ":constants", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":chunk_header", + ":constants", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "chunk_header_test", + srcs = ["chunk_header_test.cc"], + deps = [ + ":chunk_header", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + deps = [ + ":create_riff", + ":reader", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":create_riff", + ":extractor", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor_result", + "//testing:jumbf_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "create_riff", + testonly = 1, + srcs = ["create_riff.cc"], + hdrs = ["create_riff.h"], + deps = [ + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":constants", + ":reader", + "//constants:labels", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":chunk_header", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/riff/assessor.cc b/formats/riff/assessor.cc new file mode 100644 index 0000000..890165a --- /dev/null +++ b/formats/riff/assessor.cc
@@ -0,0 +1,41 @@ +// 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 "formats/riff/assessor.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "formats/riff/chunk_header.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +absl::StatusOr<bool> RiffAssessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + ChunkHeader riff_chunk_header; + ABSL_RETURN_IF_ERROR(riff_chunk_header.Read(input)); + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to starting position")); + } + + return riff_chunk_header.id == "RIFF"; +} + +} // namespace credentio
diff --git a/formats/riff/assessor.h b/formats/riff/assessor.h new file mode 100644 index 0000000..b7c248b --- /dev/null +++ b/formats/riff/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the RIFF image format. +class RiffAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_ASSESSOR_H_
diff --git a/formats/riff/assessor_test.cc b/formats/riff/assessor_test.cc new file mode 100644 index 0000000..8fb2f50 --- /dev/null +++ b/formats/riff/assessor_test.cc
@@ -0,0 +1,68 @@ +// 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 "formats/riff/assessor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(IsSupportedTest, ErrorTooFewBytes) { + std::string image = "\xab"; + riegeli::StringReader<> input(image); + EXPECT_THAT( + RiffAssessor().IsSupported(input), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); +} + +TEST(IsSupportedTest, FalseForInvalidBeginning) { + std::string image = "this_is_not_a_riff"; + riegeli::StringReader<> input(image); + EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, ValidStartingBytes) { + std::string image = "RIFF_1234"; + riegeli::StringReader<> input(image); + EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidStartingBytesAtOffset2) { + std::string image = "abRIFF_1234"; + riegeli::StringReader<> input(image); + + // Invalid at 0 + EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +} // namespace +} // namespace credentio
diff --git a/formats/riff/chunk_header.h b/formats/riff/chunk_header.h new file mode 100644 index 0000000..be4132c --- /dev/null +++ b/formats/riff/chunk_header.h
@@ -0,0 +1,73 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CHUNK_HEADER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CHUNK_HEADER_H_ + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/strings/substitute.h" +#include "formats/riff/constants.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_reading.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +// Header of a chunk in a RIFF file. +struct ChunkHeader { + std::string id; // 4-character chunk ID. + uint32_t data_size; // Size of the chunk data, not including the header. + + static uint64_t header_size() { return kRiffIdSize + sizeof(uint32_t); } + absl::Status Read(riegeli::Reader& input) { + if (!input.Read(kRiffIdSize, id)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; chunk_id")); + } + if (!riegeli::ReadLittleEndian<uint32_t>(input, data_size)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; chunk_size")); + } + return absl::OkStatus(); + } + absl::Status Write(riegeli::Writer& output) { + if (id.size() != kRiffIdSize) { + return absl::InvalidArgumentError(absl::Substitute( + "chunk_id size is $0, expected $1", id.size(), kRiffIdSize)); + } + if (!output.Write(id)) { + return output.StatusOrAnnotate( + absl::DataLossError("kDataLoss; chunk_id")); + } + if (!riegeli::WriteLittleEndian<uint32_t>(data_size, output)) { + return output.StatusOrAnnotate( + absl::DataLossError("kDataLoss; chunk_size")); + } + return absl::OkStatus(); + } + + uint64_t pad_size() const { return (data_size % 2 == 1) ? 1 : 0; } + uint64_t chunk_size() const { + return static_cast<uint64_t>(data_size) + 8 + pad_size(); + } +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CHUNK_HEADER_H_
diff --git a/formats/riff/chunk_header_test.cc b/formats/riff/chunk_header_test.cc new file mode 100644 index 0000000..df012ff --- /dev/null +++ b/formats/riff/chunk_header_test.cc
@@ -0,0 +1,163 @@ +// 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 "formats/riff/chunk_header.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::Eq; + +TEST(ChunkHeaderTest, HeaderSize) { + EXPECT_THAT(ChunkHeader::header_size(), Eq(8)); +} + +TEST(ChunkHeaderTest, PadSize) { + ChunkHeader header; + header.data_size = 0; + EXPECT_THAT(header.pad_size(), Eq(0)); + header.data_size = 1; + EXPECT_THAT(header.pad_size(), Eq(1)); + header.data_size = 2; + EXPECT_THAT(header.pad_size(), Eq(0)); + header.data_size = 1023; + EXPECT_THAT(header.pad_size(), Eq(1)); + header.data_size = 1024; + EXPECT_THAT(header.pad_size(), Eq(0)); +} + +TEST(ChunkHeaderTest, ChunkSize) { + ChunkHeader header; + header.data_size = 0; + EXPECT_THAT(header.chunk_size(), Eq(8)); // header only + header.data_size = 1; + EXPECT_THAT(header.chunk_size(), Eq(10)); // header + data + pad + header.data_size = 2; + EXPECT_THAT(header.chunk_size(), Eq(10)); // header + data + header.data_size = 1023; + EXPECT_THAT(header.chunk_size(), Eq(1032)); // 8 + 1023 + 1 + header.data_size = 1024; + EXPECT_THAT(header.chunk_size(), Eq(1032)); // 8 + 1024 +} + +TEST(ChunkHeaderTest, ChunkSizeOverflow) { + ChunkHeader header; + header.data_size = 0xFFFFFFF8; + EXPECT_THAT(header.chunk_size(), Eq(0xFFFFFFF8ULL + 8)); +} + +TEST(ChunkHeaderTest, ReadSuccess) { + // "TEST" ID, size 12 (0x0000000C in little-endian) + const unsigned char data[] = {'T', 'E', 'S', 'T', 0x0C, 0x00, 0x00, 0x00}; + std::string data_str(reinterpret_cast<const char*>(data), sizeof(data)); + riegeli::StringReader input(data_str); + + ChunkHeader header; + EXPECT_THAT(header.Read(input), IsOk()); + EXPECT_THAT(header.id, Eq("TEST")); + EXPECT_THAT(header.data_size, Eq(12)); +} + +TEST(ChunkHeaderTest, ReadSuccessOddSize) { + // "ODDT" ID, size 13 (0x0000000D in little-endian) + const unsigned char data[] = {'O', 'D', 'D', 'T', 0x0D, 0x00, 0x00, 0x00}; + std::string data_str(reinterpret_cast<const char*>(data), sizeof(data)); + riegeli::StringReader input(data_str); + + ChunkHeader header; + EXPECT_THAT(header.Read(input), IsOk()); + EXPECT_THAT(header.id, Eq("ODDT")); + EXPECT_THAT(header.data_size, Eq(13)); +} + +TEST(ChunkHeaderTest, ReadInputTooShortForId) { + const unsigned char data[] = {'T', 'E', 'S'}; + std::string data_str(reinterpret_cast<const char*>(data), sizeof(data)); + riegeli::StringReader input(data_str); + + ChunkHeader header; + EXPECT_THAT(header.Read(input), StatusIs(absl::StatusCode::kDataLoss)); +} + +TEST(ChunkHeaderTest, ReadInputTooShortForSize) { + const unsigned char data[] = {'T', 'E', 'S', 'T', 0x0C, 0x00, 0x00}; + std::string data_str(reinterpret_cast<const char*>(data), sizeof(data)); + riegeli::StringReader input(data_str); + + ChunkHeader header; + EXPECT_THAT(header.Read(input), StatusIs(absl::StatusCode::kDataLoss)); +} + +TEST(ChunkHeaderTest, WriteSuccess) { + ChunkHeader header; + header.id = "WAVE"; + header.data_size = 42; // 0x2A + + std::string output_str; + riegeli::StringWriter output(&output_str); + EXPECT_THAT(header.Write(output), IsOk()); + EXPECT_TRUE(output.Close()); + + const unsigned char expected_data[] = {'W', 'A', 'V', 'E', + 0x2A, 0x00, 0x00, 0x00}; + EXPECT_THAT(output_str, + Eq(std::string(reinterpret_cast<const char*>(expected_data), + sizeof(expected_data)))); +} + +TEST(ChunkHeaderTest, WriteSuccessOddSize) { + ChunkHeader header; + header.id = "FMT "; + header.data_size = 17; // 0x11 + + std::string output_str; + riegeli::StringWriter output(&output_str); + EXPECT_THAT(header.Write(output), IsOk()); + EXPECT_TRUE(output.Close()); + + const unsigned char expected_data[] = {'F', 'M', 'T', ' ', + 0x11, 0x00, 0x00, 0x00}; + EXPECT_THAT(output_str, + Eq(std::string(reinterpret_cast<const char*>(expected_data), + sizeof(expected_data)))); +} + +TEST(ChunkHeaderTest, WriteInvalidIdLength) { + ChunkHeader header; + header.id = "TOO_LONG"; + header.data_size = 10; + + std::string output_str; + riegeli::StringWriter output(&output_str); + EXPECT_THAT(header.Write(output), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_TRUE(output.Close()); + EXPECT_TRUE(output_str.empty()); +} + +} // namespace +} // namespace credentio
diff --git a/formats/riff/constants.h b/formats/riff/constants.h new file mode 100644 index 0000000..eeec51f --- /dev/null +++ b/formats/riff/constants.h
@@ -0,0 +1,35 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CONSTANTS_H_ + +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { + +constexpr uint32_t kRiffIdSize = 4; +constexpr uint32_t kRiffSizeSize = 4; +constexpr uint32_t kRiffFormSize = 4; + +constexpr absl::string_view kRiffChunkIdRiff = "RIFF"; +constexpr absl::string_view kRiffChunkIdList = "LIST"; +constexpr absl::string_view kRiffChunkIdC2pa = "C2PA"; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CONSTANTS_H_
diff --git a/formats/riff/create_riff.cc b/formats/riff/create_riff.cc new file mode 100644 index 0000000..1b66cd1 --- /dev/null +++ b/formats/riff/create_riff.cc
@@ -0,0 +1,54 @@ +// 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 "formats/riff/create_riff.h" + +#include <cstdint> +#include <string> + +#include "absl/log/check.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio_riff { + +std::string Chunk(absl::string_view chunk_id, absl::string_view data) { + CHECK_EQ(chunk_id.length(), 4); + + // RIFF is little-endian. + char size_buf[4]; + riegeli::WriteLittleEndian<uint32_t>(static_cast<uint32_t>(data.size()), + size_buf); + absl::string_view size_bytes(size_buf, 4); + + // Pad byte (not included in the length value). + absl::string_view pad = (data.length() % 2 == 1) ? absl::string_view("\0", 1) + : absl::string_view(); + + return absl::StrCat(chunk_id, size_bytes, data, pad); +} + +std::string RiffChunk(absl::string_view form, absl::string_view chunks) { + CHECK_EQ(form.length(), 4); + return Chunk("RIFF", absl::StrCat(form, chunks)); +} + +std::string ListChunk(absl::string_view type, absl::string_view chunks) { + CHECK_EQ(type.length(), 4); + return Chunk("LIST", absl::StrCat(type, chunks)); +} + +} // namespace credentio_riff
diff --git a/formats/riff/create_riff.h b/formats/riff/create_riff.h new file mode 100644 index 0000000..983ab7c --- /dev/null +++ b/formats/riff/create_riff.h
@@ -0,0 +1,36 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CREATE_RIFF_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CREATE_RIFF_H_ + +#include <string> + +#include "absl/strings/string_view.h" + +namespace credentio_riff { + +// Creates a RIFF chunk with the given ID and data. +std::string Chunk(absl::string_view chunk_id, absl::string_view data); + +// Creates a RIFF chunk with ID "RIFF", with the given form and subchunks. +std::string RiffChunk(absl::string_view form, absl::string_view chunks); + +// Creates a RIFF chunk with ID "LIST", with the given type and subchunks. +std::string ListChunk(absl::string_view type, absl::string_view chunks); + +} // namespace credentio_riff + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CREATE_RIFF_H_
diff --git a/formats/riff/extractor.cc b/formats/riff/extractor.cc new file mode 100644 index 0000000..ccaabaa --- /dev/null +++ b/formats/riff/extractor.cc
@@ -0,0 +1,218 @@ +// 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. +// + +// RIFF format information: +// https://www.loc.gov/preservation/digital/formats/fdd/fdd000025.shtml +// https://johnloomis.org/cpe102/asgn/asgn1/riff.html +// https://www.tactilemedia.com/info/MCI_Control_Info.html +// C2PA embedding: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_riff_based_assets +#include "formats/riff/extractor.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/riff/constants.h" +#include "formats/riff/reader.h" +#include "jumbf/utils.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +absl::StatusOr<std::vector<AssetBox>> ExtractBoxesInternal( + riegeli::Reader& reader, int64_t end_offset) { + std::vector<AssetBox> result; + bool first = true; + std::vector<uint64_t> container_ends; + + auto status = IterateOverRiffChunks( + reader, + [&result, &first, &container_ends, + &reader](const RiffChunk& chunk) -> absl::StatusOr<bool> { + while (!container_ends.empty() && + chunk.offset >= container_ends.back()) { + container_ends.pop_back(); + } + int depth = container_ends.size(); + + if (depth == 0) { + if (first) { + if (chunk.id != kRiffChunkIdRiff) { + return absl::InvalidArgumentError("RIFF chunk not found"); + } + first = false; + } + + result.push_back({ + .identifier = chunk.id, + .byte_range = {.offset = chunk.offset, .length = 12}, + }); + + if (chunk.HasSubchunks()) { + container_ends.push_back(chunk.offset + chunk.length); + } + return true; + } + + if (depth == 1) { + if (chunk.id == kRiffChunkIdC2pa && IsRiffChunkTooLarge(chunk)) { + return absl::InvalidArgumentError( + "RIFF C2PA chunk is too large to extract"); + } + + result.push_back({ + .identifier = chunk.id, + .byte_range = {.offset = chunk.offset, .length = chunk.length}, + }); + + if (chunk.HasSubchunks()) { + reader.Seek(chunk.offset + chunk.length); + } + return true; + } + + return absl::InternalError("Unexpected depth in RIFF parser"); + }, + end_offset); + + ABSL_RETURN_IF_ERROR(status); + return result; +} + +// Returns the C2PA subchunk within the RIFF chunk, if any. +absl::StatusOr<std::optional<RiffChunk>> FindC2paChunk(riegeli::Reader& reader, + int64_t end_offset) { + std::optional<RiffChunk> c2pa_chunk; + RiffChunk riff_chunk; + bool first = true; + + auto status = IterateOverRiffChunks( + reader, + [&c2pa_chunk, &first, + &riff_chunk](const RiffChunk& chunk) -> absl::StatusOr<bool> { + if (first) { + if (chunk.id != kRiffChunkIdRiff) { + return absl::InvalidArgumentError( + "Input does not start with RIFF chunk"); + } + riff_chunk = chunk; + first = false; + return true; + } + if (chunk.offset >= riff_chunk.offset + riff_chunk.length) { + return false; // Stop iterating, we finished the first RIFF chunk + } + if (chunk.id == kRiffChunkIdC2pa) { + if (IsRiffChunkTooLarge(chunk)) { + return absl::InvalidArgumentError( + "RIFF C2PA chunk is too large to extract"); + } + c2pa_chunk = chunk; + return false; // Found it, stop iterating + } + return true; + }, + end_offset); + + ABSL_RETURN_IF_ERROR(status); + if (first) { + return absl::NotFoundError("No manifest store found"); + } + return c2pa_chunk; +} + +} // namespace + +absl::StatusOr<std::string> RiffExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + ABSL_ASSIGN_OR_RETURN(std::optional<RiffChunk> c2pa_chunk, + FindC2paChunk(input, /*end_offset=*/-1)); + if (!c2pa_chunk.has_value()) { + return absl::NotFoundError("No manifest store found"); + } + int64_t offset = c2pa_chunk->data_offset; + int64_t length = c2pa_chunk->data_length; + if (!input.Seek(offset) || input.pos() != offset) { + return absl::InvalidArgumentError( + "The input could not be reset to the manifest store location."); + } + std::string result; + if (!input.Read(length, result)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read manifest store")); + } + return std::move(result); +} + +absl::StatusOr<std::optional<ByteRange>> +RiffExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + ABSL_ASSIGN_OR_RETURN(std::optional<RiffChunk> c2pa_chunk, + FindC2paChunk(input, options.end_offset)); + if (!c2pa_chunk.has_value()) { + if (options.requires_c2pa) { + return absl::NotFoundError("No manifest store found"); + } + return std::nullopt; + } + return ByteRange{ + .offset = c2pa_chunk->offset, + .length = c2pa_chunk->length, + }; +} + +absl::StatusOr<std::vector<AssetBox>> RiffExtractor::ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const { + ABSL_ASSIGN_OR_RETURN(std::vector<AssetBox> boxes, + ExtractBoxesInternal(input, options.end_offset)); + if (!options.requires_c2pa) { + return std::move(boxes); + } + + int64_t c2pa_count = 0; + for (const auto& box : boxes) { + if (box.identifier == kRiffChunkIdC2pa) { + c2pa_count++; + if (c2pa_count > 1) { + return absl::NotFoundError("Multiple manifest stores found"); + } + } + } + if (c2pa_count == 0) { + return absl::NotFoundError("No manifest store found"); + } + return std::move(boxes); +} + +bool RiffExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} + +} // namespace credentio
diff --git a/formats/riff/extractor.h b/formats/riff/extractor.h new file mode 100644 index 0000000..7e9cfe8 --- /dev/null +++ b/formats/riff/extractor.h
@@ -0,0 +1,52 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Extracts C2PA Manifest Store from a RIFF based file (e.g., webp). +class RiffExtractor : public FormatExtractor { + public: + RiffExtractor() = default; + ~RiffExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override; + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_EXTRACTOR_H_
diff --git a/formats/riff/extractor_test.cc b/formats/riff/extractor_test.cc new file mode 100644 index 0000000..ff469c2 --- /dev/null +++ b/formats/riff/extractor_test.cc
@@ -0,0 +1,386 @@ +// 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 "formats/riff/extractor.h" + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor_result.h" +#include "formats/riff/create_riff.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "testing/jumbf_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio_riff::Chunk; +using ::credentio_riff::ListChunk; +using ::credentio_riff::RiffChunk; +using ::testing::ElementsAre; +using ::testing::HasSubstr; + +constexpr absl::string_view kManifestStore = "1234567890"; + +std::vector<AssetBox> CreateBoxes( + std::vector<std::pair<std::string, uint32_t>> expected) { + uint64_t offset = 0; + std::vector<AssetBox> boxes; + for (const auto& name_and_size : expected) { + boxes.push_back(AssetBox{ + .identifier = name_and_size.first, + .byte_range = {.offset = offset, .length = name_and_size.second}, + }); + offset += name_and_size.second; + } + return boxes; +} + +struct TestCase { + std::string name; + std::string contents; + absl::StatusOr<ExtractorResult> result; +}; + +class ExtractorTest : public testing::TestWithParam<TestCase> {}; + +INSTANTIATE_TEST_SUITE_P( + ExtractorTests, ExtractorTest, + testing::ValuesIn({ + TestCase{ + .name = "EmptyContent", + .contents = "", + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "EmptyRiffChunk", + .contents = RiffChunk("ABCD", ""), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "VariousChunksNoC2pa", + .contents = RiffChunk( + "WEBP", + absl::StrCat(Chunk("ABCD", "some data"), Chunk("HIJK", "etc"), + Chunk("DEFG", "more irrelevant data"))), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "C2paChunkTooLarge", + .contents = RiffChunk( + "WEBP", + absl::StrCat(Chunk("ABCD", std::string(100000, 'X')), + Chunk("C2PA", + std::string((1024 * 1024 * 10) + 1, 'Z')))), + .result = absl::InvalidArgumentError( + "RIFF C2PA chunk is too large to extract"), + }, + TestCase{ + .name = "SingleC2paChunk", + .contents = RiffChunk("WEBP", Chunk("C2PA", kManifestStore)), + .result = + ExtractorResult{ + .manifest_store = std::string(kManifestStore), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 12, + .length = 18}, + .boxes = CreateBoxes({{"RIFF", /*size=*/12}, + {"C2PA", /*size=*/18}}), + }, + }, + }, + TestCase{ + .name = "VariousChunksIncludingC2pa", + .contents = + RiffChunk( + "WEBP", + absl::StrCat( + Chunk("ABCD", + "123456789"), // odd length, will have pad byte + Chunk("EFGH", std::string(100000, 'Z')), // large chunk + Chunk("C2PA", kManifestStore), Chunk("DEFG", "12"))), + .result = + ExtractorResult{ + .manifest_store = std::string(kManifestStore), + .asset_byte_info = + { + .manifest_store_location = + ByteRange{.offset = 100038, .length = 18}, + .boxes = CreateBoxes({{"RIFF", /*size=*/12}, + {"ABCD", /*size=*/18}, + {"EFGH", /*size=*/100008}, + {"C2PA", /*size=*/18}, + {"DEFG", /*size=*/10}}), + }}, + }, + TestCase{ + .name = "ContainingListChunk", + .contents = RiffChunk( + "WEBP", absl::StrCat(Chunk("ABCD", "1234"), + ListChunk("DATA", Chunk("HIJK", "1234")), + Chunk("DEFG", "1234"), + Chunk("C2PA", kManifestStore))), + .result = + ExtractorResult{ + .manifest_store = std::string(kManifestStore), + .asset_byte_info = + { + .manifest_store_location = ByteRange{.offset = 60, + .length = 18}, + .boxes = CreateBoxes({{"RIFF", /*size=*/12}, + {"ABCD", /*size=*/12}, + {"LIST", /*size=*/24}, + {"DEFG", /*size=*/12}, + {"C2PA", /*size=*/18}}), + }}, + }, + }), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +TEST(ExtractorOverflowTest, DeeplyNestedRiffWithoutC2pa) { + std::string contents = Chunk("NMBR", "1234"); + for (int32_t i = 0; i < 200; ++i) { + contents = ListChunk("LAYR", contents); + } + contents = RiffChunk("WEBP", contents); + + riegeli::StringReader<> input(contents); + + EXPECT_THAT(RiffExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found"))); +} + +TEST_P(ExtractorTest, ExtractManifestStore) { + const TestCase& test_case = GetParam(); + riegeli::StringReader<absl::string_view> input(test_case.contents); + + absl::StatusOr<std::string> result = + RiffExtractor().ExtractManifestStore(input); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_EQ(*result, expected_result->manifest_store); + } else { + EXPECT_THAT(result.status(), + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) { + const TestCase& test_case = GetParam(); + std::string contents = "padding" + test_case.contents; + riegeli::StringReader<> input(contents); + + ASSERT_TRUE(input.Seek(7)); + absl::StatusOr<std::string> result = + RiffExtractor().ExtractManifestStore(input); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_EQ(*result, expected_result->manifest_store); + } else { + EXPECT_THAT(result.status(), + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocation) { + const TestCase& test_case = GetParam(); + riegeli::StringReader<absl::string_view> input(test_case.contents); + + absl::StatusOr<std::optional<ByteRange>> result = + RiffExtractor().ExtractManifestStoreLocation(input, {}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_EQ(*result, + expected_result->asset_byte_info.manifest_store_location); + } else { + EXPECT_THAT(result.status(), + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxes) { + const TestCase& test_case = GetParam(); + riegeli::StringReader<absl::string_view> input(test_case.contents); + + absl::StatusOr<std::vector<AssetBox>> result = + RiffExtractor().ExtractBoxes(input, {}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, expected_result->asset_byte_info.boxes); + } else { + EXPECT_THAT(result.status(), + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffixPadded) { + const TestCase& test_case = GetParam(); + std::string contents = test_case.contents + "padding8"; + riegeli::StringReader<> input(contents); + + absl::StatusOr<std::optional<ByteRange>> result = + RiffExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(contents.size()) - 8}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_EQ(*result, + expected_result->asset_byte_info.manifest_store_location); + } else { + EXPECT_THAT(result.status(), + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxesWithSuffixPadded) { + const TestCase& test_case = GetParam(); + std::string contents = test_case.contents + "padding8"; + riegeli::StringReader<> input(contents); + + absl::StatusOr<std::vector<AssetBox>> result = RiffExtractor().ExtractBoxes( + input, {.end_offset = static_cast<int64_t>(contents.size()) - 8}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, expected_result->asset_byte_info.boxes); + } else { + EXPECT_THAT(result.status(), + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST(RiffExtractorTest, IsManifestStore) { + EXPECT_TRUE(RiffExtractor().MightBeC2paManifestStore( + CreateStartOfManifestStorePayload())); +} + +TEST(RiffExtractorTest, ExtractManifestStoreMultipleRiffChunks) { + std::string contents = + absl::StrCat(RiffChunk("WEBP", Chunk("C2PA", kManifestStore)), + RiffChunk("BLAH", Chunk("ABCD", "second riff chunk data"))); + riegeli::StringReader<> input(contents); + + EXPECT_THAT(RiffExtractor().ExtractManifestStore(input), + IsOkAndHolds(kManifestStore)); +} + +TEST(RiffExtractorTest, ExtractManifestStoreC2paInSecondRiffChunk) { + std::string contents = + absl::StrCat(RiffChunk("WEBP", Chunk("ABCD", "first riff chunk data")), + RiffChunk("BLAH", Chunk("C2PA", kManifestStore))); + riegeli::StringReader<> input(contents); + + EXPECT_THAT(RiffExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, "No manifest store found")); +} + +TEST(RiffExtractorTest, ExtractBoxesNonRiffTopLevelChunk) { + std::string contents = + absl::StrCat(RiffChunk("WEBP", Chunk("ABCD", "first riff chunk data")), + Chunk("BLAH", "non-riff top-level chunk data")); + riegeli::StringReader<> input(contents); + + auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false}); + ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk()); + std::vector<AssetBox> boxes = *boxes_or; + + std::vector<std::string> box_names; + for (const auto& box : boxes) { + box_names.push_back(box.identifier); + } + EXPECT_THAT(box_names, ElementsAre("RIFF", "ABCD", "BLAH")); +} + +TEST(RiffExtractorTest, MultipleC2paChunks) { + std::string contents = + RiffChunk("WEBP", absl::StrCat(Chunk("C2PA", kManifestStore), + Chunk("C2PA", kManifestStore))); + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + RiffExtractor().ExtractBoxes(input, {.requires_c2pa = true}), + StatusIs(absl::StatusCode::kNotFound, "Multiple manifest stores found")); +} + +TEST(RiffExtractorTest, ExtractManifestStoreDoesNotStartWithRiff) { + std::string contents = Chunk("ABCD", "some data"); + riegeli::StringReader<> input(contents); + EXPECT_THAT(RiffExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + "Input does not start with RIFF chunk")); +} + +TEST(RiffExtractorTest, ExtractBoxesDoesNotStartWithRiff) { + std::string contents = Chunk("ABCD", "some data"); + riegeli::StringReader<> input(contents); + EXPECT_THAT( + RiffExtractor().ExtractBoxes(input, {}), + StatusIs(absl::StatusCode::kInvalidArgument, "RIFF chunk not found")); +} + +TEST(RiffExtractorTest, ExtractBoxesMultipleRiffChunks) { + std::string contents = + absl::StrCat(RiffChunk("WEBP", Chunk("ABCD", "first riff chunk data")), + RiffChunk("BLAH", Chunk("EFGH", "second riff chunk data"))); + riegeli::StringReader<> input(contents); + + auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false}); + ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk()); + std::vector<AssetBox> boxes = *boxes_or; + + std::vector<std::string> box_names; + for (const auto& box : boxes) { + box_names.push_back(box.identifier); + } + EXPECT_THAT(box_names, ElementsAre("RIFF", "ABCD", "RIFF", "EFGH")); +} + +} // namespace +} // namespace credentio
diff --git a/formats/riff/format.cc b/formats/riff/format.cc new file mode 100644 index 0000000..f2c620f --- /dev/null +++ b/formats/riff/format.cc
@@ -0,0 +1,35 @@ +// 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 "formats/riff/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/riff/assessor.h" +#include "formats/riff/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateRiffFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<RiffAssessor>(), + .extractor = std::make_unique<RiffExtractor>(), + .mime_types = {"image/webp", "audio/wav"}, + }); +} + +} // namespace credentio
diff --git a/formats/riff/format.h b/formats/riff/format.h new file mode 100644 index 0000000..b1a1a99 --- /dev/null +++ b/formats/riff/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateRiffFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_FORMAT_H_
diff --git a/formats/riff/reader.cc b/formats/riff/reader.cc new file mode 100644 index 0000000..923e302 --- /dev/null +++ b/formats/riff/reader.cc
@@ -0,0 +1,154 @@ +// 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 "formats/riff/reader.h" + +#include <algorithm> +#include <cstdint> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/substitute.h" +#include "formats/riff/chunk_header.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB + +absl::StatusOr<bool> IterateOverRiffChunksInternal( + riegeli::Reader& reader, uint64_t end_offset, + RiffChunkProcessor processor) { + while (reader.pos() + ChunkHeader::header_size() <= end_offset) { + uint64_t chunk_offset = reader.pos(); + ChunkHeader chunk_header; + ABSL_RETURN_IF_ERROR(chunk_header.Read(reader)); + + RiffChunk chunk = { + .offset = chunk_offset, + .length = chunk_header.chunk_size(), + .id = chunk_header.id, + .type = "", + .data_offset = chunk_offset + chunk_header.header_size(), + .data_length = chunk_header.data_size, + }; + + if (chunk.HasSubchunks()) { + if (chunk.data_length < 4) { + return absl::InvalidArgumentError( + "RIFF/LIST chunk too short to contain a form type"); + } + if (!reader.Read(4, chunk.type)) { + return reader.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; type")); + } + chunk.data_offset += 4; + chunk.data_length -= 4; + } + + if (chunk.length < ChunkHeader::header_size()) { + return absl::InvalidArgumentError("Invalid RIFF chunk size"); + } + if (chunk.length > end_offset - chunk.offset) { + return absl::InvalidArgumentError( + "RIFF chunk extends beyond the end of the file"); + } + uint64_t chunk_end_offset = chunk.offset + chunk.length; + + bool continue_processing; + if (auto result = processor(chunk); result.ok()) { + continue_processing = *result; + } else { + return result.status(); + } + if (!continue_processing) { + return false; + } + + if (reader.pos() < chunk.data_offset + chunk.data_length && + chunk.HasSubchunks()) { + if (auto sub_continue_or = IterateOverRiffChunksInternal( + reader, chunk_end_offset, processor); + sub_continue_or.ok()) { + continue_processing = *sub_continue_or; + } else { + return sub_continue_or.status(); + } + if (!continue_processing) { + return false; + } + } + + reader.Seek(chunk_end_offset); + if (reader.pos() <= chunk_offset) { + return absl::InternalError( + "RIFF parser detected a read stall (infinite loop prevented)"); + } + } + return true; +} + +} // namespace + +absl::Status IterateOverRiffChunks(riegeli::Reader& reader, + RiffChunkProcessor processor, + int64_t end_offset) { + if (!reader.SupportsSize() || !reader.Size().has_value()) { + return absl::InvalidArgumentError( + "manifest store not embedded: reader size cannot be determined"); + } + uint64_t actual_end_offset = + end_offset < 0 ? *reader.Size() + : std::min(static_cast<uint64_t>(*reader.Size()), + static_cast<uint64_t>(end_offset)); + + if ((actual_end_offset - reader.pos()) == 0) { + return absl::OkStatus(); + } + + return IterateOverRiffChunksInternal(reader, actual_end_offset, processor) + .status(); +} + +absl::StatusOr<std::string> ReadRiffChunkData(riegeli::Reader& reader, + const RiffChunk& chunk) { + if (IsRiffChunkTooLarge(chunk)) { + return absl::InvalidArgumentError( + absl::Substitute("RIFF C2PA chunk is too large to extract ($0 > $1)", + chunk.data_length, kMaxPayloadSize)); + } + + if (reader.pos() != chunk.data_offset) { + if (!reader.Seek(chunk.data_offset) || reader.pos() != chunk.data_offset) { + return absl::InvalidArgumentError("Failed to seek to chunk data offset"); + } + } + + std::string data; + if (!reader.Read(chunk.data_length, data)) { + return reader.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; data")); + } + return std::move(data); +} + +bool IsRiffChunkTooLarge(const RiffChunk& chunk) { + return chunk.data_length > kMaxPayloadSize; +} + +} // namespace credentio
diff --git a/formats/riff/reader.h b/formats/riff/reader.h new file mode 100644 index 0000000..6a7b000 --- /dev/null +++ b/formats/riff/reader.h
@@ -0,0 +1,97 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_RIFF_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_READER_H_ + +#include <cstdint> +#include <ostream> +#include <string> + +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "formats/riff/constants.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Information about a chunk in a RIFF file. +struct RiffChunk { + uint64_t offset = 0; + uint64_t length = 0; + std::string id; // 4-character chunk ID. + std::string type; // 4-character form type (only for RIFF/LIST chunks). + + uint64_t data_offset = 0; + uint64_t data_length = 0; + + bool HasSubchunks() const { + return id == kRiffChunkIdRiff || id == kRiffChunkIdList; + } + + friend bool operator==(const RiffChunk& lhs, const RiffChunk& rhs) { + return lhs.offset == rhs.offset && lhs.length == rhs.length && + lhs.id == rhs.id && lhs.type == rhs.type && + lhs.data_offset == rhs.data_offset && + lhs.data_length == rhs.data_length; + } + + friend std::ostream& operator<<(std::ostream& os, const RiffChunk& chunk) { + return os << absl::StrCat("RiffChunk{offset: ", chunk.offset, + ", length: ", chunk.length, ", id: ", chunk.id, + ", type: ", chunk.type, + ", data_offset: ", chunk.data_offset, + ", data_length: ", chunk.data_length, "}"); + } +}; + +// The function signature for processing a RIFF chunk. +using RiffChunkProcessor = + absl::FunctionRef<absl::StatusOr<bool>(const RiffChunk&)>; + +// Iterates over all RIFF chunks in the given reader, calling the given +// `processor` function for each chunk. +// +// The `processor` function controls the iteration by returning an +// `absl::StatusOr<bool>`: +// - Return `true` to continue iterating over subsequent chunks. +// - Return `false` to stop iterating early. `IterateOverRiffChunks` will stop +// and return `absl::OkStatus()`. +// - Return an error status to abort iteration immediately and propagate the +// error back to the caller. +// +// The processor can also affect iteration by seeking the reader: +// - For container chunks (RIFF/LIST), sub-chunk iteration will start reading +// from wherever the processor leaves the reader. +// - To skip processing sub-chunks of a container chunk, the processor can seek +// the reader to the end of the current chunk. +// - The iterator will always reset the reader position to the end of the +// current chunk before proceeding to the next sibling chunk. +absl::Status IterateOverRiffChunks(riegeli::Reader& reader, + RiffChunkProcessor processor, + int64_t end_offset = -1); + +// Reads the raw data for the given chunk. +absl::StatusOr<std::string> ReadRiffChunkData(riegeli::Reader& reader, + const RiffChunk& chunk); + +// Returns true if the chunk is too large to read. +bool IsRiffChunkTooLarge(const RiffChunk& chunk); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_READER_H_
diff --git a/formats/riff/reader_test.cc b/formats/riff/reader_test.cc new file mode 100644 index 0000000..28bc933 --- /dev/null +++ b/formats/riff/reader_test.cc
@@ -0,0 +1,335 @@ +// 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 "formats/riff/reader.h" + +#include <cstddef> +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "formats/riff/create_riff.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::HasSubstr; +using ::testing::IsEmpty; + +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB + +std::string WriteRiffChunkMissingInvalidForm() { + riegeli::StringWriter writer; + writer.Write("RIFF"); + riegeli::WriteLittleEndian<uint32_t>(2, writer); + writer.Write("AB"); + writer.Close(); + return writer.dest(); +} + +absl::StatusOr<std::vector<RiffChunk>> GetChunks(riegeli::Reader& reader) { + std::vector<RiffChunk> chunks; + ABSL_RETURN_IF_ERROR( + IterateOverRiffChunks(reader, [&chunks](const RiffChunk& chunk) { + chunks.push_back(chunk); + return true; + })); + return chunks; +} + +absl::StatusOr<std::vector<std::string>> ReadChunkPayloads( + riegeli::Reader& reader, const std::vector<RiffChunk>& chunks) { + std::vector<std::string> payloads; + for (const auto& chunk : chunks) { + ABSL_ASSIGN_OR_RETURN(auto payload, ReadRiffChunkData(reader, chunk)); + payloads.push_back(payload); + } + return payloads; +} + +TEST(RiffReaderUnitTest, ReadsChunks) { + std::string contents = credentio_riff::RiffChunk( + "XYZW", absl::StrCat(credentio_riff::Chunk("ABCD", "first chunk"), + credentio_riff::Chunk("EFGH", "second chunk"), + credentio_riff::Chunk("IJKL", "third chunk"), + credentio_riff::Chunk("MNOP", "fourth chunk"))); + riegeli::StringReader<> input(contents); + auto chunks_or = GetChunks(input); + ASSERT_THAT(chunks_or.status(), IsOk()); + auto chunks = *chunks_or; + ASSERT_THAT(chunks, ElementsAre( + RiffChunk{ + .offset = 0, + .length = 92, + .id = "RIFF", + .type = "XYZW", + .data_offset = 12, + .data_length = 80, + }, + RiffChunk{ + .offset = 12, + .length = 20, + .id = "ABCD", + .type = "", + .data_offset = 20, + .data_length = 11, + }, + RiffChunk{ + .offset = 32, + .length = 20, + .id = "EFGH", + .type = "", + .data_offset = 40, + .data_length = 12, + }, + RiffChunk{ + .offset = 52, + .length = 20, + .id = "IJKL", + .type = "", + .data_offset = 60, + .data_length = 11, + }, + RiffChunk{ + .offset = 72, + .length = 20, + .id = "MNOP", + .type = "", + .data_offset = 80, + .data_length = 12, + })); + + // Re-reading payloads requires seeking, ReadRiffChunkData handles it. + EXPECT_THAT(ReadChunkPayloads(input, {chunks.begin() + 1, chunks.end()}), + IsOkAndHolds(ElementsAre("first chunk", "second chunk", + "third chunk", "fourth chunk"))); +} + +TEST(RiffReaderUnitTest, MissingChunkTypeForChunkWithSubchunks) { + std::string contents = WriteRiffChunkMissingInvalidForm(); + riegeli::StringReader<> input(contents); + + EXPECT_THAT(GetChunks(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("RIFF/LIST chunk too short"))); +} + +TEST(ReadChunkDataTest, SuccessfulRead) { + std::string contents = "RIFF....WEBPVP8 ...."; + // 01234567890123456789 + riegeli::StringReader<> input(contents); + RiffChunk chunk = { + .offset = 12, + .length = 8, + .id = "VP8 ", + .type = "", + .data_offset = 12, + .data_length = 8, + }; + EXPECT_THAT(ReadRiffChunkData(input, chunk), IsOkAndHolds("VP8 ....")); +} + +TEST(ReadChunkDataTest, ReadBeyondEOF) { + std::string contents = "RIFF....WEBPVP8 ...."; + // 01234567890123456789 + riegeli::StringReader<> input(contents); + RiffChunk chunk = { + .offset = 12, + .length = 8, + .id = "VP8 ", + .type = "", + .data_offset = 12, + .data_length = 10, // Reads past end of contents + }; + EXPECT_THAT( + ReadRiffChunkData(input, chunk), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); +} + +TEST(ReadChunkDataTest, EmptyChunk) { + std::string contents = "RIFF....WEBPVP8 ...."; + riegeli::StringReader<> input(contents); + RiffChunk chunk = { + .offset = 12, + .length = 0, + .id = "VP8 ", + .type = "", + .data_offset = 12, + .data_length = 0, + }; + EXPECT_THAT(ReadRiffChunkData(input, chunk), IsOkAndHolds(IsEmpty())); +} + +TEST(ReadChunkDataTest, ReadAtEOF) { + std::string contents = "RIFF....WEBPVP8 ...."; + riegeli::StringReader<> input(contents); + RiffChunk chunk = { + .offset = 20, + .length = 0, + .id = "....", + .type = "", + .data_offset = 20, + .data_length = 0, + }; + EXPECT_THAT(ReadRiffChunkData(input, chunk), IsOkAndHolds(IsEmpty())); +} + +TEST(ReadChunkDataTest, InvalidDataOffset) { + std::string contents = "RIFF....WEBPVP8 ...."; + riegeli::StringReader<> input(contents); + RiffChunk chunk = { + .offset = 12, + .length = 8, + .id = "VP8 ", + .type = "", + .data_offset = 100, // Past EOF + .data_length = 1, + }; + EXPECT_THAT(ReadRiffChunkData(input, chunk), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to seek to chunk data offset"))); +} + +TEST(ReadChunkDataTest, ChunkTooLarge) { + std::string contents = ""; // Not actually read in this test + riegeli::StringReader<> input(contents); + RiffChunk chunk = { + .offset = 12, + .length = kMaxPayloadSize + 9, + .id = "C2PA", + .type = "", + .data_offset = 20, + .data_length = kMaxPayloadSize + 1, + }; + EXPECT_THAT(ReadRiffChunkData(input, chunk), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("RIFF C2PA chunk is too large to extract"))); +} + +TEST(RiffReaderUnitTest, ProcessorConsumesDataOfContainer) { + std::string contents = + credentio_riff::RiffChunk("WAVE", credentio_riff::ListChunk("INFO", "x")); + riegeli::StringReader<> input(contents); + + auto processor = [&input](const RiffChunk& chunk) -> absl::StatusOr<bool> { + if (chunk.id == "LIST") { + std::string data; + if (!input.Read(1, data)) { + return absl::DataLossError("failed to read"); + } + EXPECT_EQ(data, "x"); + } + return true; + }; + + EXPECT_THAT(IterateOverRiffChunks(input, processor), IsOk()); +} + +TEST(RiffReaderUnitTest, OddContainerWithOddSubchunkAtEnd) { + riegeli::StringWriter writer; + writer.Write("RIFF"); + riegeli::WriteLittleEndian<uint32_t>(26, writer); + writer.Write("WAVE"); + writer.Write("LIST"); + riegeli::WriteLittleEndian<uint32_t>(13, writer); + writer.Write("INFO"); + writer.Write("ISFT"); + riegeli::WriteLittleEndian<uint32_t>(1, writer); + writer.Write("a"); + writer.Write('\0'); // LIST pad + writer.Close(); + std::string contents = writer.dest(); + + riegeli::StringReader<> input(contents); + auto chunks_or = GetChunks(input); + ASSERT_THAT(chunks_or.status(), IsOk()); + auto chunks = *chunks_or; + ASSERT_THAT(chunks, ElementsAre( + RiffChunk{ + .offset = 0, + .length = 34, + .id = "RIFF", + .type = "WAVE", + .data_offset = 12, + .data_length = 22, + }, + RiffChunk{ + .offset = 12, + .length = 22, + .id = "LIST", + .type = "INFO", + .data_offset = 24, + .data_length = 9, + }, + RiffChunk{ + .offset = 24, + .length = 10, + .id = "ISFT", + .type = "", + .data_offset = 32, + .data_length = 1, + })); +} + +class NoSizeReader : public riegeli::Reader { + public: + bool SupportsSize() override { return false; } + bool PullSlow(size_t min_length, size_t recommended_length) override { + return false; + } +}; + +TEST(RiffReaderTest, IterateOverRiffChunksRequiresSize) { + NoSizeReader reader; + EXPECT_THAT( + IterateOverRiffChunks(reader, [](const RiffChunk&) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("reader size cannot be determined"))); +} + +TEST(RiffReaderTest, OverflowChunkSizeNoInfiniteLoop) { + riegeli::StringWriter writer; + writer.Write("RIFF"); + riegeli::WriteLittleEndian<uint32_t>(20, writer); + writer.Write("WAVE"); + writer.Write("JUNK"); + riegeli::WriteLittleEndian<uint32_t>(0xFFFFFFF8, writer); + writer.Close(); + std::string contents = writer.dest(); + + riegeli::StringReader<> input(contents); + EXPECT_THAT( + IterateOverRiffChunks(input, [](const RiffChunk&) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("RIFF chunk extends beyond the end of the file"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/tiff/BUILD b/formats/tiff/BUILD new file mode 100644 index 0000000..ddc69bc --- /dev/null +++ b/formats/tiff/BUILD
@@ -0,0 +1,139 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = ["@abseil-cpp//absl/strings:string_view"], +) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":constants", + "//formats:byte_range", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + deps = [ + ":constants", + ":reader", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":constants", + ":reader", + "//constants:labels", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + deps = [ + ":constants", + ":extractor", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor_result", + "//utils:byte_writers", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":constants", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/tiff/assessor.cc b/formats/tiff/assessor.cc new file mode 100644 index 0000000..8fb1f1f --- /dev/null +++ b/formats/tiff/assessor.cc
@@ -0,0 +1,44 @@ +// 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 "formats/tiff/assessor.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/tiff/constants.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { + +absl::StatusOr<bool> TiffAssessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + uint32_t leading_bytes; + if (!riegeli::ReadBigEndian<uint32_t>(input, leading_bytes)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; header")); + } + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek")); + } + + return leading_bytes == kTiffLittleEndianHeader || + leading_bytes == kTiffBigEndianHeader; +} + +} // namespace credentio
diff --git a/formats/tiff/assessor.h b/formats/tiff/assessor.h new file mode 100644 index 0000000..cfd4fe4 --- /dev/null +++ b/formats/tiff/assessor.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_TIFF_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Assessor for the TIFF image format. +class TiffAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_ASSESSOR_H_
diff --git a/formats/tiff/assessor_test.cc b/formats/tiff/assessor_test.cc new file mode 100644 index 0000000..daef9c4 --- /dev/null +++ b/formats/tiff/assessor_test.cc
@@ -0,0 +1,94 @@ +// 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 "formats/tiff/assessor.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(IsAssetSupportedTest, ErrorTooFewBytes) { + std::string image = "\xab"; + riegeli::StringReader<> input(image); + EXPECT_THAT( + TiffAssessor().IsSupported(input), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); +} + +TEST(IsAssetSupportedTest, FalseForInvalidBeginning) { + std::string image = "this_is_not_a_riff"; + riegeli::StringReader<> input(image); + EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(false)); +} + +TEST(IsAssetSupportedTest, ValidStartingBigEndianBytes) { + std::vector<uint8_t> data = {0x4d, 0x4d, 0x00, 0x2a}; + std::string image = std::string(data.begin(), data.end()); + riegeli::StringReader<> input(image); + EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsAssetSupportedTest, ValidStartingLittleEndianBytes) { + std::vector<uint8_t> data = {0x49, 0x49, 0x2a, 0x00}; + std::string image = std::string(data.begin(), data.end()); + riegeli::StringReader<> input(image); + EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true)); +} + +TEST(IsAssetSupportedTest, ValidStartingBigEndianBytesAtOffset2) { + std::vector<uint8_t> data = {'a', 'b', 0x4d, 0x4d, 0x00, 0x2a}; + std::string image = std::string(data.begin(), data.end()); + riegeli::StringReader<> input(image); + + // Invalid at 0 + EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +TEST(IsAssetSupportedTest, ValidStartingLittleEndianBytesAtOffset2) { + std::vector<uint8_t> data = {'a', 'b', 0x49, 0x49, 0x2a, 0x00}; + std::string image = std::string(data.begin(), data.end()); + riegeli::StringReader<> input(image); + + // Invalid at 0 + EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(false)); + EXPECT_EQ(input.pos(), 0); + + // Valid at 2 + ASSERT_TRUE(input.Seek(2)); + EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true)); + EXPECT_EQ(input.pos(), 2); +} + +} // namespace +} // namespace credentio
diff --git a/formats/tiff/constants.h b/formats/tiff/constants.h new file mode 100644 index 0000000..91bcd9c --- /dev/null +++ b/formats/tiff/constants.h
@@ -0,0 +1,115 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_TIFF_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_CONSTANTS_H_ + +#include <cstdint> + +#include "absl/strings/string_view.h" + +namespace credentio { + +// The size of an IFD Entry: tag (2) + type (2) + count (4) + value/offset (4) +constexpr int64_t kTiffIfdEntrySize = 12; + +// The size of an IFD: count (2) + next offset (4) + with a single entry. +constexpr int64_t kTiffIdfOneEntrySize = 6 + kTiffIfdEntrySize; + +constexpr uint16_t kTiffBigEndian = 0x4d4d; +constexpr uint16_t kTiffLittleEndian = 0x4949; + +constexpr uint16_t kTiffMarker = 0x002a; + +constexpr uint32_t kTiffBigEndianHeader = 0x4d4d002a; +// This is for quick asset checking in BigEndian order if the asset is a valid +// LittleEndian TIFF. +constexpr uint32_t kTiffLittleEndianHeader = 0x49492a00; + +// This is the tag for the C2PA IFD Entry. +constexpr uint16_t kTiffTagC2pa = 0xcd41; + +// These are the tag pairs that are used for offsets and byte counts. +constexpr uint16_t kTiffTagStripOffsets = 0x0111; +constexpr uint16_t kTiffTagStripByteCounts = 0x0117; +constexpr uint16_t kTiffTagFreeOffsets = 0x0120; +constexpr uint16_t kTiffTagFreeByteCounts = 0x0121; +constexpr uint16_t kTiffTagTileOffsets = 0x0144; +constexpr uint16_t kTiffTagTileByteCounts = 0x0145; + +// The hex codes for IFD Entry Tags which contain Sub-IFDs. +constexpr uint16_t kTiffTagSubIfd = 0x014a; +constexpr uint16_t kTiffTagExifIfd = 0x8769; +constexpr uint16_t kTiffTagGpsInfo = 0x8825; +constexpr uint16_t kTiffTagInteroptabilityIfd = 0xA005; + +// This is the decimal tag for the GPS Info Sub-IFD converted to a string. +constexpr absl::string_view kTiffGpsInfoIdentifier = "34853"; + +// The maximum valid tag ID in the GPS Info Sub-IFD according to EXIF 2.32. +constexpr uint16_t kMaxGpsTag = 0x001f; + +// The hex codes for each of the IFD Entry Types. +constexpr uint16_t kTiffTypeByte = 0x0001; // uint8_t +constexpr uint16_t kTiffTypeAscii = 0x0002; // char +constexpr uint16_t kTiffTypeShort = 0x0003; // uint16_t +constexpr uint16_t kTiffTypeLong = 0x0004; // uint32_t +constexpr uint16_t kTiffTypeRational = 0x0005; // uint32_t/uint32_t +constexpr uint16_t kTiffTypeSByte = 0x0006; // int8_t +constexpr uint16_t kTiffTypeUndefined = 0x0007; // uint8_t (arbitrary data) +constexpr uint16_t kTiffTypeSShort = 0x0008; // int16_t +constexpr uint16_t kTiffTypeSLong = 0x0009; // int32_t +constexpr uint16_t kTiffTypeSRational = 0x000a; // int32_t/int32_t +constexpr uint16_t kTiffTypeFloat = 0x000b; // float (4-byte) +constexpr uint16_t kTiffTypeDouble = 0x000c; // double (8-byte) + +constexpr uint64_t SizeOfTiffType(uint16_t field_type) { + switch (field_type) { + case kTiffTypeByte: + return 1; + case kTiffTypeAscii: + return 1; + case kTiffTypeShort: + return 2; + case kTiffTypeLong: + return 4; + case kTiffTypeRational: + return 8; + case kTiffTypeSByte: + return 1; + case kTiffTypeUndefined: + return 1; + case kTiffTypeSShort: + return 2; + case kTiffTypeSLong: + return 4; + case kTiffTypeSRational: + return 8; + case kTiffTypeFloat: + return 4; + case kTiffTypeDouble: + return 8; + default: + return 1; + } +} + +constexpr uint64_t ByteSizeOfImageFileDirectory(uint16_t entry_count) { + return sizeof(uint16_t) + entry_count * kTiffIfdEntrySize + sizeof(uint32_t); +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_CONSTANTS_H_
diff --git a/formats/tiff/extractor.cc b/formats/tiff/extractor.cc new file mode 100644 index 0000000..89409f6 --- /dev/null +++ b/formats/tiff/extractor.cc
@@ -0,0 +1,162 @@ +// 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. +// + +// TIFF Format Information: +// https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf +// C2PA embedding: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_tiff_based_assets +#include "formats/tiff/extractor.h" + +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <string> +#include <utility> + +#include "absl/container/flat_hash_set.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/byte_range.h" +#include "formats/tiff/constants.h" +#include "formats/tiff/reader.h" +#include "jumbf/utils.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +namespace { + +constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10; // 10 MiB + +absl::StatusOr<std::optional<ByteRange>> FindManifestStore( + riegeli::Reader& source) { + absl::flat_hash_set<uint32_t> visited_offsets; + + std::optional<ByteRange> manifest_store_location = std::nullopt; + bool reading_first_ifd = true; + absl::Status iteration_status = IterateOverImageFileDirectories( + source, std::nullopt, visited_offsets, + [&reading_first_ifd, &manifest_store_location]( + const TiffImageFileDirectory& ifd, + TiffEndianness endianness) -> absl::StatusOr<bool> { + // A previous loop found a manifest store. + if (manifest_store_location.has_value()) { + return absl::NotFoundError("Manifest Store must be in the last IFD"); + } + + for (const TiffImageFileDirectoryEntry& entry : ifd.entries) { + if (entry.tag != kTiffTagC2pa) { + continue; + } + + if (!reading_first_ifd && ifd.entries.size() != 1) { + return absl::NotFoundError( + "Manifest Store must be the only entry in the IFD"); + } + + if (manifest_store_location.has_value()) { + return absl::NotFoundError("Multiple manifest stores found"); + } + + manifest_store_location = entry.RangeOfValue(); + } + + reading_first_ifd = false; + return true; + }); + ABSL_RETURN_IF_ERROR(iteration_status); + return manifest_store_location; +} + +bool IsPartialAsset(int64_t current_offset, int64_t end_offset, + int64_t asset_size) { + if (end_offset == -1) { + // Not a windowed read, so not partial. + return false; + } + if (current_offset == 0 && end_offset == asset_size) { + // The window covers the entire asset, so it is not partial. + return false; + } + return true; +} + +} // namespace + +absl::StatusOr<std::string> TiffExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + ABSL_ASSIGN_OR_RETURN(std::optional<ByteRange> location, + ExtractManifestStoreLocation(input, {})); + if (!location.has_value()) { + return absl::NotFoundError("No manifest store found"); + } + + if (location->length > kMaxPayloadSize) { + return absl::InvalidArgumentError( + absl::StrCat("Manifest store is too large: ", location->length, " > ", + kMaxPayloadSize)); + } + + if (!input.Seek(location->offset)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + std::string manifest_store; + if (!input.Read(location->length, manifest_store)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to read manifest store")); + } + return std::move(manifest_store); +} + +absl::StatusOr<std::optional<ByteRange>> +TiffExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + if (IsPartialAsset(input.pos(), options.end_offset, + input.Size().value_or(0))) { + return absl::InvalidArgumentError( + "TiffExtractor::ExtractManifestStoreLocation only supports operations " + "over the entire file."); + } + + if (!input.Seek(0)) { + return input.StatusOrAnnotate( + absl::DataLossError("Failed to seek to offset")); + } + + ABSL_ASSIGN_OR_RETURN(auto result, FindManifestStore(input)); + if (!options.requires_c2pa) { + return result; + } + if (!result.has_value()) { + return absl::NotFoundError("No manifest store found"); + } + return result; +} + +bool TiffExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} + +} // namespace credentio
diff --git a/formats/tiff/extractor.h b/formats/tiff/extractor.h new file mode 100644 index 0000000..2e0134f --- /dev/null +++ b/formats/tiff/extractor.h
@@ -0,0 +1,63 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_TIFF_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Implements the C2PA Manifest Store extractor for TIFF files as described in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_tiff_based_assets +class TiffExtractor : public FormatExtractor { + public: + TiffExtractor() = default; + ~TiffExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + // Given the heavy reliance on exact offsets built into this format, we can + // only support extraction methods that operate on the entire file (options: + // {.end_offset = -1}). + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + // Given the heavy reliance on exact offsets built into this format, we can + // only support extraction methods that operate on the entire file (options: + // {.end_offset = -1}). + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError( + "Extracting boxes is not supported for TIFF files."); + } + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_EXTRACTOR_H_
diff --git a/formats/tiff/extractor_test.cc b/formats/tiff/extractor_test.cc new file mode 100644 index 0000000..6c580e8 --- /dev/null +++ b/formats/tiff/extractor_test.cc
@@ -0,0 +1,786 @@ +// 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 "formats/tiff/extractor.h" + +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor_result.h" +#include "formats/tiff/constants.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "utils/byte_writers.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::Eq; + +#ifndef ASSERT_OK_AND_ASSIGN +#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y +#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) + +#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ + ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) + +#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ + auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ + ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ + ::absl_testing::IsOk()); \ + lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) +#endif + +using ::testing::HasSubstr; + +struct TestCase { + std::string name; + absl::StatusOr<std::string> contents; + absl::StatusOr<ExtractorResult> result; +}; + +void WriteTiffHeader(uint32_t next_ifd_offset, std::vector<uint8_t>& data) { + WriteUint16NetworkOrder(kTiffBigEndian, &data); // Endian marker + WriteUint16NetworkOrder(kTiffMarker, &data); // TIFF marker + WriteUint32NetworkOrder(next_ifd_offset, &data); // Next IFD offset +} + +void WriteString(absl::string_view contents, std::vector<uint8_t>& data) { + for (int i = 0; i < contents.size(); ++i) { + data.push_back(contents[i]); + } +} + +struct Ifde { + uint16_t tag; + uint16_t type; + uint32_t count; + uint32_t value; +}; + +void WriteIfd(std::vector<Ifde> entries, uint32_t next_ifd, + std::vector<uint8_t>& data) { + WriteUint16NetworkOrder(entries.size(), &data); // IFD entry count + for (const Ifde& entry : entries) { + WriteUint16NetworkOrder(entry.tag, &data); // Tag + WriteUint16NetworkOrder(entry.type, &data); // Type + WriteUint32NetworkOrder(entry.count, &data); // Count + WriteUint32NetworkOrder(entry.value, &data); // Value + } + WriteUint32NetworkOrder(next_ifd, &data); // Next IFD offset +} + +void WriteUint16(std::vector<uint16_t> entries, std::vector<uint8_t>& data) { + for (const uint16_t entry : entries) { + WriteUint16NetworkOrder(entry, &data); + } +} + +void WriteUint32(std::vector<uint32_t> entries, std::vector<uint8_t>& data) { + for (const uint32_t entry : entries) { + WriteUint32NetworkOrder(entry, &data); + } +} + +class ExtractorTest : public testing::TestWithParam<TestCase> { + public: + void SetUp() override { ASSERT_THAT(GetParam().contents.status(), IsOk()); } +}; + +INSTANTIATE_TEST_SUITE_P( + ExtractorTests, ExtractorTest, + testing::ValuesIn( + {TestCase{ + .name = "EmptyContent", + .contents = "", + .result = absl::DataLossError("kUnexpectedEof"), + }, + TestCase{ + .name = "InvalidEndianMarker", + .contents = "\x12\x34", + .result = absl::DataLossError("endianness"), + }, + TestCase{ + .name = "InvalidTiffMarker", + .contents = "\x4d\x4d\x12\x34", + .result = absl::DataLossError("invalid endianness"), + }, + TestCase{ + .name = "SingleIfdNoEntries", + .contents = + []() { + std::vector<uint8_t> data = {}; + WriteTiffHeader(8, data); + WriteIfd({}, 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "SingleIfdNoC2paEntry", + .contents = + []() { + std::vector<uint8_t> data = {}; + WriteTiffHeader(8, data); + WriteIfd({{.tag = 0x1234, + .type = 4, + .count = 1, + .value = 0x12345678}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = absl::NotFoundError("No manifest store found"), + }, + TestCase{ + .name = "ValidThreeValueOffsetTag", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(45, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 33) Offset Data + WriteUint16({0x1234, 0x5678, 0x9abc}, data); + // [33, 39) Offset Val + WriteUint16({27, 29, 31}, data); + // [39, 45) Counts Val + WriteUint16({2, 2, 2}, data); + // [45, 75) IFD + WriteIfd( + {{.tag = 0x0111, .type = 3, .count = 3, .value = 33}, + {.tag = 0x0117, .type = 3, .count = 3, .value = 39}}, + 75, data); + // [75, 93) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "EntryWithMultipleUint8Values", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) First IFD + WriteIfd({Ifde{.tag = 0x1234, + .type = 1, + .count = 3, + .value = 0x01020300}}, + 45, data); + // [45, 61) Second IFD + WriteIfd({Ifde{.tag = kTiffTagC2pa, + .type = 0x0007, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "EntryWithMultipleUint16Values", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) First IFD + WriteIfd({Ifde{.tag = 0x1234, + .type = 3, + .count = 2, + .value = 0x01020304}}, + 45, data); + // [45, 61) Second IFD + WriteIfd({Ifde{.tag = kTiffTagC2pa, + .type = 0x0007, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "ValidSingleValueOffset", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(31, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 31) Offset Data + WriteUint32({0x12345678}, data); + // [31, 73) IFD + WriteIfd( + { + {.tag = 0x0111, .type = 4, .count = 1, .value = 27}, + {.tag = 0x0115, .type = 4, .count = 1, .value = 4}, + {.tag = 0x0117, .type = 4, .count = 1, .value = 4}, + }, + 73, data); + // [73, 91) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "ValidSingleValueOffsetWithExtraData", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(31, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 31) Offset Data + WriteUint32({0x12345678}, data); + // [31, 73) IFD + WriteIfd( + { + {.tag = 0x0111, .type = 4, .count = 1, .value = 27}, + {.tag = 0x0115, .type = 4, .count = 1, .value = 4}, + {.tag = 0x0117, .type = 4, .count = 1, .value = 4}, + }, + 73, data); + // [73, 91) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + // [91, 101) Extra Data (not part of the IFD) + return std::string(data.begin(), data.end()) + "extra_data"; + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "ValidSingleSubIfd", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(45, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) Sub IFD + WriteIfd({{.tag = 0x1234, + .type = 4, + .count = 1, + .value = 0x12345678}}, + 0, data); + // [45, 63) IFD + WriteIfd( + {{.tag = 0x014a, .type = 4, .count = 1, .value = 27}}, + 63, data); + // [63, 81) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "ValidTwoLayerSubIfd", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(63, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) Child Sub IFD + WriteIfd({{.tag = 0x1234, + .type = 4, + .count = 1, + .value = 0x12345678}}, + 0, data); + // [45, 63) Parent Sub IFD + WriteIfd( + {{.tag = 0x014a, .type = 4, .count = 1, .value = 27}}, 0, + data); + // [63, 81) First Base IFD + WriteIfd( + {{.tag = 0x014a, .type = 4, .count = 1, .value = 45}}, + 81, data); + // [81, 99) Second Base IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "ValidSubWithChainedIfd", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(63, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) Second Sub IFD + WriteIfd({{.tag = 0x1234, + .type = 4, + .count = 1, + .value = 0x12345678}}, + 0, data); + // [45, 63) First Sub IFD + WriteIfd({{.tag = 0x1234, + .type = 4, + .count = 1, + .value = 0x12345678}}, + 27, data); + // [63, 81) First Base IFD + WriteIfd( + {{.tag = 0x014a, .type = 4, .count = 1, .value = 45}}, + 81, data); + // [81, 99) Second Base IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = ExtractorResult{.manifest_store = "test_manifest_store", + .asset_byte_info = + {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + // This is technically not a valid TIFF file, as there is no other + // data, but it abides by the C2PA spec. + .name = "SingleIfdOnlyC2paEntry", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = + ExtractorResult{ + .manifest_store = "test_manifest_store", + .asset_byte_info = {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "SingleIfdC2paEntryBeforeOtherEntry", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd( + {{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}, + {.tag = 0x1234, .type = 1, .count = 1, .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = + ExtractorResult{ + .manifest_store = "test_manifest_store", + .asset_byte_info = {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "SingleIfdC2paEntryAfterOtherEntry", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd({{.tag = 0x1234, .type = 1, .count = 1, .value = 8}, + {.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = + ExtractorResult{ + .manifest_store = "test_manifest_store", + .asset_byte_info = {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "MultipleIfdC2paInFirstIfd", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 45, data); + // [45, 81) Second IFD + WriteIfd( + {{.tag = 0x1234, .type = 1, .count = 1, .value = 8}}, 0, + data); + return std::string(data.begin(), data.end()); + }(), + .result = + absl::NotFoundError("Manifest Store must be in the last IFD"), + }, + TestCase{ + .name = "MultipleIfdC2paInSecondIfd", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd( + {{.tag = 0x1234, .type = 1, .count = 1, .value = 8}}, 45, + data); + // [45, 81) Second IFD + WriteIfd({{.tag = 0x1234, .type = 1, .count = 1, .value = 8}, + {.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = absl::NotFoundError( + "Manifest Store must be the only entry in the IFD"), + }, + TestCase{ + .name = "UnknownIfdEntryType", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd({{.tag = 0x1234, + .type = 0x1234, + .count = 1, + .value = 8}}, + 45, data); + // [45, 81) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = + ExtractorResult{ + .manifest_store = "test_manifest_store", + .asset_byte_info = {.manifest_store_location = + {.offset = 8, .length = 19}}}, + }, + TestCase{ + .name = "MultipleIfdC2paInBoth", + .contents = + []() { + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(49, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 49) Manifest Store + WriteString("another_manifest_store", data); + // [49, 67) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 67, data); + // [67, 85) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(), + .result = + absl::NotFoundError("Manifest Store must be in the last IFD"), + }, + TestCase{ + .name = "FuzzTestRegression_b421332332", + .contents = + std::string("MM\000*" + "\000\000\000\t\000\000\001\001JJJJ\303\303" + "\004\000\000\000\t\004\000\000\000", + 27), + .result = absl::InternalError("Failed to seek to offs"), + }, + { + .name = "FuzzTestRegression_b422060716", + .contents = std::string( + "MM\000*\000\000\000\t\000\000\002\001 " + "\000\003\000\000\000\002\000\000\000\000\001!" + "\000\004\000\000kkkkkkk\000..\000\000\000\303s\000\000", + 46), + .result = absl::InternalError("Failed to seek to offset"), + }, + { + .name = "FuzzTestRegression_SmallC2paSize", + .contents = std::string( + "II*" + "\000e\000\000\000\014\001\232\001\014\000\000\356\000\000\224" + "\000\000\000\001A\002\000l\205\000\0004\000\000\000\002\210" + "\007\000\005\000@" + "\304\214\000\270\000\000\300\n\377\305\324\200\377\377\000b" + "\000\000\000\241\001\003\000pT@" + "\225\257R\000\000J\001\004\000\001\003y\000\326\000\000\000" + "\373\377\373\372\005\022\000\000\254\322\333\000\000\035\001" + "\003\000\001\000A\315\007\000\004\000\000\000\362\001\000\000" + "\000\000\000\000\t\343\337\000\2372\010\000\000\211\000\000" + "\000\000\371\000\000\000r\000\000\200\340\005\000\017\004\000" + "\000\034\010\000\000\000\027\000\230X\353\233\005\270\372=*" + "\032\006\3768\332\034%" + "\000\000\000\000N\310\000\000\332\357\213\213\020@\003 " + "\000\242\027\000\001&" + "\023\316\000E\000\000\000\000\000\002\250\001\001\345\226\000" + "\000\000\000\000\200\000\000\000u\202)" + "\030\327u\000\000\000\000\000\000\000\000\000\000\000\000\000" + "\000\000\000\372\372\373\000\373PPPP\325\000\000\336@" + "\000\000JJ\037\265\345S\000AA\003\332\376\000U\266\004\000" + "\000\000\000\000\000\000\200\0148\007\000\000\000\000\000\000" + "\377\377\177\375\255\351\205\003\000\000\273\001^" + "\004E\201\034\211\371Iw\325\234\377\252\032S.\006n\344$" + "\277\036\000\006;t)\324\307@)\332f\003\001\000[\000\246\307b " + "l\177\010\000\000\000\000\257\354\334\244\243\243\243\243\243" + "\243\243\243\332\332\332\243\247\243\243\243\243\243\243\243" + "\243\243\243\243\243\243\243\243\243\243\315\243\243\243\243" + "\243\243\243\243\243\243\243\247\250\247{" + "\253\236\241\243\000\000\244\302,\230\230\230\230-" + "1\002x\361xx\221\221\000\000\000\346\000m\2544\024\000\030" + "\000\000\000\000\000\000\367\267AAAAAAAA\264\366|\304\264!" + "\000\005\355\000\000\000\367\372\236#" + "\374\000\230\300\300\300\300\300\300\300\300\300\300\300\300" + "\300\300\300\2304\000\000\000", + 487), + .result = + ExtractorResult{ + .manifest_store = std::string("\xF2\x01\x00\x00", 4), + .asset_byte_info = {.manifest_store_location = + {.offset = 111, .length = 4}}}, + }}), + [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) { + return info.param.name; + }); + +TEST_P(ExtractorTest, ExtractManifestStore) { + const TestCase& test_case = GetParam(); + ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents); + riegeli::StringReader<> input(contents); + absl::StatusOr<std::string> result = + TiffExtractor().ExtractManifestStore(input); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, Eq(expected_result->manifest_store)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractManifestStoreLocation) { + const TestCase& test_case = GetParam(); + ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents); + riegeli::StringReader<> input(contents); + absl::StatusOr<std::optional<ByteRange>> result = + TiffExtractor().ExtractManifestStoreLocation(input, {}); + + const auto& expected_result = test_case.result; + if (expected_result.ok()) { + ASSERT_THAT(result, IsOk()); + EXPECT_THAT(*result, + Eq(expected_result->asset_byte_info.manifest_store_location)); + } else { + EXPECT_THAT(result, + StatusIs(expected_result.status().code(), + HasSubstr(expected_result.status().message()))); + } +} + +TEST_P(ExtractorTest, ExtractBoxes) { + ASSERT_OK_AND_ASSIGN(std::string contents, GetParam().contents); + riegeli::StringReader<> input(contents); + absl::StatusOr<std::vector<AssetBox>> result = + TiffExtractor().ExtractBoxes(input, {}); + + EXPECT_THAT( + result, + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("Extracting boxes is not supported for TIFF files."))); +} + +TEST(OffsetTiffExtractorTest, FailsWhenEndOffsetIsSmallerThanAsset) { + std::string contents = []() { + // Adding a prefix to the contents, need to offset all the offsets by 7. + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(); + riegeli::StringReader<> input(contents); + + EXPECT_THAT( + TiffExtractor().ExtractManifestStoreLocation(input, {.end_offset = 20}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("TiffExtractor::ExtractManifestStoreLocation only " + "supports operations over the entire file."))); +} + +TEST(OffsetTiffExtractorTest, + FailsWhenEndOffsetEqualToAssetSizeAndCurrentOffsetIsNonZero) { + std::string contents = []() { + // Adding a prefix to the contents, need to offset all the offsets by 7. + std::vector<uint8_t> data = {}; + // [0, 8) Tiff Header + WriteTiffHeader(27, data); + // [8, 27) Manifest Store + WriteString("test_manifest_store", data); + // [27, 45) IFD + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 19, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(); + riegeli::StringReader<> input(contents); + + ASSERT_TRUE(input.Seek(7)); + EXPECT_THAT( + TiffExtractor().ExtractManifestStoreLocation( + input, {.end_offset = static_cast<int64_t>(contents.size())}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("TiffExtractor::ExtractManifestStoreLocation only " + "supports operations over the entire file."))); +} + +TEST(TiffExtractorTest, UnboundedManifestStoreAllocation) { + std::string contents = []() { + std::vector<uint8_t> data = {}; + WriteTiffHeader(13, data); + WriteString("short", data); + WriteIfd({{.tag = kTiffTagC2pa, + .type = kTiffTypeUndefined, + .count = 11 * 1024 * 1024, + .value = 8}}, + 0, data); + return std::string(data.begin(), data.end()); + }(); + riegeli::StringReader<> input(contents); + EXPECT_THAT(TiffExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Manifest store is too large"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/tiff/format.cc b/formats/tiff/format.cc new file mode 100644 index 0000000..f6cef44 --- /dev/null +++ b/formats/tiff/format.cc
@@ -0,0 +1,36 @@ +// 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 "formats/tiff/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/tiff/assessor.h" +#include "formats/tiff/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateTiffFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<TiffAssessor>(), + .extractor = std::make_unique<TiffExtractor>(), + .mime_types = {"image/x-adobe-dng", "image/tiff", "image/tiff-fx", + "asset/octet-stream"}, + }); +} + +} // namespace credentio
diff --git a/formats/tiff/format.h b/formats/tiff/format.h new file mode 100644 index 0000000..98b7c97 --- /dev/null +++ b/formats/tiff/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_TIFF_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateTiffFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_FORMAT_H_
diff --git a/formats/tiff/reader.cc b/formats/tiff/reader.cc new file mode 100644 index 0000000..89afe5e --- /dev/null +++ b/formats/tiff/reader.cc
@@ -0,0 +1,152 @@ +// 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 "formats/tiff/reader.h" + +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <vector> + +#include "absl/container/flat_hash_set.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "formats/byte_range.h" +#include "formats/tiff/constants.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { +namespace { +template <typename T> +absl::Status ReadNumber(riegeli::Reader& source, TiffEndianness endianness, + T& value) { + bool success = endianness == TiffEndianness::kBigEndian + ? riegeli::ReadBigEndian<T>(source, value) + : riegeli::ReadLittleEndian<T>(source, value); + if (!success) { + return source.StatusOrAnnotate(absl::DataLossError("failed to read value")); + } + return absl::OkStatus(); +} + +absl::StatusOr<TiffEndianness> GetEndianness(riegeli::Reader& source) { + uint32_t header; + if (!riegeli::ReadBigEndian<uint32_t>(source, header)) { + return source.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; endianness")); + } + switch (header) { + case kTiffBigEndianHeader: + return TiffEndianness::kBigEndian; + case kTiffLittleEndianHeader: + return TiffEndianness::kLittleEndian; + default: + return absl::DataLossError("invalid endianness"); + } +} + +} // namespace + +ByteRange TiffImageFileDirectoryEntry::RangeOfCount() const { + return ByteRange{.offset = offset + sizeof(uint16_t) + sizeof(uint16_t), + .length = sizeof(uint32_t)}; +} + +ByteRange TiffImageFileDirectoryEntry::RangeOfValue() const { + uint64_t value_size = size(); + + return ByteRange{.offset = value_size > sizeof(uint32_t) + ? value_or_offset + : offset + sizeof(uint16_t) + + sizeof(uint16_t) + sizeof(uint32_t), + .length = value_size}; +} + +absl::Status IterateOverImageFileDirectories( + riegeli::Reader& source, std::optional<TiffEndianness> known_endianness, + absl::flat_hash_set<uint32_t>& visited_offsets, + ImageFileDirectoryProcessor process_ifd) { + TiffEndianness endianness; + if (known_endianness.has_value()) { + endianness = *known_endianness; + } else { + ABSL_ASSIGN_OR_RETURN(endianness, GetEndianness(source)); + } + + while (true) { + TiffImageFileDirectory ifd{.offset_of_pointer = + static_cast<uint32_t>(source.pos())}; + + uint32_t offset; + ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, offset)); + ifd.offset = offset; + + if (!visited_offsets.insert(offset).second) { + return absl::InvalidArgumentError("infinite loop detected"); + } + + if (!source.Seek(offset)) { + return source.StatusOrAnnotate(absl::InternalError( + absl::StrCat("Failed to seek to offset: ", offset))); + } + + uint16_t count; + ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, count)); + ifd.entries.resize(count); + for (int i = 0; i < count; ++i) { + TiffImageFileDirectoryEntry& entry = ifd.entries[i]; + entry.offset = source.pos(); + ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, entry.tag)); + ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, entry.type)); + ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, entry.count)); + ABSL_RETURN_IF_ERROR( + ReadNumber(source, endianness, entry.value_or_offset)); + } + + uint64_t next_offset_position = source.pos(); + + ABSL_ASSIGN_OR_RETURN(bool continue_processing, + process_ifd(ifd, endianness)); + + if (!continue_processing) { + break; + } + + if (!source.Seek(next_offset_position)) { + return source.StatusOrAnnotate(absl::InternalError( + absl::StrCat("Failed to seek to offset: ", next_offset_position))); + } + + uint32_t next_offset; + ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, next_offset)); + + if (!source.Seek(next_offset_position)) { + return source.StatusOrAnnotate(absl::InternalError( + absl::StrCat("Failed to seek to offset: ", next_offset_position))); + } + + if (next_offset == 0) { + break; + } + } + + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/formats/tiff/reader.h b/formats/tiff/reader.h new file mode 100644 index 0000000..602e044 --- /dev/null +++ b/formats/tiff/reader.h
@@ -0,0 +1,73 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_TIFF_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_READER_H_ + +#include <stdbool.h> +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <vector> + +#include "absl/container/flat_hash_set.h" +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/byte_range.h" +#include "formats/tiff/constants.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +enum class TiffEndianness { + kBigEndian, + kLittleEndian, +}; + +struct TiffImageFileDirectoryEntry { + uint64_t offset; + + uint16_t tag; + uint16_t type; + uint32_t count; + uint32_t value_or_offset; + + uint64_t size() const { return SizeOfTiffType(type) * count; } + + ByteRange RangeOfCount() const; + + ByteRange RangeOfValue() const; +}; + +struct TiffImageFileDirectory { + uint32_t offset_of_pointer; + + uint32_t offset; + std::vector<TiffImageFileDirectoryEntry> entries; +}; + +using ImageFileDirectoryProcessor = absl::FunctionRef<absl::StatusOr<bool>( + const TiffImageFileDirectory&, TiffEndianness)>; + +absl::Status IterateOverImageFileDirectories( + riegeli::Reader& source, std::optional<TiffEndianness> known_endianness, + absl::flat_hash_set<uint32_t>& visited_offsets, + ImageFileDirectoryProcessor process_ifd); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_READER_H_
diff --git a/formats/tiff/reader_test.cc b/formats/tiff/reader_test.cc new file mode 100644 index 0000000..4d5f3aa --- /dev/null +++ b/formats/tiff/reader_test.cc
@@ -0,0 +1,219 @@ +// 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 "formats/tiff/reader.h" + +#include <sys/types.h> + +#include <cstdint> +#include <optional> +#include <string> +#include <vector> + +#include "absl/container/flat_hash_set.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "formats/tiff/constants.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(TiffImageFileDirectoryEntryTest, RangeOfCount) { + TiffImageFileDirectoryEntry entry{.offset = 100}; + // tag (2) + type (2) = 4 + EXPECT_EQ(entry.RangeOfCount().offset, 104); + EXPECT_EQ(entry.RangeOfCount().length, 4); +} + +TEST(TiffImageFileDirectoryEntryTest, RangeOfValueInline) { + TiffImageFileDirectoryEntry entry{ + .offset = 100, .type = kTiffTypeShort, .count = 1, .value_or_offset = 42}; + // Inline value is at offset + 2 (tag) + 2 (type) + 4 (count) = 108 + EXPECT_EQ(entry.RangeOfValue().offset, 108); + EXPECT_EQ(entry.RangeOfValue().length, 2); +} + +TEST(TiffImageFileDirectoryEntryTest, RangeOfValueAtOffset) { + TiffImageFileDirectoryEntry entry{ + .offset = 100, .type = kTiffTypeLong, .count = 2, .value_or_offset = 500}; + // 2 samples of Long (4 bytes each) = 8 bytes. > 4 bytes, so it's at an + // offset. + EXPECT_EQ(entry.RangeOfValue().offset, 500); + EXPECT_EQ(entry.RangeOfValue().length, 8); +} + +TEST(IterateOverImageFileDirectoriesTest, SingleIfd) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer); + riegeli::WriteBigEndian<uint32_t>(8, writer); // Offset to first IFD + riegeli::WriteBigEndian<uint16_t>(1, writer); // count + riegeli::WriteBigEndian<uint16_t>(0x0100, writer); // tag + riegeli::WriteBigEndian<uint16_t>(3, writer); // type + riegeli::WriteBigEndian<uint32_t>(1, writer); // count + riegeli::WriteBigEndian<uint32_t>(10, writer); // value + riegeli::WriteBigEndian<uint32_t>(0, writer); // next IFD + writer.Close(); + + riegeli::StringReader reader(buffer); + absl::flat_hash_set<uint32_t> visited_offsets; + int ifd_count = 0; + EXPECT_THAT( + IterateOverImageFileDirectories( + reader, /*known_endianness=*/std::nullopt, visited_offsets, + [&](const TiffImageFileDirectory& ifd, TiffEndianness endianness) { + ifd_count++; + EXPECT_EQ(ifd.entries.size(), 1); + EXPECT_EQ(ifd.entries[0].tag, 0x0100); + return true; + }), + IsOk()); + EXPECT_EQ(ifd_count, 1); +} + +TEST(IterateOverImageFileDirectoriesTest, StopEarly) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer); + riegeli::WriteBigEndian<uint32_t>(8, writer); // Offset to first IFD + + // First IFD (starts at 8): + riegeli::WriteBigEndian<uint16_t>(1, writer); // count + riegeli::WriteBigEndian<uint16_t>(0x0100, writer); // tag + riegeli::WriteBigEndian<uint16_t>(3, writer); // type + riegeli::WriteBigEndian<uint32_t>(1, writer); // count + riegeli::WriteBigEndian<uint32_t>(10, writer); // value + riegeli::WriteBigEndian<uint32_t>(26, + writer); // next IFD (8 + 2 + 12 + 4 = 26) + + // Second IFD (starts at 26): + riegeli::WriteBigEndian<uint16_t>(1, writer); // count + riegeli::WriteBigEndian<uint16_t>(0x0101, writer); // tag + riegeli::WriteBigEndian<uint16_t>(3, writer); // type + riegeli::WriteBigEndian<uint32_t>(1, writer); // count + riegeli::WriteBigEndian<uint32_t>(20, writer); // value + riegeli::WriteBigEndian<uint32_t>(0, writer); // next IFD + + writer.Close(); + + riegeli::StringReader reader(buffer); + absl::flat_hash_set<uint32_t> visited_offsets; + int ifd_count = 0; + EXPECT_THAT(IterateOverImageFileDirectories( + reader, /*known_endianness=*/std::nullopt, visited_offsets, + [&](const TiffImageFileDirectory& ifd, + TiffEndianness endianness) -> absl::StatusOr<bool> { + ifd_count++; + EXPECT_EQ(ifd.entries.size(), 1); + EXPECT_EQ(ifd.entries[0].tag, 0x0100); + return false; // Stop early! + }), + IsOk()); + EXPECT_EQ(ifd_count, 1); +} + +TEST(IterateOverImageFileDirectoriesTest, InfiniteLoop) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer); + riegeli::WriteBigEndian<uint32_t>(8, writer); // Offset to first IFD + riegeli::WriteBigEndian<uint16_t>(0, writer); // count + riegeli::WriteBigEndian<uint32_t>(8, writer); // Points back to self + writer.Close(); + + riegeli::StringReader reader(buffer); + absl::flat_hash_set<uint32_t> visited_offsets; + EXPECT_THAT(IterateOverImageFileDirectories( + reader, /*known_endianness=*/std::nullopt, visited_offsets, + [](const auto&, auto) { return true; }), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("infinite loop detected"))); +} + +TEST(IterateOverImageFileDirectoriesTest, KnownEndianness) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + // No header, straight to IFD offset + riegeli::WriteBigEndian<uint32_t>(4, writer); // Offset to first IFD + riegeli::WriteBigEndian<uint16_t>(1, writer); // count + riegeli::WriteBigEndian<uint16_t>(0x0100, writer); // tag + riegeli::WriteBigEndian<uint16_t>(3, writer); // type + riegeli::WriteBigEndian<uint32_t>(1, writer); // count + riegeli::WriteBigEndian<uint32_t>(10, writer); // value + riegeli::WriteBigEndian<uint32_t>(0, writer); // next IFD + writer.Close(); + + riegeli::StringReader reader(buffer); + absl::flat_hash_set<uint32_t> visited_offsets; + int ifd_count = 0; + EXPECT_THAT( + IterateOverImageFileDirectories( + reader, TiffEndianness::kBigEndian, visited_offsets, + [&](const TiffImageFileDirectory& ifd, TiffEndianness endianness) { + ifd_count++; + EXPECT_EQ(endianness, TiffEndianness::kBigEndian); + EXPECT_EQ(ifd.entries.size(), 1); + EXPECT_EQ(ifd.entries[0].tag, 0x0100); + return true; + }), + IsOk()); + EXPECT_EQ(ifd_count, 1); +} + +TEST(IterateOverImageFileDirectoriesTest, MissingFirstIfdOffset) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer); + // Missing the offset! + writer.Close(); + + riegeli::StringReader reader(buffer); + absl::flat_hash_set<uint32_t> visited_offsets; + EXPECT_THAT( + IterateOverImageFileDirectories(reader, /*known_endianness=*/std::nullopt, + visited_offsets, + [](const auto&, auto) { return true; }), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to read value"))); +} + +TEST(IterateOverImageFileDirectoriesTest, MissingIfdCount) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer); + riegeli::WriteBigEndian<uint32_t>(8, writer); // Offset to first IFD + // File ends here, size is 8. + writer.Close(); + + riegeli::StringReader reader(buffer); + absl::flat_hash_set<uint32_t> visited_offsets; + EXPECT_THAT( + IterateOverImageFileDirectories(reader, /*known_endianness=*/std::nullopt, + visited_offsets, + [](const auto&, auto) { return true; }), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to read value"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/BUILD b/formats/zip/BUILD new file mode 100644 index 0000000..10b9ff8 --- /dev/null +++ b/formats/zip/BUILD
@@ -0,0 +1,151 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = [ + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "reader", + srcs = ["reader.cc"], + hdrs = ["reader.h"], + deps = [ + ":constants", + "//formats:byte_range", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/memory", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "reader_test", + srcs = ["reader_test.cc"], + data = [ + "//formats/zip/testing:assets", + ], + deps = [ + ":reader", + "//formats:byte_range", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:cfile_reader", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "extractor", + srcs = ["extractor.cc"], + hdrs = ["extractor.h"], + deps = [ + ":constants", + ":reader", + "//constants:labels", + "//formats:asset_box", + "//formats:byte_range", + "//formats:extractor", + "//jumbf:utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "extractor_test", + srcs = ["extractor_test.cc"], + data = [ + "//formats/zip/testing:assets", + ], + deps = [ + ":constants", + ":extractor", + "//formats:byte_range", + "//testing:jumbf_utils", + "//testing:test_file_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "assessor", + srcs = ["assessor.cc"], + hdrs = ["assessor.h"], + deps = [ + ":constants", + "//formats:assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_test( + name = "assessor_test", + srcs = ["assessor_test.cc"], + deps = [ + ":assessor", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + ], +) + +cc_library( + name = "format", + srcs = ["format.cc"], + hdrs = ["format.h"], + deps = [ + ":assessor", + ":extractor", + "//formats:format", + "@abseil-cpp//absl/status:statusor", + ], +)
diff --git a/formats/zip/assessor.cc b/formats/zip/assessor.cc new file mode 100644 index 0000000..c6c7ed6 --- /dev/null +++ b/formats/zip/assessor.cc
@@ -0,0 +1,43 @@ +// 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 "formats/zip/assessor.h" + +#include <cstdint> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "formats/zip/constants.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +absl::StatusOr<bool> ZipAssessor::IsSupported(riegeli::Reader& input) const { + int64_t starting_position = input.pos(); + + std::string file_header; + if (!input.Read(kZipLocalFileHeaderSignature.size(), file_header)) { + return input.StatusOrAnnotate( + absl::DataLossError("kUnexpectedEof; header")); + } + if (!input.Seek(starting_position) || input.pos() != starting_position) { + return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek")); + } + + return file_header == kZipLocalFileHeaderSignature; +} + +} // namespace credentio
diff --git a/formats/zip/assessor.h b/formats/zip/assessor.h new file mode 100644 index 0000000..c52aa5c --- /dev/null +++ b/formats/zip/assessor.h
@@ -0,0 +1,32 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ASSESSOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ASSESSOR_H_ + +#include "absl/status/statusor.h" +#include "formats/assessor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +class ZipAssessor : public FormatAssessor { + public: + // Returns true if the supplied asset is supported. + absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ASSESSOR_H_
diff --git a/formats/zip/assessor_test.cc b/formats/zip/assessor_test.cc new file mode 100644 index 0000000..8455e77 --- /dev/null +++ b/formats/zip/assessor_test.cc
@@ -0,0 +1,68 @@ +// 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 "formats/zip/assessor.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(IsSupportedTest, ErrorTooFewBytes) { + std::string zip = "P"; + riegeli::StringReader<> reader(zip); + + EXPECT_THAT( + ZipAssessor().IsSupported(reader), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof"))); +} + +TEST(IsSupportedTest, FalseForInvalidBeginning) { + std::string zip = "this_is_not_a_zip"; + riegeli::StringReader<> reader(zip); + + EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(false)); +} + +TEST(IsSupportedTest, ValidStartingBytes) { + std::string zip = "PK\x03\x04\xab\xcd"; + riegeli::StringReader<> reader(zip); + + EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(true)); +} + +TEST(IsSupportedTest, ValidStartingBytesAtOffset2) { + std::string zip = "\xab\xcdPK\x03\x04"; + riegeli::StringReader<> reader(zip); + + // Invalid at offset 0 + EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(false)); + ASSERT_TRUE(reader.Seek(2)); + // Valid at offset 2 + EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(true)); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/constants.h b/formats/zip/constants.h new file mode 100644 index 0000000..055621b --- /dev/null +++ b/formats/zip/constants.h
@@ -0,0 +1,50 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONSTANTS_H_ + +#include "absl/strings/string_view.h" + +namespace credentio { + +constexpr absl::string_view kZipCentralDirectorySignature = "PK\x01\x02"; +constexpr absl::string_view kZipLocalFileHeaderSignature = "PK\x03\x04"; +constexpr absl::string_view kZipEndOfCentralDirectorySignature = "PK\x05\x06"; +constexpr absl::string_view kZipZip64EndOfCentralDirectorySignature = + "PK\x06\x07"; +constexpr absl::string_view kZipDataDescriptorSignature = "PK\x07\x08"; + +// Size of the data descriptor, with or without the optional signature. +constexpr int kDataDescriptorWithSignatureSize = 16; +constexpr int kDataDescriptorSize = 12; +// Size of the fixed portion of the local file header, excluding the file name +// and extra fields. +constexpr int kLocalFileHeaderFixedSize = 30; +// Size of the fixed portion of the central directory header, excluding the file +// name, extra field and file comment. +constexpr int kCentralDirectoryHeaderFixedSize = 46; +// Size of the End of Central Directory (EOCD) record. +// ZIP files based on ISO/IEC 29500:2 (OOXML) do not allow a comment field in +// the EOCD record, making it a fixed size. +constexpr int kEocdRecordSize = 22; + +constexpr absl::string_view kZipManifestFileName = + "META-INF/content_credential.c2pa"; +constexpr absl::string_view kZipC2paManifestMimeType = "application/c2pa"; +constexpr absl::string_view kOoxmlContentTypesFileName = "[Content_Types].xml"; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONSTANTS_H_
diff --git a/formats/zip/extractor.cc b/formats/zip/extractor.cc new file mode 100644 index 0000000..a739332 --- /dev/null +++ b/formats/zip/extractor.cc
@@ -0,0 +1,118 @@ +// 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 "formats/zip/extractor.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "formats/byte_range.h" +#include "formats/zip/constants.h" +#include "formats/zip/reader.h" +#include "jumbf/utils.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { +namespace { + +struct C2paManifestStore { + ByteRange location; + std::string content; +}; + +absl::StatusOr<C2paManifestStore> ExtractManifestStoreInternal( + riegeli::Reader& input) { + ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ZipReader> reader, + ZipReader::Create(&input)); + + C2paManifestStore manifest_store; + while (reader->HasNext()) { + ABSL_ASSIGN_OR_RETURN(ZipReader::FileEntry entry, reader->Next()); + if (entry.file_name == kZipManifestFileName) { + if (!manifest_store.content.empty()) { + return absl::NotFoundError("Multiple manifest stores found"); + } + if (entry.compression_method != 0) { + return absl::InvalidArgumentError( + "Manifest store must not be compressed."); + } + manifest_store.location = entry.file_range; + // Manifest store is typically up to a few MBs (usually under 16 MB). + constexpr uint64_t kMaxManifestStoreSize = 16 * 1024 * 1024; + if (manifest_store.location.length > kMaxManifestStoreSize || + entry.uncompressed_size > kMaxManifestStoreSize) { + return absl::InvalidArgumentError( + "Manifest store size exceeds maximum limit."); + } + std::optional<riegeli::Position> source_size = input.Size(); + if (source_size.has_value() && + manifest_store.location.length > *source_size) { + return absl::InvalidArgumentError( + "Manifest store length exceeds source size."); + } + if (!input.Seek(entry.file_range.offset) || + input.pos() != entry.file_range.offset) { + return absl::InvalidArgumentError( + "The input could not be reset to the manifest store location."); + } + if (!input.Read(manifest_store.location.length, manifest_store.content)) { + return input.StatusOrAnnotate( + absl::InternalError("Failed to read manifest store")); + } + } + } + if (manifest_store.content.empty()) { + return absl::NotFoundError("No manifest store found"); + } + return manifest_store; +} +} // namespace + +absl::StatusOr<std::string> ZipExtractor::ExtractManifestStore( + riegeli::Reader& input) const { + ABSL_ASSIGN_OR_RETURN(auto manifest_store, + ExtractManifestStoreInternal(input)); + return manifest_store.content; +} + +absl::StatusOr<std::optional<ByteRange>> +ZipExtractor::ExtractManifestStoreLocation(riegeli::Reader& input, + ExtractOptions options) const { + auto manifest_store = ExtractManifestStoreInternal(input); + if (!manifest_store.ok()) { + if (options.requires_c2pa) { + return manifest_store.status(); + } + return std::nullopt; + } + return manifest_store->location; +} + +bool ZipExtractor::MightBeC2paManifestStore(absl::string_view payload) const { + return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid, + kMinimumJumbfDescriptionToggles, + kManifestStoreLabel) + .value_or(false); +} + +} // namespace credentio
diff --git a/formats/zip/extractor.h b/formats/zip/extractor.h new file mode 100644 index 0000000..6d90996 --- /dev/null +++ b/formats/zip/extractor.h
@@ -0,0 +1,57 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EXTRACTOR_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EXTRACTOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/asset_box.h" +#include "formats/byte_range.h" +#include "formats/extractor.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Implements the C2PA Manifest Store extractor for ZIP-based assets as +// described in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_zip_based_formats +class ZipExtractor : public FormatExtractor { + public: + ZipExtractor() = default; + ~ZipExtractor() override = default; + + absl::StatusOr<std::string> ExtractManifestStore( + riegeli::Reader& input) const override; + + absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation( + riegeli::Reader& input, ExtractOptions options) const override; + + absl::StatusOr<std::vector<AssetBox>> ExtractBoxes( + riegeli::Reader& input, ExtractOptions options) const override { + return absl::UnimplementedError("ZIP does not have a concept of boxes"); + }; + + bool MightBeC2paManifestStore(absl::string_view payload) const override; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EXTRACTOR_H_
diff --git a/formats/zip/extractor_test.cc b/formats/zip/extractor_test.cc new file mode 100644 index 0000000..a2f6021 --- /dev/null +++ b/formats/zip/extractor_test.cc
@@ -0,0 +1,418 @@ +// 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 "formats/zip/extractor.h" + +#include <cstddef> +#include <cstdint> +#include <cstdlib> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "formats/byte_range.h" +#include "formats/zip/constants.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "riegeli/endian/endian_writing.h" +#include "testing/jumbf_utils.h" +#include "testing/test_file_utils.h" + +// Compatibility macros for OSS +#ifndef ASSERT_OK +#define ASSERT_OK(expr) ASSERT_THAT(expr, ::absl_testing::IsOk()) +#endif + +#ifndef ASSERT_OK_AND_ASSIGN +#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y +#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) + +#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \ + ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__) + +#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id) \ + auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \ + ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id), \ + ::absl_testing::IsOk()); \ + lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id)) +#endif + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Eq; +using ::testing::Field; +using ::testing::HasSubstr; + +// A Zip file entry. +struct ZipEntry { + std::string name; + std::string content; +}; + +// Creates a minimal Zip file in memory. +// This is not a full-fledged Zip creator, but enough to satisfy ZipReader. +absl::StatusOr<std::string> CreateFakeZip(absl::Span<const ZipEntry> entries) { + std::string buffer; + riegeli::StringWriter writer(&buffer); + + // Write local file headers and data. + std::vector<uint32_t> offsets; + for (const auto& entry : entries) { + offsets.push_back(writer.pos()); + + writer.Write(kZipLocalFileHeaderSignature); + // Version needed to extract (2.0) + riegeli::WriteLittleEndian<uint16_t>(20, writer); + // General purpose bit flag + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Compression method (stored) + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Last mod file time + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Last mod file date + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // CRC-32 (0 for now, we don't check it) + riegeli::WriteLittleEndian<uint32_t>(0, writer); + // Compressed size + riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer); + // Uncompressed size + riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer); + // File name length + riegeli::WriteLittleEndian<uint16_t>(entry.name.size(), writer); + // Extra field length + riegeli::WriteLittleEndian<uint16_t>(0, writer); + + // File name + writer.Write(entry.name); + // File data + writer.Write(entry.content); + } + + uint32_t central_directory_start_offset = writer.pos(); + + // Write central directory. + for (size_t i = 0; i < entries.size(); ++i) { + const auto& entry = entries[i]; + writer.Write(kZipCentralDirectorySignature); + // Version made by + riegeli::WriteLittleEndian<uint16_t>(20, writer); + // Version needed to extract + riegeli::WriteLittleEndian<uint16_t>(20, writer); + // General purpose bit flag + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Compression method + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Last mod file time + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Last mod file date + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // CRC-32 + riegeli::WriteLittleEndian<uint32_t>(0, writer); + // Compressed size + riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer); + // Uncompressed size + riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer); + // File name length + riegeli::WriteLittleEndian<uint16_t>(entry.name.size(), writer); + // Extra field length + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // File comment length + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Disk number start + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Internal file attributes + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // External file attributes + riegeli::WriteLittleEndian<uint32_t>(0, writer); + // Relative offset of local header + riegeli::WriteLittleEndian<uint32_t>(offsets[i], writer); + + // File name + writer.Write(entry.name); + } + + uint32_t central_directory_size = + writer.pos() - central_directory_start_offset; + + // Write End of Central Directory (EOCD) record. + writer.Write(kZipEndOfCentralDirectorySignature); + // Number of this disk + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Number of the disk with the start of the central directory + riegeli::WriteLittleEndian<uint16_t>(0, writer); + // Total number of entries in the central directory on this disk + riegeli::WriteLittleEndian<uint16_t>(entries.size(), writer); + // Total number of entries in the central directory + riegeli::WriteLittleEndian<uint16_t>(entries.size(), writer); + // Size of the central directory + riegeli::WriteLittleEndian<uint32_t>(central_directory_size, writer); + // Offset of start of central directory with respect to the starting disk + // number + riegeli::WriteLittleEndian<uint32_t>(central_directory_start_offset, writer); + // .ZIP file comment length + riegeli::WriteLittleEndian<uint16_t>(0, writer); + + if (!writer.Close()) { + return writer.status(); + } + return buffer; +} + +TEST(ZipExtractorTest, ExtractManifestStore) { + ASSERT_OK_AND_ASSIGN(auto input, + credentio_testing::GetFileReader( + "c2pa/formats/zip/testing/asset_fake_c2pa.zip")); + + EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input), + IsOkAndHolds(HasSubstr("c2pa manifest"))); +} + +TEST(ZipExtractorTest, + ExtractManifestStoreThrowsErrorIfMultipleC2paManifestStoresFound) { + // Create a fake Zip file with duplicate manifest stores. + ASSERT_OK_AND_ASSIGN(auto zip_file_contents, + CreateFakeZip({ + {.name = "META-INF/content_credential.c2pa", + .content = "c2pa manifest 1"}, + {.name = "META-INF/content_credential.c2pa", + .content = "c2pa manifest 2"}, + })); + riegeli::StringReader<> input(zip_file_contents); + + EXPECT_THAT(ZipExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("Multiple manifest stores found"))); +} + +TEST(ZipExtractorTest, ExtractManifestStoreThrowsNotFoundErrorIfNoC2pa) { + ASSERT_OK_AND_ASSIGN(auto input, + credentio_testing::GetFileReader( + "c2pa/formats/zip/testing/asset_no_c2pa.zip")); + + EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found"))); +} + +TEST(ZipExtractorTest, ExtractManifestStoreLocation) { + ASSERT_OK_AND_ASSIGN(auto input, + credentio_testing::GetFileReader( + "c2pa/formats/zip/testing/asset_fake_c2pa.zip")); + ASSERT_OK_AND_ASSIGN(auto manifest_store_location, + ZipExtractor().ExtractManifestStoreLocation( + *input, {.requires_c2pa = true})); + + EXPECT_THAT(manifest_store_location, + Optional(AllOf(Field(&ByteRange::offset, Eq(201)), + Field(&ByteRange::length, Eq(14))))); +} + +TEST(ZipExtractorTest, + ExtractManifestStoreLocationReturnsNulloptIfNoC2paAndNotRequired) { + ASSERT_OK_AND_ASSIGN(auto input, + credentio_testing::GetFileReader( + "c2pa/formats/zip/testing/asset_no_c2pa.zip")); + ASSERT_OK_AND_ASSIGN(auto manifest_store_location, + ZipExtractor().ExtractManifestStoreLocation( + *input, {.requires_c2pa = false})); + + EXPECT_EQ(manifest_store_location, std::nullopt); +} + +TEST(ZipExtractorTest, + ExtractManifestStoreLocationReturnsErrorIfNoC2paButRequired) { + ASSERT_OK_AND_ASSIGN(auto input, + credentio_testing::GetFileReader( + "c2pa/formats/zip/testing/asset_no_c2pa.zip")); + EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation( + *input, {.requires_c2pa = true}), + StatusIs(absl::StatusCode::kNotFound, + HasSubstr("No manifest store found"))); +} + +TEST(ZipExtractorTest, ExtractBoxesReturnsUnimplemented) { + ASSERT_OK_AND_ASSIGN(auto zip, CreateFakeZip({})); + riegeli::StringReader<> input(zip); + + EXPECT_THAT(ZipExtractor().ExtractBoxes(input, {}), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("ZIP does not have a concept of boxes"))); +} + +TEST(ZipExtractorTest, IsManifestStore) { + EXPECT_TRUE(ZipExtractor().MightBeC2paManifestStore( + CreateStartOfManifestStorePayload())); + EXPECT_FALSE(ZipExtractor().MightBeC2paManifestStore("not a manifest store")); +} + +TEST(ZipExtractorTest, + ExtractManifestStoreThrowsErrorIfManifestStoreExceedsLimit) { + std::string large_content(17 * 1024 * 1024, 'a'); + ASSERT_OK_AND_ASSIGN(auto zip, + CreateFakeZip({ + {.name = "META-INF/content_credential.c2pa", + .content = std::move(large_content)}, + })); + riegeli::StringReader<> input(zip); + + EXPECT_THAT(ZipExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Manifest store size exceeds maximum limit"))); +} + +TEST(ZipExtractorTest, ExtractManifestStoreThrowsErrorIfCompressed) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + zip.append("PK\x03\x04", 4); + write_le16(zip, 20); + write_le16(zip, 0); + write_le16(zip, 8); // LFH compression method = 8 (deflated) + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 20); + std::string filename = "META-INF/content_credential.c2pa"; + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + zip.append("PK\x01\x02", 4); + write_le16(zip, 20); + write_le16(zip, 20); + write_le16(zip, 0); + write_le16(zip, 8); // CD compression method = 8 (deflated) + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 20); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT(ZipExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Manifest store must not be compressed"))); +} + +TEST(ZipExtractorTest, + ExtractManifestStoreThrowsErrorIfUncompressedSizeExceedsLimit) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + zip.append("PK\x03\x04", 4); + write_le16(zip, 20); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 17 * 1024 * 1024); // Uncompressed size = 17MB + std::string filename = "META-INF/content_credential.c2pa"; + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + zip.append("PK\x01\x02", 4); + write_le16(zip, 20); + write_le16(zip, 20); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 17 * 1024 * 1024); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT(ZipExtractor().ExtractManifestStore(input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Manifest store size exceeds maximum limit"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/format.cc b/formats/zip/format.cc new file mode 100644 index 0000000..7c6856e --- /dev/null +++ b/formats/zip/format.cc
@@ -0,0 +1,41 @@ +// 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 "formats/zip/format.h" + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" +#include "formats/zip/assessor.h" +#include "formats/zip/extractor.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateZipFormat() { + return Format::Create(FormatOptions{ + .assessor = std::make_unique<ZipAssessor>(), + .extractor = std::make_unique<ZipExtractor>(), + .mime_types = + {"application/" + "vnd.openxmlformats-officedocument.presentationml.presentation", + "application/" + "vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/" + "vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + }); +} + +} // namespace credentio
diff --git a/formats/zip/format.h b/formats/zip/format.h new file mode 100644 index 0000000..48d7e79 --- /dev/null +++ b/formats/zip/format.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_FORMAT_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_FORMAT_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "formats/format.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<Format>> CreateZipFormat(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_FORMAT_H_
diff --git a/formats/zip/reader.cc b/formats/zip/reader.cc new file mode 100644 index 0000000..d208981 --- /dev/null +++ b/formats/zip/reader.cc
@@ -0,0 +1,409 @@ +// 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 "formats/zip/reader.h" + +#include <algorithm> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/memory/memory.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "absl/types/span.h" +#include "formats/zip/constants.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { +namespace { + +// Holds information from the End of Central Directory Record (EOCD) needed to +// locate and read the central directory. +struct CentralDirectoryLocator { + // Offset of the start of the central directory. + uint32_t offset; + // Total number of entries in the central directory. + uint16_t entry_count; +}; + +// Reads the End of Central Directory (EOCD) record from the end of the input +// to locate the central directory. +absl::StatusOr<CentralDirectoryLocator> FindCentralDirectory( + riegeli::Reader& input) { + if (!input.SupportsSize() || !input.Size().has_value()) { + return absl::InvalidArgumentError( + "Input does not support size or size is unknown."); + } + int64_t file_size = *input.Size(); + + if (file_size < kEocdRecordSize) { + return absl::InvalidArgumentError("Input is too small to be a ZIP file."); + } + if (!input.Seek(file_size - kEocdRecordSize)) { + return absl::InvalidArgumentError( + "Failed to seek to the end of the input."); + } + + CentralDirectoryLocator locator; + std::string eocd_signature; + if (!input.Read(kZipEndOfCentralDirectorySignature.size(), eocd_signature) || + eocd_signature != kZipEndOfCentralDirectorySignature) { + return absl::InvalidArgumentError(absl::Substitute( + "Invalid End of Central Directory record signature: $0", + eocd_signature)); + } + + // Skip the following fields since they are not needed for locating the + // central directory: + // - Number of this disk (2 bytes) + // - Number of the disk with the start of the central directory (2 bytes) + // - Total number of entries in the central directory on this disk (2 bytes) + if (!input.Skip(6)) { + return absl::InvalidArgumentError( + "Failed to skip fields in the End of Central Directory record."); + } + + // Total number of entries in the central directory (2 bytes, little-endian) + if (!riegeli::ReadLittleEndian<uint16_t>(input, locator.entry_count)) { + return absl::InvalidArgumentError( + "Failed to read the total number of entries in the central directory."); + } + if (locator.entry_count == 0xffff) { + return absl::UnimplementedError( + "ZIP64 format is not supported. Central directory entry " + "count must " + "not be 0xffff."); + } + + // Skip the size of the central directory (4 bytes) + if (!input.Skip(4)) { + return absl::InvalidArgumentError( + "Failed to skip the size of the central directory."); + } + + // Offset of the start of the central directory (4 bytes, little-endian) + if (!riegeli::ReadLittleEndian<uint32_t>(input, locator.offset)) { + return absl::InvalidArgumentError( + "Failed to read the offset of the start of the central directory."); + } + if (locator.offset == 0xffffffff) { + return absl::UnimplementedError( + "ZIP64 format is not supported. Central Directory offset " + "must not be " + "0xffffffff."); + } + + return locator; +} + +// Reads the central directory entries, and extracts file header information. +absl::StatusOr<std::vector<ZipReader::FileEntry>> +ExtractFileEntriesFromCentralDirectory(riegeli::Reader& input, + const CentralDirectoryLocator& locator) { + if (!input.Seek(locator.offset) || input.pos() != locator.offset) { + return absl::InvalidArgumentError( + "Failed to seek to the start of the central directory."); + } + std::vector<ZipReader::FileEntry> file_entries; + file_entries.reserve(locator.entry_count); + + for (int i = 0; i < locator.entry_count; ++i) { + ZipReader::FileEntry entry; + entry.central_directory_header_offset = input.pos(); + + // Central directory signature (4 bytes) + std::string central_directory_signature; + if (!input.Read(kZipCentralDirectorySignature.size(), + central_directory_signature) || + central_directory_signature != kZipCentralDirectorySignature) { + return absl::InvalidArgumentError( + "Central directory signature not found."); + } + + // Skip the following fields since they are not needed: + // - Version made by (2 bytes) + // - Version needed to extract (2 bytes) + if (!input.Skip(4)) { + return absl::InvalidArgumentError( + "Failed to skip version fields in the central directory entry."); + } + + // General purpose bit flag (2 bytes, little-endian) + uint16_t general_purpose_bit_flag; + if (!riegeli::ReadLittleEndian<uint16_t>(input, general_purpose_bit_flag)) { + return absl::InvalidArgumentError( + "Failed to read the general purpose bit flag."); + } + // Bit 3 in the general purpose bit flag indicates whether the file has a + // data descriptor. + entry.has_data_descriptor = general_purpose_bit_flag & 0x0008; + entry.general_purpose_bit_flag = general_purpose_bit_flag; + + // Compression method (2 bytes, little-endian) + if (!riegeli::ReadLittleEndian<uint16_t>(input, entry.compression_method)) { + return absl::InvalidArgumentError( + "Failed to read the compression method."); + } + + // Skip the following fields since they are not needed: + // - Last mod file time (2 bytes) + // - Last mod file date (2 bytes) + // - CRC-32 (4 bytes) + if (!input.Skip(8)) { + return absl::InvalidArgumentError( + "Failed to skip fields in the central directory entry."); + } + + // Compressed size (4 bytes, little-endian) + uint32_t compressed_size; + if (!riegeli::ReadLittleEndian<uint32_t>(input, compressed_size)) { + return absl::InvalidArgumentError("Failed to read the compressed size."); + } + if (compressed_size == 0xffffffff) { + return absl::UnimplementedError( + "ZIP64 format is not supported. Compressed size must " + "not be " + "0xffffffff."); + } + std::optional<riegeli::Position> input_size = input.Size(); + if (input_size.has_value() && compressed_size > *input_size) { + return absl::InvalidArgumentError("Compressed size exceeds file limits."); + } + entry.file_range.length = compressed_size; + + // Uncompressed size (4 bytes, little-endian) + if (!riegeli::ReadLittleEndian<uint32_t>(input, entry.uncompressed_size)) { + return absl::InvalidArgumentError( + "Failed to read the uncompressed size."); + } + + // File name length (2 bytes, little-endian) + uint16_t file_name_length; + if (!riegeli::ReadLittleEndian<uint16_t>(input, file_name_length)) { + return absl::InvalidArgumentError("Failed to read the file name length."); + } + + // Extra field length (2 bytes, little-endian) + uint16_t extra_field_length; + if (!riegeli::ReadLittleEndian<uint16_t>(input, extra_field_length)) { + return absl::InvalidArgumentError( + "Failed to read the extra field length."); + } + entry.extra_field_length = extra_field_length; + + // File comment length (2 bytes, little-endian) + uint16_t file_comment_length; + if (!riegeli::ReadLittleEndian<uint16_t>(input, file_comment_length)) { + return absl::InvalidArgumentError( + "Failed to read the file comment length."); + } + entry.file_comment_length = file_comment_length; + + // Skip the following fields since they are not needed: + // - Disk number (2 bytes) + // - Internal file attributes (2 bytes) + // - External file attributes (4 bytes) + if (!input.Skip(8)) { + return absl::InvalidArgumentError( + "Failed to skip fields in the central directory entry."); + } + + // Local file header offset (4 bytes, little-endian) + uint32_t local_file_header_offset; + if (!riegeli::ReadLittleEndian<uint32_t>(input, local_file_header_offset)) { + return absl::InvalidArgumentError( + "Failed to read the local file header offset."); + } + entry.local_file_header_offset = local_file_header_offset; + + // File name (length is determined by `file_name_length`) + if (!input.Read(file_name_length, entry.file_name)) { + return absl::InvalidArgumentError("Failed to read the file name."); + } + + // Skip extra field and file comment in central directory + if (!input.Skip(extra_field_length + file_comment_length)) { + return absl::InvalidArgumentError( + "Failed to skip extra field and file comment in central directory."); + } + + // The file data starts immediately after the local file header, which + // consists of: + // - Fixed length fields (in total of 30 bytes) + // - File name (variable length) + // - Extra field (variable length) + entry.file_range.offset = entry.local_file_header_offset + + kLocalFileHeaderFixedSize + + entry.file_name.size() + entry.extra_field_length; + + file_entries.push_back(entry); + } + + // Reads and validates LFH signatures and ensures that the LFH + // extra_field_length matches the Central Directory requirement. + const int64_t cd_end_pos = input.pos(); + ABSL_ASSIGN_OR_RETURN(auto sorted_entries, + ZipReader::SortFileEntries(file_entries)); + + for (const auto& entry : sorted_entries) { + if (!input.Seek(entry.local_file_header_offset) || + input.pos() != entry.local_file_header_offset) { + return absl::InvalidArgumentError( + "Failed to seek to local file header offset."); + } + std::string lfh_sig; + if (!input.Read(kZipLocalFileHeaderSignature.size(), lfh_sig) || + lfh_sig != kZipLocalFileHeaderSignature) { + return absl::InvalidArgumentError("Invalid local file header signature."); + } + if (!input.Skip(2)) { + return absl::InvalidArgumentError( + "Failed to skip fields in local file header."); + } + uint16_t lfh_general_purpose_bit_flag; + if (!riegeli::ReadLittleEndian<uint16_t>(input, + lfh_general_purpose_bit_flag)) { + return absl::InvalidArgumentError( + "Failed to read general purpose bit flag from local file header."); + } + if ((entry.general_purpose_bit_flag & 0x0808) != + (lfh_general_purpose_bit_flag & 0x0808)) { + return absl::InvalidArgumentError( + "Local File Header bit flag does not match Central Directory."); + } + uint16_t lfh_compression_method; + if (!riegeli::ReadLittleEndian<uint16_t>(input, lfh_compression_method)) { + return absl::InvalidArgumentError( + "Failed to read compression method from local file header."); + } + if (lfh_compression_method != entry.compression_method) { + return absl::InvalidArgumentError( + "Local File Header compression method does not match Central " + "Directory."); + } + if (!input.Skip(8)) { + return absl::InvalidArgumentError( + "Failed to skip fields in local file header."); + } + uint32_t lfh_compressed_size; + if (!riegeli::ReadLittleEndian<uint32_t>(input, lfh_compressed_size)) { + return absl::InvalidArgumentError( + "Failed to read compressed size from local file header."); + } + uint32_t lfh_uncompressed_size; + if (!riegeli::ReadLittleEndian<uint32_t>(input, lfh_uncompressed_size)) { + return absl::InvalidArgumentError( + "Failed to read uncompressed size from local file header."); + } + if (!entry.has_data_descriptor) { + if (lfh_compressed_size != entry.file_range.length || + lfh_uncompressed_size != entry.uncompressed_size) { + return absl::InvalidArgumentError( + "Local File Header sizes do not match Central Directory."); + } + } else { + if ((lfh_compressed_size != 0 && + lfh_compressed_size != entry.file_range.length) || + (lfh_uncompressed_size != 0 && + lfh_uncompressed_size != entry.uncompressed_size)) { + return absl::InvalidArgumentError( + "Local File Header sizes do not match Central Directory."); + } + } + uint16_t lfh_file_name_length; + if (!riegeli::ReadLittleEndian<uint16_t>(input, lfh_file_name_length)) { + return absl::InvalidArgumentError( + "Failed to read file name length from local file header."); + } + if (lfh_file_name_length != entry.file_name.size()) { + return absl::InvalidArgumentError( + "Local File Header file name length does not match Central " + "Directory."); + } + uint16_t lfh_extra_field_length; + if (!riegeli::ReadLittleEndian<uint16_t>(input, lfh_extra_field_length)) { + return absl::InvalidArgumentError( + "Failed to read extra field length from local file header."); + } + if (lfh_extra_field_length != entry.extra_field_length) { + return absl::InvalidArgumentError( + "Local File Header extra field length does not match Central " + "Directory."); + } + } + + if (!input.Seek(cd_end_pos) || input.pos() != cd_end_pos) { + return absl::InvalidArgumentError( + "Failed to seek back to central directory position."); + } + + return sorted_entries; +} + +} // namespace + +// Sorts ZIP file entries by header offset and checks that they do not have +// duplicate offsets or overlapping ranges to prevent ZIP bomb conditions. +absl::StatusOr<std::vector<ZipReader::FileEntry>> ZipReader::SortFileEntries( + absl::Span<const ZipReader::FileEntry> file_entries) { + std::vector<ZipReader::FileEntry> sorted_entries(file_entries.begin(), + file_entries.end()); + std::sort(sorted_entries.begin(), sorted_entries.end(), + [](const ZipReader::FileEntry& a, const ZipReader::FileEntry& b) { + return a.local_file_header_offset < b.local_file_header_offset; + }); + + int64_t previous_entry_end = 0; + for (const auto& entry : sorted_entries) { + if (entry.local_file_header_offset < previous_entry_end) { + return absl::InvalidArgumentError( + "Overlapping or duplicate ZIP entries detected."); + } + previous_entry_end = entry.file_range.offset + entry.file_range.length; + } + return sorted_entries; +} + +absl::StatusOr<std::unique_ptr<ZipReader>> ZipReader::Create( + riegeli::Reader* absl_nonnull input) { + ABSL_ASSIGN_OR_RETURN(CentralDirectoryLocator locator, + FindCentralDirectory(*input)); + + ABSL_ASSIGN_OR_RETURN( + std::vector<FileEntry> file_entries, + ExtractFileEntriesFromCentralDirectory(*input, locator)); + + auto reader = + absl::WrapUnique(new ZipReader(std::move(file_entries), locator.offset)); + return std::move(reader); +} + +absl::StatusOr<ZipReader::FileEntry> ZipReader::Next() { + if (!HasNext()) { + return absl::OutOfRangeError("No more file entries to read."); + } + return *current_it_++; +} +} // namespace credentio
diff --git a/formats/zip/reader.h b/formats/zip/reader.h new file mode 100644 index 0000000..8c061f0 --- /dev/null +++ b/formats/zip/reader.h
@@ -0,0 +1,101 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_FORMATS_ZIP_READER_H_ +#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_READER_H_ + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/attributes.h" +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "absl/types/span.h" +#include "formats/byte_range.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +// Zip Reader for C2PA, holds onto a reference to a `riegeli::Reader` which +// must outlive the reader. +class ZipReader { + public: + // Represents a file entry in a ZIP archive, derived from the file header in + // the central directory. + struct FileEntry { + uint64_t central_directory_header_offset = 0; + uint64_t local_file_header_offset = 0; + std::string file_name; + ByteRange file_range; + bool has_data_descriptor = false; + uint16_t general_purpose_bit_flag = 0; + uint16_t compression_method = 0; + uint32_t uncompressed_size = 0; + uint16_t extra_field_length = 0; + uint16_t file_comment_length = 0; + + bool operator==(const FileEntry& other) const { + return central_directory_header_offset == + other.central_directory_header_offset && + local_file_header_offset == other.local_file_header_offset && + file_name == other.file_name && file_range == other.file_range && + has_data_descriptor == other.has_data_descriptor && + general_purpose_bit_flag == other.general_purpose_bit_flag && + compression_method == other.compression_method && + uncompressed_size == other.uncompressed_size && + extra_field_length == other.extra_field_length && + file_comment_length == other.file_comment_length; + } + }; + + static absl::StatusOr<std::unique_ptr<ZipReader>> Create( + riegeli::Reader* absl_nonnull input ABSL_ATTRIBUTE_LIFETIME_BOUND); + + // Sorts ZIP file entries by header offset and checks that they do not have + // duplicate offsets or overlapping ranges to prevent ZIP bomb conditions. + static absl::StatusOr<std::vector<ZipReader::FileEntry>> SortFileEntries( + absl::Span<const ZipReader::FileEntry> file_entries); + + // Returns the offset of the start of the central directory. + uint64_t central_directory_offset() const { + return central_directory_offset_; + } + + // Returns true if there is a file entry to read. + bool HasNext() const { return current_it_ < file_entries_.end(); }; + + // Reads the next file entry in the ZIP archive. Returns an OUT_OF_RANGE + // error if there are no more entries to read. + absl::StatusOr<FileEntry> Next(); + + private: + explicit ZipReader(std::vector<ZipReader::FileEntry>&& file_entries, + uint64_t central_directory_offset) + : file_entries_(std::move(file_entries)), + current_it_(file_entries_.begin()), + central_directory_offset_(central_directory_offset) {} + + // All the file entries in the ZIP archive. + const std::vector<ZipReader::FileEntry> file_entries_; + std::vector<ZipReader::FileEntry>::const_iterator current_it_; + + uint64_t central_directory_offset_; +}; +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_READER_H_
diff --git a/formats/zip/reader_test.cc b/formats/zip/reader_test.cc new file mode 100644 index 0000000..2bbbfa5 --- /dev/null +++ b/formats/zip/reader_test.cc
@@ -0,0 +1,1130 @@ +// 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 "formats/zip/reader.h" + +#include <algorithm> +#include <cstdint> +#include <cstdlib> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "formats/byte_range.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/cfile_reader.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/string_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::AllOf; +using ::testing::Contains; +using ::testing::Field; +using ::testing::HasSubstr; + +void write_le16(std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); +} + +void write_le32(std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); +} + +std::string MakeLfh(absl::string_view filename) { + std::string lfh; + lfh.append("PK\x03\x04", 4); + write_le16(lfh, 10); // version needed + write_le16(lfh, 0); // GP flag + write_le16(lfh, 0); // comp method + write_le16(lfh, 0); // last mod time + write_le16(lfh, 0); // last mod date + write_le32(lfh, 0); // crc32 + write_le32(lfh, 0); // compressed size + write_le32(lfh, 0); // uncompressed size + write_le16(lfh, filename.size()); + write_le16(lfh, 0); // extra field length + lfh.append(filename); + return lfh; +} + +std::string MakeCdEntry(absl::string_view filename, + uint32_t local_header_offset) { + std::string entry; + entry.append("PK\x01\x02", 4); + write_le16(entry, 0); // version made by + write_le16(entry, 0); // version needed + write_le16(entry, 0); // GP flag + write_le16(entry, 0); // comp method + write_le16(entry, 0); // last mod time + write_le16(entry, 0); // last mod date + write_le32(entry, 0); // crc32 + write_le32(entry, 0); // compressed size + write_le32(entry, 0); // uncompressed size + write_le16(entry, filename.size()); + write_le16(entry, 0); // extra field length + write_le16(entry, 0); // file comment length + write_le16(entry, 0); // disk number start + write_le16(entry, 0); // internal attr + write_le32(entry, 0); // external attr + write_le32(entry, local_header_offset); + entry.append(filename); + return entry; +} + +std::string MakeEocd(uint32_t cd_size, uint32_t cd_offset, + uint16_t entry_count) { + std::string eocd; + eocd.append("PK\x05\x06", 4); + write_le16(eocd, 0); // disk number + write_le16(eocd, 0); // disk where CD starts + write_le16(eocd, entry_count); // CD records on this disk + write_le16(eocd, entry_count); // total CD records + write_le32(eocd, cd_size); + write_le32(eocd, cd_offset); + write_le16(eocd, 0); // comment length + return eocd; +} + +std::string GetTestDataPath(absl::string_view relative_path) { + const char* test_srcdir = std::getenv("TEST_SRCDIR"); + const char* test_workspace = std::getenv("TEST_WORKSPACE"); + if (test_srcdir == nullptr || test_workspace == nullptr) { + return std::string(relative_path); + } + return absl::StrCat(test_srcdir, "/", test_workspace, "/", relative_path); +} + +constexpr char kEmptyZipFile[] = { + 'P', 'K', '\5', '\6', '\0', '\0', '\0', '\0', '\0', '\0', '\0', + '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', +}; + +TEST(ZipReaderTest, FileTooSmall) { + std::string file_contents = "PK\x03\x04\xab\xcd\xab\xcd\xab\xcd"; + riegeli::StringReader<> input(file_contents); + + EXPECT_THAT(ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Input is too small to be a ZIP file"))); +} + +TEST(ZipReaderTest, InvalidEocdSignature) { + std::string file_contents = "PKPKPK12345678901234567890"; + riegeli::StringReader<> input(file_contents); + + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid End of Central Directory record signature"))); +} + +TEST(ZipReaderTest, InvalidCentralDirectorySignature) { + std::string file_contents = std::string( + "PK\x01\x03" + "PK\x05\x06" + "\x00\x00" + "\x00\x00" + "\x01\x00" + "\x01\x00" + "\x04\x00\x00\x00" + "\x00\x00\x00\x00" + "\x00\x00", + 26); + riegeli::StringReader<> input(file_contents); + + EXPECT_THAT(ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Central directory signature not found"))); +} + +TEST(ZipReaderTest, EmptyZipFile) { + std::string empty_zip_file_contents(kEmptyZipFile, sizeof(kEmptyZipFile)); + riegeli::StringReader<> input(empty_zip_file_contents); + auto reader_or = ZipReader::Create(&input); + ASSERT_THAT(reader_or, IsOk()); + auto reader = std::move(*reader_or); + + EXPECT_FALSE(reader->HasNext()); +} + +TEST(ZipReaderTest, Zip64Unsupported) { + std::string zip = GetTestDataPath( + "formats/zip/testing/" + "asset_unsupported_zip64.zip"); + riegeli::CFileReader<> input(zip); + ASSERT_THAT(input.status(), IsOk()); + + EXPECT_THAT(ZipReader::Create(&input), + StatusIs(absl::StatusCode::kUnimplemented, + HasSubstr("ZIP64 format is not supported"))); +} + +TEST(ZipReaderTest, DetectsDataDescriptorCorrectly) { + // clang-format off + constexpr char kZipWithDataDescriptor[] = { + // Local File Header + 'P', 'K', '\x03', '\x04', + '\x00', '\x00', // Version needed to extract + '\x08', '\x00', // General purpose bit flag + '\x00', '\x00', // Compression method + '\x00', '\x00', // Last mod file time + '\x00', '\x00', // Last mod file date + '\x00', '\x00', '\x00', '\x00', // CRC-32 + '\x00', '\x00', '\x00', '\x00', // Compressed size + '\x00', '\x00', '\x00', '\x00', // Uncompressed size + '\x01', '\x00', // File name length (1 byte) + '\x00', '\x00', // Extra field length + 'a', // File name (1 byte) + // Central Directory Record + 'P', 'K', '\x01', '\x02', + '\x00', '\x00', // Version made by + '\x00', '\x00', // Version needed to extract + '\x08', '\x00', // General purpose bit flag (Bit 3 set) + '\x00', '\x00', // Compression method + '\x00', '\x00', // Last mod file time + '\x00', '\x00', // Last mod file date + '\x00', '\x00', '\x00', '\x00', // CRC-32 + '\x00', '\x00', '\x00', '\x00', // Compressed size + '\x00', '\x00', '\x00', '\x00', // Uncompressed size + '\x01', '\x00', // File name length (1 byte) + '\x00', '\x00', // Extra field length + '\x00', '\x00', // File comment length + '\x00', '\x00', // Disk number start + '\x00', '\x00', // Internal file attributes + '\x00', '\x00', '\x00', '\x00', // External file attributes + '\x00', '\x00', '\x00', '\x00', // Relative offset of local header + 'a', // File name (1 byte) + // End of central directory record (EOCD) + 'P', 'K', '\x05', '\x06', + '\x00', '\x00', // Number of this disk + '\x00', '\x00', // Disk where CD starts + '\x01', '\x00', // Number of CD records on this disk + '\x01', '\x00', // Total number of CD records + '\x2f', '\x00', '\x00', '\x00', // Size of CD (47 bytes) + '\x1f', '\x00', '\x00', '\x00', // Offset of start of CD (31 bytes) + '\x00', '\x00', // ZIP file comment length (0 bytes) + }; + // clang-format on + std::string zip_contents(kZipWithDataDescriptor, + sizeof(kZipWithDataDescriptor)); + riegeli::StringReader<> input(zip_contents); + auto reader_or = ZipReader::Create(&input); + ASSERT_THAT(reader_or, IsOk()); + auto reader = std::move(*reader_or); + + ASSERT_TRUE(reader->HasNext()); + auto entry_or = reader->Next(); + ASSERT_THAT(entry_or, IsOk()); + auto entry = std::move(*entry_or); + + EXPECT_TRUE(entry.has_data_descriptor); +} + +TEST(ZipReaderTest, ReadsZipFile) { + std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip"); + riegeli::CFileReader<> input(zip); + ASSERT_THAT(input.status(), IsOk()); + auto reader_or = ZipReader::Create(&input); + ASSERT_THAT(reader_or, IsOk()); + auto reader = std::move(*reader_or); + + std::vector<ZipReader::FileEntry> entries; + while (reader->HasNext()) { + auto entry_or = reader->Next(); + ASSERT_THAT(entry_or, IsOk()); + entries.push_back(std::move(*entry_or)); + } + + EXPECT_THAT(entries, + testing::UnorderedElementsAre( + ZipReader::FileEntry{ + .central_directory_header_offset = 139, + .local_file_header_offset = 0, + .file_name = "file_1.txt", + .file_range = ByteRange{.offset = 40, .length = 44}, + .has_data_descriptor = false, + .general_purpose_bit_flag = 0, + .compression_method = 0, + .uncompressed_size = 44, + }, + ZipReader::FileEntry{ + .central_directory_header_offset = 195, + .local_file_header_offset = 84, + .file_name = "file_2.txt", + .file_range = ByteRange{.offset = 124, .length = 15}, + .has_data_descriptor = false, + .general_purpose_bit_flag = 0, + .compression_method = 0, + .uncompressed_size = 15, + })); + EXPECT_THAT(reader->Next(), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("No more file entries to read"))); +} + +TEST(ZipReaderTest, ReadsPptxFile) { + std::string pptx = + GetTestDataPath("formats/zip/testing/no_c2pa_032_asset.pptx"); + riegeli::CFileReader<> input(pptx); + ASSERT_THAT(input.status(), IsOk()); + auto reader_or = ZipReader::Create(&input); + ASSERT_THAT(reader_or, IsOk()); + auto reader = std::move(*reader_or); + + std::vector<ZipReader::FileEntry> entries; + while (reader->HasNext()) { + auto entry_or = reader->Next(); + ASSERT_THAT(entry_or, IsOk()); + entries.push_back(std::move(*entry_or)); + } + + EXPECT_EQ(entries.size(), 46); + // OOXML files should have a [Content_Types].xml file entry with a data + // descriptor. + EXPECT_THAT( + entries, + Contains( + AllOf(Field(&ZipReader::FileEntry::file_name, "[Content_Types].xml"), + Field(&ZipReader::FileEntry::has_data_descriptor, true)))); +} + +TEST(ZipReaderTest, CentralDirectoryOffsetEmptyZip) { + std::string empty_zip_file_contents(kEmptyZipFile, sizeof(kEmptyZipFile)); + riegeli::StringReader<> input(empty_zip_file_contents); + auto reader_or = ZipReader::Create(&input); + ASSERT_THAT(reader_or, IsOk()); + auto reader = std::move(*reader_or); + + EXPECT_EQ(reader->central_directory_offset(), 0); +} + +TEST(ZipReaderTest, CentralDirectoryOffset) { + std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip"); + riegeli::CFileReader<> input(zip); + ASSERT_THAT(input.status(), IsOk()); + auto reader_or = ZipReader::Create(&input); + ASSERT_THAT(reader_or, IsOk()); + auto reader = std::move(*reader_or); + + EXPECT_EQ(reader->central_directory_offset(), 139); +} + +TEST(ZipReaderTest, LocalFileHeaderExtraFieldMismatchRejected) { + std::string zip; + // Local File Header for file_1.txt + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + std::string filename = "file_1.txt"; + write_le16(zip, filename.size()); + write_le16(zip, 8); // LFH Extra field length = 8! + zip.append(filename); + zip.append("12345678"); // LFH Extra field data + zip.append("abcdefghij"); // Actual file data (10 bytes) + + uint32_t cd_offset = zip.size(); + // Central Directory Record for file_1.txt + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); // CD Extra field length = 0! Mismatched! + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + // End of Central Directory (EOCD) + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Local File Header extra field length does not match " + "Central Directory"))); +} + +TEST(ZipReaderTest, LocalFileHeaderInvalidSignatureRejected) { + std::string zip; + // Local File Header with corrupted signature + zip.append("PK\x03\x05", 4); // PK\x03\x05 instead of PK\x03\x04! + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + std::string filename = "file_1.txt"; + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + // Central Directory Record + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); // Local Header Offset = 0 + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + // End of Central Directory (EOCD) + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT(ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Invalid local file header signature"))); +} + +TEST(ZipReaderTest, LocalFileHeaderSeekFailureRejected) { + std::string zip; + // No LFH at offset 0 (we start with CD) + uint32_t cd_offset = zip.size(); + std::string filename = "file_1.txt"; + // Central Directory Record + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 9999); // Local Header Offset points to invalid offset 9999! + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + // End of Central Directory (EOCD) + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to seek to local file header offset"))); +} + +TEST(ZipReaderTest, VulnerabilityZipEntryOverlapDetected) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + std::string filename = "file_1.txt"; + // Local File Header at offset 0 + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("0123456789", 10); + + uint32_t cd_offset = zip.size(); + // Central Directory Record 1 pointing to offset 0 + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + // Central Directory Record 2 duplicates offset 0 (overlapping/duplicate) + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + // End of Central Directory (EOCD) + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 2); + write_le16(zip, 2); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Overlapping or duplicate ZIP entries detected"))); +} + +TEST(ZipReaderTest, CompressedSizeExceedsFileLimitsRejected) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + // Local File Header for file_1.txt + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + std::string filename = "file_1.txt"; + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + // Central Directory Record for file_1.txt + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0x7FFFFFFF); // CD Compressed size exceeds file size + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + // End of Central Directory (EOCD) + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT(ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Compressed size exceeds file limits"))); +} + +class MockLargeZipReader : public riegeli::Reader { + public: + MockLargeZipReader(std::string lfh1, std::string lfh2, std::string cd, + std::string eocd, uint64_t lfh2_offset, uint64_t cd_offset, + uint64_t eocd_offset) + : lfh1_(lfh1), + lfh2_(lfh2), + cd_(cd), + eocd_(eocd), + lfh2_offset_(lfh2_offset), + cd_offset_(cd_offset), + eocd_offset_(eocd_offset), + size_(eocd_offset + eocd.size()) { + set_limit_pos(0); + } + + bool SupportsRandomAccess() override { return true; } + bool SupportsSize() override { return true; } + + protected: + bool PullSlow(size_t min_length, size_t recommended_length) override { + uint64_t current_pos = pos(); + if (current_pos >= size_) return false; + + if (current_pos < lfh1_.size()) { + set_buffer(lfh1_.data(), lfh1_.size(), current_pos); + set_limit_pos(lfh1_.size()); + return true; + } + if (current_pos >= lfh2_offset_ && + current_pos < lfh2_offset_ + lfh2_.size()) { + set_buffer(lfh2_.data(), lfh2_.size(), current_pos - lfh2_offset_); + set_limit_pos(lfh2_offset_ + lfh2_.size()); + return true; + } + if (current_pos >= cd_offset_ && current_pos < cd_offset_ + cd_.size()) { + set_buffer(cd_.data(), cd_.size(), current_pos - cd_offset_); + set_limit_pos(cd_offset_ + cd_.size()); + return true; + } + if (current_pos >= eocd_offset_ && + current_pos < eocd_offset_ + eocd_.size()) { + set_buffer(eocd_.data(), eocd_.size(), current_pos - eocd_offset_); + set_limit_pos(eocd_offset_ + eocd_.size()); + return true; + } + + // Gap handling + uint64_t next_offset = size_; + if (current_pos < lfh2_offset_) { + next_offset = lfh2_offset_; + } else if (current_pos < cd_offset_) { + next_offset = cd_offset_; + } else if (current_pos < eocd_offset_) { + next_offset = eocd_offset_; + } + + uint64_t remaining = next_offset - current_pos; + size_t chunk = + std::min(sizeof(kZeroBuffer), static_cast<size_t>(remaining)); + set_buffer(kZeroBuffer, chunk, 0); + set_limit_pos(current_pos + chunk); + return true; + } + + bool SeekSlow(riegeli::Position new_pos) override { + if (new_pos > size_) { + set_buffer(); + set_limit_pos(size_); + return false; + } + set_buffer(); + set_limit_pos(new_pos); + return true; + } + + std::optional<riegeli::Position> SizeImpl() override { return size_; } + + private: + static constexpr char kZeroBuffer[1024] = {0}; + + std::string lfh1_; + std::string lfh2_; + std::string cd_; + std::string eocd_; + uint64_t lfh2_offset_; + uint64_t cd_offset_; + uint64_t eocd_offset_; + uint64_t size_; +}; + +TEST(ZipReaderTest, LargeZipFile) { + // We want to test that ZipReader can handle file entries that start after + // 4GB. We construct a mock ZIP file layout: + // - LFH1 at 0 (size 40) + // - Large file data of size 4294967063 (approx 4GB) + // - LFH2 at 4294967103 (size 43) + // - File 2 (manifest) data of size 100 + // - CD at 4294967250 (starts before 4GB, ends after 4GB) + // - CD Entry 1 (for large_file) starts at 4294967250, size 56. + // - CD Entry 2 (for manifest.json) starts at 4294967306 (after 4GB!), + // size 59. + // - EOCD at 4294967365 (size 22) + // Total size = 4294967387. + + std::string filename1 = "large_file"; + std::string filename2 = "manifest.json"; + + std::string lfh1 = MakeLfh(filename1); + std::string lfh2 = MakeLfh(filename2); + + uint64_t lfh2_offset = 4294967103; + uint64_t cd_offset = 4294967250; + + std::string cd_entry1 = MakeCdEntry(filename1, 0); + std::string cd_entry2 = MakeCdEntry(filename2, lfh2_offset); + std::string cd = cd_entry1 + cd_entry2; + + uint64_t eocd_offset = cd_offset + cd.size(); + std::string eocd = MakeEocd(cd.size(), cd_offset, 2); + + MockLargeZipReader input(lfh1, lfh2, cd, eocd, lfh2_offset, cd_offset, + eocd_offset); + + auto reader_or = ZipReader::Create(&input); + ASSERT_THAT(reader_or, IsOk()); + auto reader = std::move(*reader_or); + + std::vector<ZipReader::FileEntry> entries; + while (reader->HasNext()) { + auto entry_or = reader->Next(); + ASSERT_THAT(entry_or, IsOk()); + entries.push_back(std::move(*entry_or)); + } + + EXPECT_THAT( + entries, + testing::UnorderedElementsAre( + ZipReader::FileEntry{ + .central_directory_header_offset = cd_offset, + .local_file_header_offset = 0, + .file_name = filename1, + .file_range = ByteRange{.offset = 40, .length = 0}, + .has_data_descriptor = false, + }, + ZipReader::FileEntry{ + .central_directory_header_offset = cd_offset + cd_entry1.size(), + .local_file_header_offset = lfh2_offset, + .file_name = filename2, + .file_range = + ByteRange{.offset = lfh2_offset + 30 + filename2.size(), + .length = 0}, + .has_data_descriptor = false, + })); +} + +TEST(ZipReaderTest, LocalFileHeaderSizeMismatchRejected) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + // Local File Header with compressed_size = 999 + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 999); // LFH compressed size = 999! + write_le32(zip, 10); + std::string filename = "file_1.txt"; + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + // Central Directory Record with compressed_size = 10 + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); // CD compressed size = 10! + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT(ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Local File Header sizes do not match Central " + "Directory"))); +} + +TEST(ZipReaderTest, LocalFileHeaderFileNameLengthMismatchRejected) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + std::string filename = "file_1.txt"; + write_le16(zip, filename.size() + 5); // LFH filename length mismatched! + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Local File Header file name length does not match " + "Central Directory"))); +} + +TEST(ZipReaderTest, LocalFileHeaderBitFlagMismatchRejected) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0x0800); // LFH has UTF-8 flag set + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + std::string filename = "file_1.txt"; + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); // CD bit flag is 0 + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Local File Header bit flag does not match Central " + "Directory"))); +} + +TEST(ZipReaderTest, LocalFileHeaderCompressionMethodMismatchRejected) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + zip.append("PK\x03\x04", 4); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 8); // LFH compression method = 8 + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + std::string filename = "file_1.txt"; + write_le16(zip, filename.size()); + write_le16(zip, 0); + zip.append(filename); + zip.append("abcdefghij"); + + uint32_t cd_offset = zip.size(); + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); // CD compression method = 0 + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 0); + zip.append(filename); + + uint32_t cd_size = zip.size() - cd_offset; + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + write_le32(zip, cd_size); + write_le32(zip, cd_offset); + write_le16(zip, 0); + + riegeli::StringReader<> input(zip); + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Local File Header compression method does not match " + "Central Directory"))); +} + +TEST(ZipReaderTest, LocalFileHeaderTruncatedRejected) { + auto write_le16 = [](std::string& s, uint16_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + }; + auto write_le32 = [](std::string& s, uint32_t val) { + s.push_back(static_cast<char>(val & 0xff)); + s.push_back(static_cast<char>((val >> 8) & 0xff)); + s.push_back(static_cast<char>((val >> 16) & 0xff)); + s.push_back(static_cast<char>((val >> 24) & 0xff)); + }; + + std::string zip; + uint32_t cd_offset = 0; + std::string filename = "file_1.txt"; + + // CD Record at offset 0 + zip.append("PK\x01\x02", 4); + write_le16(zip, 10); + write_le16(zip, 10); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + write_le32(zip, 10); + write_le32(zip, 10); + write_le16(zip, filename.size()); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 0); + write_le32(zip, 0); + + uint32_t lfh_offset = 46 + filename.size() + 12; // Offset 12 inside EOCD + write_le32(zip, lfh_offset); + zip.append(filename); + + // EOCD Record + zip.append("PK\x05\x06", 4); + write_le16(zip, 0); + write_le16(zip, 0); + write_le16(zip, 1); + write_le16(zip, 1); + zip.append("PK\x03\x04", 4); // Size of CD field serves as fake LFH signature + write_le32(zip, cd_offset); // Offset of CD = 0 (serves as flags = 0) + write_le16(zip, 0); // Comment length = 0 (serves as comp method = 0) + + riegeli::StringReader<> input(zip); + EXPECT_THAT( + ZipReader::Create(&input), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Failed to skip fields in local file header"))); +} + +} // namespace +} // namespace credentio
diff --git a/formats/zip/testing/BUILD b/formats/zip/testing/BUILD new file mode 100644 index 0000000..ac9a788 --- /dev/null +++ b/formats/zip/testing/BUILD
@@ -0,0 +1,24 @@ +# 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. +# + +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "assets", + srcs = glob(include = [ + "*.pptx", + "*.zip", + ]), +)
diff --git a/formats/zip/testing/asset_fake_c2pa.zip b/formats/zip/testing/asset_fake_c2pa.zip new file mode 100644 index 0000000..b7a66a7 --- /dev/null +++ b/formats/zip/testing/asset_fake_c2pa.zip Binary files differ
diff --git a/formats/zip/testing/asset_no_c2pa.zip b/formats/zip/testing/asset_no_c2pa.zip new file mode 100644 index 0000000..8ac2a7e --- /dev/null +++ b/formats/zip/testing/asset_no_c2pa.zip Binary files differ
diff --git a/formats/zip/testing/asset_unsupported_zip64.zip b/formats/zip/testing/asset_unsupported_zip64.zip new file mode 100644 index 0000000..ddb4b00 --- /dev/null +++ b/formats/zip/testing/asset_unsupported_zip64.zip Binary files differ
diff --git a/formats/zip/testing/no_c2pa_032_asset.pptx b/formats/zip/testing/no_c2pa_032_asset.pptx new file mode 100644 index 0000000..b57fc29 --- /dev/null +++ b/formats/zip/testing/no_c2pa_032_asset.pptx Binary files differ
diff --git a/jumbf/BUILD b/jumbf/BUILD new file mode 100644 index 0000000..a22c1e4 --- /dev/null +++ b/jumbf/BUILD
@@ -0,0 +1,224 @@ +# 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. +# + +load("@rules_cc//cc:cc_test.bzl", "cc_test") +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "constants", + hdrs = ["constants.h"], + deps = ["//uuid"], +) + +cc_library( + name = "test_utils", + testonly = True, + srcs = ["test_utils.cc"], + hdrs = ["test_utils.h"], + deps = [ + ":constants", + "//uuid", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "box", + hdrs = ["box.h"], + deps = [ + "//uuid", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "box_builder", + srcs = ["box_builder.cc"], + hdrs = ["box_builder.h"], + deps = [ + ":constants", + ":encode_params", + "//jumbf/internal:encode", + "//uuid", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/bytes:cord_backward_writer", + ], +) + +cc_test( + name = "box_builder_test", + srcs = ["box_builder_test.cc"], + deps = [ + ":box", + ":box_builder", + ":constants", + ":parse", + ":test_utils", + "//uuid", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "parse", + srcs = ["parse.cc"], + hdrs = ["parse.h"], + deps = [ + ":box", + ":constants", + "//jumbf/internal:consume_box", + "//jumbf/internal:intermediate", + "//jumbf/internal:parse_embedded_file_description_box", + "//jumbf/internal:parse_super_box", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "parse_test", + srcs = ["parse_test.cc"], + deps = [ + ":box", + ":constants", + ":parse", + ":test_utils", + "//uuid", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "uri", + srcs = ["uri.cc"], + hdrs = ["uri.h"], + deps = [ + ":box", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "uri_test", + srcs = ["uri_test.cc"], + deps = [ + ":box", + ":uri", + "//uuid", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "utils", + srcs = ["utils.cc"], + hdrs = ["utils.h"], + deps = [ + ":box", + ":constants", + "//jumbf/internal:serialization_utils", + "//uuid", + "@abseil-cpp//absl/functional:overload", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "encode_params", + hdrs = ["encode_params.h"], + deps = ["@abseil-cpp//absl/strings:cord"], +) + +cc_test( + name = "utils_test", + srcs = ["utils_test.cc"], + deps = [ + ":box", + ":box_builder", + ":constants", + ":parse", + ":test_utils", + ":utils", + "//uuid", + "@abseil-cpp//absl/numeric:int128", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "convert_json", + srcs = ["convert_json.cc"], + hdrs = ["convert_json.h"], + deps = [ + ":box", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@libcppbor", + "@nlohmann_json//:json", + ], +)
diff --git a/jumbf/README.md b/jumbf/README.md new file mode 100644 index 0000000..98a041d --- /dev/null +++ b/jumbf/README.md
@@ -0,0 +1,77 @@ +# JUMBF Parser + +This is a basic parser for the binary format described in ISO/IEC 19566-5. It is +intended to be a general-purpose library, but was created specifically for C2PA, +and only the features specifically needed for that use case have been developed +or tested. + +## Supported features + +### Parsing + +- [`ConsumeSuperBox(...)`](http://c2pa/jumbf/parse.h): Recursively parses a + superbox and all contained boxes into + [view-only structs](http://c2pa/jumbf/box.h). + +- Basic box structure: Complete + +- Box file format (Annex A): Complete + +- Content Types (Annex B): Partial, only supports: + + - JSON (B.4) + - Embedded file (B.6) + - CBOR (B.7) + - C2PA Salt (Not defined by JUMBF, see C2PA 2.0 section 8.3.1.3) + + Other content types can be parsed, but will be returned as `UnknownBox`, and + callers are responsible for interpreting the TBox value and handling the raw + bytes appropriately. + +- References and requests (Annex C): Partial, only supports: + + - Lookup of boxes by URI or path (C.2) + +## Serialization + +- [`SuperBoxBuilder`](http://c2pa/jumbf/box_builder.h): Stateful builder for + creating superboxes. + +- Basic box / superbox structure: Complete + +- Box file format (Annex A): Complete except for the following: + + - Description box numerical IDs + + - Description box hashes + +- Content Types (Annex B): All supported but callers are responsible for + assembling raw payload and using the correct TBox value. + +## Validation + +This library is intentionally very permissive. It only validates box lengths and +the presence of description and content boxes within super boxes. + +Callers are responsible for checking that content box types align with those +declared in description boxes. + +## Super Box naming convention + +The standard, somewhat confusingly, uses the term "JUMBF box" to refer to a +"superbox that shall contain exactly one JUMBF Description Box followed by one +or more JUMBF Content Boxes and at most one Padding Box." However, there are no +mentions anywhere in the standard of "superboxes" that are not "JUMBF Boxes". +So, for the sake of clarity, this library uses the term "Super Box" (in code as +"SuperBox" or "super_box") in place of the term "JUMBF box" as defined by the +standard. + +## Directory structure + +The public API is contained in the top-level directory, c2pa/jumbf. Internal +parsing functions are located in the +["internal" subdirectory](http://c2pa/jumbf/internal). + +As a general rule, recursive parsing and assembling of the output format happens +in the top-level directory, while functions in the internal subdirectory handle +consuming the raw bytes.
diff --git a/jumbf/box.h b/jumbf/box.h new file mode 100644 index 0000000..2e9f795 --- /dev/null +++ b/jumbf/box.h
@@ -0,0 +1,144 @@ +// 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. +// + +// Types to represent deserialized JUMBF boxes, as defined by ISO/IEC 19566-5. +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_BOX_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_BOX_H_ + +#include <cstdint> +#include <optional> +#include <variant> +#include <vector> + +#include "absl/strings/string_view.h" +#include "uuid/uuid.h" + +namespace jumbf { + +struct ContentBox; + +struct UnknownBox { + uint32_t type; + absl::string_view payload; + + bool operator==(const UnknownBox&) const = default; +}; + +// See section A.3 +struct DescriptionBox { + // The type UUID, referred to simply as "type" in section A.3, not to be + // confused with the 32 bit box type described in section 4.3 (all description + // boxes must have box type "jumd"). + credentio::Uuid type_uuid; + + bool requestable = false; + std::optional<absl::string_view> label; + std::optional<uint32_t> id; + // The SHA-256 hash of the associated content boxes. In identical byte order + // to the serialized form as defined in the standard (FIPS PUB 180-4). This + // value is taken directly from the serialized data and has not been validated + // in any way. + std::optional<absl::string_view> unvalidated_hash; + std::vector<ContentBox> private_content; + + bool operator==(const DescriptionBox&) const = default; +}; + +// See section A.2, also see note on naming conventions in README.md +struct SuperBox { + DescriptionBox description; + std::vector<ContentBox> contents; + absl::string_view raw_bytes; + + bool operator==(const SuperBox&) const = default; +}; + +// See section B.7 +struct CborBox { + absl::string_view payload; + + bool operator==(const CborBox&) const = default; +}; + +// See section B.6.2 +struct EmbeddedFileDescriptionBox { + absl::string_view media_type; + bool external = false; + std::optional<absl::string_view> file_name; + + bool operator==(const EmbeddedFileDescriptionBox&) const = default; +}; + +// See section B.6.3 +struct BinaryDataBox { + absl::string_view payload; + + bool operator==(const BinaryDataBox&) const = default; +}; + +// A super box that was not parsed because the recursion limit was reached. +struct SerializedSuperbox { + // The entire serialized super box. + absl::string_view serialized; + + bool operator==(const SerializedSuperbox&) const = default; +}; + +// See C2PA 2.0 section 8.3.1.3 +struct C2paSaltBox { + absl::string_view salt; + + bool operator==(const C2paSaltBox&) const = default; +}; + +struct JsonBox { + absl::string_view payload; + + bool operator==(const JsonBox&) const = default; +}; + +struct UuidBox { + absl::string_view payload; + + bool operator==(const UuidBox&) const = default; +}; + +struct ContentBox { + absl::string_view raw_bytes; + + std::variant<UnknownBox, CborBox, SerializedSuperbox, SuperBox, + EmbeddedFileDescriptionBox, BinaryDataBox, C2paSaltBox, JsonBox, + UuidBox> + payload; + + bool operator==(const ContentBox&) const = default; + + template <typename ExpectedBox> + bool Holds() const { + return std::holds_alternative<ExpectedBox>(payload); + } + + // Retrieves the payload of type `DesiredBox`. Crashes if this content box + // does not contain that type. Use `Holds<ExpectedBox>()` above to check the + // contained type first to guarantee safety. + template <typename DesiredBox> + const DesiredBox& Get() const { + return std::get<DesiredBox>(payload); + } +}; + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_BOX_H_
diff --git a/jumbf/box_builder.cc b/jumbf/box_builder.cc new file mode 100644 index 0000000..ea90401 --- /dev/null +++ b/jumbf/box_builder.cc
@@ -0,0 +1,143 @@ +// 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 "jumbf/box_builder.h" + +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> +#include <utility> + +#include "absl/algorithm/container.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "jumbf/internal/encode.h" +#include "uuid/uuid.h" + +namespace jumbf { + +SuperBoxBuilder::SuperBoxBuilder(credentio::Uuid type_uuid, + DescriptionBoxOptions description_options) + : type_uuid_(std::move(type_uuid)), + description_label_(description_options.label), + description_options_(std::move(description_options)) { + if (description_label_.has_value()) { + description_label_->Flatten(); + } +} + +absl::Status SuperBoxBuilder::AssertStateIn( + absl::Span<const SuperBoxBuildingState> want) const { + if (absl::c_linear_search(want, state_)) { + return absl::OkStatus(); + } + + std::string want_str; + if (want.size() == 1) { + want_str = absl::StrCat(want[0]); + } else { + want_str = absl::StrCat("one of [", absl::StrJoin(want, ", "), "]"); + } + return absl::FailedPreconditionError( + absl::StrCat("builder in invalid state for operation: got: ", state_, + ", want: ", want_str)); +} + +void SuperBoxBuilder::StateTransition( + absl::Span<const SuperBoxBuildingState> from, SuperBoxBuildingState to) { + if (absl::c_linear_search(from, state_)) { + state_ = to; + } +} + +absl::Status SuperBoxBuilder::AddPadding(size_t len) { + ABSL_RETURN_IF_ERROR(AssertStateIn({SuperBoxBuildingState::kEmpty})); + StateTransition({SuperBoxBuildingState::kEmpty}, + SuperBoxBuildingState::kContainsPadding); + return EncodePaddingBox(len, &writer_); +} + +absl::Status SuperBoxBuilder::AddChild(SuperBoxBuilder&& child) { + ABSL_RETURN_IF_ERROR(AssertStateIn( + {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding, + SuperBoxBuildingState::kContainsContent})); + StateTransition( + {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding}, + SuperBoxBuildingState::kContainsContent); + + ABSL_ASSIGN_OR_RETURN(auto serialized_child, std::move(child).Finalize()); + return AddChild(std::move(serialized_child)); +}; + +absl::Status SuperBoxBuilder::AddContent(uint32_t tbox, absl::Cord payload) { + ABSL_RETURN_IF_ERROR(AssertStateIn( + {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding, + SuperBoxBuildingState::kContainsContent})); + StateTransition( + {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding}, + SuperBoxBuildingState::kContainsContent); + return EncodeContentBox(tbox, std::move(payload), &writer_); +} + +absl::Status SuperBoxBuilder::AddChild(BuiltSuperBox&& serialized_superbox) { + StateTransition( + {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding}, + SuperBoxBuildingState::kContainsContent); + return EncodeSerializedSuperBox(std::move(serialized_superbox).AsCord(), + &writer_); +} + +absl::Status SuperBoxBuilder::AddChild(absl::Cord serialized_superbox) { + StateTransition( + {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding}, + SuperBoxBuildingState::kContainsContent); + return EncodeSerializedSuperBox(std::move(serialized_superbox), &writer_); +} + +absl::StatusOr<BuiltSuperBox> SuperBoxBuilder::Finalize() && { + // JUMBF requires at least one content box. + ABSL_RETURN_IF_ERROR( + AssertStateIn({SuperBoxBuildingState::kContainsContent})); + + ABSL_RETURN_IF_ERROR(EncodeDescriptionBox( + std::move(type_uuid_), std::move(description_options_), &writer_)); + ABSL_RETURN_IF_ERROR(EncodeBoxHeader(writer_.pos(), kSuperBoxType, &writer_)); + if (!writer_.Close()) { + return writer_.status(); + } + + std::optional<std::string> description_label; + if (description_label_.has_value()) { + description_label = std::string(*description_label_); + } + auto result = + BuiltSuperBox(std::move(writer_.dest()), std::move(description_label)); + + // Clear out everything to Builder in a reasonable state post-move: + state_ = SuperBoxBuildingState::kEmpty; + writer_.Reset(); + return result; +} + +} // namespace jumbf
diff --git a/jumbf/box_builder.h b/jumbf/box_builder.h new file mode 100644 index 0000000..003f1d4 --- /dev/null +++ b/jumbf/box_builder.h
@@ -0,0 +1,141 @@ +// 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. +// + +// SuperBoxEncoder: Stateful JUMBF encoder. +// +// This class tracks box lengths to correctly write headers. See +// c2pa/jumbf/box_builder_test.cc for usage +// examples. + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_ + +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> +#include <utility> + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "jumbf/encode_params.h" +#include "riegeli/bytes/cord_backward_writer.h" +#include "uuid/uuid.h" + +namespace jumbf { + +enum class SuperBoxBuildingState { + kEmpty, + kContainsPadding, + kContainsContent, +}; + +class BuiltSuperBox; + +class SuperBoxBuilder { + public: + SuperBoxBuilder(credentio::Uuid type_uuid, + DescriptionBoxOptions description_options); + + // Adds a padding box to the superbox. May only be called on an empty encoder. + absl::Status AddPadding(size_t len); + + // Adds a child superbox as a content box of this superbox. New boxes are + // added to the front of this box's content. + absl::Status AddChild(SuperBoxBuilder&& child); + // Add a child superbox as a content box of this superbox. New boxes are + // added to the front of this box's content. + absl::Status AddChild(BuiltSuperBox&& serialized_superbox); + // Add a child superbox as a content box of this superbox. New boxes are + // added to the front of this box's content. + absl::Status AddChild(absl::Cord serialized_superbox); + // Adds a content box. New boxes are added to the front of this box's + // content. + absl::Status AddContent(uint32_t tbox, absl::Cord payload); + + // Finish writing the box. This consumes the builder, use + // `std::move(builder).Finalize()`; + absl::StatusOr<BuiltSuperBox> Finalize() &&; + + private: + absl::Status AssertStateIn( + absl::Span<const SuperBoxBuildingState> want) const; + + void StateTransition(absl::Span<const SuperBoxBuildingState> from, + SuperBoxBuildingState to); + + credentio::Uuid type_uuid_; + // Need to cache the underlying `absl::Cord` for the label, because the + // `DescriptionBoxOptions` gets consumed by `Finalize()`. + std::optional<absl::Cord> description_label_; + DescriptionBoxOptions description_options_; + SuperBoxBuildingState state_ = SuperBoxBuildingState::kEmpty; + riegeli::CordBackwardWriter<absl::Cord> writer_; +}; + +class BuiltSuperBox { + public: + BuiltSuperBox() = default; + + std::optional<absl::string_view> description_label() const { + return description_label_; + } + + // Peeks at the underlying cord. Less efficient than `AsCord()` if the value + // is to be stored elsewhere. + const absl::Cord& Peek() const { return rep_; } + // Moves the underlying cord out of this object. Use + // `std::move(box).AsCord()`. + absl::Cord AsCord() && { + description_label_ = std::nullopt; + return std::move(rep_); + } + + private: + friend absl::StatusOr<BuiltSuperBox> SuperBoxBuilder::Finalize() &&; + + explicit BuiltSuperBox(absl::Cord&& rep, + std::optional<std::string> description_label) + : rep_(std::move(rep)), + description_label_(std::move(description_label)) {} + absl::Cord rep_; + std::optional<std::string> description_label_; +}; + +template <typename Sink> +void AbslStringify(Sink& sink, SuperBoxBuildingState state) { + switch (state) { + case SuperBoxBuildingState::kEmpty: + sink.Append("EMPTY"); + return; + case jumbf::SuperBoxBuildingState::kContainsPadding: + sink.Append("CONTAINS_PADDING"); + return; + case jumbf::SuperBoxBuildingState::kContainsContent: + sink.Append("CONTAINS_CONTENT"); + return; + }; + LOG(DFATAL) << "Fell through end of exhaustive switch statement."; + sink.Append(absl::StrCat("UNKNOWN_STATE_", static_cast<int>(state))); +} + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_
diff --git a/jumbf/box_builder_test.cc b/jumbf/box_builder_test.cc new file mode 100644 index 0000000..fbe0f5f --- /dev/null +++ b/jumbf/box_builder_test.cc
@@ -0,0 +1,231 @@ +// 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 "jumbf/box_builder.h" + +#include <cassert> +#include <optional> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/parse.h" +#include "jumbf/test_utils.h" +#include "uuid/uuid.h" + +namespace jumbf { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::EndsWith; +using ::testing::Eq; +using ::testing::Optional; + +TEST(BoxBuilderTest, ContentBox) { + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + ABSL_ASSERT_OK(builder.AddContent( + jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR"))); + + auto built_box_or = std::move(builder).Finalize(); + ABSL_ASSERT_OK(built_box_or); + auto built_box = std::move(*built_box_or); + absl::Cord serialized = std::move(built_box).AsCord(); + absl::string_view serialized_view = serialized.Flatten(); + auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1); + ABSL_ASSERT_OK(super_box_or); + SuperBox super_box = std::move(*super_box_or); + EXPECT_EQ(super_box.description.type_uuid, kCborBoxTypeUuid); + ASSERT_EQ(super_box.contents.size(), 1); + ASSERT_TRUE(super_box.contents[0].Holds<CborBox>()); + EXPECT_EQ(super_box.contents[0].Get<CborBox>().payload, "this is not CBOR"); +} + +TEST(BoxBuilderTest, InspectLabelAfterFinalize) { + jumbf::SuperBoxBuilder with_label(jumbf::kCborBoxTypeUuid, + {.label = CordFromConstexpr("boxLabel")}); + ABSL_ASSERT_OK(with_label.AddContent( + jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR"))); + auto box_or = std::move(with_label).Finalize(); + ABSL_ASSERT_OK(box_or); + jumbf::BuiltSuperBox box = std::move(*box_or); + + EXPECT_THAT(box.description_label(), Optional(Eq("boxLabel"))); +} + +TEST(BoxBuilderTest, AccessNonexistentLabelAfterFinalize) { + jumbf::SuperBoxBuilder with_label(jumbf::kCborBoxTypeUuid, {}); + ABSL_ASSERT_OK(with_label.AddContent( + jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR"))); + auto box_or = std::move(with_label).Finalize(); + ABSL_ASSERT_OK(box_or); + jumbf::BuiltSuperBox box = std::move(*box_or); + + EXPECT_THAT(box.description_label(), Eq(std::nullopt)); +} + +TEST(BoxBuilderTest, PaddingAndContentBox) { + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + ABSL_ASSERT_OK(builder.AddPadding(20)); + ABSL_ASSERT_OK(builder.AddContent( + jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR"))); + auto box_or = std::move(builder).Finalize(); + ABSL_ASSERT_OK(box_or); + jumbf::BuiltSuperBox box = std::move(*box_or); + + EXPECT_THAT(box.Peek(), EndsWith(std::string(20, 0))); + + absl::Cord serialized = std::move(box).AsCord(); + absl::string_view serialized_view = serialized.Flatten(); + auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1); + ABSL_ASSERT_OK(super_box_or); + SuperBox super_box = std::move(*super_box_or); + EXPECT_EQ(super_box.description.type_uuid, kCborBoxTypeUuid); + ASSERT_EQ(super_box.contents.size(), 1); + ASSERT_TRUE(super_box.contents[0].Holds<CborBox>()); + EXPECT_EQ(super_box.contents[0].Get<CborBox>().payload, "this is not CBOR"); +} + +TEST(BoxBuilderTest, MultipleContentBox) { + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + ABSL_ASSERT_OK(builder.AddContent( + jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR"))); + ABSL_ASSERT_OK(builder.AddContent(kBinaryDataBoxType, + jumbf::CordFromConstexpr("I am data!"))); + + auto built_box_or = std::move(builder).Finalize(); + ABSL_ASSERT_OK(built_box_or); + auto built_box = std::move(*built_box_or); + absl::Cord serialized = std::move(built_box).AsCord(); + absl::string_view serialized_view = serialized.Flatten(); + auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1); + ABSL_ASSERT_OK(super_box_or); + SuperBox super_box = std::move(*super_box_or); + EXPECT_EQ(super_box.description.type_uuid, kCborBoxTypeUuid); + ASSERT_EQ(super_box.contents.size(), 2); + ASSERT_TRUE(super_box.contents[0].Holds<BinaryDataBox>()); + EXPECT_EQ(super_box.contents[0].Get<BinaryDataBox>().payload, "I am data!"); + ASSERT_TRUE(super_box.contents[1].Holds<CborBox>()); + EXPECT_EQ(super_box.contents[1].Get<CborBox>().payload, "this is not CBOR"); +} + +TEST(BoxBuilderTest, NestedSuperBox) { + credentio::Uuid uuid1(0xfeedbabedefec8ed, 0xdeadbeeff00dbabe); + credentio::Uuid uuid2 = jumbf::kCborBoxTypeUuid; + credentio::Uuid uuid3(0xdeadbeeff00dbabe, 0xfeedbabedefec8ed); + + jumbf::SuperBoxBuilder first_inner_builder(uuid1, {}); + ABSL_ASSERT_OK(first_inner_builder.AddContent( + jumbf::kCborBoxType, jumbf::CordFromConstexpr("I go in third"))); + auto first_inner_box_or = std::move(first_inner_builder).Finalize(); + ABSL_ASSERT_OK(first_inner_box_or); + jumbf::BuiltSuperBox first_inner_box = std::move(*first_inner_box_or); + jumbf::SuperBoxBuilder second_inner_builder(uuid2, {}); + ABSL_ASSERT_OK(second_inner_builder.AddContent( + jumbf::kCborBoxType, jumbf::CordFromConstexpr("I go in second"))); + auto second_inner_box_or = std::move(second_inner_builder).Finalize(); + ABSL_ASSERT_OK(second_inner_box_or); + BuiltSuperBox second_inner_box = std::move(*second_inner_box_or); + jumbf::SuperBoxBuilder third_inner_builder(uuid3, {}); + ABSL_ASSERT_OK(third_inner_builder.AddContent( + kBinaryDataBoxType, CordFromConstexpr("I go in first"))); + auto third_inner_box_or = std::move(third_inner_builder).Finalize(); + ABSL_ASSERT_OK(third_inner_box_or); + BuiltSuperBox third_inner_box = std::move(*third_inner_box_or); + + jumbf::SuperBoxBuilder outer( + credentio::Uuid(0xdefec8edb44df00d, 0xfeedbabef00dbabe), {}); + ABSL_ASSERT_OK(outer.AddChild(std::move(third_inner_box))); + ABSL_ASSERT_OK(outer.AddChild(std::move(second_inner_box))); + ABSL_ASSERT_OK(outer.AddChild(std::move(first_inner_box))); + + auto built_box_or = std::move(outer).Finalize(); + ABSL_ASSERT_OK(built_box_or); + auto built_box = std::move(*built_box_or); + absl::Cord serialized = std::move(built_box).AsCord(); + absl::string_view serialized_view = serialized.Flatten(); + auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1); + ABSL_ASSERT_OK(super_box_or); + SuperBox super_box = std::move(*super_box_or); + + EXPECT_EQ(super_box.description.type_uuid, + credentio::Uuid(0xdefec8edb44df00d, 0xfeedbabef00dbabe)); + ASSERT_EQ(super_box.contents.size(), 3); + + // Box 0: third_inner_box + ASSERT_TRUE(super_box.contents[0].Holds<SuperBox>()); + const auto& nested0 = super_box.contents[0].Get<SuperBox>(); + EXPECT_EQ(nested0.description.type_uuid, uuid1); + ASSERT_EQ(nested0.contents.size(), 1); + ASSERT_TRUE(nested0.contents[0].Holds<CborBox>()); + EXPECT_EQ(nested0.contents[0].Get<CborBox>().payload, "I go in third"); + + // Box 1: second_inner_box + ASSERT_TRUE(super_box.contents[1].Holds<SuperBox>()); + const auto& nested1 = super_box.contents[1].Get<SuperBox>(); + EXPECT_EQ(nested1.description.type_uuid, uuid2); + ASSERT_EQ(nested1.contents.size(), 1); + ASSERT_TRUE(nested1.contents[0].Holds<CborBox>()); + EXPECT_EQ(nested1.contents[0].Get<CborBox>().payload, "I go in second"); + + // Box 2: first_inner_box + ASSERT_TRUE(super_box.contents[2].Holds<SuperBox>()); + const auto& nested2 = super_box.contents[2].Get<SuperBox>(); + EXPECT_EQ(nested2.description.type_uuid, uuid3); + ASSERT_EQ(nested2.contents.size(), 1); + ASSERT_TRUE(nested2.contents[0].Holds<BinaryDataBox>()); + EXPECT_EQ(nested2.contents[0].Get<BinaryDataBox>().payload, "I go in first"); +} + +TEST(BoxBuilderTest, EmptyBoxFails) { + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + + EXPECT_THAT(std::move(builder).Finalize(), + StatusIs(absl::StatusCode::kFailedPrecondition, + "builder in invalid state for operation: got: EMPTY, " + "want: CONTAINS_CONTENT")); +} + +TEST(BoxBuilderTest, MultiplePaddingFails) { + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + ABSL_ASSERT_OK(builder.AddPadding(20)); + + EXPECT_THAT(builder.AddPadding(22), + StatusIs(absl::StatusCode::kFailedPrecondition, + "builder in invalid state for operation: got: " + "CONTAINS_PADDING, want: EMPTY")); +} + +TEST(BoxBuilderTest, PaddingAfterContentFails) { + jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {}); + ABSL_ASSERT_OK(builder.AddContent( + kBinaryDataBoxType, CordFromConstexpr("bbbbbbbbbbbbbbbbbbbbbb"))); + + EXPECT_THAT(builder.AddPadding(22), + StatusIs(absl::StatusCode::kFailedPrecondition, + "builder in invalid state for operation: got: " + "CONTAINS_CONTENT, want: EMPTY")); +} + +} // namespace + +} // namespace jumbf
diff --git a/jumbf/constants.h b/jumbf/constants.h new file mode 100644 index 0000000..c3c6de7 --- /dev/null +++ b/jumbf/constants.h
@@ -0,0 +1,73 @@ +// 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. +// + +// Constants for use in parsing JUMBF data. All values from ISO/IEC 19566-5. +// +// Note that all values here are in machine endianness. The standard calls for +// them to be serialized in big-endian order. +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_CONSTANTS_H_ + +#include <cstdint> + +#include "uuid/uuid.h" + +namespace jumbf { + +// Toggle masks for description boxes. (See A.3) +inline constexpr uint8_t kDescriptionToggleEmpty = 0b0000'0000; +inline constexpr uint8_t kDescriptionToggleRequestable = 0b0000'0001; +inline constexpr uint8_t kDescriptionToggleLabelPresent = 0b0000'0010; +inline constexpr uint8_t kDescriptionToggleIdPresent = 0b0000'0100; +inline constexpr uint8_t kDescriptionToggleHashPresent = 0b0000'1000; +inline constexpr uint8_t kDescriptionTogglePrivatePresent = 0b0001'0000; + +// Toggle masks for embedded file description boxes (EFDB). (See B.6.2) +inline constexpr uint8_t kEfdbToggleFileNamePresent = 0b0000'0001; +inline constexpr uint8_t kEfdbToggleExternal = 0b0000'0010; + +// Box types (TBox values). These are the values that apply to individual boxes, +// not description boxes. (See 4.3, A.1 and B) +// +// "jumb" - Superbox (also referred to in the standard as simply "box") +inline constexpr uint32_t kSuperBoxType = 0x6a75'6d62; +// "jumd" - Description box +inline constexpr uint32_t kDescriptionBoxType = 0x6a75'6d64; +// "cbor" - CBOR content box +inline constexpr uint32_t kCborBoxType = 0x6362'6f72; +// "free" - Padding box +inline constexpr uint32_t kPaddingBoxType = 0x6672'6565; +// "bfdb" - Embedded file description box +inline constexpr uint32_t kEmbeddedFileDescriptionBoxType = 0x6266'6462; +// "bidb" - Binary data box +inline constexpr uint32_t kBinaryDataBoxType = 0x6269'6462; +// "priv" - Private content super box +inline constexpr uint32_t kPrivateContentBoxType = 0x7072'6976; +// "c2sh" - C2PA salt box, see C2PA 2.0 section 8.3.1.3 +inline constexpr uint32_t kC2paSaltBoxType = 0x6332'7368; +// "json" - JSON content box +inline constexpr uint32_t kJsonBoxType = 0x6a73'6f6e; +// "uuid" - UUID content box +inline constexpr uint32_t kUuidBoxType = 0x7575'6964; + +inline constexpr auto kCborBoxTypeUuid = + credentio::Uuid::FromStringOrDie("63626F72-0011-0010-8000-00AA00389B71"); + +inline constexpr auto kC2PARedactionUuid = + credentio::Uuid::FromStringOrDie("CAA98EEE-9D4D-F80E-86AD-4DFFCA263973"); + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_CONSTANTS_H_
diff --git a/jumbf/convert_json.cc b/jumbf/convert_json.cc new file mode 100644 index 0000000..650f406 --- /dev/null +++ b/jumbf/convert_json.cc
@@ -0,0 +1,278 @@ +// 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 "jumbf/convert_json.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "cppbor/cppbor.h" +#include "cppbor/cppbor_parse.h" +#include "jumbf/box.h" +#include "nlohmann/json.hpp" +#include "nlohmann/json_fwd.hpp" + +namespace jumbf { +namespace { + +using Json = ::nlohmann::json; + +std::string EncodePrefixedBase64(absl::string_view raw) { + return absl::StrCat("b64'", absl::Base64Escape(raw), "'"); +} + +std::string EncodeCbor(cppbor::Item* item) { + std::vector<uint8_t> buf; + buf.resize(item->encodedSize()); + item->encode(buf.data(), buf.data() + buf.size()); + return std::string(reinterpret_cast<const char*>(buf.data()), buf.size()); +} + +cppbor::Map BstrToB64(cppbor::Map* map); + +std::unique_ptr<cppbor::Item> UnwrapTag(std::unique_ptr<cppbor::Item> item) { + while (item->asSemanticTag() != nullptr) { + if (auto t = item->asTstr(); t != nullptr) return t->clone(); + if (auto u = item->asUint(); u != nullptr) return u->clone(); + if (auto i = item->asInt(); i != nullptr) return i->clone(); + if (auto b = item->asBool(); b != nullptr) return b->clone(); + if (auto f = item->asFloat(); f != nullptr) return f->clone(); + if (auto d = item->asDouble(); d != nullptr) return d->clone(); + if (auto s = item->asSimple(); s != nullptr) return s->clone(); + break; + } + return item; +} + +// Recursively replaces all Bstr with base64-encoded Tstr. +cppbor::Array BstrToB64(cppbor::Array* array) { + cppbor::Array new_array; + for (auto& item_ref : *array) { + auto item = UnwrapTag(std::move(item_ref)); + if (auto s = item->asBstr(); s != nullptr) { + absl::string_view sv(reinterpret_cast<const char*>(s->value().data()), + s->value().size()); + new_array.add(cppbor::Tstr(EncodePrefixedBase64(sv))); + } else if (auto m = item->asMap(); m != nullptr) { + new_array.add(BstrToB64(m)); + } else if (auto a = item->asArray(); a != nullptr) { + new_array.add(BstrToB64(a)); + } else { + new_array.add(std::move(item)); + } + } + return new_array; +} + +// Recursively replaces all Bstr with base64-encoded Tstr. +cppbor::Map BstrToB64(cppbor::Map* map) { + cppbor::Map new_map; + for (auto& [key_ref, value_ref] : *map) { + auto key = UnwrapTag(std::move(key_ref)); + auto value = UnwrapTag(std::move(value_ref)); + if (auto s = value->asBstr(); s != nullptr) { + absl::string_view sv(reinterpret_cast<const char*>(s->value().data()), + s->value().size()); + new_map.add(std::move(key), cppbor::Tstr(EncodePrefixedBase64(sv))); + } else if (auto m = value->asMap(); m != nullptr) { + new_map.add(std::move(key), BstrToB64(m)); + } else if (auto v = value->asArray(); v != nullptr) { + cppbor::Array new_array = BstrToB64(v); + new_map.add(std::move(key), std::move(new_array)); + } else { + new_map.add(std::move(key), std::move(value)); + } + } + return new_map; +} + +// Recursively replaces all Bstr with base64-encoded Tstr. Supports only arrays +// and maps. +absl::StatusOr<std::string> BstrToB64(absl::string_view raw) { + auto [item, new_position, error] = + cppbor::parse(reinterpret_cast<const uint8_t*>(raw.data()), raw.length()); + if (!error.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("CBOR parsing failed: ", error)); + } + if (new_position != + reinterpret_cast<const uint8_t*>(raw.data() + raw.length())) { + return absl::InvalidArgumentError( + "Trailing bytes after the parsed CBOR item"); + } + + if (auto map = item->asMap(); map != nullptr) { + auto new_map = BstrToB64(map); + return EncodeCbor(&new_map); + } else if (auto array = item->asArray(); array != nullptr) { + auto new_array = BstrToB64(array); + return EncodeCbor(&new_array); + } + return absl::InvalidArgumentError( + absl::StrCat("Input CBOR item is ", item->type(), + "; expected array (128) or map (160).")); +} + +class AllExceptionParser + : public nlohmann::detail::json_sax_dom_parser< + Json, nlohmann::detail::iterator_input_adapter<const char*>> { + public: + explicit AllExceptionParser(Json& j) + : nlohmann::detail::json_sax_dom_parser< + Json, nlohmann::detail::iterator_input_adapter<const char*>>( + j, false) {} + + bool parse_error(std::size_t position, absl::string_view last_token, + const Json::exception& ex) { + errors_.push_back(absl::StrFormat("%s", ex.what())); + return false; + } + + std::vector<std::string> errors() { return errors_; } + + private: + std::vector<std::string> errors_; +}; + +} // namespace + +void to_json(Json& j, const DescriptionBox& box) { + if (box.label.has_value()) { + j["label"] = box.label.value(); + } + if (box.id.has_value()) { + j["id"] = box.id.value(); + } + if (box.unvalidated_hash.has_value()) { + j["hash"] = box.unvalidated_hash.value(); + } + j["uuid"] = box.type_uuid.ToString(); + j["requestable"] = box.requestable; + + for (const auto& box : box.private_content) { + j["private_content"].push_back(box); + } + + j["_meta"]["_type"] = "description"; +} + +void to_json(Json& j, const SuperBox& box) { + j["description"] = box.description; + + for (const auto& box : box.contents) { + j["contents"].push_back(box); + } + + j["_meta"]["_type"] = "super"; +} + +void to_json(Json& j, const UnknownBox& box) { + j["type"] = box.type; + + j["_meta"]["_type"] = "unknown"; + j["_meta"]["_payload_size"] = box.payload.size(); +} + +void to_json(Json& j, const CborBox& box) { + j["_meta"]["_type"] = "cbor"; + j["_meta"]["_payload_size"] = box.payload.size(); + + absl::StatusOr<std::string> encoded = BstrToB64(box.payload); + if (!encoded.ok()) { + j["_meta"]["_error"] = encoded.status().ToString(); + return; + } + + AllExceptionParser parser(j["cbor"]); + auto ia = nlohmann::detail::input_adapter(*encoded); + nlohmann::detail::binary_reader<Json, decltype(ia), AllExceptionParser> + reader(std::move(ia), Json::input_format_t::cbor); + reader.sax_parse(Json::input_format_t::cbor, &parser, /*strict=*/true, + /*tag_handler=*/Json::cbor_tag_handler_t::ignore); + if (!parser.errors().empty()) { + j["_meta"]["_error"] = parser.errors(); + } +} + +void to_json(Json& j, const SerializedSuperbox& box) { + j["_meta"]["_type"] = "serialized_superbox"; + j["_meta"]["_serialized_size"] = box.serialized.size(); +} + +void to_json(Json& j, const EmbeddedFileDescriptionBox& box) { + j["media_type"] = box.media_type; + j["external"] = box.external; + if (box.file_name.has_value()) { + j["file_name"] = box.file_name.value(); + } + + j["_meta"]["_type"] = "embedded_file_description"; +} + +void to_json(Json& j, const BinaryDataBox& box) { + j["_meta"]["_type"] = "binary"; + j["_meta"]["_payload_size"] = box.payload.size(); +} + +void to_json(Json& j, const C2paSaltBox& box) { + j["_meta"]["_type"] = "c2pa_salt"; + j["_meta"]["_salt_size"] = box.salt.size(); +} + +void to_json(Json& j, const JsonBox& box) { + AllExceptionParser parser(j["json"]); + Json::sax_parse(box.payload, &parser, Json::input_format_t::json); + + if (!parser.errors().empty()) { + j["_meta"]["_error"] = parser.errors(); + } + j["_meta"]["_type"] = "json"; + j["_meta"]["_payload_size"] = box.payload.size(); +} + +void to_json(Json& j, const ContentBox& box) { + if (box.Holds<jumbf::SuperBox>()) { + j = box.Get<jumbf::SuperBox>(); + } else if (box.Holds<jumbf::UnknownBox>()) { + j = box.Get<jumbf::UnknownBox>(); + } else if (box.Holds<jumbf::CborBox>()) { + j = box.Get<jumbf::CborBox>(); + } else if (box.Holds<jumbf::SerializedSuperbox>()) { + j = box.Get<jumbf::SerializedSuperbox>(); + } else if (box.Holds<jumbf::EmbeddedFileDescriptionBox>()) { + j = box.Get<jumbf::EmbeddedFileDescriptionBox>(); + } else if (box.Holds<jumbf::BinaryDataBox>()) { + j = box.Get<jumbf::BinaryDataBox>(); + } else if (box.Holds<jumbf::C2paSaltBox>()) { + j = box.Get<jumbf::C2paSaltBox>(); + } else if (box.Holds<jumbf::JsonBox>()) { + j = box.Get<jumbf::JsonBox>(); + } else { + j["_meta"]["_error"] = "Contents Not Accounted For"; + } +} + +} // namespace jumbf
diff --git a/jumbf/convert_json.h b/jumbf/convert_json.h new file mode 100644 index 0000000..1373ecc --- /dev/null +++ b/jumbf/convert_json.h
@@ -0,0 +1,37 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_CONVERT_JSON_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_CONVERT_JSON_H_ + +#include "jumbf/box.h" +#include "nlohmann/json_fwd.hpp" + +namespace jumbf { + +void to_json(nlohmann::json& json, const ContentBox& box); +void to_json(nlohmann::json& json, const DescriptionBox& box); +void to_json(nlohmann::json& json, const SuperBox& box); +void to_json(nlohmann::json& json, const UnknownBox& box); +void to_json(nlohmann::json& json, const CborBox& box); +void to_json(nlohmann::json& json, const SerializedSuperbox& box); +void to_json(nlohmann::json& json, const BinaryDataBox& box); +void to_json(nlohmann::json& json, const C2paSaltBox& box); +void to_json(nlohmann::json& json, const JsonBox& box); +void to_json(nlohmann::json& json, const ContentBox& box); + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_CONVERT_JSON_H_
diff --git a/jumbf/encode_params.h b/jumbf/encode_params.h new file mode 100644 index 0000000..9ede39f --- /dev/null +++ b/jumbf/encode_params.h
@@ -0,0 +1,38 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_ENCODE_PARAMS_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_ENCODE_PARAMS_H_ + +#include <optional> + +#include "absl/strings/cord.h" + +namespace jumbf { + +// Common options for all encoding functions that write description boxes. +struct DescriptionBoxOptions { + // Whether to set the requestable bit IFF `label` has a value (setting the + // requestable bit without providing a label is not possible because it is not + // permitted by the standard.) + bool requestable_iff_label = true; + std::optional<absl::Cord> label = std::nullopt; + // Fully-encoded private content. + std::optional<absl::Cord> private_content = std::nullopt; +}; + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_ENCODE_PARAMS_H_
diff --git a/jumbf/internal/BUILD b/jumbf/internal/BUILD new file mode 100644 index 0000000..447a37a --- /dev/null +++ b/jumbf/internal/BUILD
@@ -0,0 +1,238 @@ +# 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. +# + +load("@rules_cc//cc:cc_test.bzl", "cc_test") +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "encode", + srcs = ["encode.cc"], + hdrs = ["encode.h"], + deps = [ + ":serialization_utils", + "//jumbf:constants", + "//jumbf:encode_params", + "//uuid", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings:cord", + "@riegeli//riegeli/base:types", + "@riegeli//riegeli/bytes:backward_writer", + ], +) + +cc_test( + name = "encode_test", + srcs = ["encode_test.cc"], + deps = [ + ":encode", + ":serialization_utils", + "//jumbf:constants", + "//jumbf:encode_params", + "//jumbf:test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/base:chain", + "@riegeli//riegeli/base:initializer", + "@riegeli//riegeli/bytes:chain_backward_writer", + "@riegeli//riegeli/bytes:position_shifting_backward_writer", + ], +) + +cc_library( + name = "intermediate", + hdrs = ["intermediate.h"], + deps = [ + "//uuid", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "consume_box", + srcs = ["consume_box.cc"], + hdrs = ["consume_box.h"], + deps = [ + ":intermediate", + ":serialization_utils", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "parse_super_box", + srcs = ["parse_super_box.cc"], + hdrs = ["parse_super_box.h"], + deps = [ + ":consume_box", + ":intermediate", + ":parse_description_box", + "//jumbf:constants", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "serialization_utils", + srcs = ["serialization_utils.cc"], + hdrs = ["serialization_utils.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:cord", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/base:byte_fill", + "@riegeli//riegeli/bytes:backward_writer", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "parse_description_box", + srcs = ["parse_description_box.cc"], + hdrs = ["parse_description_box.h"], + deps = [ + ":consume_box", + ":intermediate", + ":serialization_utils", + "//jumbf:constants", + "//uuid", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "parse_embedded_file_description_box", + srcs = ["parse_embedded_file_description_box.cc"], + hdrs = ["parse_embedded_file_description_box.h"], + deps = [ + ":intermediate", + ":serialization_utils", + "//jumbf:box", + "//jumbf:constants", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "parse_super_box_test", + srcs = ["parse_super_box_test.cc"], + deps = [ + ":intermediate", + ":parse_super_box", + "//jumbf:constants", + "//jumbf:test_utils", + "//uuid", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "serialization_utils_test", + srcs = ["serialization_utils_test.cc"], + deps = [ + ":serialization_utils", + "//jumbf:test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/base:initializer", + "@riegeli//riegeli/bytes:chain_backward_writer", + "@riegeli//riegeli/bytes:position_shifting_backward_writer", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "parse_description_box_test", + srcs = ["parse_description_box_test.cc"], + deps = [ + ":intermediate", + ":parse_description_box", + "//jumbf:constants", + "//jumbf:test_utils", + "//uuid", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "consume_box_test", + srcs = ["consume_box_test.cc"], + tags = ["requires-mem:16g"], + deps = [ + ":consume_box", + ":intermediate", + "//jumbf:test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "parse_embedded_file_description_box_test", + srcs = ["parse_embedded_file_description_box_test.cc"], + deps = [ + ":intermediate", + ":parse_embedded_file_description_box", + "//jumbf:box", + "//jumbf:constants", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + "@riegeli//riegeli/endian:endian_writing", + ], +)
diff --git a/jumbf/internal/consume_box.cc b/jumbf/internal/consume_box.cc new file mode 100644 index 0000000..b7919b0 --- /dev/null +++ b/jumbf/internal/consume_box.cc
@@ -0,0 +1,97 @@ +// 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 "jumbf/internal/consume_box.h" + +#include <cstddef> +#include <cstdint> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/internal/serialization_utils.h" + +namespace jumbf_internal { + +absl::StatusOr<IntermediateBox> ConsumeBox(absl::string_view* input, + bool allow_implicit_length) { + absl::string_view temp_input = *input; + + auto lbox = ConsumeInteger<uint32_t>(&temp_input); + if (!lbox.ok()) { + return absl::Status( + lbox.status().code(), + absl::StrCat("cannot read LBox value: ", lbox.status().message())); + } + + auto tbox = ConsumeInteger<uint32_t>(&temp_input); + if (!tbox.ok()) { + return absl::Status( + tbox.status().code(), + absl::StrCat("cannot read TBox value: ", tbox.status().message())); + } + uint64_t box_length; + + if (*lbox == 0) { + if (allow_implicit_length) { + box_length = input->length(); + } else { + return absl::InvalidArgumentError( + R"(LBox is 0, indicating box extends to end of input, but context does not allow for implicit box lengths; implicit boxes are only allowed if they are the last box in the file, and if they are contained within a super box, that super box must also have an implicit length)"); + } + + } else if (*lbox == 1) { + auto xlbox = ConsumeInteger<uint64_t>(&temp_input); + if (!xlbox.ok()) { + return absl::Status( + xlbox.status().code(), + absl::StrCat("cannot read XLBox value: ", xlbox.status().message())); + } + if (*xlbox < 16) { + return absl::InvalidArgumentError(absl::StrCat( + "XLBox is ", *xlbox, + ", which is less than the number of bytes already consumed by " + "this box's header")); + } + box_length = *xlbox; + } else if (*lbox <= 7) { + return absl::InvalidArgumentError(absl::StrCat( + "LBox is ", *lbox, "; LBox values 2-7 are reserved by the standard")); + } else { + box_length = *lbox; + } + + if (box_length > input->length()) { + return absl::InvalidArgumentError(absl::StrCat( + "not enough input bytes for declared box size; have ", input->length(), + " input bytes, box declares its size as ", box_length, " bytes")); + } + + size_t header_bytes = temp_input.data() - input->data(); + absl::string_view serialized = input->substr(0, box_length); + input->remove_prefix(box_length); + + return IntermediateBox{ + .payload = temp_input.substr(0, box_length - header_bytes), + .serialized = serialized, + .type = *tbox, + .implicit_length = *lbox == 0, + }; +} + +} // namespace jumbf_internal
diff --git a/jumbf/internal/consume_box.h b/jumbf/internal/consume_box.h new file mode 100644 index 0000000..bb2f1b0 --- /dev/null +++ b/jumbf/internal/consume_box.h
@@ -0,0 +1,35 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_CONSUME_BOX_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_CONSUME_BOX_H_ + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "jumbf/internal/intermediate.h" + +namespace jumbf_internal { + +// Consumes the first box in `input`. Only parses the basic box structure +// (section 4.3). Only validates box size values. If an error occurs, `input` +// will not be modified. `allow_implicit_length` determines if LBox values +// of 0 are allowed. This value should be set to true only if the current +// parsing context would allow for an implicit box length. +absl::StatusOr<IntermediateBox> ConsumeBox(absl::string_view* input, + bool allow_implicit_length); + +} // namespace jumbf_internal + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_CONSUME_BOX_H_
diff --git a/jumbf/internal/consume_box_test.cc b/jumbf/internal/consume_box_test.cc new file mode 100644 index 0000000..f8dd440 --- /dev/null +++ b/jumbf/internal/consume_box_test.cc
@@ -0,0 +1,324 @@ +// 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 "jumbf/internal/consume_box.h" + +#include <cstddef> +#include <cstdint> +#include <cstring> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/test_utils.h" +#include "riegeli/endian/endian_writing.h" + +namespace jumbf_internal { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::jumbf::WrapBox; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::IsEmpty; + +TEST(ConsumeBoxTest, ConsumeSuccessfulExtraData) { + constexpr absl::string_view kExtraData = R"(this is not JUMBF data)"; + constexpr absl::string_view kPayload = R"(this is the box's payload)"; + + const std::string buffer = absl::StrCat(WrapBox(kPayload, 1234), kExtraData); + absl::string_view input = buffer; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + IsOkAndHolds(IntermediateBox{ + .payload = kPayload, + .serialized = buffer.substr(0, kPayload.size() + 8), + .type = 1234, + })); + EXPECT_THAT(input, Eq(kExtraData)); +} + +TEST(ConsumeBoxTest, ConsumeSuccessfulTwoBoxes) { + constexpr absl::string_view kFirstPayload = + R"(this is the first box's payload)"; + constexpr absl::string_view kSecondPayload = + R"(this is the second box's payload)"; + + const std::string buffer = + absl::StrCat(WrapBox(kFirstPayload, 1234), WrapBox(kSecondPayload, 5678)); + absl::string_view input = buffer; + + size_t first_box_size = kFirstPayload.size() + 8; + size_t second_box_size = kSecondPayload.size() + 8; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + IsOkAndHolds(IntermediateBox{ + .payload = kFirstPayload, + .serialized = buffer.substr(0, first_box_size), + .type = 1234, + })); + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + IsOkAndHolds(IntermediateBox{ + .payload = kSecondPayload, + .serialized = buffer.substr(first_box_size, second_box_size), + .type = 5678, + })); + EXPECT_THAT(input, IsEmpty()); +} + +TEST(ConsumeBoxTest, ConsumeSuccessful) { + constexpr absl::string_view kPayload = R"(this is the box's payload)"; + + const std::string buffer = WrapBox(kPayload, 1234); + absl::string_view input = buffer; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + IsOkAndHolds(IntermediateBox{ + .payload = kPayload, .serialized = buffer, .type = 1234})); + EXPECT_THAT(input, IsEmpty()); +} + +TEST(ConsumeBoxTest, EmptyInputFails) { + absl::string_view input = ""; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + StatusIs(absl::StatusCode::kOutOfRange, + "cannot read LBox value: not enough input bytes " + "remaining; have 0 need 4")); + EXPECT_THAT(input, IsEmpty()); +} + +TEST(ConsumeBoxTest, SizeTooLargeFails) { + constexpr absl::string_view kPayload = R"(this is the box's payload)"; + + std::string buffer = WrapBox(kPayload, 1234); + char* buffer_data = buffer.data(); + // Overwrite size with too large of value + riegeli::WriteBigEndian<uint32_t>(5280, buffer_data); + buffer_data += sizeof(uint32_t); + absl::string_view input = buffer; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + "not enough input bytes for declared box size; have 33 " + "input bytes, box declares its size as 5280 bytes")); + EXPECT_THAT(input, Eq(buffer)); +} + +TEST(ConsumeBoxTest, XlBoxSizeSmallPayloadSucceeds) { + // Small payload, could have fit without using XLBox but the standard never + // states a minimum size to use XLBox. + constexpr absl::string_view kFakePayload = "some random data blah blah"; + constexpr uint32_t kLBox = 1; + constexpr uint32_t kTBox = 12345; + constexpr uint64_t kXlBox = + /*LBox*/ 4 + /*TBox*/ 4 + /*XLBox*/ 8 + kFakePayload.length(); + + std::string buffer(kXlBox, 'X'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint64_t>(kXlBox, cursor); + cursor += sizeof(uint64_t); + // Safe because size of string was computed to be large enough for + // kFakePayload above. + memcpy(cursor, kFakePayload.data(), kFakePayload.size()); + + absl::string_view input = buffer; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + IsOkAndHolds(IntermediateBox{ + .payload = kFakePayload, + .serialized = buffer, + .type = kTBox, + .implicit_length = false, + })); + EXPECT_THAT(input, IsEmpty()); +} + +TEST(ConsumeBoxTest, XlBoxSizeLargePayloadSucceeds) { + // Large payload, could not have fit without using XLBox. + const std::string payload(5'000'000'000, 'X'); + constexpr uint32_t kLBox = 1; + constexpr uint32_t kTBox = 12345; + const uint64_t XlBox = + /*LBox=*/4 + /*TBox=*/4 + /*XLBox=*/8 + payload.length(); + + std::string buffer(XlBox, 'X'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint64_t>(XlBox, cursor); + cursor += sizeof(uint64_t); + // Safe because size of string was computed to be large enough for + // kFakePayload above. + memcpy(cursor, payload.data(), payload.size()); + + absl::string_view input = buffer; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + IsOkAndHolds(IntermediateBox{ + .payload = payload, + .serialized = buffer, + .type = kTBox, + .implicit_length = false, + })); + EXPECT_THAT(input, IsEmpty()); +} + +TEST(ConsumeBoxTest, XlBoxSizeTooLargeFails) { + constexpr absl::string_view kFakePayload = "some random data blah blah"; + constexpr uint32_t kLBox = 1; + constexpr uint32_t kTBox = 12345; + constexpr uint64_t kXlBox = 8008888888; + + std::string buffer( + /*LBox=*/4 + /*XLBox=*/8 + /*TBox=*/4 + kFakePayload.length(), 'X'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint64_t>(kXlBox, cursor); + cursor += sizeof(uint64_t); + // Safe because size of string was computed to be large enough for + // kFakePayload above. + memcpy(cursor, kFakePayload.data(), kFakePayload.size()); + + absl::string_view input = buffer; + + EXPECT_THAT( + ConsumeBox(&input, /*allow_implicit_length=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + "not enough input bytes for declared box size; have 42 " + "input bytes, box declares its size as 8008888888 bytes")); + EXPECT_THAT(input, Eq(buffer)); +} + +TEST(ConsumeBoxTest, XlBoxSizeTooSmallFails) { + constexpr absl::string_view kFakePayload = "some random data blah blah"; + constexpr uint32_t kLBox = 1; + constexpr uint32_t kTBox = 12345; + constexpr uint64_t kXlBox = 2; + + std::string buffer( + /*LBox=*/4 + /*XLBox=*/8 + /*TBox=*/4 + kFakePayload.length(), 'X'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint64_t>(kXlBox, cursor); + cursor += sizeof(uint64_t); + // Safe because size of string was computed to be large enough for + // kFakePayload above. + memcpy(cursor, kFakePayload.data(), kFakePayload.size()); + + absl::string_view input = buffer; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + "XLBox is 2, which is less than the number of bytes " + "already consumed by this box's header")); + EXPECT_THAT(input, Eq(buffer)); +} + +TEST(ConsumeBoxTest, ReservedBoxLengthFails) { + constexpr uint32_t kLBox = 5; + constexpr uint32_t kTBox = 12345; + + std::string buffer(8, 'A'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + + absl::string_view input = buffer; + + EXPECT_THAT( + ConsumeBox(&input, /*allow_implicit_length=*/true), + StatusIs(absl::StatusCode::kInvalidArgument, + "LBox is 5; LBox values 2-7 are reserved by the standard")); + EXPECT_THAT(input, Eq(buffer)); +} + +TEST(ConsumeBoxTest, ZeroBoxLengthSucceedsWhenPermitted) { + constexpr uint32_t kLBox = 0; + constexpr uint32_t kTBox = 12345; + constexpr absl::string_view kContent = + R"(blah blah blah blah when will I stop writing? I don't know so you won't know until you reach the end because the box length is 0 okay now I'm done bye)"; + + std::string buffer(8 + kContent.length(), '\0'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + memcpy(cursor, kContent.data(), kContent.size()); + + absl::string_view input = buffer; + + EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true), + IsOkAndHolds(IntermediateBox{ + .payload = kContent, + .serialized = buffer, + .type = kTBox, + .implicit_length = true, + })); + EXPECT_THAT(input, IsEmpty()); +} + +TEST(ConsumeBoxTest, ZeroBoxLengthFailsWhenNotPermitted) { + constexpr uint32_t kLBox = 0; + constexpr uint32_t kTBox = 12345; + + std::string buffer(8, '\0'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + + absl::string_view input = buffer; + + EXPECT_THAT( + ConsumeBox(&input, /*allow_implicit_length=*/false), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("LBox is 0, indicating box extends to end of input, but " + "context does not allow for implicit box lengths"))); + EXPECT_THAT(input, Eq(buffer)); +} + +} // namespace +} // namespace jumbf_internal
diff --git a/jumbf/internal/encode.cc b/jumbf/internal/encode.cc new file mode 100644 index 0000000..1b628bb --- /dev/null +++ b/jumbf/internal/encode.cc
@@ -0,0 +1,108 @@ +// 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 "jumbf/internal/encode.h" + +#include <cstddef> +#include <cstdint> +#include <limits> +#include <utility> + +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/cord.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "jumbf/internal/serialization_utils.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/backward_writer.h" +#include "uuid/uuid.h" + +namespace jumbf { + +using ::jumbf_internal::WriteInteger; +using ::jumbf_internal::WriteNulTerminated; +using ::jumbf_internal::WritePadding; +using ::jumbf_internal::WriteRaw; + +static_assert(std::numeric_limits<uint64_t>::max() >= + std::numeric_limits<size_t>::max(), + "This library converts from `size_t` to `uint64_t` when writing " + "XLBox values."); + +absl::Status EncodePaddingBox(uint64_t padding_size, + riegeli::BackwardWriter* writer) { + ABSL_RETURN_IF_ERROR(WritePadding(padding_size, writer)); + return EncodeBoxHeader(padding_size, kPaddingBoxType, writer); +} + +absl::Status EncodeSerializedSuperBox(absl::Cord payload, + riegeli::BackwardWriter* writer) { + return WriteRaw(std::move(payload), writer); +} + +absl::Status EncodeContentBox(uint32_t tbox, absl::Cord payload, + riegeli::BackwardWriter* writer) { + size_t payload_size = payload.size(); + ABSL_RETURN_IF_ERROR(WriteRaw(std::move(payload), writer)); + return EncodeBoxHeader(payload_size, tbox, writer); +} + +absl::Status EncodeDescriptionBox(credentio::Uuid type_uuid, + DescriptionBoxOptions options, + riegeli::BackwardWriter* writer) { + uint8_t toggles = 0; + const riegeli::Position pos_before = writer->pos(); + if (options.private_content.has_value()) { + toggles |= kDescriptionTogglePrivatePresent; + ABSL_RETURN_IF_ERROR(WriteRaw(*std::move(options.private_content), writer)); + } + if (options.label.has_value()) { + if (options.requestable_iff_label) { + toggles |= kDescriptionToggleRequestable; + } + toggles |= kDescriptionToggleLabelPresent; + ABSL_RETURN_IF_ERROR(WriteNulTerminated(*std::move(options.label), writer)); + } + ABSL_RETURN_IF_ERROR(WriteInteger(toggles, writer)); + ABSL_RETURN_IF_ERROR(WriteRaw(absl::Cord(type_uuid.ToProtoBytes()), writer)); + return EncodeBoxHeader(writer->pos() - pos_before, kDescriptionBoxType, + writer); +} + +absl::Status EncodeBoxHeader(riegeli::Position payload_length, uint32_t tbox, + riegeli::BackwardWriter* writer) { + // Minimum box length is payload + mandatory header fields. + constexpr size_t kMandatoryHeaderLength = + 2 * sizeof(uint32_t); // LBox and TBox + if (payload_length > std::numeric_limits<size_t>::max() - + kMandatoryHeaderLength - sizeof(uint64_t)) { + return absl::OutOfRangeError("maximum box size exceeded"); + } + size_t box_length = payload_length + kMandatoryHeaderLength; + uint32_t lbox; + if (box_length > std::numeric_limits<uint32_t>::max()) { + uint64_t xlbox = box_length + sizeof(uint64_t); + ABSL_RETURN_IF_ERROR(WriteInteger(xlbox, writer)); + lbox = 1; + } else { + lbox = box_length; + } + ABSL_RETURN_IF_ERROR(WriteInteger(tbox, writer)); + return WriteInteger(lbox, writer); +} + +} // namespace jumbf
diff --git a/jumbf/internal/encode.h b/jumbf/internal/encode.h new file mode 100644 index 0000000..3990565 --- /dev/null +++ b/jumbf/internal/encode.h
@@ -0,0 +1,72 @@ +// 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. +// + +// Internal encoding primitives: Functions for writing individual JUMBF +// constructs. +// +// These functions each write a single JUMBF construct. Care is needed to use +// them properly, as callers must track the number of bytes written to properly +// close out Superboxes. +// +// For example, to create a superbox containing a single CBOR content box: +// +// size_t superbox_size = 0; +// CHECK_OK(EncodeContentBox(kCborBoxType, cbor_data, &writer, &superbox_size)); +// CHECK_OK(EncodeDescriptionBox(kCborBoxTypeUuid, DescriptionBoxOptions{ +// .label = riegeli::ExternalRef::From("mylabel"), +// }, &writer, &superbox_size)); +// CHECK_OK(EncodeBoxHeader(superbox_size, kSuperBoxType, &writer, +// &superbox_size)); +// +// The public `SuperBoxBuilder` class in +// c2pa/jumbf/box_builder.h is easier to use. + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_ + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/strings/cord.h" +#include "jumbf/encode_params.h" +#include "riegeli/base/types.h" +#include "riegeli/bytes/backward_writer.h" +#include "uuid/uuid.h" + +namespace jumbf { +// Writes a padding box to `writer`. +absl::Status EncodePaddingBox(uint64_t padding_size, + riegeli::BackwardWriter* writer); + +// Writes `payload` of the serialized super box to `writer`. +absl::Status EncodeSerializedSuperBox(absl::Cord payload, + riegeli::BackwardWriter* writer); + +// Wraps `payload` in a box of type `tbox` and writes it to `writer`. +absl::Status EncodeContentBox(uint32_t tbox, absl::Cord payload, + riegeli::BackwardWriter* writer); + +// Writes a description box to `writer`. +absl::Status EncodeDescriptionBox(credentio::Uuid type_uuid, + DescriptionBoxOptions options, + riegeli::BackwardWriter* writer); + +// Closes a box by writing the header to `writer`. +absl::Status EncodeBoxHeader(riegeli::Position payload_length, uint32_t tbox, + riegeli::BackwardWriter* writer); + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_
diff --git a/jumbf/internal/encode_test.cc b/jumbf/internal/encode_test.cc new file mode 100644 index 0000000..b19b387 --- /dev/null +++ b/jumbf/internal/encode_test.cc
@@ -0,0 +1,332 @@ +// 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 "jumbf/internal/encode.h" + +#include <sys/types.h> + +#include <cstddef> +#include <cstdint> +#include <limits> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/constants.h" +#include "jumbf/encode_params.h" +#include "jumbf/internal/serialization_utils.h" +#include "jumbf/test_utils.h" +#include "riegeli/base/chain.h" +#include "riegeli/base/maker.h" +#include "riegeli/bytes/chain_backward_writer.h" +#include "riegeli/bytes/position_shifting_backward_writer.h" + +namespace jumbf { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::jumbf_internal::ConsumeInteger; +using ::testing::Eq; +using ::testing::IsEmpty; + +TEST(EncodePaddingBoxTest, EncodesPaddingBox) { + constexpr uint64_t kPaddingSize = 7; + constexpr size_t kExpectedOutputSize = + sizeof(uint32_t) /*LBox*/ + sizeof(uint32_t) /*TBox*/ + kPaddingSize; + + riegeli::ChainBackwardWriter writer; + ABSL_ASSERT_OK(EncodePaddingBox(7, &writer)); + writer.Close(); + absl::string_view result_view = writer.dest().Flatten(); + + EXPECT_THAT(writer.pos(), kExpectedOutputSize); + // LBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kExpectedOutputSize)); + // TBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds('free')); + EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, kPaddingSize), + IsOkAndHolds(std::string("\0\0\0\0\0\0\0\0", 7))); + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeContentBoxTest, EncodesContentBox) { + constexpr absl::string_view kPayload = R"(I am payload!)"; + constexpr uint32_t kTbox = 0xfeedbabe; + constexpr size_t kExpectedOutputSize = + sizeof(uint32_t) /*LBox*/ + sizeof(uint32_t) /*TBox*/ + kPayload.length(); + // Simulating content already written in superbox + constexpr size_t kTotalBytesInitial = 5280; + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kTotalBytesInitial)); + ABSL_ASSERT_OK(EncodeContentBox(kTbox, CordFromConstexpr(kPayload), &writer)); + writer.Close(); + absl::string_view result_view = writer.dest().dest().Flatten(); + + EXPECT_THAT(writer.pos(), Eq(kTotalBytesInitial + kExpectedOutputSize)); + // LBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kExpectedOutputSize)); + // TBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kTbox)); + EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, kPayload.length()), + IsOkAndHolds(kPayload)); + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeDescriptionBoxTest, DefaultOptions) { + constexpr size_t kExpectedOutputSize = 25; + // Simulating content already written in superbox + constexpr size_t kTotalBytesInitial = 0xf00dbabe; + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kTotalBytesInitial)); + ABSL_ASSERT_OK( + EncodeDescriptionBox(kCborBoxTypeUuid, DescriptionBoxOptions{}, &writer)); + + ASSERT_TRUE(writer.Close()); + absl::string_view result_view = writer.dest().dest().Flatten(); + + EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + 0xf00dbabe)); + + // LBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kExpectedOutputSize)); + // TBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kDescriptionBoxType)); + + // Type UUID + EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16), + IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes())); + // Toggles + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view), + IsOkAndHolds(0)); + + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeDescriptionBoxTest, HasLabel) { + constexpr absl::string_view kLabel = "mylabel"; + constexpr size_t kExpectedOutputSize = + 4 /*lbox*/ + 4 /*tbox*/ + 16 /*type UUID*/ + 1 /*toggles*/ + + kLabel.length() + 1 /*label NUL terminator*/; + // Simulating content already written in superbox + constexpr size_t kTotalBytesInitial = 0xdeadbeef; + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kTotalBytesInitial)); + ABSL_ASSERT_OK(EncodeDescriptionBox(kCborBoxTypeUuid, + DescriptionBoxOptions{ + .label = CordFromConstexpr(kLabel), + }, + &writer)); + + ASSERT_TRUE(writer.Close()); + absl::string_view result_view = writer.dest().dest().Flatten(); + + EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + kTotalBytesInitial)); + + // LBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kExpectedOutputSize)); + // TBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kDescriptionBoxType)); + + // Type UUID + EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16), + IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes())); + // Toggles + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view), + IsOkAndHolds(kDescriptionToggleLabelPresent | + kDescriptionToggleRequestable)); + // Label + EXPECT_THAT(jumbf_internal::ConsumeNullTerminated(&result_view), + IsOkAndHolds("mylabel")); + + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeDescriptionBoxTest, HasPrivateContent) { + constexpr absl::string_view kPrivateContent = + "Hi, I'm not actually JUMBF data but for this test it doesn't matter."; + constexpr size_t kExpectedOutputSize = 4 /*lbox*/ + 4 /*tbox*/ + + 16 /*type UUID*/ + 1 /*toggles*/ + + kPrivateContent.length(); + // Simulating content already written in superbox + constexpr size_t kTotalBytesInitial = 0xbaadf00d; + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kTotalBytesInitial)); + ABSL_ASSERT_OK(EncodeDescriptionBox( + kCborBoxTypeUuid, + DescriptionBoxOptions{ + .requestable_iff_label = false, + .private_content = CordFromConstexpr(kPrivateContent), + }, + &writer)); + + ASSERT_TRUE(writer.Close()); + absl::string_view result_view = writer.dest().dest().Flatten(); + + EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + kTotalBytesInitial)); + + // LBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kExpectedOutputSize)); + // TBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kDescriptionBoxType)); + + // Type UUID + EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16), + IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes())); + // Toggles + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view), + IsOkAndHolds(kDescriptionTogglePrivatePresent)); + // Private content + EXPECT_THAT( + jumbf_internal::ConsumeBytes(&result_view, kPrivateContent.length()), + IsOkAndHolds(kPrivateContent)); + + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeDescriptionBoxTest, HasLabelNotRequestable) { + constexpr size_t kExpectedOutputSize = 33; + constexpr absl::string_view kLabel = "mylabel"; + // Simulating content already written in superbox + constexpr size_t kTotalBytesInitial = 0xdefec8ed; + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kTotalBytesInitial)); + ABSL_ASSERT_OK(EncodeDescriptionBox(kCborBoxTypeUuid, + DescriptionBoxOptions{ + .requestable_iff_label = false, + .label = CordFromConstexpr(kLabel), + }, + &writer)); + + EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + kTotalBytesInitial)); + + ASSERT_TRUE(writer.Close()); + absl::string_view result_view = writer.dest().dest().Flatten(); + + // LBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kExpectedOutputSize)); + // TBox + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(kDescriptionBoxType)); + + // Type UUID + EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16), + IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes())); + // Toggles + EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view), + IsOkAndHolds(kDescriptionToggleLabelPresent)); + // Label + EXPECT_THAT(jumbf_internal::ConsumeNullTerminated(&result_view), + IsOkAndHolds("mylabel")); + + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeBoxHeaderTest, LBox) { + // Fake value, pretending we already wrote some content to `writer`. + constexpr size_t kPayloadSize = 10; + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kPayloadSize)); + ABSL_EXPECT_OK(EncodeBoxHeader(kPayloadSize, 0xdeadbeef, &writer)); + + ASSERT_TRUE(writer.Close()); + absl::string_view result_view = writer.dest().dest().Flatten(); + + // Should have been updated to include the (fake) content plus LBox and TBox. + EXPECT_THAT(writer.pos(), Eq(kPayloadSize + 8)); + + // LBox + EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view), + IsOkAndHolds(writer.pos())); + // TBox + EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view), IsOkAndHolds(0xdeadbeef)); + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeBoxHeaderTest, XLBox) { + // Fake value, pretending we already wrote some content to `writer`. + constexpr size_t kPayloadSize = + static_cast<size_t>(std::numeric_limits<uint32_t>::max()) + 10; + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kPayloadSize)); + ABSL_EXPECT_OK(EncodeBoxHeader(kPayloadSize, 0xdeadbeef, &writer)); + + ASSERT_TRUE(writer.Close()); + absl::string_view result_view = writer.dest().dest().Flatten(); + + // Should have been updated to include the (fake) content plus LBox, TBox, and + // XLBox. + EXPECT_THAT(writer.pos(), Eq(kPayloadSize + 16)); + + // LBox + EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view), IsOkAndHolds(1)); + // TBox + EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view), IsOkAndHolds(0xdeadbeef)); + // XLBox + EXPECT_THAT(ConsumeInteger<uint64_t>(&result_view), + IsOkAndHolds(writer.pos())); + EXPECT_THAT(result_view, IsEmpty()); +} + +TEST(EncodeBoxHeaderTest, XLBoxPayloadExceedsMaxBoxSize) { + // Fake value, pretending we already wrote some content to `writer`. + constexpr size_t kPayloadSize = std::numeric_limits<size_t>::max(); + + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kPayloadSize)); + EXPECT_THAT( + EncodeBoxHeader(kPayloadSize, 0xdeadbeef, &writer), + StatusIs(absl::StatusCode::kOutOfRange, "maximum box size exceeded")); +} + +} // namespace +} // namespace jumbf
diff --git a/jumbf/internal/intermediate.h b/jumbf/internal/intermediate.h new file mode 100644 index 0000000..81c3fd8 --- /dev/null +++ b/jumbf/internal/intermediate.h
@@ -0,0 +1,79 @@ +// 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. +// + +// Intermediate types to represent partially-parsed JUMBF structures. Most +// contain references back to the underlying serialized array to minimize +// copies. This behavior is more thoroughly documented at the functions that +// return these structs. +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_INTERMEDIATE_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_INTERMEDIATE_H_ + +#include <cstdint> +#include <optional> +#include <vector> + +#include "absl/strings/string_view.h" +#include "uuid/uuid.h" + +namespace jumbf_internal { + +// A generic box structure, may be of any type (see section 4.3 of standard) +struct IntermediateBox { + absl::string_view payload; + // The original serialized representation of this box. + absl::string_view serialized; + uint32_t type; + // Whether this box has an LBox of 0, indicating that it extends to the end + // of the file. + bool implicit_length; + + bool operator==(const IntermediateBox&) const = default; +}; + +// A description box (see section A.3 of standard) +struct IntermediateDescriptionBox { + // The type UUID, referred to simply as "type" in section A.3, not to be + // confused with the 32 bit box type described in section 4.3 (all description + // boxes must have box type "jumd"). + credentio::Uuid type_uuid; + + bool requestable; + std::optional<absl::string_view> label; + std::optional<uint32_t> id; + // The SHA-256 hash of the associated content boxes. In identical byte order + // to the serialized form as defined in the standard. Note that this value is + // not verified during the initial parse stage that produces this intermediate + // struct. + std::optional<absl::string_view> hash; + std::optional<IntermediateBox> private_box; + + bool operator==(const IntermediateDescriptionBox&) const = default; +}; + +// A Super Box, according to the naming convention used in this library. In the +// standard it's referred to as a "JUMBF Box" with the term "superbox" simply +// meaning a box that contains other boxes (see section A.2 of the standard). +struct IntermediateSuperBox { + IntermediateDescriptionBox description; + std::vector<IntermediateBox> content; + // The original serialized representation of this box. + absl::string_view serialized; + + bool operator==(const IntermediateSuperBox&) const = default; +}; + +} // namespace jumbf_internal + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_INTERMEDIATE_H_
diff --git a/jumbf/internal/parse_description_box.cc b/jumbf/internal/parse_description_box.cc new file mode 100644 index 0000000..b2d4d37 --- /dev/null +++ b/jumbf/internal/parse_description_box.cc
@@ -0,0 +1,87 @@ +// 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 "jumbf/internal/parse_description_box.h" + +#include <cstdint> +#include <utility> + +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "jumbf/constants.h" +#include "jumbf/internal/consume_box.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/internal/serialization_utils.h" +#include "uuid/uuid.h" + +namespace jumbf_internal { + +absl::StatusOr<IntermediateDescriptionBox> ParseDescriptionBox( + const IntermediateBox& unwrapped_box) { + if (unwrapped_box.type != jumbf::kDescriptionBoxType) { + return absl::InvalidArgumentError( + absl::StrCat("box is not a description box; got type 0x", + absl::Hex(unwrapped_box.type), " need 0x", + absl::Hex(jumbf::kDescriptionBoxType), " ('jumd')")); + } + IntermediateDescriptionBox description_box; + + absl::string_view content = unwrapped_box.payload; + ABSL_ASSIGN_OR_RETURN(auto uuid_str, ConsumeBytes(&content, 16)); + ABSL_ASSIGN_OR_RETURN(auto type_uuid, + credentio::Uuid::FromProtoBytes(std::move(uuid_str))); + description_box.type_uuid = std::move(type_uuid); + + ABSL_ASSIGN_OR_RETURN(auto toggles, ConsumeInteger<uint8_t>(&content), + _.SetPrepend() << "cannot read toggles: "); + + description_box.requestable = toggles & jumbf::kDescriptionToggleRequestable; + + if (toggles & jumbf::kDescriptionToggleLabelPresent) { + ABSL_ASSIGN_OR_RETURN(description_box.label, + ConsumeNullTerminated(&content), + _.SetPrepend() << "cannot read label: "); + } else { + if (description_box.requestable) { + return absl::InvalidArgumentError( + R"(requestable bit is set, but box does not have a label; if a box is requestable the label is required)"); + } + } + + if (toggles & jumbf::kDescriptionToggleIdPresent) { + ABSL_ASSIGN_OR_RETURN(description_box.id, + ConsumeInteger<uint32_t>(&content), + _.SetPrepend() << "cannot read id: "); + } + + if (toggles & jumbf::kDescriptionToggleHashPresent) { + ABSL_ASSIGN_OR_RETURN(description_box.hash, ConsumeBytes(&content, 32), + _.SetPrepend() << "cannot read hash: "); + } + + if (toggles & jumbf::kDescriptionTogglePrivatePresent) { + ABSL_ASSIGN_OR_RETURN(description_box.private_box, + ConsumeBox(&content, /*allow_implicit_length=*/false), + _.SetPrepend() << "cannot read private content: "); + } + + return description_box; +} + +} // namespace jumbf_internal
diff --git a/jumbf/internal/parse_description_box.h b/jumbf/internal/parse_description_box.h new file mode 100644 index 0000000..5fc3359 --- /dev/null +++ b/jumbf/internal/parse_description_box.h
@@ -0,0 +1,29 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_DESCRIPTION_BOX_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_DESCRIPTION_BOX_H_ + +#include "absl/status/statusor.h" +#include "jumbf/internal/intermediate.h" + +namespace jumbf_internal { + +absl::StatusOr<IntermediateDescriptionBox> ParseDescriptionBox( + const IntermediateBox& unwrapped_box); + +} // namespace jumbf_internal + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_DESCRIPTION_BOX_H_
diff --git a/jumbf/internal/parse_description_box_test.cc b/jumbf/internal/parse_description_box_test.cc new file mode 100644 index 0000000..e239d5c --- /dev/null +++ b/jumbf/internal/parse_description_box_test.cc
@@ -0,0 +1,311 @@ +// 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 "jumbf/internal/parse_description_box.h" + +#include <cstddef> +#include <cstdint> +#include <cstring> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/constants.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/test_utils.h" +#include "riegeli/endian/endian_writing.h" +#include "uuid/uuid.h" + +namespace jumbf_internal { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio::Uuid; +using ::jumbf::SerializeUuid; + +constexpr absl::string_view kFakeUuid = + R"(deadbeef-f00d-babe-b4df-00d528013378)"; + +TEST(ParseDescriptionBoxTest, ParseOkNoToggles) { + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1; + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(0, content_cursor); + content_cursor += sizeof(uint8_t); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT(ParseDescriptionBox(unwrapped_description), + IsOkAndHolds(IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid)})); +} + +TEST(ParseDescriptionBoxTest, InvalidPayloadSize) { + // A fuzzer crafted this box, with a payload that is too short. + IntermediateBox box = { + .payload = "", + .serialized = absl::string_view("\x00\x00\x00\x08jumd", 8), + .type = 1786080612, + .implicit_length = false}; + + EXPECT_THAT(ParseDescriptionBox(box), + StatusIs(absl::StatusCode::kOutOfRange)); +} + +TEST(ParseDescriptionBoxTest, ParseOkAllToggles) { + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + constexpr char kFakeLabel[] = "fakelabel"; + constexpr uint32_t kFakeId = 0xB33FD00D; + const std::string fake_hash(32, 'X'); + constexpr absl::string_view kPrivatePayload = + R"(The format of this data is unimportant for this test case.)"; + const std::string private_box = jumbf::WrapBox(kPrivatePayload, 12345); + const size_t description_content_length = + /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel) + + /*label null terminator*/ 1 + /*id*/ 4 + fake_hash.length() + + private_box.length(); + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + uint8_t toggles = jumbf::kDescriptionToggleRequestable | + jumbf::kDescriptionToggleLabelPresent | + jumbf::kDescriptionToggleIdPresent | + jumbf::kDescriptionToggleHashPresent | + jumbf::kDescriptionTogglePrivatePresent; + riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1); + content_cursor += strlen(kFakeLabel) + 1; + riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor); + content_cursor += sizeof(uint32_t); + memcpy(content_cursor, fake_hash.data(), 32); + content_cursor += 32; + memcpy(content_cursor, private_box.data(), private_box.length()); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT(ParseDescriptionBox(unwrapped_description), + IsOkAndHolds(IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .requestable = true, + .label = kFakeLabel, + .id = kFakeId, + .hash = fake_hash, + .private_box = + IntermediateBox{ + .payload = kPrivatePayload, + .serialized = private_box, + .type = 12345, + }, + })); +} + +TEST(ParseDescriptionBoxTest, RequestableNoLabelFails) { + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1; + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleRequestable, + content_cursor); + content_cursor += sizeof(uint8_t); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT(ParseDescriptionBox(unwrapped_description), + StatusIs(absl::StatusCode::kInvalidArgument, + "requestable bit is set, but box does not have a label; " + "if a box is requestable the label is required")); +} + +TEST(ParseDescriptionBoxTest, ParseOkWithLabel) { + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + constexpr char kFakeLabel[] = "fakelabel"; + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 + + strlen(kFakeLabel) + + /*label null terminator*/ 1; + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleLabelPresent, + content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1); + content_cursor += strlen(kFakeLabel) + 1; + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT( + ParseDescriptionBox(unwrapped_description), + IsOkAndHolds(IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), .label = kFakeLabel})); +} + +TEST(ParseDescriptionBoxTest, ParseOkWithId) { + constexpr uint32_t kFakeId = 0xB33FD00D; + const size_t description_content_length = + /*uuid*/ 16 + /*toggles*/ 1 + /*id*/ 4; + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleIdPresent, + content_cursor); + content_cursor += sizeof(uint8_t); + riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor); + content_cursor += sizeof(uint32_t); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT( + ParseDescriptionBox(unwrapped_description), + IsOkAndHolds(IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), .id = kFakeId})); +} + +TEST(ParseDescriptionBoxTest, ParseOkWithHash) { + const std::string fake_hash(32, 'X'); + const size_t description_content_length = + /*uuid*/ 16 + /*toggles*/ 1 + fake_hash.length(); + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleHashPresent, + content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, fake_hash.data(), 32); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT( + ParseDescriptionBox(unwrapped_description), + IsOkAndHolds(IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), .hash = fake_hash})); +} + +TEST(ParseDescriptionBoxTest, ParseOkWithPrivate) { + constexpr absl::string_view kPrivatePayload = + R"(The format of this data is unimportant for this test case.)"; + const std::string private_box = jumbf::WrapBox(kPrivatePayload, 12345); + const size_t description_content_length = + /*uuid*/ 16 + /*toggles*/ 1 + private_box.length(); + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionTogglePrivatePresent, + content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, private_box.data(), private_box.length()); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT(ParseDescriptionBox(unwrapped_description), + IsOkAndHolds(IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .private_box = + IntermediateBox{ + .payload = kPrivatePayload, + .serialized = private_box, + .type = 12345, + }, + })); +} + +TEST(ParseDescriptionBoxTest, WrongTypeFails) { + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1; + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(0, content_cursor); + content_cursor += sizeof(uint8_t); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kSuperBoxType, + }; + + EXPECT_THAT(ParseDescriptionBox(unwrapped_description), + StatusIs(absl::StatusCode::kInvalidArgument, + "box is not a description box; got type 0x6a756d62 need " + "0x6a756d64 ('jumd')")); +} + +TEST(ParseDescriptionBoxTest, UnterminatedLabelFails) { + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + constexpr char kFakeLabel[] = "fakelabel"; + const size_t description_content_length = + /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel); + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleLabelPresent, + content_cursor); + content_cursor += sizeof(uint8_t); + // Intentionally not copying null terminator. + memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel)); + + IntermediateBox unwrapped_description{ + .payload = fake_description_contents, + .type = jumbf::kDescriptionBoxType, + }; + + EXPECT_THAT(ParseDescriptionBox(unwrapped_description), + StatusIs(absl::StatusCode::kOutOfRange, + "cannot read label: ran out of input bytes before " + "reading null terminator")); +} + +} // namespace +} // namespace jumbf_internal
diff --git a/jumbf/internal/parse_embedded_file_description_box.cc b/jumbf/internal/parse_embedded_file_description_box.cc new file mode 100644 index 0000000..ae97422 --- /dev/null +++ b/jumbf/internal/parse_embedded_file_description_box.cc
@@ -0,0 +1,68 @@ +// 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 "jumbf/internal/parse_embedded_file_description_box.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/internal/serialization_utils.h" + +namespace jumbf_internal { + +absl::StatusOr<jumbf::EmbeddedFileDescriptionBox> +ParseEmbeddedFileDescriptionBox(const IntermediateBox& unwrapped_box) { + if (unwrapped_box.type != jumbf::kEmbeddedFileDescriptionBoxType) { + return absl::InvalidArgumentError(absl::StrCat( + "box is not an embedded file description box; got type 0x", + absl::Hex(unwrapped_box.type), " need 0x", + absl::Hex(jumbf::kEmbeddedFileDescriptionBoxType), " ('bfdb')")); + } + + absl::string_view temp_payload = unwrapped_box.payload; + jumbf::EmbeddedFileDescriptionBox efdb; + + ABSL_ASSIGN_OR_RETURN(auto toggles, ConsumeInteger<uint8_t>(&temp_payload), + _.SetPrepend() << "could not read toggles: "); + ABSL_ASSIGN_OR_RETURN(efdb.media_type, ConsumeNullTerminated(&temp_payload), + _.SetPrepend() << "could not read media type: "); + + if (toggles & jumbf::kEfdbToggleFileNamePresent) { + ABSL_ASSIGN_OR_RETURN(efdb.file_name, ConsumeNullTerminated(&temp_payload), + _.SetPrepend() << "could not read file name: "); + } + + if (toggles & jumbf::kEfdbToggleExternal) { + efdb.external = true; + } + + if (!temp_payload.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("excess data in embedded file description box payload; " + "after parsing complete, valid payload, ", + temp_payload.length(), " bytes were remaining")); + } + + return efdb; +} + +} // namespace jumbf_internal
diff --git a/jumbf/internal/parse_embedded_file_description_box.h b/jumbf/internal/parse_embedded_file_description_box.h new file mode 100644 index 0000000..ea096ef --- /dev/null +++ b/jumbf/internal/parse_embedded_file_description_box.h
@@ -0,0 +1,30 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_EMBEDDED_FILE_DESCRIPTION_BOX_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_EMBEDDED_FILE_DESCRIPTION_BOX_H_ + +#include "absl/status/statusor.h" +#include "jumbf/box.h" +#include "jumbf/internal/intermediate.h" + +namespace jumbf_internal { + +absl::StatusOr<jumbf::EmbeddedFileDescriptionBox> +ParseEmbeddedFileDescriptionBox(const IntermediateBox& unwrapped_box); + +} // namespace jumbf_internal + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_EMBEDDED_FILE_DESCRIPTION_BOX_H_
diff --git a/jumbf/internal/parse_embedded_file_description_box_test.cc b/jumbf/internal/parse_embedded_file_description_box_test.cc new file mode 100644 index 0000000..2a1bced --- /dev/null +++ b/jumbf/internal/parse_embedded_file_description_box_test.cc
@@ -0,0 +1,207 @@ +// 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 "jumbf/internal/parse_embedded_file_description_box.h" + +#include <cstdint> +#include <cstring> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/internal/intermediate.h" +#include "riegeli/endian/endian_writing.h" + +namespace jumbf_internal { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; + +TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkAllToggles) { + constexpr uint8_t kToggles = + jumbf::kEfdbToggleFileNamePresent | jumbf::kEfdbToggleExternal; + // Can't use absl::string_view because the linter does not believe it is + // null terminated, even when the underlying string definitely is. + constexpr const char kFakeMediatype[] = "media/MyFormat"; + constexpr const char kFakePath[] = "path/to/some/file"; + + const size_t payload_length = + 1 /*toggles*/ + sizeof(kFakeMediatype) + sizeof(kFakePath); + std::string payload(payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype)); + cursor += sizeof(kFakeMediatype); + memcpy(cursor, kFakePath, sizeof(kFakePath)); + cursor += sizeof(kFakePath); + + const IntermediateBox unwrapped_box{ + .payload = payload, + .type = jumbf::kEmbeddedFileDescriptionBoxType, + }; + + auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box); + + EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{ + .media_type = kFakeMediatype, + .external = true, + .file_name = kFakePath, + })); +} + +TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkFileNamePresent) { + constexpr uint8_t kToggles = jumbf::kEfdbToggleFileNamePresent; + // Can't use absl::string_view because the linter does not believe it is + // null terminated, even when the underlying string definitely is. + constexpr const char kFakeMediatype[] = "media/MyFormat"; + constexpr const char kFakePath[] = "path/to/some/file"; + + const size_t payload_length = + 1 /*toggles*/ + sizeof(kFakeMediatype) + sizeof(kFakePath); + std::string payload(payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype)); + cursor += sizeof(kFakeMediatype); + memcpy(cursor, kFakePath, sizeof(kFakePath)); + cursor += sizeof(kFakePath); + + const IntermediateBox unwrapped_box{ + .payload = payload, + .type = jumbf::kEmbeddedFileDescriptionBoxType, + }; + + auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box); + + EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{ + .media_type = kFakeMediatype, + .external = false, + .file_name = kFakePath, + })); +} + +TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkExternal) { + constexpr uint8_t kToggles = jumbf::kEfdbToggleExternal; + // Can't use absl::string_view because the linter does not believe it is + // null terminated, even when the underlying string definitely is. + constexpr const char kFakeMediatype[] = "media/MyFormat"; + + const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype); + std::string payload(payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype)); + cursor += sizeof(kFakeMediatype); + + const IntermediateBox unwrapped_box{ + .payload = payload, + .type = jumbf::kEmbeddedFileDescriptionBoxType, + }; + + auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box); + + EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{ + .media_type = kFakeMediatype, + .external = true, + })); +} + +TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkNoTogglesl) { + constexpr uint8_t kToggles = 0; + constexpr const char kFakeMediatype[] = "media/MyFormat"; + + const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype); + std::string payload(payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype)); + cursor += sizeof(kFakeMediatype); + + const IntermediateBox unwrapped_box{ + .payload = payload, + .type = jumbf::kEmbeddedFileDescriptionBoxType, + }; + + auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box); + + EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{ + .media_type = kFakeMediatype, + .external = false, + })); +} + +TEST(ParseEmbeddedFileDescriptionBoxTest, WrongTypeFails) { + constexpr uint8_t kToggles = 0; + constexpr const char kFakeMediatype[] = "media/MyFormat"; + + const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype); + std::string payload(payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype)); + cursor += sizeof(kFakeMediatype); + + const IntermediateBox unwrapped_box{ + .payload = payload, + .type = jumbf::kSuperBoxType, + }; + + auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box); + + EXPECT_THAT(efdb, StatusIs(absl::StatusCode::kInvalidArgument, + "box is not an embedded file description box; got " + "type 0x6a756d62 need 0x62666462 ('bfdb')")); +} + +TEST(ParseEmbeddedFileDescriptionBoxTest, ExtraDataFails) { + constexpr uint8_t kToggles = 0; + constexpr const char kFakeMediatype[] = "media/MyFormat"; + + const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype); + std::string payload(payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype)); + cursor += sizeof(kFakeMediatype); + + payload.append("some extra data nobody asked for"); + + const IntermediateBox unwrapped_box{ + .payload = payload, + .type = jumbf::kEmbeddedFileDescriptionBoxType, + }; + + auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box); + + EXPECT_THAT( + efdb, + StatusIs(absl::StatusCode::kInvalidArgument, + "excess data in embedded file description box payload; after " + "parsing complete, valid payload, 32 bytes were remaining")); +} + +} // namespace +} // namespace jumbf_internal
diff --git a/jumbf/internal/parse_super_box.cc b/jumbf/internal/parse_super_box.cc new file mode 100644 index 0000000..7b7cb30 --- /dev/null +++ b/jumbf/internal/parse_super_box.cc
@@ -0,0 +1,88 @@ +// 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 "jumbf/internal/parse_super_box.h" + +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "jumbf/constants.h" +#include "jumbf/internal/consume_box.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/internal/parse_description_box.h" + +namespace jumbf_internal { + +absl::StatusOr<IntermediateSuperBox> ParseSuperBox( + const IntermediateBox& unwrapped_super_box) { + if (unwrapped_super_box.type != jumbf::kSuperBoxType) { + return absl::InvalidArgumentError( + absl::StrCat("box is not a super box: got type 0x", + absl::Hex(unwrapped_super_box.type), " need 0x", + absl::Hex(jumbf::kSuperBoxType), " ('jumb')")); + } + + absl::string_view temp_super_box_payload = unwrapped_super_box.payload; + + ABSL_ASSIGN_OR_RETURN( + auto unwrapped_description_box, + ConsumeBox(&temp_super_box_payload, /*allow_implicit_length=*/false), + _.SetPrepend() << "cannot unwrap description box: "); + + ABSL_ASSIGN_OR_RETURN(auto description_box, + ParseDescriptionBox(unwrapped_description_box), + _.SetPrepend() << "cannot parse description box: "); + + if (temp_super_box_payload.empty()) { + return absl::InvalidArgumentError( + R"(super box has no content; at least one content box is required)"); + } + + std::vector<IntermediateBox> content; + while (!temp_super_box_payload.empty()) { + ABSL_ASSIGN_OR_RETURN( + auto unwrapped_content, + ConsumeBox( + &temp_super_box_payload, + /*allow_implicit_length=*/unwrapped_super_box.implicit_length), + _.SetPrepend() << "cannot unwrap content box for index " + << content.size() << ": "); + if (unwrapped_content.type == jumbf::kPaddingBoxType) { + if (!temp_super_box_payload.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("input remaining after padding box; if a padding box " + "is present within a super box, it must occupy the " + "entire remainder of the box's size; after reading " + "one padding box there were ", + temp_super_box_payload.length(), " bytes remaining")); + } + } else { + content.push_back(std::move(unwrapped_content)); + } + } + + return IntermediateSuperBox{ + .description = std::move(description_box), + .content = std::move(content), + .serialized = unwrapped_super_box.serialized, + }; +} + +} // namespace jumbf_internal
diff --git a/jumbf/internal/parse_super_box.h b/jumbf/internal/parse_super_box.h new file mode 100644 index 0000000..e326e9b --- /dev/null +++ b/jumbf/internal/parse_super_box.h
@@ -0,0 +1,29 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_SUPER_BOX_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_SUPER_BOX_H_ + +#include "absl/status/statusor.h" +#include "jumbf/internal/intermediate.h" + +namespace jumbf_internal { + +absl::StatusOr<IntermediateSuperBox> ParseSuperBox( + const IntermediateBox& unwrapped_super_box); + +} // namespace jumbf_internal + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_SUPER_BOX_H_
diff --git a/jumbf/internal/parse_super_box_test.cc b/jumbf/internal/parse_super_box_test.cc new file mode 100644 index 0000000..c7c0fad --- /dev/null +++ b/jumbf/internal/parse_super_box_test.cc
@@ -0,0 +1,298 @@ +// 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 "jumbf/internal/parse_super_box.h" + +#include <cstddef> +#include <cstdint> +#include <cstring> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/constants.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/test_utils.h" +#include "riegeli/endian/endian_writing.h" +#include "uuid/uuid.h" + +namespace jumbf_internal { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio::Uuid; +using ::jumbf::SerializeUuid; +using ::jumbf::WrapBox; +using ::testing::HasSubstr; + +constexpr absl::string_view kFakeUuid = + R"(deadbeef-f00d-babe-b4df-00d528013378)"; + +std::string FakeDescriptionBoxContents() { + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + constexpr char kFakeLabel[] = "fakelabel"; + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 + + strlen(kFakeLabel) + + /*label null terminator*/ 1; + std::string fake_description_contents(description_content_length, '\0'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleLabelPresent, + content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1); + content_cursor += strlen(kFakeLabel) + 1; + + return fake_description_contents; +} + +TEST(ParseSuperBoxTest, ParseOkSingleContent) { + std::string serialized_content_box = + WrapBox("fake CBOR data", jumbf::kCborBoxType); + std::string super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_content_box); + std::string serialized_super_box = + WrapBox(super_box_payload, jumbf::kSuperBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = super_box_payload, + .serialized = serialized_super_box, + .type = jumbf::kSuperBoxType, + }; + + EXPECT_THAT( + ParseSuperBox(unwrapped_super_box), + IsOkAndHolds(IntermediateSuperBox{ + .description = + IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .content = {IntermediateBox{.payload = "fake CBOR data", + .serialized = serialized_content_box, + .type = jumbf::kCborBoxType}}, + .serialized = serialized_super_box})); +} + +TEST(ParseSuperBoxTest, ParseOkTwoContent) { + std::string serialized_first_content_box = + WrapBox("first fake CBOR data", jumbf::kCborBoxType); + std::string serialized_second_content_box = + WrapBox("second fake CBOR data", jumbf::kCborBoxType); + std::string super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_first_content_box, serialized_second_content_box); + std::string serialized_super_box = + WrapBox(super_box_payload, jumbf::kSuperBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = super_box_payload, + .serialized = serialized_super_box, + .type = jumbf::kSuperBoxType, + }; + + EXPECT_THAT( + ParseSuperBox(unwrapped_super_box), + IsOkAndHolds(IntermediateSuperBox{ + .description = + IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .content = + { + IntermediateBox{.payload = "first fake CBOR data", + .serialized = serialized_first_content_box, + .type = jumbf::kCborBoxType}, + IntermediateBox{.payload = "second fake CBOR data", + .serialized = serialized_second_content_box, + .type = jumbf::kCborBoxType}, + }, + .serialized = serialized_super_box})); +} + +TEST(ParseSuperBoxTest, ParseOkPaddingBox) { + std::string serialized_content_box = + WrapBox("fake CBOR data", jumbf::kCborBoxType); + std::string serialized_padding_box = + WrapBox(std::string(199, '\0'), jumbf::kPaddingBoxType); + std::string super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_content_box, serialized_padding_box); + std::string serialized_super_box = + WrapBox(super_box_payload, jumbf::kSuperBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = super_box_payload, + .serialized = serialized_super_box, + .type = jumbf::kSuperBoxType, + }; + + EXPECT_THAT(ParseSuperBox(unwrapped_super_box), + IsOkAndHolds(IntermediateSuperBox{ + .description = + IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .content = + { + IntermediateBox{.payload = "fake CBOR data", + .serialized = serialized_content_box, + .type = jumbf::kCborBoxType}, + }, + .serialized = serialized_super_box})); +} + +TEST(ParseSuperBoxTest, ParseImplicitSuperBoxAndContent) { + std::string serialized_content_box = + jumbf::WrapUndeterminedLengthBox("fake CBOR data", jumbf::kCborBoxType); + std::string super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_content_box); + std::string serialized_super_box = + jumbf::WrapUndeterminedLengthBox(super_box_payload, jumbf::kSuperBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = super_box_payload, + .serialized = serialized_super_box, + .type = jumbf::kSuperBoxType, + .implicit_length = true, + }; + + EXPECT_THAT(ParseSuperBox(unwrapped_super_box), + IsOkAndHolds(IntermediateSuperBox{ + .description = + IntermediateDescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .content = + { + IntermediateBox{ + .payload = "fake CBOR data", + .serialized = serialized_content_box, + .type = jumbf::kCborBoxType, + .implicit_length = true, + }, + }, + .serialized = serialized_super_box})); +} + +TEST(ParseSuperBoxTest, ImplicitLengthContentInExplicitLengthSuperBoxFails) { + std::string serialized_content_box = + jumbf::WrapUndeterminedLengthBox("fake CBOR data", jumbf::kCborBoxType); + std::string inner_super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_content_box); + std::string serialized_inner_super_box = jumbf::WrapUndeterminedLengthBox( + inner_super_box_payload, jumbf::kSuperBoxType); + std::string outer_super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_inner_super_box); + std::string serialized_outer_super_box = + WrapBox(outer_super_box_payload, jumbf::kSuperBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = outer_super_box_payload, + .serialized = serialized_outer_super_box, + .type = jumbf::kSuperBoxType, + .implicit_length = false, + }; + + EXPECT_THAT( + ParseSuperBox(unwrapped_super_box), + StatusIs( + absl::StatusCode::kInvalidArgument, + HasSubstr("LBox is 0, indicating box extends to end of input, but " + "context does not allow for implicit box lengths"))); +} + +TEST(ParseSuperBoxTest, NoContentFails) { + std::string super_box_payload = + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType); + std::string serialized_super_box = + WrapBox(super_box_payload, jumbf::kSuperBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = super_box_payload, + .serialized = serialized_super_box, + .type = jumbf::kSuperBoxType, + }; + + EXPECT_THAT( + ParseSuperBox(unwrapped_super_box), + StatusIs( + absl::StatusCode::kInvalidArgument, + "super box has no content; at least one content box is required")); +} + +TEST(ParseSuperBoxTest, ContentAfterPaddingBoxFails) { + std::string serialized_content_box = + WrapBox("fake CBOR data", jumbf::kCborBoxType); + std::string serialized_padding_box = + WrapBox(std::string(199, '\0'), jumbf::kPaddingBoxType); + std::string super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_content_box, serialized_padding_box, + WrapBox("extra content that should not be here", jumbf::kCborBoxType)); + std::string serialized_super_box = + WrapBox(super_box_payload, jumbf::kSuperBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = super_box_payload, + .serialized = serialized_super_box, + .type = jumbf::kSuperBoxType, + }; + + EXPECT_THAT(ParseSuperBox(unwrapped_super_box), + StatusIs(absl::StatusCode::kInvalidArgument, + "input remaining after padding box; if a padding box is " + "present within a super box, it must occupy the entire " + "remainder of the box's size; after reading one padding " + "box there were 45 bytes remaining")); +} + +TEST(ParseSuperBoxTest, WrongTypeFails) { + std::string serialized_content_box = + WrapBox("fake CBOR data", jumbf::kCborBoxType); + std::string super_box_payload = absl::StrCat( + WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType), + serialized_content_box); + std::string serialized_super_box = + WrapBox(super_box_payload, jumbf::kCborBoxType); + + IntermediateBox unwrapped_super_box{ + .payload = super_box_payload, + .serialized = serialized_super_box, + .type = jumbf::kCborBoxType, + }; + + EXPECT_THAT(ParseSuperBox(unwrapped_super_box), + StatusIs(absl::StatusCode::kInvalidArgument, + "box is not a super box: got type 0x63626f72 need " + "0x6a756d62 ('jumb')")); +} + +} // namespace +} // namespace jumbf_internal
diff --git a/jumbf/internal/serialization_utils.cc b/jumbf/internal/serialization_utils.cc new file mode 100644 index 0000000..01d2854 --- /dev/null +++ b/jumbf/internal/serialization_utils.cc
@@ -0,0 +1,119 @@ +// 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 "jumbf/internal/serialization_utils.h" + +#include <climits> +#include <cstddef> +#include <cstdint> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "riegeli/base/byte_fill.h" +#include "riegeli/bytes/backward_writer.h" +#include "riegeli/endian/endian_writing.h" + +#define WRITER_CALL_OR_RETURN(writer, call) \ + do { \ + if (!writer->call) { \ + return writer->status(); \ + } \ + } while (false) + +namespace jumbf_internal { + +// This library stores non-text data in strings and string_views, and reads +// values from byte offsets within them. No effort has been made to take into +// account alternate char widths. +static_assert(CHAR_BIT == 8); + +absl::StatusOr<absl::string_view> ConsumeNullTerminated(absl::string_view* sv) { + size_t len = sv->find('\0'); + if (len == absl::string_view::npos) { + return absl::OutOfRangeError( + "ran out of input bytes before reading null terminator"); + } + + // `len` does not include null terminator, which would have been contained + // within the bounds of `sv`. + absl::string_view output = sv->substr(0, len); + sv->remove_prefix(len + 1); + return output; +} + +// Consumes the first `n` bytes of `sv`. +absl::StatusOr<absl::string_view> ConsumeBytes(absl::string_view* sv, + size_t n) { + if (sv->length() < n) { + return absl::OutOfRangeError(absl::StrCat("ran out of input bytes; ", n, + " bytes requested, ", + sv->length(), " remaining")); + } + + absl::string_view output = sv->substr(0, n); + sv->remove_prefix(n); + return output; +} + +absl::Status WriteRaw(absl::Cord content, riegeli::BackwardWriter* writer) { + WRITER_CALL_OR_RETURN(writer, Write(std::move(content))); + return absl::OkStatus(); +} + +absl::Status WriteNulTerminated(absl::Cord content, + riegeli::BackwardWriter* writer) { + for (absl::string_view chunk : content.Chunks()) { + if (absl::StrContains(chunk, '\0')) { + return absl::InvalidArgumentError( + "cannot create NUL-terminated string from content: content contains " + "NUL"); + } + } + WRITER_CALL_OR_RETURN(writer, WriteByte('\0')); + WRITER_CALL_OR_RETURN(writer, Write(std::move(content))); + return absl::OkStatus(); +} + +template <> +absl::Status WriteInteger(uint8_t n, riegeli::BackwardWriter* writer) { + WRITER_CALL_OR_RETURN(writer, WriteByte(n)); + return absl::OkStatus(); +} +template <> +absl::Status WriteInteger(uint32_t n, riegeli::BackwardWriter* writer) { + char buf[4]; + riegeli::WriteBigEndian<uint32_t>(n, buf); + WRITER_CALL_OR_RETURN(writer, Write(absl::string_view(buf, 4))); + return absl::OkStatus(); +} +template <> +absl::Status WriteInteger(uint64_t n, riegeli::BackwardWriter* writer) { + char buf[8]; + riegeli::WriteBigEndian<uint64_t>(n, buf); + WRITER_CALL_OR_RETURN(writer, Write(absl::string_view(buf, 8))); + return absl::OkStatus(); +} + +absl::Status WritePadding(uint64_t length, riegeli::BackwardWriter* writer) { + WRITER_CALL_OR_RETURN(writer, Write(riegeli::ByteFill(length))); + return absl::OkStatus(); +} + +} // namespace jumbf_internal
diff --git a/jumbf/internal/serialization_utils.h b/jumbf/internal/serialization_utils.h new file mode 100644 index 0000000..ce72b5f --- /dev/null +++ b/jumbf/internal/serialization_utils.h
@@ -0,0 +1,74 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_SERIALIZATION_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_SERIALIZATION_UTILS_H_ + +#include <climits> +#include <cstddef> +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/cord.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/backward_writer.h" +#include "riegeli/endian/endian_reading.h" + +static_assert(CHAR_BIT == 8); + +namespace jumbf_internal { + +// Consumes any integer type that is supported by riegeli::ReadBigEndian. +template <typename T> +absl::StatusOr<T> ConsumeInteger(absl::string_view* sv) { + if (sv->length() < sizeof(T)) { + return absl::OutOfRangeError( + absl::StrCat("not enough input bytes remaining; have ", sv->length(), + " need ", sizeof(T))); + } + + T value = riegeli::ReadBigEndian<T>(sv->data()); + sv->remove_prefix(sizeof(T)); + + return value; +} + +// Consumes a null terminated string from within `sv`. The string's null +// terminator must be inside the usable range of `sv`. +absl::StatusOr<absl::string_view> ConsumeNullTerminated(absl::string_view* sv); + +// Consumes the first `n` bytes of `sv`. +absl::StatusOr<absl::string_view> ConsumeBytes(absl::string_view* sv, size_t n); + +// Writes `contents` to `writer`. +absl::Status WriteRaw(absl::Cord content, riegeli::BackwardWriter* writer); + +// Writes `content` to `writer`, appending a NUL terminator. +absl::Status WriteNulTerminated(absl::Cord content, + riegeli::BackwardWriter* writer); + +// Writes `n` to `writer`. Only types with explicit template specializations +// are supported. +template <typename T> +absl::Status WriteInteger(T n, riegeli::BackwardWriter* writer); + +// Writes `length` zero bytes to `writer`. +absl::Status WritePadding(uint64_t length, riegeli::BackwardWriter* writer); + +} // namespace jumbf_internal + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_SERIALIZATION_UTILS_H_
diff --git a/jumbf/internal/serialization_utils_test.cc b/jumbf/internal/serialization_utils_test.cc new file mode 100644 index 0000000..4263156 --- /dev/null +++ b/jumbf/internal/serialization_utils_test.cc
@@ -0,0 +1,251 @@ +// 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 "jumbf/internal/serialization_utils.h" + +#include <cstddef> +#include <cstdint> +#include <cstring> +#include <limits> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/test_utils.h" +#include "riegeli/base/maker.h" +#include "riegeli/bytes/chain_backward_writer.h" +#include "riegeli/bytes/position_shifting_backward_writer.h" +#include "riegeli/endian/endian_writing.h" + +namespace jumbf_internal { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::jumbf::CordFromConstexpr; +using ::testing::Eq; +using ::testing::IsEmpty; +using ::testing::Test; + +constexpr absl::string_view kExtraData = "12345678"; + +auto IsBoxSizeExceededStatus() { + return StatusIs(absl::StatusCode::kResourceExhausted); +} + +TEST(ConsumeBigEndianTest, ConsumeSuccessfulExtraData) { + constexpr size_t kBufferLength = sizeof(uint32_t) + kExtraData.length(); + std::string buffer(kBufferLength, 'A'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(0xF00DBABE, cursor); + cursor += sizeof(uint32_t); + memcpy(cursor, kExtraData.data(), kExtraData.length()); + + absl::string_view sv = buffer; + EXPECT_THAT(ConsumeInteger<uint32_t>(&sv), IsOkAndHolds(0xF00DBABE)); + EXPECT_THAT(sv, Eq(kExtraData)); +} + +TEST(ConsumeBigEndianTest, ConsumeSuccessful) { + constexpr size_t kBufferLength = sizeof(uint32_t); + std::string buffer(kBufferLength, 'A'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(0xF00DBABE, cursor); + cursor += sizeof(uint32_t); + + absl::string_view sv = buffer; + EXPECT_THAT(ConsumeInteger<uint32_t>(&sv), IsOkAndHolds(0xF00DBABE)); + EXPECT_THAT(sv, IsEmpty()); +} + +TEST(ConsumeBigEndianTest, InputTooShort) { + constexpr size_t kBufferLength = sizeof(uint32_t); + std::string buffer(kBufferLength, 'A'); + char* cursor = buffer.data(); + + riegeli::WriteBigEndian<uint32_t>(0xF00DBABE, cursor); + cursor += sizeof(uint32_t); + + absl::string_view sv = buffer; + EXPECT_THAT(ConsumeInteger<uint64_t>(&sv), + StatusIs(absl::StatusCode::kOutOfRange, + "not enough input bytes remaining; have 4 need 8")); + EXPECT_THAT(sv, Eq(buffer)); +} + +TEST(ConsumeBigEndianTest, InputEmpty) { + std::string buffer; + + absl::string_view sv = buffer; + EXPECT_THAT(ConsumeInteger<uint64_t>(&sv), + StatusIs(absl::StatusCode::kOutOfRange, + "not enough input bytes remaining; have 0 need 8")); + EXPECT_THAT(sv, Eq(buffer)); +} + +TEST(ConsumeNullTerminatedTest, ConsumeSuccessfulExtraData) { + // Can't directly initialize `absl::string_view` because the null in the + // middle confuses the constructor. + const char raw_input[] = "hello\0extradata"; + absl::string_view sv(raw_input, sizeof(raw_input) - 1); + + EXPECT_THAT(ConsumeNullTerminated(&sv), IsOkAndHolds("hello")); + EXPECT_THAT(sv, Eq("extradata")); +} + +TEST(ConsumeNullTerminatedTest, ConsumeSuccessful) { + // Can't directly initialize `absl::string_view` because the null in the + // middle confuses the constructor. + const char raw_input[] = "hello\0"; + absl::string_view sv(raw_input, sizeof(raw_input) - 1); + + EXPECT_THAT(ConsumeNullTerminated(&sv), IsOkAndHolds("hello")); + EXPECT_THAT(sv, IsEmpty()); +} + +TEST(ConsumeNullTerminatedTest, NullTerminatorOutOfBounds) { + // A standard string, which is null terminated, but outside of the size of + // the `absl::string_view`. + absl::string_view sv = "hello"; + + EXPECT_THAT( + ConsumeNullTerminated(&sv), + StatusIs(absl::StatusCode::kOutOfRange, + "ran out of input bytes before reading null terminator")); + EXPECT_THAT(sv, Eq("hello")); +} + +TEST(ConsumeBytesTest, ConsumeSuccessful) { + constexpr absl::string_view kTestData = "87654321"; + absl::string_view sv = kTestData; + + EXPECT_THAT(ConsumeBytes(&sv, 8), IsOkAndHolds(kTestData)); + EXPECT_THAT(sv, IsEmpty()); +} + +TEST(ConsumeBytesTest, ConsumeSuccessfulExtraData) { + constexpr absl::string_view kTestData = "87654321"; + const std::string buffer = absl::StrCat(kTestData, kExtraData); + absl::string_view sv = buffer; + + EXPECT_THAT(ConsumeBytes(&sv, 8), IsOkAndHolds(kTestData)); + EXPECT_THAT(sv, Eq(kExtraData)); +} + +TEST(ConsumeBytesTest, LengthOutOfBounds) { + absl::string_view sv = kExtraData; + + EXPECT_THAT( + ConsumeBytes(&sv, 11), + StatusIs(absl::StatusCode::kOutOfRange, + "ran out of input bytes; 11 bytes requested, 8 remaining")); + EXPECT_THAT(sv, Eq(kExtraData)); +} + +TEST(WriteRawTest, WriteSucceeds) { + constexpr absl::string_view kPayload = R"(Hi, I am a payload!)"; + riegeli::ChainBackwardWriter writer; + + ABSL_ASSERT_OK(WriteRaw(CordFromConstexpr(kPayload), &writer)); + ASSERT_TRUE(writer.Close()); + + EXPECT_THAT(writer.pos(), Eq(kPayload.length())); + EXPECT_THAT(writer.dest().Flatten(), Eq(kPayload)); +} + +TEST(WriteRawTest, MaximumSizeExceeded) { + constexpr absl::string_view kPayload = R"(Hi, I am a payload!)"; + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + std::numeric_limits<size_t>::max())); + + EXPECT_THAT(WriteRaw(CordFromConstexpr(kPayload), &writer), + IsBoxSizeExceededStatus()); +} + +TEST(WriteNulTerminatedTest, WriteSucceeds) { + constexpr absl::string_view kPayload = R"(Hi, I am a payload!)"; + riegeli::ChainBackwardWriter writer; + // This is safe because the underlying string literal is actually NUL + // terminated. + absl::string_view expected(kPayload.data(), kPayload.length() + 1); + + ABSL_ASSERT_OK(WriteNulTerminated(CordFromConstexpr(kPayload), &writer)); + ASSERT_TRUE(writer.Close()); + + EXPECT_THAT(writer.pos(), Eq(kPayload.length() + 1)); + EXPECT_THAT(writer.dest().Flatten(), Eq(expected)); +} + +TEST(WriteNulTerminatedTest, MaximumSizeExceeded) { + constexpr absl::string_view kPayload = R"(Hi, I am a payload!)"; + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + std::numeric_limits<size_t>::max())); + + EXPECT_THAT(WriteNulTerminated(CordFromConstexpr(kPayload), &writer), + IsBoxSizeExceededStatus()); +} + +template <typename T> +class WriteIntegerTest : public Test {}; + +TYPED_TEST_SUITE_P(WriteIntegerTest); + +TYPED_TEST_P(WriteIntegerTest, WriteSucceeds) { + const TypeParam expected = 42; + constexpr size_t kTotalBytesInitial = 55; + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + kTotalBytesInitial)); + + ABSL_ASSERT_OK(WriteInteger(expected, &writer)); + ASSERT_TRUE(writer.Close()); + absl::string_view result_view = writer.dest().dest().Flatten(); + + EXPECT_THAT(ConsumeInteger<TypeParam>(&result_view), IsOkAndHolds(expected)); + EXPECT_THAT(result_view, IsEmpty()); + EXPECT_THAT(writer.pos(), Eq(kTotalBytesInitial + sizeof(TypeParam))); +} + +TYPED_TEST_P(WriteIntegerTest, MaximumSizeExceeded) { + const TypeParam expected = 42; + riegeli::PositionShiftingBackwardWriter writer( + riegeli::Maker<riegeli::ChainBackwardWriter>(), + riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos( + std::numeric_limits<size_t>::max())); + + EXPECT_THAT(WriteInteger(expected, &writer), IsBoxSizeExceededStatus()); +} + +REGISTER_TYPED_TEST_SUITE_P(WriteIntegerTest, WriteSucceeds, + MaximumSizeExceeded); + +using WriteIntegerTestTypes = testing::Types<uint8_t, uint32_t, uint64_t>; +INSTANTIATE_TYPED_TEST_SUITE_P(SerializationUtilsTest, WriteIntegerTest, + WriteIntegerTestTypes); + +} // namespace +} // namespace jumbf_internal
diff --git a/jumbf/parse.cc b/jumbf/parse.cc new file mode 100644 index 0000000..ce98f42 --- /dev/null +++ b/jumbf/parse.cc
@@ -0,0 +1,210 @@ +// 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 "jumbf/parse.h" + +#include <algorithm> +#include <optional> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/internal/consume_box.h" +#include "jumbf/internal/intermediate.h" +#include "jumbf/internal/parse_embedded_file_description_box.h" +#include "jumbf/internal/parse_super_box.h" + +namespace jumbf { + +using ::jumbf_internal::ConsumeBox; +using ::jumbf_internal::IntermediateBox; +using ::jumbf_internal::IntermediateDescriptionBox; +using ::jumbf_internal::ParseEmbeddedFileDescriptionBox; + +namespace { + +absl::StatusOr<ContentBox> ParseContentBox(const IntermediateBox& unwrapped_box, + int recursion_limit); + +absl::StatusOr<SuperBox> ParseSuperBox( + const IntermediateBox& unwrapped_super_box, int recursion_limit); + +absl::StatusOr<DescriptionBox> FinalizeDescriptionBox( + const IntermediateDescriptionBox& intermediate_box, int recursion_limit) { + std::vector<ContentBox> private_contents; + if (intermediate_box.private_box.has_value()) { + const IntermediateBox& private_box = *intermediate_box.private_box; + absl::string_view private_content_view = private_box.payload; + if (private_box.type == kPrivateContentBoxType) { + // Special private box type, repeated list of arbitrary boxes. + while (!private_content_view.empty()) { + ABSL_ASSIGN_OR_RETURN( + auto unwrapped_private_element, + ConsumeBox(&private_content_view, /*allow_implicit_length=*/false), + _.SetPrepend() << "could not unwrap private content box for index " + << private_contents.size() << ": "); + + ABSL_ASSIGN_OR_RETURN( + auto private_element, + ParseContentBox(unwrapped_private_element, recursion_limit), + _.SetPrepend() << "could not parse private content box for index " + << private_contents.size() << ": "); + + private_contents.push_back(std::move(private_element)); + } + } else { + ABSL_ASSIGN_OR_RETURN(auto private_element, + ParseContentBox(private_box, recursion_limit)); + private_contents.push_back(std::move(private_element)); + } + } + + return DescriptionBox{ + .type_uuid = intermediate_box.type_uuid, + .requestable = intermediate_box.requestable, + .label = intermediate_box.label, + .id = intermediate_box.id, + .unvalidated_hash = intermediate_box.hash, + .private_content = std::move(private_contents), + }; +} + +absl::StatusOr<ContentBox> ParseContentBox(const IntermediateBox& unwrapped_box, + int recursion_limit) { + if (recursion_limit < 0) { + // All negative values mean "no recursion limit". Pick -1 to avoid worries + // about underflow when we subtract from it. + recursion_limit = -1; + } + switch (unwrapped_box.type) { + case kCborBoxType: + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = + CborBox{ + .payload = unwrapped_box.payload, + }, + }; + case kSuperBoxType: { + if (recursion_limit == 0) { + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = + SerializedSuperbox{ + .serialized = unwrapped_box.serialized, + }, + }; + } else { + ABSL_ASSIGN_OR_RETURN( + auto super_box, + ParseSuperBox(unwrapped_box, std::max(recursion_limit - 1, -1))); + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = std::move(super_box), + }; + } + break; + } + case kEmbeddedFileDescriptionBoxType: { + ABSL_ASSIGN_OR_RETURN(auto efdb, + ParseEmbeddedFileDescriptionBox(unwrapped_box)); + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = std::move(efdb), + }; + } + case kBinaryDataBoxType: + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = BinaryDataBox{.payload = unwrapped_box.payload}, + }; + case kC2paSaltBoxType: + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = C2paSaltBox{.salt = unwrapped_box.payload}, + }; + case kJsonBoxType: + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = JsonBox{.payload = unwrapped_box.payload}, + }; + case kUuidBoxType: + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = UuidBox{.payload = unwrapped_box.payload}, + }; + default: + return ContentBox{ + .raw_bytes = unwrapped_box.serialized, + .payload = + UnknownBox{ + .type = unwrapped_box.type, + .payload = unwrapped_box.payload, + }, + }; + } +} + +absl::StatusOr<SuperBox> ParseSuperBox( + const IntermediateBox& unwrapped_super_box, int recursion_limit) { + ABSL_ASSIGN_OR_RETURN(auto intermediate_super_box, + jumbf_internal::ParseSuperBox(unwrapped_super_box)); + + std::vector<ContentBox> contents; + contents.reserve(intermediate_super_box.content.size()); + for (int i = 0; i < intermediate_super_box.content.size(); ++i) { + ABSL_ASSIGN_OR_RETURN( + auto content, + ParseContentBox(intermediate_super_box.content[i], recursion_limit), + _.SetPrepend() << "could not parse content box for index " << i + << ": "); + contents.push_back(std::move(content)); + } + + ABSL_ASSIGN_OR_RETURN( + auto description, + FinalizeDescriptionBox(intermediate_super_box.description, + recursion_limit), + _.SetPrepend() << "could not finalize description box: "); + + return SuperBox{ + .description = std::move(description), + .contents = std::move(contents), + .raw_bytes = intermediate_super_box.serialized, + }; +} + +} // namespace + +absl::StatusOr<SuperBox> ConsumeSuperBox( + absl::string_view* serialized_super_box, int recursion_limit, + bool allow_implicit_length) { + absl::string_view temp_serialized_super_box = *serialized_super_box; + ABSL_ASSIGN_OR_RETURN( + auto unwrapped_super_box, + ConsumeBox(&temp_serialized_super_box, allow_implicit_length), + _.SetPrepend() << "cannot unwrap super box: "); + ABSL_ASSIGN_OR_RETURN(auto result, + ParseSuperBox(unwrapped_super_box, recursion_limit)); + *serialized_super_box = temp_serialized_super_box; + return result; +} + +} // namespace jumbf
diff --git a/jumbf/parse.h b/jumbf/parse.h new file mode 100644 index 0000000..3d83e98 --- /dev/null +++ b/jumbf/parse.h
@@ -0,0 +1,45 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_PARSE_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_PARSE_H_ + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "jumbf/box.h" + +namespace jumbf { + +// Consumes the first serialized JUMBF super_box in `serialized_super_box`. +// Performs only basic validation. Does not check that description boxes +// correspond with content box types. `recursion_limit` is the maximum number of +// levels of super_box that will be parsed. If negative, no limit will be +// enforced. If the limit is reached, the raw content of any further nested +// super_boxes will be returned as `SerializedSuperBox` structs. If parsing is +// not successful, `serialized_super_box` will not be modified. +// +// `allow_implicit_length` determines if boxes may set LBox to 0 to indicate +// that the box extends to the end of the input. +// +// The return value contains references (in the form of `absl::string_view`) to +// the string underlying `serialized_super_box`. Callers must ensure that string +// outlives the return value. +absl::StatusOr<SuperBox> ConsumeSuperBox( + absl::string_view* serialized_super_box, int recursion_limit, + bool allow_implicit_length = true); + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_PARSE_H_
diff --git a/jumbf/parse_test.cc b/jumbf/parse_test.cc new file mode 100644 index 0000000..5d1d76f --- /dev/null +++ b/jumbf/parse_test.cc
@@ -0,0 +1,722 @@ +// 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 "jumbf/parse.h" + +#include <cstdint> +#include <cstring> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/test_utils.h" +#include "riegeli/endian/endian_writing.h" +#include "uuid/uuid.h" + +namespace jumbf { +namespace { +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio::Uuid; +using ::testing::Eq; +using ::testing::IsEmpty; + +constexpr absl::string_view kFakeUuid = "DEADBEEF-F00D-BABE-B4DF-00D528013378"; + +std::string FakeDescriptionBoxContents() { + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + constexpr char kFakeLabel[] = "fakelabel"; + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 + + strlen(kFakeLabel) + + /*label null terminator*/ 1; + std::string fake_description_contents(description_content_length, '\0'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + riegeli::WriteBigEndian<uint8_t>(kDescriptionToggleLabelPresent, + content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1); + content_cursor += strlen(kFakeLabel) + 1; + + return fake_description_contents; +} + +TEST(ParseTest, InvalidSuperBoxFails) { + absl::string_view empty; + + EXPECT_THAT( + ConsumeSuperBox(&empty, /*recursion_limit=*/1), + StatusIs( + absl::StatusCode::kOutOfRange, + R"(cannot unwrap super box: cannot read LBox value: not enough input bytes remaining; have 0 need 4)")); + EXPECT_THAT(empty, IsEmpty()); +} + +TEST(ParseTest, InvalidSuperBoxPayloadFails) { + absl::string_view kContent = + R"(This content doesn't really matter for this test case.)"; + std::string serialized = WrapBox(kContent, 0xf00dbabe); + absl::string_view serialized_view = serialized; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + StatusIs(absl::StatusCode::kInvalidArgument, + "box is not a super box: got type 0xf00dbabe need " + "0x6a756d62 ('jumb')")); + EXPECT_THAT(serialized_view, Eq(serialized)); +} + +TEST(ParseTest, InvalidDescriptionBoxFails) { + constexpr uint32_t kLBox = 5; + constexpr uint32_t kTBox = 12345; + + std::string inner_box(8, '\0'); + char* cursor = inner_box.data(); + + riegeli::WriteBigEndian<uint32_t>(kLBox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(kTBox, cursor); + cursor += sizeof(uint32_t); + + const std::string super_box = WrapBox(inner_box, kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + StatusIs(absl::StatusCode::kInvalidArgument, + "cannot unwrap description box: LBox is 5; LBox values " + "2-7 are reserved by the standard")); + EXPECT_THAT(serialized_view, Eq(super_box)); +} + +TEST(ParseTest, InvalidDescriptionBoxPayloadFails) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), /*tbox=*/12345); + std::string content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string super_box = + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + StatusIs(absl::StatusCode::kInvalidArgument, + "cannot parse description box: box is not a description " + "box; got type 0x3039 need 0x6a756d64 ('jumd')")); + EXPECT_THAT(serialized_view, Eq(super_box)); +} + +TEST(ParseTest, NegativeRecursionLimitCausesNoUnderflow) { + absl::string_view box_from_fuzzer( + "\000\000\000\000jumb\000\000\000\031jumd\000\000\000\000\000\000\000\000" + "\000\000\000\000\000\000\000\000\000\000\000\000\000jumb\000\000\000\031" + "jumd\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000" + "\000\000\031\000\000\000\000\000\000\000\000\000", + 78); + EXPECT_THAT( + ConsumeSuperBox(&box_from_fuzzer, /*recursion_limit=*/-2147483648), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseToProtoTest, AllDescriptionBoxTogglesSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + constexpr absl::string_view kFakeSalt = R"(0123456701234567)"; + const std::string private_box = jumbf::WrapBox(kFakeSalt, kC2paSaltBoxType); + + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + constexpr char kFakeLabel[] = "fakelabel"; + constexpr uint32_t kFakeId = 0xB33FD00D; + const std::string fake_hash(32, 'X'); + const size_t description_content_length = + /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel) + + /*label null terminator*/ 1 + /*id*/ 4 + fake_hash.length() + + private_box.length(); + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + uint8_t toggles = jumbf::kDescriptionToggleRequestable | + jumbf::kDescriptionToggleLabelPresent | + jumbf::kDescriptionToggleIdPresent | + jumbf::kDescriptionToggleHashPresent | + jumbf::kDescriptionTogglePrivatePresent; + riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1); + content_cursor += strlen(kFakeLabel) + 1; + riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor); + content_cursor += sizeof(uint32_t); + memcpy(content_cursor, fake_hash.data(), 32); + content_cursor += 32; + memcpy(content_cursor, private_box.data(), private_box.length()); + + std::string content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string super_box = WrapBox( + absl::StrCat(WrapBox(fake_description_contents, kDescriptionBoxType), + content_box), + kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .requestable = true, + .label = "fakelabel", + .id = kFakeId, + .unvalidated_hash = fake_hash, + .private_content{ContentBox{.raw_bytes = private_box, + .payload = + C2paSaltBox{ + .salt = kFakeSalt, + }}}}, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + CborBox{ + .payload = kFakeCborData, + }}}, + .raw_bytes = super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseToProtoTest, DescriptionBoxMultiplePrivateContentSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + constexpr absl::string_view kFirstPrivatePayload = + R"(The format of this data is unimportant for this test case.)"; + constexpr absl::string_view kSecondPrivatePayload = + R"(The format of this data is also unimportant for this test case.)"; + const std::string private_box_payload[] = {WrapBox(kFirstPrivatePayload, 1), + WrapBox(kSecondPrivatePayload, 2)}; + const std::string private_box = + WrapBox(absl::StrJoin(private_box_payload, ""), kPrivateContentBoxType); + + // Can't use absl::string_view because the compiler warns against assuming it + // is null terminated, even when it truly is null terminated. + constexpr char kFakeLabel[] = "fakelabel"; + constexpr uint32_t kFakeId = 0xB33FD00D; + const std::string fake_hash(32, 'X'); + const size_t description_content_length = + /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel) + + /*label null terminator*/ 1 + /*id*/ 4 + fake_hash.length() + + private_box.length(); + std::string fake_description_contents(description_content_length, 'A'); + char* content_cursor = fake_description_contents.data(); + + SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid)); + uint8_t toggles = + kDescriptionToggleRequestable | kDescriptionToggleLabelPresent | + kDescriptionToggleIdPresent | kDescriptionToggleHashPresent | + kDescriptionTogglePrivatePresent; + riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1); + content_cursor += strlen(kFakeLabel) + 1; + riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor); + content_cursor += sizeof(uint32_t); + memcpy(content_cursor, fake_hash.data(), 32); + content_cursor += 32; + memcpy(content_cursor, private_box.data(), private_box.length()); + + std::string content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string super_box = WrapBox( + absl::StrCat(WrapBox(fake_description_contents, kDescriptionBoxType), + content_box), + kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT( + ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .requestable = true, + .label = "fakelabel", + .id = kFakeId, + .unvalidated_hash = fake_hash, + .private_content{ + ContentBox{ + .raw_bytes = WrapBox(kFirstPrivatePayload, 1), + .payload = + UnknownBox{ + .type = 1, + .payload = kFirstPrivatePayload, + }, + }, + ContentBox{ + .raw_bytes = WrapBox(kSecondPrivatePayload, 2), + .payload = + UnknownBox{ + .type = 2, + .payload = kSecondPrivatePayload, + }, + }, + }}, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + CborBox{ + .payload = kFakeCborData, + }}}, + .raw_bytes = super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, SingleCborContentBoxSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string super_box = + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + CborBox{ + .payload = kFakeCborData, + }}}, + .raw_bytes = super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, MultipleCborContentBoxSucceeds) { + constexpr absl::string_view kFakeFirstCborData = + R"(first content: the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + constexpr absl::string_view kFakeSecondCborData = + R"(second content: the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string super_box = WrapBox( + absl::StrCat(description_box, WrapBox(kFakeFirstCborData, kCborBoxType), + WrapBox(kFakeSecondCborData, kCborBoxType)), + kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = + { + ContentBox{.raw_bytes = WrapBox(kFakeFirstCborData, + kCborBoxType), + .payload = + CborBox{ + .payload = kFakeFirstCborData, + }}, + ContentBox{.raw_bytes = WrapBox(kFakeSecondCborData, + kCborBoxType), + .payload = + CborBox{ + .payload = kFakeSecondCborData, + }}, + }, + .raw_bytes = super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, SingleEmbeddedFileDescriptionBoxSucceeds) { + constexpr uint8_t kToggles = 0; + constexpr const char kFakeMediatype[] = "media/MyFormat"; + + const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype); + std::string payload(payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype)); + cursor += sizeof(kFakeMediatype); + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string content_box = WrapBox(payload, kEmbeddedFileDescriptionBoxType); + std::string super_box = + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + EmbeddedFileDescriptionBox{ + .media_type = kFakeMediatype, + }}}, + .raw_bytes = super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, SingleBinaryDataBoxSucceeds) { + constexpr absl::string_view kPayload = + R"(blah blah blah blah this is just some data in no particular format)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string content_box = WrapBox(kPayload, kBinaryDataBoxType); + std::string super_box = + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + BinaryDataBox{ + .payload = kPayload, + }}}, + .raw_bytes = super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, RecursiveSuperBoxUnderRecursionLimitSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string inner_super_box = + WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType); + + std::string outer_super_box = + WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType); + + absl::string_view serialized_view = outer_super_box; + + EXPECT_THAT( + ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{ + .raw_bytes = inner_super_box, + .payload = + SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = inner_content_box, + .payload = + CborBox{ + .payload = kFakeCborData, + }}}, + .raw_bytes = inner_super_box}}}, + .raw_bytes = outer_super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, RecursiveSuperBoxNoRecursionLimitSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string inner_super_box = + WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType); + + std::string outer_super_box = + WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType); + + absl::string_view serialized_view = outer_super_box; + + EXPECT_THAT( + ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{ + .raw_bytes = inner_super_box, + .payload = + SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = inner_content_box, + .payload = + CborBox{ + .payload = kFakeCborData, + }}}, + .raw_bytes = inner_super_box}}}, + .raw_bytes = outer_super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, RecursiveSuperBoxExceedsZeroRecursionLimitSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string inner_super_box = + WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType); + + std::string outer_super_box = + WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType); + + absl::string_view serialized_view = outer_super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/0), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = inner_super_box, + .payload = + SerializedSuperbox{ + .serialized = inner_super_box, + }}}, + .raw_bytes = outer_super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, RecursiveSuperBoxExceedsNonzeroRecursionLimitSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string inner_super_box = + WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType); + + std::string middle_super_box = + WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType); + + std::string outer_super_box = + WrapBox(absl::StrCat(description_box, middle_super_box), kSuperBoxType); + + absl::string_view serialized_view = outer_super_box; + + EXPECT_THAT( + ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{ + .raw_bytes = middle_super_box, + .payload = SuperBox{.description = + DescriptionBox{ + .type_uuid = + Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{ + .raw_bytes = inner_super_box, + .payload = + SerializedSuperbox{ + .serialized = inner_super_box, + }}}, + .raw_bytes = middle_super_box}}}, + .raw_bytes = outer_super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, SingleUnknownContentBoxSucceeds) { + constexpr absl::string_view kUnknownContent = + R"(this is some data in a format this parser does not understand)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string content_box = WrapBox(kUnknownContent, 12345678); + std::string super_box = + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + UnknownBox{ + .type = 12345678, + .payload = kUnknownContent, + }}}, + .raw_bytes = super_box})); + + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, ExcessContentSucceeds) { + constexpr absl::string_view kFakeCborData = + R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)"; + + constexpr absl::string_view kExtraData = + R"(this is some extra data in the input, the parser should not look at this)"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string content_box = WrapBox(kFakeCborData, kCborBoxType); + std::string super_box = absl::StrCat( + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType), + kExtraData); + + absl::string_view serialized_view = super_box; + + EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1), + IsOkAndHolds(SuperBox{ + .description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + CborBox{ + .payload = kFakeCborData, + }}}, + .raw_bytes = super_box.substr( + 0, super_box.size() - kExtraData.size())})); + + EXPECT_THAT(serialized_view, Eq(kExtraData)); +} + +TEST(ParseTest, SingleJsonDataBoxSucceeds) { + constexpr absl::string_view kPayload = R"({"abc": "xyz"})"; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string content_box = WrapBox(kPayload, kJsonBoxType); + std::string super_box = + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType); + + absl::string_view serialized_view = super_box; + + auto superbox_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1); + ASSERT_TRUE(superbox_or.ok()); + auto superbox = std::move(*superbox_or); + EXPECT_THAT(superbox, + Eq(SuperBox{.description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + JsonBox{ + .payload = kPayload, + }}}, + .raw_bytes = super_box})); + EXPECT_THAT(serialized_view, IsEmpty()); +} + +TEST(ParseTest, UuidBoxSucceeds) { + std::string uuid_box_payload = jumbf::kC2PARedactionUuid.ToProtoBytes(); + absl::string_view payload = uuid_box_payload; + + std::string description_box = + WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType); + std::string content_box = WrapBox(payload, kUuidBoxType); + std::string super_box = + WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType); + + absl::string_view serialized_view = super_box; + + auto superbox_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1); + ASSERT_TRUE(superbox_or.ok()); + auto superbox = std::move(*superbox_or); + EXPECT_THAT(superbox, + Eq(SuperBox{.description = + DescriptionBox{ + .type_uuid = Uuid::FromStringOrDie(kFakeUuid), + .label = "fakelabel", + }, + .contents = {ContentBox{.raw_bytes = content_box, + .payload = + UuidBox{ + .payload = payload, + }}}, + .raw_bytes = super_box})); + EXPECT_THAT(serialized_view, IsEmpty()); +} + +} // namespace +} // namespace jumbf
diff --git a/jumbf/test_utils.cc b/jumbf/test_utils.cc new file mode 100644 index 0000000..ae9eeb4 --- /dev/null +++ b/jumbf/test_utils.cc
@@ -0,0 +1,130 @@ +// 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 "jumbf/test_utils.h" + +#include <cstdint> +#include <cstring> +#include <optional> +#include <string> + +#include "absl/log/check.h" +#include "absl/strings/cord.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "jumbf/constants.h" +#include "riegeli/endian/endian_writing.h" +#include "uuid/uuid.h" + +namespace jumbf { + +std::string WrapBox(absl::string_view content, uint32_t tbox) { + const uint32_t lbox = content.length() + /*lbox*/ 4 + /*tbox*/ 4; + std::string serialized(lbox, '\0'); + char* cursor = serialized.data(); + riegeli::WriteBigEndian<uint32_t>(lbox, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(tbox, cursor); + cursor += sizeof(uint32_t); + // This is safe because the length of `serialized` has been calculated above + // to always have enough room for `content`. + memcpy(cursor, content.data(), content.length()); + return serialized; +} + +std::string WrapUndeterminedLengthBox(absl::string_view content, + uint32_t tbox) { + const uint32_t box_length = content.length() + /*lbox*/ 4 + /*tbox*/ 4; + std::string serialized(box_length, '\0'); + char* cursor = serialized.data(); + // LBox value + riegeli::WriteBigEndian<uint32_t>(0, cursor); + cursor += sizeof(uint32_t); + riegeli::WriteBigEndian<uint32_t>(tbox, cursor); + cursor += sizeof(uint32_t); + // This is safe because the length of `serialized` has been calculated above + // to always have enough room for `content`. + memcpy(cursor, content.data(), content.length()); + return serialized; +} + +void SerializeUuid(char** dest, credentio::Uuid uuid) { + const std::string bytes = uuid.ToProtoBytes(); + if (bytes.size() == 16) { + memcpy(*dest, bytes.data(), 16); + } else { + memset(*dest, 0, 16); + } + *dest += 16; +} + +std::string EncodeDescriptionBox(credentio::Uuid uuid, + std::optional<absl::string_view> label, + bool requestable) { + if (label.has_value()) { + CHECK(!absl::StrContains(*label, '\0')); + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 + + label->size() + + /*label NUL terminator*/ 1; + std::string description_content(description_content_length, '\0'); + char* content_cursor = description_content.data(); + + jumbf::SerializeUuid(&content_cursor, uuid); + uint8_t toggles = kDescriptionToggleLabelPresent; + if (requestable) { + toggles |= kDescriptionToggleRequestable; + } + riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor); + content_cursor += sizeof(uint8_t); + memcpy(content_cursor, label->data(), label->size()); + content_cursor += label->size(); + *content_cursor++ = '\0'; + return WrapBox(description_content, kDescriptionBoxType); + } + const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 + + /*label NUL terminator*/ 1; + std::string description_content(description_content_length, '\0'); + char* content_cursor = description_content.data(); + + jumbf::SerializeUuid(&content_cursor, uuid); + uint8_t toggles = kDescriptionToggleEmpty; + riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor); + content_cursor += sizeof(uint8_t); + *content_cursor++ = '\0'; + return WrapBox(description_content, kDescriptionBoxType); +} + +std::string EncodeCborBox(absl::string_view contents) { + return WrapBox(contents, kCborBoxType); +} + +std::string EncodeSuperBox(credentio::Uuid uuid, + std::optional<absl::string_view> label, + absl::Span<const absl::string_view> contents, + bool requestable) { + return jumbf::WrapBox( + absl::StrCat(EncodeDescriptionBox(uuid, label, requestable), + absl::StrJoin(contents, "")), + jumbf::kSuperBoxType); +} + +absl::Cord CordFromConstexpr(absl::string_view data) { + return absl::MakeCordFromExternal(data, [](absl::string_view data) {}); +} + +} // namespace jumbf
diff --git a/jumbf/test_utils.h b/jumbf/test_utils.h new file mode 100644 index 0000000..5e1f13a --- /dev/null +++ b/jumbf/test_utils.h
@@ -0,0 +1,63 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_TEST_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_TEST_UTILS_H_ + +#include <stdbool.h> + +#include <cstdint> +#include <optional> +#include <string> + +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "uuid/uuid.h" + +namespace jumbf { + +// Wraps `content` in the basic box structure (section 4.3) +std::string WrapBox(absl::string_view content, uint32_t tbox); + +// Wraps `content` in the basic box structure, using an LBox value of 0 +// (section 4.3) +std::string WrapUndeterminedLengthBox(absl::string_view content, uint32_t tbox); + +// Serializes a 16 byte UUID in RFC 4122 (ISO / IEC 11578) format and advances +// `dest`. +void SerializeUuid(char** dest, credentio::Uuid uuid); + +// Serializes a description box (section A.3) with the given UUID and label. +std::string EncodeDescriptionBox(credentio::Uuid uuid, + std::optional<absl::string_view> label, + bool requestable = false); + +// Serializes a CBOR box (section B.7) with the given contents. +std::string EncodeCborBox(absl::string_view contents); + +// Serializes a superbox (section A.2) with the given UUID, label, and +// contents. +std::string EncodeSuperBox(credentio::Uuid uuid, + std::optional<absl::string_view> label, + absl::Span<const absl::string_view> contents, + bool requestable = false); + +// This is only safe if the array backing `data` is a compile-time constant. +absl::Cord CordFromConstexpr(absl::string_view data); + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_TEST_UTILS_H_
diff --git a/jumbf/uri.cc b/jumbf/uri.cc new file mode 100644 index 0000000..2d37002 --- /dev/null +++ b/jumbf/uri.cc
@@ -0,0 +1,105 @@ +// 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 "jumbf/uri.h" + +#include <string> +#include <utility> +#include <variant> + +#include "absl/base/nullability.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" +#include "jumbf/box.h" + +namespace jumbf { +namespace { + +absl::string_view GetLabel(const SuperBox& box) { + if (box.description.label.has_value()) { + return *box.description.label; + } + return ""; +} + +// Returns the first child superbox with the given label, or error if not +// found. +absl::StatusOr<const SuperBox*> GetChild(const SuperBox* box, + absl::string_view label) { + if (label == "." || label == "..") { + return absl::InvalidArgumentError("Unsupported path component"); + } + for (const ContentBox& content : box->contents) { + if (std::holds_alternative<SuperBox>(content.payload)) { + const SuperBox& child = std::get<SuperBox>(content.payload); + if (GetLabel(child) == label) { + return &child; + } + } + } + return absl::NotFoundError(""); +} + +} // namespace + +UriResolver UriResolver::WithSingleRootChild(const SuperBox* root_child) { + return UriResolver(root_child, absl::StrCat("/", GetLabel(*root_child), "/")); +} + +UriResolver::UriResolver(const SuperBox* absl_nonnull root_box, + std::string root_path) + : root_box_(*ABSL_DIE_IF_NULL(root_box)), + root_path_(std::move(root_path)) {} + +absl::StatusOr<std::string> UriResolver::GetAbsolutePathFromUri( + absl::string_view uri, absl::string_view current_path) { + if (!absl::ConsumePrefix(&uri, "self#jumbf=")) { + return absl::InvalidArgumentError("self#jumbf= prefix required"); + } + return GetAbsolutePath(uri, current_path); +} + +std::string UriResolver::GetAbsolutePath(absl::string_view path, + absl::string_view current_path) { + if (absl::StartsWith(path, "/")) { + // Absolute path. + return std::string(path); + } + // Relative path. + return absl::StrCat(current_path, "/", path); +} + +absl::StatusOr<const SuperBox*> UriResolver::ResolvePath( + absl::string_view absolute_path) const { + const SuperBox* box = nullptr; + if (!absl::ConsumePrefix(&absolute_path, root_path_)) { + return absl::NotFoundError(""); + } + box = &root_box_; + for (absl::string_view label : absl::StrSplit(absolute_path, '/')) { + ABSL_ASSIGN_OR_RETURN(auto child_box, GetChild(box, label)); + box = std::move(child_box); + } + return box; +} + +} // namespace jumbf
diff --git a/jumbf/uri.h b/jumbf/uri.h new file mode 100644 index 0000000..b161073 --- /dev/null +++ b/jumbf/uri.h
@@ -0,0 +1,64 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_URI_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_URI_H_ + +#include <string> + +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "jumbf/box.h" + +namespace jumbf { + +class UriResolver { + public: + // Creates a new UriResolver where the root node has a single child. + static UriResolver WithSingleRootChild(const SuperBox* root_child); + + // Resolves a JUMBF URI in one of these forms to an absolute path: + // * `self#jumbf=/label1/label2/label3` --> `/label1/label2/label3` + // * `self#jumbf=label3/label4` --> `/label1/label2/label3/label4` + // (if `current_path` is `/label1/label2`) + // + // `current_path` must be an absolute path (e.g., `/label1/label2`). + static absl::StatusOr<std::string> GetAbsolutePathFromUri( + absl::string_view uri, absl::string_view current_path); + + // Resolves a path in one of these forms to an absolute path: + // * `/label1/label2/label3` -- absolute path + // * `label3/label4` -- path gets `{current_path}/` prepended + // `current_path` must be an absolute path (e.g., `/label1/label2`). + static std::string GetAbsolutePath(absl::string_view path, + absl::string_view current_path); + + // Resolves the given absolute path (produced by GetAbsolutePathFromUri() or + // GetAbsolutePath()) to a SuperBox. No "." or ".." path components are + // allowed. + absl::StatusOr<const SuperBox*> ResolvePath( + absl::string_view absolute_path) const; + + private: + UriResolver(const SuperBox* absl_nonnull root_box, std::string root_path); + + const SuperBox& root_box_; + std::string root_path_; // Path to root_box_, with trailing slash. +}; + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_URI_H_
diff --git a/jumbf/uri_test.cc b/jumbf/uri_test.cc new file mode 100644 index 0000000..095c494 --- /dev/null +++ b/jumbf/uri_test.cc
@@ -0,0 +1,329 @@ +// 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 "jumbf/uri.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/no_destructor.h" +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "uuid/uuid.h" + +namespace jumbf { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::Eq; + +constexpr absl::string_view kUuid1 = "DEADBEEF-F00D-BABE-B4DF-000000000001"; + +DescriptionBox Desc(std::optional<absl::string_view> label) { + return DescriptionBox{ + .type_uuid = credentio::Uuid::FromStringOrDie(kUuid1), + .label = std::move(label), + }; +} + +// Concise representation of a SuperBox tree for testing. +struct TestSuperBox { + std::optional<absl::string_view> label; + std::vector<TestSuperBox> children; +}; + +// Creates a payload based on the provided label, backed by a singleton map. +// Not thread-safe. +absl::string_view TestPayload(std::optional<absl::string_view> label) { + static absl::NoDestructor< + absl::flat_hash_map<std::string, std::unique_ptr<std::string>>> + payloads; + std::string label_str = std::string(label.value_or("")); + if (auto it = payloads->find(label_str); it != payloads->end()) { + return *it->second; + } + std::string payload = absl::StrCat(label_str, "-payload"); + auto [it, _] = payloads->insert( + std::make_pair(label_str, std::make_unique<std::string>(payload))); + return *it->second; +} + +SuperBox MakeSuperBox(const TestSuperBox& test_box) { + SuperBox super_box = {.description = Desc(test_box.label)}; + if (test_box.children.empty()) { + super_box.contents.push_back( + ContentBox{.payload = CborBox{.payload = TestPayload(test_box.label)}}); + } + for (const TestSuperBox& child : test_box.children) { + super_box.contents.push_back(ContentBox{.payload = MakeSuperBox(child)}); + } + return super_box; +} + +absl::StatusOr<const SuperBox*> Get(const UriResolver& resolver, + absl::string_view uri, + absl::string_view current_path) { + ABSL_ASSIGN_OR_RETURN(auto absolute_path, + resolver.GetAbsolutePathFromUri(uri, current_path)); + return resolver.ResolvePath(absolute_path); +} + +// Predicate matcher for a SuperBox* with a single CBOR content box. +MATCHER_P(HasCborPayload, value, "") { + return arg->contents.size() == 1 && + std::holds_alternative<CborBox>(arg->contents[0].payload) && + std::get<CborBox>(arg->contents[0].payload).payload == value; +} + +TEST(UriTest, RelativePaths) { + SuperBox root_box = MakeSuperBox(TestSuperBox{ + .label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = std::nullopt}, + TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}, + TestSuperBox{ + .label = "label5", + .children = {TestSuperBox{.label = "label6"}}, + }}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(Get(resolver, "self#jumbf=label3", "/label1/label2"), + IsOkAndHolds(HasCborPayload("label3-payload"))); + EXPECT_THAT(Get(resolver, "self#jumbf=label4", "/label1/label2"), + IsOkAndHolds(HasCborPayload("label4-payload"))); + EXPECT_THAT(Get(resolver, "self#jumbf=label5/label6", "/label1/label2"), + IsOkAndHolds(HasCborPayload("label6-payload"))); + + EXPECT_THAT(Get(resolver, "self#jumbf=label2", "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(Get(resolver, "self#jumbf=label4/label5", "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(Get(resolver, "self#jumbf=label5/label6/", "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT( + Get(resolver, "self#jumbf=label5/label6/label7", "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(UriTest, AbsolutePaths) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = std::nullopt}, + TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT( + Get(resolver, "self#jumbf=/label1/label2/label3", "/label1/label2"), + IsOkAndHolds(HasCborPayload("label3-payload"))); + EXPECT_THAT(Get(resolver, "self#jumbf=/label1/label2/label4", + "/label1/label2/label3"), + IsOkAndHolds(HasCborPayload("label4-payload"))); + + EXPECT_THAT(Get(resolver, "self#jumbf=/", "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(Get(resolver, "self#jumbf=/label3", "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(Get(resolver, "self#jumbf=/label1/label2/label3/label5", + "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(UriTest, EmptyPathNotFound) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(Get(resolver, "self#jumbf=", "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(UriTest, AbsolutePathWrongRootPrefix) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(Get(resolver, "self#jumbf=/somethingelse/label2/label3", + "/label1/label2"), + StatusIs(absl::StatusCode::kNotFound)); +} + +// No self#jumbf= prefix. +TEST(UriTest, NoSelfPrefix) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(Get(resolver, "/label1/label2/label3", "/label1/label2"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(UriTest, DotPathComponentsNotSupported) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(Get(resolver, "self#jumbf=/label1/label2/label3/../label4", + "/label1/label2"), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(Get(resolver, "self#jumbf=../label1/label2/label3/label4", + "/label1/label2"), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT( + Get(resolver, "self#jumbf=/label1/label2/./label3", "/label1/label2"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ResolvePathTest, AbsolutePaths) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = std::nullopt}, + TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(resolver.ResolvePath("/label1/label2/label3"), + IsOkAndHolds(HasCborPayload("label3-payload"))); + EXPECT_THAT(resolver.ResolvePath("/label1/label2/label4"), + IsOkAndHolds(HasCborPayload("label4-payload"))); + + EXPECT_THAT(resolver.ResolvePath("/"), StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(resolver.ResolvePath("/label3"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(resolver.ResolvePath("/label1/label2/label3/label5"), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(ResolvePathTest, EmptyPathNotFound) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(resolver.ResolvePath(""), StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(ResolvePathTest, AbsolutePathWrongRootPrefix) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(resolver.ResolvePath("/somethingelse/label2/label3"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(resolver.ResolvePath("self#jumbf=/label1/label2/label3"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(resolver.ResolvePath("label1/label2/label3"), + StatusIs(absl::StatusCode::kNotFound)); +} + +TEST(ResolvePathTest, DotPathComponentsNotSupported) { + SuperBox root_box = MakeSuperBox( + TestSuperBox{.label = "label1", + .children = {TestSuperBox{ + .label = "label2", + .children = {TestSuperBox{.label = "label3"}, + TestSuperBox{.label = "label4"}}}}}); + + UriResolver resolver = UriResolver::WithSingleRootChild(&root_box); + EXPECT_THAT(resolver.ResolvePath("/label1/label2/label3/../label4"), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(resolver.ResolvePath("../label1/label2/label3/label4"), + StatusIs(absl::StatusCode::kNotFound)); + EXPECT_THAT(resolver.ResolvePath("/label1/label2/./label3"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(GetAbsolutePath, RelativePaths) { + EXPECT_THAT(UriResolver::GetAbsolutePath("label3", "/label1/label2"), + Eq("/label1/label2/label3")); + EXPECT_THAT(UriResolver::GetAbsolutePath("label3/label4", "/label1/label2"), + Eq("/label1/label2/label3/label4")); +} + +TEST(GetAbsolutePath, AbsolutePaths) { + EXPECT_THAT(UriResolver::GetAbsolutePath("/label3", "/label1/label2"), + Eq("/label3")); + EXPECT_THAT(UriResolver::GetAbsolutePath("/label3/label4", "/label1/label2"), + Eq("/label3/label4")); +} + +TEST(GetAbsolutePathFromUri, RelativePaths) { + EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=label3", + "/label1/label2"), + IsOkAndHolds("/label1/label2/label3")); + EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=label3/label4", + "/label1/label2"), + IsOkAndHolds("/label1/label2/label3/label4")); +} + +TEST(GetAbsolutePathFromUri, AbsolutePaths) { + EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=/label3", + "/label1/label2"), + IsOkAndHolds("/label3")); + EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=/label3/label4", + "/label1/label2"), + IsOkAndHolds("/label3/label4")); +} + +TEST(GetAbsolutePathFromUri, WrongPrefix) { + EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("/label3", "/label1/label2"), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("https://google.com/label3", + "/label1/label2"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +} // namespace +} // namespace jumbf
diff --git a/jumbf/utils.cc b/jumbf/utils.cc new file mode 100644 index 0000000..97e1555 --- /dev/null +++ b/jumbf/utils.cc
@@ -0,0 +1,145 @@ +// 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 "jumbf/utils.h" + +#include <sys/types.h> + +#include <cstdint> +#include <utility> +#include <variant> + +#include "absl/functional/overload.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/internal/serialization_utils.h" +#include "uuid/uuid.h" + +namespace jumbf { + +namespace { + +// Consumes the header of a JUMBF Box and returns the TBox. +// +// The header is a 4 byte unsigned integer (LBox) followed by a 4 byte unsigned +// integer (TBox). If the first integer is 1, it's followed by a 8 byte unsigned +// integer (XLBox). +absl::StatusOr<uint32_t> ConsumeHeader(absl::string_view* payload) { + ABSL_ASSIGN_OR_RETURN(auto lbox, + jumbf_internal::ConsumeInteger<uint32_t>(payload)); + ABSL_ASSIGN_OR_RETURN(auto tbox, + jumbf_internal::ConsumeInteger<uint32_t>(payload)); + if (lbox == 1) { + ABSL_RETURN_IF_ERROR( + jumbf_internal::ConsumeBytes(payload, sizeof(uint64_t)).status()); + } + return tbox; +} + +} // namespace + +absl::StatusOr<absl::string_view> StripBoxHeaders(absl::string_view raw_bytes) { + ABSL_ASSIGN_OR_RETURN(auto lbox, + jumbf_internal::ConsumeInteger<uint32_t>(&raw_bytes)); + ABSL_RETURN_IF_ERROR( + jumbf_internal::ConsumeBytes(&raw_bytes, sizeof(uint32_t)).status()); + if (lbox == 1) { + ABSL_RETURN_IF_ERROR( + jumbf_internal::ConsumeBytes(&raw_bytes, sizeof(uint64_t)).status()); + } + return raw_bytes; +} + +absl::StatusOr<bool> HasDescriptionBoxMatching(absl::string_view raw_bytes, + const credentio::Uuid& uuid, + uint8_t toggles_mask, + absl::string_view label) { + // Super Box Header + ABSL_ASSIGN_OR_RETURN(auto super_tbox, ConsumeHeader(&raw_bytes)); + if (super_tbox != kSuperBoxType) { + return false; + } + + // Description Box Header + ABSL_ASSIGN_OR_RETURN(auto description_tbox, ConsumeHeader(&raw_bytes)); + if (description_tbox != kDescriptionBoxType) { + return false; + } + + // Description Box UUID + if (raw_bytes.size() < 16) { + return absl::OutOfRangeError("Description Box UUID is missing."); + } + ABSL_ASSIGN_OR_RETURN(auto description_uuid, credentio::Uuid::FromProtoBytes( + raw_bytes.substr(0, 16))); + raw_bytes.remove_prefix(16); + if (std::move(description_uuid) != uuid) { + return false; + } + + // Description Box Toggles + ABSL_ASSIGN_OR_RETURN(auto toggles, + jumbf_internal::ConsumeInteger<uint8_t>(&raw_bytes)); + if ((toggles & toggles_mask) != toggles_mask) { + return false; + } + + // Expected Label + if (!absl::ConsumePrefix(&raw_bytes, label)) { + return false; + } + + // Ensure the next character is the null terminator + ABSL_ASSIGN_OR_RETURN(auto next_byte, + jumbf_internal::ConsumeInteger<uint8_t>(&raw_bytes)); + return next_byte == 0x00; +} + +int GetContextBoxSize(const ContentBox& box) { + int content_size = 0; + std::visit( + absl::Overload{ + [&](const UnknownBox& arg) { content_size = arg.payload.size(); }, + [&](const CborBox& arg) { content_size = arg.payload.size(); }, + [&](const SerializedSuperbox& arg) { + content_size = arg.serialized.size(); + }, + [&](const SuperBox& arg) { content_size = arg.raw_bytes.size(); }, + [&](const EmbeddedFileDescriptionBox& arg) { + // See section B.6.2 + // Always include a byte for toggles + content_size = 1; + // Add media type size + 1 for null terminator + content_size += arg.media_type.size() + 1; + if (arg.file_name.has_value()) { + // Add file name size + 1 for null terminator + content_size += arg.file_name->size() + 1; + } + }, + [&](const BinaryDataBox& arg) { content_size = arg.payload.size(); }, + [&](const C2paSaltBox& arg) { content_size = arg.salt.size(); }, + [&](const JsonBox& arg) { content_size = arg.payload.size(); }, + [&](const UuidBox& arg) { content_size = arg.payload.size(); }, + }, + box.payload); + return content_size; +} + +} // namespace jumbf
diff --git a/jumbf/utils.h b/jumbf/utils.h new file mode 100644 index 0000000..de09428 --- /dev/null +++ b/jumbf/utils.h
@@ -0,0 +1,50 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_JUMBF_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_JUMBF_UTILS_H_ + +#include <cstdint> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "jumbf/box.h" +#include "uuid/uuid.h" + +namespace jumbf { + +// Remove the JUMBF box header (LBox, TBox, XLBox). Returns the box content. For +// superboxes, this is both the description and the contents. +absl::StatusOr<absl::string_view> StripBoxHeaders(absl::string_view raw_bytes); + +// Checks if the top-level JUMBF box within the supplied bytes has a description +// box matching the supplied UUID, toggles and label by inspecting the raw +// bytes. +// +// Note: ConsumeSuperBox() function is not used because it requires passing a +// complete JUMBF box which may not be available for every use. (JPEGs possibly +// need to spread the full data across multiple segments with it being +// inadvisable to force them to combine prior to this check). +absl::StatusOr<bool> HasDescriptionBoxMatching(absl::string_view raw_bytes, + const credentio::Uuid& uuid, + uint8_t toggles_mask, + absl::string_view label); + +// Returns the size of the box payload. +int GetContextBoxSize(const ContentBox& box); + +} // namespace jumbf + +#endif // THIRD_PARTY_CREDENTIO_JUMBF_UTILS_H_
diff --git a/jumbf/utils_test.cc b/jumbf/utils_test.cc new file mode 100644 index 0000000..00fb275 --- /dev/null +++ b/jumbf/utils_test.cc
@@ -0,0 +1,441 @@ +// 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 "jumbf/utils.h" + +#include <cstddef> +#include <cstdint> +#include <cstring> +#include <optional> +#include <string> +#include <utility> +#include <vector> + +#include "absl/numeric/int128.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/strings/cord.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/box_builder.h" +#include "jumbf/constants.h" +#include "jumbf/parse.h" +#include "jumbf/test_utils.h" +#include "riegeli/endian/endian_writing.h" +#include "uuid/uuid.h" + +namespace jumbf { +namespace { + +void WriteUint32NetworkOrder(uint32_t value, std::vector<uint8_t>* bytes) { + bytes->push_back(static_cast<uint8_t>((value >> 24) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 16) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 8) & 0xFF)); + bytes->push_back(static_cast<uint8_t>(value & 0xFF)); +} + +void WriteUint64NetworkOrder(uint64_t value, std::vector<uint8_t>* bytes) { + bytes->push_back(static_cast<uint8_t>((value >> 56) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 48) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 40) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 32) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 24) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 16) & 0xFF)); + bytes->push_back(static_cast<uint8_t>((value >> 8) & 0xFF)); + bytes->push_back(static_cast<uint8_t>(value & 0xFF)); +} + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +inline constexpr uint32_t kUnknownBoxType = 0x1111'1111; +inline constexpr credentio::Uuid kUuid1( + credentio::Uuid::FromStringOrDie("11111111-1111-1111-1111-111111111111")); +inline constexpr absl::uint128 kUuid1_uint128 = + absl::MakeUint128(0x1111111111111111, 0x1111111111111111); +inline constexpr absl::uint128 kUuid2_uint128 = + absl::MakeUint128(0x2222222222222222, 0x2222222222222222); +inline constexpr uint8_t kToggles1 = 0b00001111; +inline constexpr uint8_t kToggles2 = 0b11110000; +inline constexpr absl::string_view kLabel1 = "expected"; +inline constexpr absl::string_view kLabel2 = "unexpected"; + +TEST(UtilsTest, TrimJumbfSuperBoxHeader) { + std::string input = WrapBox("content", kSuperBoxType); + EXPECT_THAT(StripBoxHeaders(input), IsOkAndHolds("content")); +} + +TEST(UtilsTest, TrimEmptyJumbfSuperBoxHeader) { + EXPECT_THAT(StripBoxHeaders(""), StatusIs(absl::StatusCode::kOutOfRange)); +} + +struct BoxData { + std::optional<uint32_t> super_lbox = std::nullopt; + std::optional<uint32_t> super_tbox = std::nullopt; + std::optional<uint64_t> super_xlbox = std::nullopt; + std::optional<uint32_t> desc_lbox = std::nullopt; + std::optional<uint32_t> desc_tbox = std::nullopt; + std::optional<uint64_t> desc_xlbox = std::nullopt; + + std::optional<absl::uint128> uuid = std::nullopt; + + std::optional<uint8_t> toggles = std::nullopt; + + std::optional<absl::string_view> label = std::nullopt; + bool null_terminator = true; +}; + +std::string CreatePayload(const BoxData& box_data) { + std::vector<uint8_t> bytes; + if (box_data.super_lbox.has_value()) { + WriteUint32NetworkOrder(*box_data.super_lbox, &bytes); + } + if (box_data.super_tbox.has_value()) { + WriteUint32NetworkOrder(*box_data.super_tbox, &bytes); + } + if (box_data.super_xlbox.has_value()) { + WriteUint64NetworkOrder(*box_data.super_xlbox, &bytes); + } + if (box_data.desc_lbox.has_value()) { + WriteUint32NetworkOrder(*box_data.desc_lbox, &bytes); + } + if (box_data.desc_tbox.has_value()) { + WriteUint32NetworkOrder(*box_data.desc_tbox, &bytes); + } + if (box_data.desc_xlbox.has_value()) { + WriteUint64NetworkOrder(*box_data.desc_xlbox, &bytes); + } + if (box_data.uuid.has_value()) { + WriteUint64NetworkOrder(absl::Uint128High64(*box_data.uuid), &bytes); + WriteUint64NetworkOrder(absl::Uint128Low64(*box_data.uuid), &bytes); + } + if (box_data.toggles.has_value()) { + bytes.push_back(*box_data.toggles); + } + if (box_data.label.has_value()) { + bytes.insert(bytes.end(), box_data.label->begin(), box_data.label->end()); + } + if (box_data.null_terminator) { + bytes.push_back(0x00); + } + return std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size()); +} + +// Serializes an absl::string_view to a char array, followed by a null +// terminator. +void SerializeStringWithNull(char** dest, absl::string_view str) { + memcpy(*dest, str.data(), str.size()); + *dest += str.size(); + **dest = 0x00; + *dest += 1; +} + +TEST(HasDescriptionBoxMatchingTest, NoData) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({}), kUuid1, kToggles1, kLabel1), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("not enough input bytes remaining; have 1 need 4"))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxLbox) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1}), kUuid1, + kToggles1, kLabel1), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("not enough input bytes remaining; have 1 need 4"))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxTbox) { + EXPECT_THAT( + HasDescriptionBoxMatching( + CreatePayload({.super_lbox = 1, .super_tbox = 1}), kUuid1, kToggles1, + kLabel1), + StatusIs( + absl::StatusCode::kOutOfRange, + HasSubstr("ran out of input bytes; 8 bytes requested, 1 remaining"))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxXlboxIncorrectTbox) { + EXPECT_THAT( + HasDescriptionBoxMatching( + CreatePayload({.super_lbox = 1, .super_tbox = 1, .super_xlbox = 1}), + kUuid1, kToggles1, kLabel1), + IsOkAndHolds(false)); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxTboxCorrect) { + EXPECT_THAT( + HasDescriptionBoxMatching( + CreatePayload( + {.super_lbox = 1, .super_tbox = kSuperBoxType, .super_xlbox = 1}), + kUuid1, kToggles1, kLabel1), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("not enough input bytes remaining; have 1 need 4"))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxLbox) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1}), + kUuid1, kToggles1, kLabel1), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("not enough input bytes remaining; have 1 need 4"))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxTbox) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = 1}), + kUuid1, kToggles1, kLabel1), + StatusIs( + absl::StatusCode::kOutOfRange, + HasSubstr("ran out of input bytes; 8 bytes requested, 1 remaining"))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxXlboxIncorrectTbox) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = 1, + .desc_xlbox = 1}), + kUuid1, kToggles1, kLabel1), + IsOkAndHolds(false)); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxTboxCorrect) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1}), + kUuid1, kToggles1, kLabel1), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("Description Box UUID is missing."))); +} + +TEST(HasDescriptionBoxMatchingTest, + ContainsDescriptorBoxTboxCorrectEmptyUuidPayload) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1, + .null_terminator = false}), + kUuid1, kToggles1, kLabel1), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("Description Box UUID is missing."))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsUuidIncorrect) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1, + .uuid = kUuid2_uint128}), + kUuid1, kToggles1, kLabel1), + IsOkAndHolds(false)); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsUuidCorrect) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1, + .uuid = kUuid1_uint128, + .null_terminator = false}), + kUuid1, kToggles1, kLabel1), + StatusIs(absl::StatusCode::kOutOfRange, + HasSubstr("not enough input bytes remaining; have 0 need 1"))); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsTogglesIncorrect) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1, + .uuid = kUuid1_uint128, + .toggles = kToggles2}), + kUuid1, kToggles1, kLabel1), + IsOkAndHolds(false)); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsTogglesCorrect) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1, + .uuid = kUuid1_uint128, + .toggles = kToggles1}), + kUuid1, kToggles1, kLabel1), + IsOkAndHolds(false)); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsWrongLabel) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1, + .uuid = kUuid1_uint128, + .toggles = kToggles1, + .label = kLabel2}), + kUuid1, kToggles1, kLabel1), + IsOkAndHolds(false)); +} + +TEST(HasDescriptionBoxMatchingTest, ContainsManifest) { + EXPECT_THAT( + HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1, + .super_tbox = kSuperBoxType, + .super_xlbox = 1, + .desc_lbox = 1, + .desc_tbox = kDescriptionBoxType, + .desc_xlbox = 1, + .uuid = kUuid1_uint128, + .toggles = kToggles1, + .label = kLabel1}), + kUuid1, kToggles1, kLabel1), + IsOkAndHolds(true)); +} + +TEST(GetContextBoxSizeTest, GetContextBoxSize) { + std::string payload = "test"; + SuperBoxBuilder builder( + kUuid1, {.requestable_iff_label = true, .label = absl::Cord("main")}); + size_t efdb_payload_length = 0; + { + constexpr uint8_t kToggles = jumbf::kEfdbToggleFileNamePresent; + constexpr absl::string_view kFakeMediatype = "media/MyFormat"; + constexpr absl::string_view kFakePath = "path/to/some/file"; + + efdb_payload_length = + 1 /*toggles*/ + kFakeMediatype.size() + 1 + kFakePath.size() + 1; + std::string payload(efdb_payload_length, 'A'); + char* cursor = payload.data(); + riegeli::WriteBigEndian<uint8_t>(kToggles, cursor); + cursor += sizeof(uint8_t); + SerializeStringWithNull(&cursor, kFakeMediatype); + SerializeStringWithNull(&cursor, kFakePath); + ABSL_ASSERT_OK(builder.AddContent(kEmbeddedFileDescriptionBoxType, + absl::Cord(payload))); + } + ABSL_ASSERT_OK(builder.AddContent(kUuidBoxType, absl::Cord(payload))); + ABSL_ASSERT_OK(builder.AddContent(kJsonBoxType, absl::Cord(payload))); + ABSL_ASSERT_OK(builder.AddContent(kC2paSaltBoxType, absl::Cord(payload))); + ABSL_ASSERT_OK(builder.AddContent(kBinaryDataBoxType, absl::Cord(payload))); + size_t superbox_payload_size = 0; + size_t child_payload_size = 0; + { + SuperBoxBuilder child_builder( + kUuid1, {.requestable_iff_label = true, .label = absl::Cord("child")}); + ABSL_ASSERT_OK( + child_builder.AddContent(kSuperBoxType, absl::Cord(payload))); + auto child_box_or = std::move(child_builder).Finalize(); + ABSL_ASSERT_OK(child_box_or); + BuiltSuperBox child_box = std::move(*child_box_or); + absl::Cord child_cord = std::move(child_box).AsCord(); + superbox_payload_size = child_cord.size(); + absl::string_view child_view = child_cord.Flatten(); + auto parsed_child_box_or = + ConsumeSuperBox(&child_view, /*recursion_limit=*/0); + ABSL_ASSERT_OK(parsed_child_box_or); + SuperBox parsed_child_box = std::move(*parsed_child_box_or); + child_payload_size = parsed_child_box.contents[0] + .Get<SerializedSuperbox>() + .serialized.size(); + ABSL_ASSERT_OK(builder.AddChild(child_cord)); + } + ABSL_ASSERT_OK(builder.AddContent(kCborBoxType, absl::Cord(payload))); + ABSL_ASSERT_OK(builder.AddContent(kUnknownBoxType, absl::Cord(payload))); + auto built_superbox_or = std::move(builder).Finalize(); + ABSL_ASSERT_OK(built_superbox_or); + BuiltSuperBox built_superbox = std::move(*built_superbox_or); + absl::Cord superbox_cord = std::move(built_superbox).AsCord(); + absl::string_view superbox_view = superbox_cord.Flatten(); + auto box_or = ConsumeSuperBox(&superbox_view, /*recursion_limit=*/1); + ABSL_ASSERT_OK(box_or); + SuperBox box = std::move(*box_or); + + EXPECT_TRUE(box.contents[0].Holds<UnknownBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[0]), payload.size()); + EXPECT_TRUE(box.contents[1].Holds<CborBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[1]), payload.size()); + EXPECT_TRUE(box.contents[2].Holds<SuperBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[2]), superbox_payload_size); + SuperBox serialized_superbox = box.contents[2].Get<SuperBox>(); + EXPECT_TRUE(serialized_superbox.contents[0].Holds<SerializedSuperbox>()); + EXPECT_THAT(GetContextBoxSize(serialized_superbox.contents[0]), + child_payload_size); + EXPECT_TRUE(box.contents[3].Holds<BinaryDataBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[3]), payload.size()); + EXPECT_TRUE(box.contents[4].Holds<C2paSaltBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[4]), payload.size()); + EXPECT_TRUE(box.contents[5].Holds<JsonBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[5]), payload.size()); + EXPECT_TRUE(box.contents[6].Holds<UuidBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[6]), payload.size()); + EXPECT_TRUE(box.contents[7].Holds<EmbeddedFileDescriptionBox>()); + EXPECT_THAT(GetContextBoxSize(box.contents[7]), efdb_payload_length); +} + +TEST(TestUtilsTest, SerializeUuid) { + char buf[32] = {'\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', + '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', + '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', + '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', + '\xff', '\xff', '\xff', '\xff'}; + char* cursor = buf; + SerializeUuid(&cursor, credentio::Uuid::kInvalid); + EXPECT_EQ(cursor - buf, 16); + for (int i = 0; i < 16; ++i) { + EXPECT_EQ(buf[i], '\0'); + } + + SerializeUuid(&cursor, kUuid1); + EXPECT_EQ(cursor - buf, 32); + EXPECT_EQ(std::string(buf + 16, 16), kUuid1.ToProtoBytes()); +} + +} // namespace +} // namespace jumbf
diff --git a/kokoro/gcp_ubuntu/build_and_test.sh b/kokoro/gcp_ubuntu/build_and_test.sh new file mode 100755 index 0000000..da5757e --- /dev/null +++ b/kokoro/gcp_ubuntu/build_and_test.sh
@@ -0,0 +1,42 @@ +#!/bin/bash +# 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. +# + +set -e + +# Navigate to the repository root +# KOKORO_ARTIFACTS_DIR is set to /tmpfs/src by default in the Kokoro Ubuntu image. +export KOKORO_ARTIFACTS_DIR="${KOKORO_ARTIFACTS_DIR:-/tmpfs/src}" +cd "${KOKORO_ARTIFACTS_DIR}/git/staging" + +# Run Bazel build and test all targets +bazel build ... +bazel test ... + +# --- Run Gradle build and unit tests for C2PA Android SDK --- +if [ -d "android" ] && [ -x "android/gradlew" ]; then + if ! java -version 2>&1 | grep -q "17\."; then + echo "Installing OpenJDK 17..." + sudo apt-get update || true + sudo apt-get install -y openjdk-17-jdk || true + export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64" + export PATH="${JAVA_HOME}/bin:${PATH}" + fi + + cd android + ./gradlew assembleDebug testDebugUnitTest --no-daemon --stacktrace +else + echo "No android/gradlew found; skipping Android Gradle build." +fi
diff --git a/kokoro/gcp_ubuntu/continuous.cfg b/kokoro/gcp_ubuntu/continuous.cfg new file mode 100644 index 0000000..b03c640 --- /dev/null +++ b/kokoro/gcp_ubuntu/continuous.cfg
@@ -0,0 +1,8 @@ +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "staging/kokoro/gcp_ubuntu/build_and_test.sh" + +container_properties { + docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest" +}
diff --git a/kokoro/gcp_ubuntu/presubmit.cfg b/kokoro/gcp_ubuntu/presubmit.cfg new file mode 100644 index 0000000..b03c640 --- /dev/null +++ b/kokoro/gcp_ubuntu/presubmit.cfg
@@ -0,0 +1,8 @@ +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "staging/kokoro/gcp_ubuntu/build_and_test.sh" + +container_properties { + docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest" +}
diff --git a/proto/BUILD b/proto/BUILD new file mode 100644 index 0000000..1a6731c --- /dev/null +++ b/proto/BUILD
@@ -0,0 +1,704 @@ +# 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. + +load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library") +load("@protobuf//bazel:java_lite_proto_library.bzl", "java_lite_proto_library") +load("@protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@protobuf//bazel:proto_library.bzl", "proto_library") +load("//cbor:cbor_codegen.bzl", "cc_cbor_proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "assurance_level_proto", + srcs = ["assurance_level.proto"], +) + +cc_proto_library( + name = "assurance_level_cc_proto", + deps = [":assurance_level_proto"], +) + +java_lite_proto_library( + name = "assurance_level_java_proto_lite", + deps = [":assurance_level_proto"], +) + +proto_library( + name = "asset_type_proto", + srcs = ["asset_type.proto"], + deps = ["//cbor:tags_proto"], +) + +cc_proto_library( + name = "asset_type_cc_proto", + deps = [":asset_type_proto"], +) + +java_lite_proto_library( + name = "asset_type_java_proto_lite", + deps = [":asset_type_proto"], +) + +cc_cbor_proto_library( + name = "asset_type_cbor_proto", + srcs = [":asset_type_proto"], + deps = [":asset_type_cc_proto"], +) + +proto_library( + name = "hashed_uri_proto", + srcs = ["hashed_uri.proto"], + deps = [ + ":asset_type_proto", + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "hashed_uri_cc_proto", + deps = [":hashed_uri_proto"], +) + +java_lite_proto_library( + name = "hashed_uri_java_proto_lite", + deps = [":hashed_uri_proto"], +) + +cc_cbor_proto_library( + name = "hashed_uri_cbor_proto", + srcs = [":hashed_uri_proto"], + deps = [":hashed_uri_cc_proto"], +) + +proto_library( + name = "asset_types_assertion_proto", + srcs = ["asset_types_assertion.proto"], + deps = [ + ":asset_type_proto", + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "asset_types_assertion_cc_proto", + deps = [":asset_types_assertion_proto"], +) + +java_lite_proto_library( + name = "asset_types_assertion_java_proto_lite", + deps = [":asset_types_assertion_proto"], +) + +cc_cbor_proto_library( + name = "asset_types_assertion_cbor_proto", + srcs = [":asset_types_assertion_proto"], + deps = [ + ":asset_types_assertion_cc_proto", + ], +) + +proto_library( + name = "asset_reference_assertion_proto", + srcs = ["asset_reference_assertion.proto"], + option_deps = ["@protobuf//:cpp_features_proto"], + deps = ["//cbor:tags_proto"], +) + +cc_proto_library( + name = "asset_reference_assertion_cc_proto", + deps = [":asset_reference_assertion_proto"], +) + +java_lite_proto_library( + name = "asset_reference_assertion_java_proto_lite", + deps = [":asset_reference_assertion_proto"], +) + +cc_cbor_proto_library( + name = "asset_reference_assertion_cbor_proto", + srcs = [":asset_reference_assertion_proto"], + deps = [":asset_reference_assertion_cc_proto"], +) + +proto_library( + name = "generator_info_proto", + srcs = ["generator_info.proto"], + deps = [ + ":hashed_uri_proto", + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "generator_info_cc_proto", + deps = [":generator_info_proto"], +) + +java_lite_proto_library( + name = "generator_info_java_proto_lite", + deps = [":generator_info_proto"], +) + +cc_cbor_proto_library( + name = "generator_info_cbor_proto", + srcs = [":generator_info_proto"], + deps = [ + ":generator_info_cc_proto", + ], +) + +proto_library( + name = "actions_assertion_proto", + srcs = ["actions_assertion.proto"], + deps = [ + ":generator_info_proto", + ":hashed_uri_proto", + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "actions_assertion_cc_proto", + deps = [":actions_assertion_proto"], +) + +java_lite_proto_library( + name = "actions_assertion_java_proto_lite", + deps = [":actions_assertion_proto"], +) + +cc_cbor_proto_library( + name = "actions_assertion_cbor_proto", + srcs = [":actions_assertion_proto"], + deps = [ + ":actions_assertion_cc_proto", + ], +) + +proto_library( + name = "thumbnail_assertion_proto", + srcs = ["thumbnail_assertion.proto"], +) + +cc_proto_library( + name = "thumbnail_assertion_cc_proto", + deps = [":thumbnail_assertion_proto"], +) + +java_lite_proto_library( + name = "thumbnail_assertion_java_proto_lite", + deps = [":thumbnail_assertion_proto"], +) + +cc_cbor_proto_library( + name = "thumbnail_assertion_cbor_proto", + srcs = [":thumbnail_assertion_proto"], + deps = [":thumbnail_assertion_cc_proto"], +) + +proto_library( + name = "validation_status_proto", + srcs = ["validation_status.proto"], + deps = ["//cbor:tags_proto"], +) + +cc_proto_library( + name = "validation_status_cc_proto", + deps = [":validation_status_proto"], +) + +java_lite_proto_library( + name = "validation_status_java_proto_lite", + deps = [":validation_status_proto"], +) + +java_proto_library( + name = "validation_status_java_proto", + deps = [":validation_status_proto"], +) + +cc_cbor_proto_library( + name = "validation_status_cbor_proto", + srcs = [":validation_status_proto"], + deps = [":validation_status_cc_proto"], +) + +proto_library( + name = "bmff_based_hash_assertion_proto", + srcs = ["bmff_based_hash_assertion.proto"], + deps = [ + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "bmff_based_hash_assertion_cc_proto", + deps = [":bmff_based_hash_assertion_proto"], +) + +java_lite_proto_library( + name = "bmff_based_hash_assertion_java_proto_lite", + deps = [":bmff_based_hash_assertion_proto"], +) + +cc_cbor_proto_library( + name = "bmff_based_hash_assertion_cbor_proto", + srcs = [":bmff_based_hash_assertion_proto"], + deps = [":bmff_based_hash_assertion_cc_proto"], +) + +proto_library( + name = "boxes_hash_assertion_proto", + srcs = ["boxes_hash_assertion.proto"], + deps = [ + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "boxes_hash_assertion_cc_proto", + deps = [":boxes_hash_assertion_proto"], +) + +java_lite_proto_library( + name = "boxes_hash_assertion_java_proto_lite", + deps = [":boxes_hash_assertion_proto"], +) + +cc_cbor_proto_library( + name = "boxes_hash_assertion_cbor_proto", + srcs = [":boxes_hash_assertion_proto"], + deps = [":boxes_hash_assertion_cc_proto"], +) + +proto_library( + name = "collection_data_hash_assertion_proto", + srcs = ["collection_data_hash_assertion.proto"], + deps = [ + ":asset_type_proto", + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "collection_data_hash_assertion_cc_proto", + deps = [":collection_data_hash_assertion_proto"], +) + +java_lite_proto_library( + name = "collection_data_hash_assertion_java_proto_lite", + deps = [":collection_data_hash_assertion_proto"], +) + +cc_cbor_proto_library( + name = "collection_data_hash_assertion_cbor_proto", + srcs = [":collection_data_hash_assertion_proto"], + deps = [ + ":collection_data_hash_assertion_cc_proto", + ], +) + +proto_library( + name = "data_hash_assertion_proto", + srcs = ["data_hash_assertion.proto"], + deps = ["//cbor:tags_proto"], +) + +cc_proto_library( + name = "data_hash_assertion_cc_proto", + deps = [":data_hash_assertion_proto"], +) + +java_lite_proto_library( + name = "data_hash_assertion_java_proto_lite", + deps = [":data_hash_assertion_proto"], +) + +cc_cbor_proto_library( + name = "data_hash_assertion_cbor_proto", + srcs = [":data_hash_assertion_proto"], + deps = [":data_hash_assertion_cc_proto"], +) + +proto_library( + name = "signature_info_proto", + srcs = ["signature_info.proto"], + deps = [ + ":assurance_level_proto", + "@protobuf//:timestamp_proto", + ], +) + +java_proto_library( + name = "signature_info_java_proto", + deps = [":signature_info_proto"], +) + +cc_proto_library( + name = "signature_info_cc_proto", + deps = [":signature_info_proto"], +) + +java_lite_proto_library( + name = "signature_info_java_proto_lite", + deps = [":signature_info_proto"], +) + +proto_library( + name = "multi_asset_hash_assertion_proto", + srcs = ["multi_asset_hash_assertion.proto"], + deps = [ + ":hashed_uri_proto", + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "multi_asset_hash_assertion_cc_proto", + deps = [":multi_asset_hash_assertion_proto"], +) + +java_lite_proto_library( + name = "multi_asset_hash_assertion_java_proto_lite", + deps = [":multi_asset_hash_assertion_proto"], +) + +cc_cbor_proto_library( + name = "multi_asset_hash_assertion_cbor_proto", + srcs = [":multi_asset_hash_assertion_proto"], + deps = [ + ":multi_asset_hash_assertion_cc_proto", + ], +) + +proto_library( + name = "redacted_assertion_proto", + srcs = ["redacted_assertion.proto"], +) + +cc_proto_library( + name = "redacted_assertion_cc_proto", + deps = [":redacted_assertion_proto"], +) + +java_lite_proto_library( + name = "redacted_assertion_java_proto_lite", + deps = [":redacted_assertion_proto"], +) + +cc_cbor_proto_library( + name = "redacted_assertion_cbor_proto", + srcs = [":redacted_assertion_proto"], + deps = [":redacted_assertion_cc_proto"], +) + +proto_library( + name = "metadata_assertion_proto", + srcs = ["metadata_assertion.proto"], + deps = [ + "//cbor:tags_proto", + "@protobuf//:struct_proto", + ], +) + +cc_proto_library( + name = "metadata_assertion_cc_proto", + deps = [":metadata_assertion_proto"], +) + +java_lite_proto_library( + name = "metadata_assertion_java_proto_lite", + deps = [":metadata_assertion_proto"], +) + +cc_cbor_proto_library( + name = "metadata_assertion_cbor_proto", + srcs = [":metadata_assertion_proto"], + deps = [":metadata_assertion_cc_proto"], +) + +proto_library( + name = "ai_disclosure_assertion_proto", + srcs = ["ai_disclosure_assertion.proto"], + deps = [ + "//cbor:tags_proto", + "@protobuf//:struct_proto", + ], +) + +cc_proto_library( + name = "ai_disclosure_assertion_cc_proto", + deps = [":ai_disclosure_assertion_proto"], +) + +java_lite_proto_library( + name = "ai_disclosure_assertion_java_proto_lite", + deps = [":ai_disclosure_assertion_proto"], +) + +cc_cbor_proto_library( + name = "ai_disclosure_assertion_cbor_proto", + srcs = [":ai_disclosure_assertion_proto"], + deps = [":ai_disclosure_assertion_cc_proto"], +) + +proto_library( + name = "environmental_sustainability_assertion_proto", + srcs = ["environmental_sustainability_assertion.proto"], + deps = [ + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "environmental_sustainability_assertion_cc_proto", + deps = [":environmental_sustainability_assertion_proto"], +) + +java_lite_proto_library( + name = "environmental_sustainability_assertion_java_proto_lite", + deps = [":environmental_sustainability_assertion_proto"], +) + +cc_cbor_proto_library( + name = "environmental_sustainability_assertion_cbor_proto", + srcs = [":environmental_sustainability_assertion_proto"], + deps = [":environmental_sustainability_assertion_cc_proto"], +) + +proto_library( + name = "repository_receipt_assertion_proto", + srcs = ["repository_receipt_assertion.proto"], + deps = [ + "//cbor:tags_proto", + "@protobuf//:struct_proto", + ], +) + +cc_proto_library( + name = "repository_receipt_assertion_cc_proto", + deps = [":repository_receipt_assertion_proto"], +) + +java_lite_proto_library( + name = "repository_receipt_assertion_java_proto_lite", + deps = [":repository_receipt_assertion_proto"], +) + +cc_cbor_proto_library( + name = "repository_receipt_assertion_cbor_proto", + srcs = [":repository_receipt_assertion_proto"], + deps = [":repository_receipt_assertion_cc_proto"], +) + +proto_library( + name = "soft_binding_assertion_proto", + srcs = ["soft_binding_assertion.proto"], + deps = [ + "//cbor:tags_proto", + "@protobuf//:struct_proto", + ], +) + +cc_proto_library( + name = "soft_binding_assertion_cc_proto", + deps = [":soft_binding_assertion_proto"], +) + +cc_cbor_proto_library( + name = "soft_binding_assertion_cbor_proto", + srcs = [":soft_binding_assertion_proto"], + deps = [ + ":soft_binding_assertion_cc_proto", + ], +) + +proto_library( + name = "assertion_proto", + srcs = ["assertion.proto"], + deps = [ + ":actions_assertion_proto", + ":ai_disclosure_assertion_proto", + ":bmff_based_hash_assertion_proto", + ":boxes_hash_assertion_proto", + ":collection_data_hash_assertion_proto", + ":data_hash_assertion_proto", + ":environmental_sustainability_assertion_proto", + ":ingredient_assertion_proto", + ":metadata_assertion_proto", + ":multi_asset_hash_assertion_proto", + ":redacted_assertion_proto", + ":repository_receipt_assertion_proto", + ":soft_binding_assertion_proto", + ":thumbnail_assertion_proto", + ], +) + +cc_proto_library( + name = "assertion_cc_proto", + deps = [":assertion_proto"], +) + +java_lite_proto_library( + name = "assertion_java_proto_lite", + deps = [":assertion_proto"], +) + +cc_cbor_proto_library( + name = "assertion_cbor_proto", + srcs = [":assertion_proto"], + deps = [ + ":assertion_cc_proto", + ":ingredient_assertion_cbor_proto", + ], +) + +proto_library( + name = "ingredient_assertion_proto", + srcs = ["ingredient_assertion.proto"], + deps = [ + ":asset_type_proto", + ":hashed_uri_proto", + ":validation_status_proto", + "//cbor:tags_proto", + ], +) + +cc_proto_library( + name = "ingredient_assertion_cc_proto", + deps = [":ingredient_assertion_proto"], +) + +java_lite_proto_library( + name = "ingredient_assertion_java_proto_lite", + deps = [":ingredient_assertion_proto"], +) + +cc_cbor_proto_library( + name = "ingredient_assertion_cbor_proto", + srcs = [":ingredient_assertion_proto"], + deps = [ + ":ingredient_assertion_cc_proto", + ], +) + +proto_library( + name = "cose_verification_result_proto", + srcs = ["cose_verification_result.proto"], + deps = [ + ":signature_info_proto", + ":validation_status_proto", + ], +) + +cc_proto_library( + name = "cose_verification_result_cc_proto", + deps = [":cose_verification_result_proto"], +) + +java_lite_proto_library( + name = "cose_verification_result_java_proto_lite", + deps = [":cose_verification_result_proto"], +) + +proto_library( + name = "manifest_proto", + srcs = ["manifest.proto"], + option_deps = ["@protobuf//:cpp_features_proto"], + deps = [ + ":assertion_proto", + ":generator_info_proto", + ":hashed_uri_proto", + ":signature_info_proto", + ":validation_status_proto", + "//cbor:tags_proto", + ], +) + +java_proto_library( + name = "manifest_java_proto", + deps = [":manifest_proto"], +) + +cc_proto_library( + name = "manifest_cc_proto", + deps = [":manifest_proto"], +) + +java_lite_proto_library( + name = "manifest_java_proto_lite", + deps = [":manifest_proto"], +) + +cc_cbor_proto_library( + name = "manifest_cbor_proto", + srcs = [":manifest_proto"], + deps = [ + ":manifest_cc_proto", + ], +) + +proto_library( + name = "validation_result_proto", + srcs = ["validation_result.proto"], + deps = [":manifest_proto"], +) + +cc_proto_library( + name = "validation_result_cc_proto", + deps = [":validation_result_proto"], +) + +java_lite_proto_library( + name = "validation_result_java_proto_lite", + deps = [":validation_result_proto"], +) + +java_proto_library( + name = "validation_result_java_proto", + deps = [":validation_result_proto"], +) + +cc_cbor_proto_library( + name = "validation_result_cbor_proto", + srcs = [":validation_result_proto"], + deps = [":validation_result_cc_proto"], +) + +proto_library( + name = "ingredient_validation_result_proto", + srcs = ["ingredient_validation_result.proto"], + option_deps = ["@protobuf//:cpp_features_proto"], + deps = [ + ":assertion_proto", + ":bmff_based_hash_assertion_proto", + ":boxes_hash_assertion_proto", + ":collection_data_hash_assertion_proto", + ":data_hash_assertion_proto", + ":generator_info_proto", + ":ingredient_assertion_proto", + ":signature_info_proto", + ], +) + +cc_proto_library( + name = "ingredient_validation_result_cc_proto", + deps = [":ingredient_validation_result_proto"], +) + +java_lite_proto_library( + name = "ingredient_validation_result_java_proto_lite", + deps = [":ingredient_validation_result_proto"], +)
diff --git a/proto/actions_assertion.proto b/proto/actions_assertion.proto new file mode 100644 index 0000000..b0968e9 --- /dev/null +++ b/proto/actions_assertion.proto
@@ -0,0 +1,125 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/generator_info.proto"; +import "proto/hashed_uri.proto"; + +// An assertion that provides information on edits and other actions taken that +// affect the asset’s content. +// +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions +// for more details. +message ActionsAssertion { + // A list of actions on the asset. + repeated ActionItem actions = 1 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + // A list of templates for the actions. + // See template behavior in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_action_templates + repeated ActionTemplate templates = 2; + repeated GeneratorInfo software_agents = 4 + [(cbor.tags) = { name: "softwareAgents" }]; + + // If true, indicates that all actions performed are included in an actions + // assertion. This must be set to a value to be considered conformant. + bool all_actions_included = 5 [(cbor.tags) = { name: "allActionsIncluded" }]; + reserved 3; +} + +message ActionsAssertionV1 { + // A list of actions on the asset. + repeated ActionItemV1 actions = 1 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + reserved 2; +} + +message ActionItem { + string action = 1 [(cbor.tags) = { is_required: true }]; + string description = 2; + string digital_source_type = 3 [(cbor.tags) = { name: "digitalSourceType" }]; + GeneratorInfo software_agent = 4 [(cbor.tags) = { name: "softwareAgent" }]; + int64 software_agent_index = 10 + [(cbor.tags) = { name: "softwareAgentIndex" }]; + // List of related actions. + repeated ActionItem related_actions = 6 [(cbor.tags) = { name: "related" }]; + // The reason why this action was performed, required when the action is + // `c2pa.redacted` + string reason = 7; + // Additional parameters of the action. + ActionParameters parameters = 9; + + reserved 5, 8; +} + +message ActionItemV1 { + string action = 1 [(cbor.tags) = { is_required: true }]; + string digital_source_type = 2 [(cbor.tags) = { name: "digitalSourceType" }]; + // Additional parameters of the action. + ActionParameters parameters = 3; +} + +message ActionTemplate { + string action = 1 [(cbor.tags) = { is_required: true }]; + string description = 2; + string digital_source_type = 3 [(cbor.tags) = { name: "digitalSourceType" }]; + GeneratorInfo software_agent = 4 [(cbor.tags) = { name: "softwareAgent" }]; + // Either an internal URI (representing an icon embedded in the manifest + // store) or an external URI. + InternalOrExternalHashedUri icon = 5; + // Additional parameters of the template. + repeated ActionParameters.Data parameters = 6 + [(cbor.tags) = { name: "templateParameters" }]; +} + +// Parameters for actions. +// +// See `parameters-map-v2` in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_9 +// for more details. +message ActionParameters { + // UUID of the modified (output) resource. + string instance_id = 1 [(cbor.tags) = { name: "instanceID" }]; + // JUMBF URI to the redacted assertion (required for `c2pa.redacted` action). + string redacted_uri = 2 [(cbor.tags) = { name: "redacted" }]; + // List of JUMBF URIs to ingredient assertions. + repeated HashedUri ingredients = 3; + // For v1 action assertions. When parsing CBOR, the contents of this field + // will be moved to the `ingredients` field, so it should always be empty. + HashedUri ingredient = 7; + // See `parameters-common-map-v2` in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_9 + // for more details. + message Data { + string key = 1; + bytes value = 2; + } + // A list of arbitrary string-to-value data. + repeated Data data = 4; + // BCP-47 code of the source language of a `c2pa.translated` action. + string source_language = 5 [(cbor.tags) = { name: "sourceLanguage" }]; + // BCP-47 code of the target language of a `c2pa.translated` action. + string target_language = 6 [(cbor.tags) = { name: "targetLanguage" }]; + // Was this action performed multiple times? + bool multiple_instances = 8 [(cbor.tags) = { name: "multipleInstances" }]; + // JUMBF URIs to related assertions. + repeated HashedUri related_assertions = 9 + [(cbor.tags) = { name: "relatedAssertions", disallow_empty: true }]; +}
diff --git a/proto/ai_disclosure_assertion.proto b/proto/ai_disclosure_assertion.proto new file mode 100644 index 0000000..5fa99e9 --- /dev/null +++ b/proto/ai_disclosure_assertion.proto
@@ -0,0 +1,52 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "google/protobuf/struct.proto"; + +// Schema for AI Model Disclosure Metadata (c2pa.ai-disclosure) +// Matches C2PA 2.4 technical specification. +message AiDisclosureAssertion { + // Required. Enumerated string identifying the AI model type. + // Examples: "c2pa.types.model.onnx", "c2pa.types.model.flax" + string model_type = 1 [(cbor.tags) = { name: "modelType" }]; + + // Optional. Human-readable name of the model. + string model_name = 2 [(cbor.tags) = { name: "modelName" }]; + + // Optional. Unique identifier for the model (e.g. a URI or PURL). + string model_identifier = 3 [(cbor.tags) = { name: "modelIdentifier" }]; + + // Optional. Structured content profile. + ContentProfile content_profile = 4 [(cbor.tags) = { name: "contentProfile" }]; + + message ContentProfile { + // Optional. Degree of human intervention. + // Spec values: "fully_autonomous", "prompt_guided", "human_validated" + string human_oversight_level = 1 + [(cbor.tags) = { name: "humanOversightLevel" }]; + + // Optional. Scientific domain of the content (e.g. arXiv taxonomy "cs.AI"). + repeated string scientific_domain = 2 + [(cbor.tags) = { name: "scientificDomain" }]; + + // Optional. Extensible metadata. + google.protobuf.Struct metadata = 3 [(cbor.tags) = { mode: MODE_SKIP }]; + } +}
diff --git a/proto/assertion.proto b/proto/assertion.proto new file mode 100644 index 0000000..737a456 --- /dev/null +++ b/proto/assertion.proto
@@ -0,0 +1,72 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "proto/actions_assertion.proto"; +import "proto/ai_disclosure_assertion.proto"; +import "proto/bmff_based_hash_assertion.proto"; +import "proto/boxes_hash_assertion.proto"; +import "proto/collection_data_hash_assertion.proto"; +import "proto/data_hash_assertion.proto"; +import "proto/environmental_sustainability_assertion.proto"; +import "proto/ingredient_assertion.proto"; +import "proto/metadata_assertion.proto"; +import "proto/multi_asset_hash_assertion.proto"; +import "proto/redacted_assertion.proto"; +import "proto/repository_receipt_assertion.proto"; +import "proto/soft_binding_assertion.proto"; +import "proto/thumbnail_assertion.proto"; + +// A standard assertion used by C2PA implementations about when, where and how +// an asset or an ingredient of a composed asset was originated or transformed. +// +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_standard_assertions +// for more details. +message Assertion { + // A label defined by the C2PA specification. The list of publicly known + // labels can be found at + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_standard_assertions. + string label = 1; + reserved 2, 3, 8; + oneof assertion { + ActionsAssertion actions = 4; + ActionsAssertionV1 actions_v1 = 13; + // No longer produced by the assertion parser. + IngredientAssertionV1 ingredient_v1 = 12 [deprecated = true]; + // No longer produced by the assertion parser. + IngredientAssertionV2 ingredient_v2 = 5 [deprecated = true]; + IngredientAssertionV3 ingredient_v3 = 14; + BmffBasedHashAssertion bmff_based_hash = 6; + DataHashAssertion data_hash = 7; + BoxesHashAssertion boxes_hash = 9; + ThumbnailAssertion claim_thumbnail = 10; + ThumbnailAssertion ingredient_thumbnail = 11; + MetadataAssertion metadata = 15; + RedactedCustomAssertion redacted_custom = 16; + RedactedGatheredAssertion redacted_gathered = 17; + RedactedDeprecatedAssertion redacted_deprecated = 18; + RedactedByGeneratorAssertion redacted_by_generator = 20; + MultiAssetHashAssertion multi_asset_hash = 19; + CollectionDataHashAssertion collection_data_hash = 21; + AiDisclosureAssertion ai_disclosure = 22; + EnvironmentalSustainabilityAssertion environmental_sustainability = 23; + RepositoryReceiptAssertion repository_receipt = 24; + SoftBindingAssertion soft_binding = 25; + } +}
diff --git a/proto/asset_reference_assertion.proto b/proto/asset_reference_assertion.proto new file mode 100644 index 0000000..2456cc8 --- /dev/null +++ b/proto/asset_reference_assertion.proto
@@ -0,0 +1,46 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; + +import option "google/protobuf/cpp_features.proto"; + +// The asset reference assertion (ARA) describes where a copy of the asset may +// be obtained. +// Based on `asset-ref-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_reference. +message AssetReferenceAssertion { + repeated AraReferenceBlock references = 1 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; +} + +message AraReferenceBlock { + AraReferenceUri reference = 1 [(cbor.tags) = { is_required: true }]; + + // Human readable description of the location. + string description = 2 [features.(pb.cpp).string_type = STRING]; +} + +message AraReferenceUri { + // URI reference a location where a copy of the asset may be obtained from. + string uri = 1 [ + features.(pb.cpp).string_type = STRING, + (cbor.tags) = { is_required: true } + ]; +}
diff --git a/proto/asset_type.proto b/proto/asset_type.proto new file mode 100644 index 0000000..e72617a --- /dev/null +++ b/proto/asset_type.proto
@@ -0,0 +1,42 @@ +// 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. +// + +edition = "2023"; + +package credentio; + +import "cbor/tags.proto"; + +option java_multiple_files = true; +option java_outer_classname = "AssetTypeOuterClass"; + +// The asset type assertion provides a way to more completely describe an asset +// specifically additional context on how to parse or otherwise process it when +// its `dc:format` is insufficient. It can also be used to describe externally +// referenced or related assets such as AI/ML models. +// +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_type +// for more details. +message AssetType { + // The type of asset. This can either be a custom value or one of the provided + // choices in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_11. + // If a custom value is used, it must satisfy the regex specified in the C2PA + // spec linked above. + string type = 1 [(cbor.tags) = { is_required: true }]; + // Version of the asset. + string version = 2; +}
diff --git a/proto/asset_types_assertion.proto b/proto/asset_types_assertion.proto new file mode 100644 index 0000000..9cbc753 --- /dev/null +++ b/proto/asset_types_assertion.proto
@@ -0,0 +1,34 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/asset_type.proto"; + +// An assertion that provides information on the type of the asset, specifically +// additional context on how to parse or otherwise process it. +// Based on `asset-type-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_type. +message AssetTypesAssertion { + string media_type = 3 + [(cbor.tags) = { is_required: false, name: "dc:format" }]; + // Types related to the asset. + repeated AssetType types = 1 [(cbor.tags) = { is_required: false }]; + + reserved 2; +}
diff --git a/proto/assurance_level.proto b/proto/assurance_level.proto new file mode 100644 index 0000000..bc910d9 --- /dev/null +++ b/proto/assurance_level.proto
@@ -0,0 +1,27 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +// C2PA Generator Product Assurance Levels. +// See +// https://github.com/c2pa-org/conformance-public/blob/main/docs/current/C2PA%20Generator%20Product%20Security%20Requirements.pdf +enum AssuranceLevel { + ASSURANCE_LEVEL_UNSPECIFIED = 0; + ASSURANCE_LEVEL1 = 1; + ASSURANCE_LEVEL2 = 2; +}
diff --git a/proto/bmff_based_hash_assertion.proto b/proto/bmff_based_hash_assertion.proto new file mode 100644 index 0000000..1c97e9f --- /dev/null +++ b/proto/bmff_based_hash_assertion.proto
@@ -0,0 +1,105 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; + +// A BMFF-based hash assertion. See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_bmff_based_hash. +message BmffBasedHashAssertion { + // Excludes certain boxes from the hash. + repeated BmffRange exclusions = 1 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + // A string identifying the cryptographic hash algorithm. + string alg = 2; + bytes hash = 3; + repeated BmffMerkle merkles = 4 [(cbor.tags) = { name: "merkle" }]; + // A human-readable description of what this hash covers. + string name = 5; +} + +message BmffRange { + // Location of box(es) of the hash starting from the root node as an xpath + // formatted string of version https://www.w3.org/TR/xpath-10/ with highly + // constrained syntax. + string xpath = 1 [(cbor.tags) = { is_required: true }]; + // Length of a leafmost box of the hash. + int64 length = 2; + // The data in the leafmost box. + repeated BmffData data = 3; + // Only subsets of the data in the leafmost box. + repeated BmffSubset subsets = 4 [(cbor.tags) = { name: "subset" }]; + int64 version = 5; + bytes flags = 6; + // Whether flags must be an exact match. + bool exact = 7; +} + +message BmffData { + int64 offset = 1 [(cbor.tags) = { is_required: true }]; + bytes value = 2 [(cbor.tags) = { is_required: true }]; +} + +message BmffSubset { + int64 offset = 1 [(cbor.tags) = { is_required: true }]; + int64 length = 2 [(cbor.tags) = { is_required: true }]; +} + +message BmffMerkle { + // Unique id that differentiates across files to determine which Merkle + // tree should be used to validate a given 'mdat' box. + int64 unique_id = 1 [(cbor.tags) = { is_required: true, name: "uniqueId" }]; + // Local id used to differentiate across multiple 'mdat' boxes within a single + // file to determine which Merkle tree should be used to validate that 'mdat' + // box. + int64 local_id = 2 [(cbor.tags) = { is_required: true, name: "localId" }]; + // Number of leaf nodes in the Merkle tree. + int64 count = 3 [(cbor.tags) = { is_required: true }]; + // A string identifying the cryptographic hash algorithm + string alg = 4; + // The hash of the entire initialization segment file for fragmented assets + // like MP4. + bytes init_hash = 5 [(cbor.tags) = { name: "initHash" }]; + // Representing a single row of the Merkle tree. + repeated bytes hashes = 6 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + // The following two fields are used to determine the block size of the + // Merkle tree and are only used for non-fragmented assets. Only one of them + // can be set. FixedBlockSize breaks the input string into fixed-size blocks, + // while VariableBlockSizes uses a list of block sizes to determine the block + // size of each block. + int64 fixed_block_size = 7 + [(cbor.tags) = { name: "fixedBlockSize", type: TYPE_UINT }]; + repeated int64 variable_block_sizes = 8 + [(cbor.tags) = { name: "variableBlockSizes" }]; +} + +// Auxiliary data for a Merkle map. See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_box_containing_the_merkle_auxiliary. +message BmffMerkleMap { + // Unique ID used in combination with the local ID to identify the BMFFMerkle + // map. + int64 unique_id = 1 [(cbor.tags) = { is_required: true, name: "uniqueId" }]; + int64 local_id = 2 [(cbor.tags) = { is_required: true, name: "localId" }]; + // The zero-based index of this leaf in the leaf row of the Merkle map. + int64 location = 3 [(cbor.tags) = { is_required: true }]; + // A series of hashes that are sequentially hashed against the leaf data hash + // via merkle proof to generate the final hash to be compared against the + // corresponding hash in the Merkle map. + repeated bytes hashes = 4 [(cbor.tags) = { disallow_empty: true }]; +}
diff --git a/proto/boxes_hash_assertion.proto b/proto/boxes_hash_assertion.proto new file mode 100644 index 0000000..de08d75 --- /dev/null +++ b/proto/boxes_hash_assertion.proto
@@ -0,0 +1,51 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; + +// Based on `box-hash-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_boxes_hash. +message BoxesHashAssertion { + repeated BoxHash boxes = 1 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + string alg = 2; +} + +message BoxExclusion { + int64 start = 1 [(cbor.tags) = { is_required: true, type: TYPE_UINT }]; + int64 length = 2 [(cbor.tags) = { is_required: true, type: TYPE_UINT }]; + int32 box_index = 3 [(cbor.tags) = { name: "boxIndex" }]; +} + +message BoxHash { + repeated string names = 1 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + string alg = 2; + bytes hash = 3 [(cbor.tags) = { is_required: true }]; + // Is the hash value excluded for this set of boxes. + bool excluded = 6; + // An array of hash exclusion ranges. Ranges have monotonically increasing + // `start` values, and no two ranges may overlap. + repeated BoxExclusion exclusions = 7; + + // For internal use in the manifest generator -- do not set or read. + bytes pad = 4 [(cbor.tags) = { mode: MODE_WRITE_ONLY }]; + // For internal use in the manifest generator -- do not set or read. + bytes pad2 = 5 [(cbor.tags) = { mode: MODE_WRITE_ONLY }]; +}
diff --git a/proto/collection_data_hash_assertion.proto b/proto/collection_data_hash_assertion.proto new file mode 100644 index 0000000..47531a9 --- /dev/null +++ b/proto/collection_data_hash_assertion.proto
@@ -0,0 +1,56 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/asset_type.proto"; + +// `CollectionDataHashAssertion` contains an array of URIs and their associated +// hashes. Based on `collection-data-hash-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_collection_data_hash. +message CollectionDataHashAssertion { + repeated UriHashedData uris = 1 [(cbor.tags) = { is_required: true }]; + + // A string identifying the cryptographic hash algorithm used to compute the + // hash on each entry of the `uris` array, taken from the C2PA hash algorithm + // identifier list. + string alg = 2 [(cbor.tags) = { is_required: true }]; + + // The hash of the ZIP central directory in ZIP-based assets. + bytes zip_central_directory_hash = 3; +} + +// `UriHashedData` is used to store a reference to a URI and its hash. Based on +// `uri-hashed-data-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_collection_data_hash. +message UriHashedData { + // Relative URI reference. + string uri = 1 [(cbor.tags) = { is_required: true }]; + + // Byte string containing the hash value. + bytes hash = 2 [(cbor.tags) = { is_required: true }]; + + // Number of bytes of data. + int64 size = 3; + + // IANA media type of the data. + string type = 4 [(cbor.tags) = { name: "dc:format" }]; + + // Additional information about the data's type. + repeated AssetType data_types = 5; +}
diff --git a/proto/cose_verification_result.proto b/proto/cose_verification_result.proto new file mode 100644 index 0000000..2180998 --- /dev/null +++ b/proto/cose_verification_result.proto
@@ -0,0 +1,33 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "proto/signature_info.proto"; +import "proto/validation_status.proto"; + +option java_outer_classname = "CoseVerificationResultOuterClass"; + +// Result of a COSE signature verification. +message CoseVerificationResult { + // Provided when the entire verification completes successfully. + credentio.SignatureInfo signature_info = 1; + + // Set of validation statuses encountered during verification. + // Validation should be considered failed if any failure statuses are present. + credentio.ValidationStatusSet validation_status = 2; +}
diff --git a/proto/data_hash_assertion.proto b/proto/data_hash_assertion.proto new file mode 100644 index 0000000..c820b03 --- /dev/null +++ b/proto/data_hash_assertion.proto
@@ -0,0 +1,46 @@ +// 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. +// + +edition = "2023"; + +package credentio; + +import "cbor/tags.proto"; + +// The data structure used to store the cryptographic hash of some or all of the +// asset's data and additional information required to compute the hash. +// Based on `data-hash-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_data_hash. +message DataHashAssertion { + repeated DataRange exclusions = 1; + // A string identifying the cryptographic hash algorithm used to compute the + // hash in this assertion. + string alg = 2; + bytes hash = 3 [(cbor.tags) = { is_required: true }]; + // A human-readable description of what this hash covers. + string name = 6; + + // For internal use in the manifest generator -- do not set or read. + bytes pad = 4 [(cbor.tags) = { is_required: true, mode: MODE_WRITE_ONLY }]; + // For internal use in the manifest generator -- do not set or read. + bytes pad2 = 5 [(cbor.tags) = { mode: MODE_WRITE_ONLY }]; +} + +message DataRange { + // Starting byte of the range. + int64 start = 1 [(cbor.tags) = { is_required: true }]; + // Number of bytes of data. + int64 length = 2 [(cbor.tags) = { is_required: true }]; +}
diff --git a/proto/environmental_sustainability_assertion.proto b/proto/environmental_sustainability_assertion.proto new file mode 100644 index 0000000..000a347 --- /dev/null +++ b/proto/environmental_sustainability_assertion.proto
@@ -0,0 +1,41 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; + +// Schema for Environmental Sustainability Metadata +// (c2pa.environmental-sustainability) Matches C2PA 2.4 technical specification. +message EnvironmentalSustainabilityAssertion { + message Metric { + // Required. The value of the metric. + double value = 1 [(cbor.tags) = { name: "value" }]; + + // Optional. The method used to measure the metric (reverse-DNS format). + string measurement_method = 2 [(cbor.tags) = { name: "measurementMethod" }]; + } + + // Optional. Energy consumption in kilowatt-hours (kWh). + Metric energy_kwh = 1 [(cbor.tags) = { name: "energy_kwh" }]; + + // Optional. Greenhouse gas emissions in kilograms of CO2 equivalent (kgCO2e). + Metric carbon_kgco2e = 2 [(cbor.tags) = { name: "carbon_kgco2e" }]; + + // Optional. Water usage in litres. + Metric water_litres = 3 [(cbor.tags) = { name: "water_litres" }]; +}
diff --git a/proto/generator_info.proto b/proto/generator_info.proto new file mode 100644 index 0000000..c91adf6 --- /dev/null +++ b/proto/generator_info.proto
@@ -0,0 +1,38 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/hashed_uri.proto"; + +// Information about the claim or action generator. +// See `generator-info-map` in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_syntax +// for more details. +message GeneratorInfo { + // Name of the claim generator. + string name = 1 [(cbor.tags) = { is_required: true }]; + // Version of the claim generator. + string version = 2; + // URI to the icon of the claim generator. + InternalOrExternalHashedUri icon = 3; + // The operating system the claim generator is running on + string operating_system = 4; + // The spec version of the claim generator. + string spec_version = 5 [(cbor.tags) = { name: "specVersion" }]; +}
diff --git a/proto/hashed_uri.proto b/proto/hashed_uri.proto new file mode 100644 index 0000000..6e23503 --- /dev/null +++ b/proto/hashed_uri.proto
@@ -0,0 +1,50 @@ +// 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. +// + +edition = "2023"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/asset_type.proto"; + +option java_multiple_files = true; +option java_outer_classname = "HashedUriOuterClass"; + +// An internal (within the manifest store) hashed URI reference. +message HashedUri { + // The URI reference. + string url = 1 [(cbor.tags) = { is_required: true }]; + // The algorithm used to compute the hash. + string algorithm = 2 [(cbor.tags) = { name: "alg" }]; + // The hash value. + bytes hash = 3 [(cbor.tags) = { is_required: true }]; +} + +// An internal (within the manifest store) or external hashed URI reference. +message InternalOrExternalHashedUri { + // The URI reference. + string url = 1 [(cbor.tags) = { is_required: true }]; + // The algorithm used to compute the hash. + string algorithm = 2 [(cbor.tags) = { name: "alg" }]; + // The hash value. + bytes hash = 3 [(cbor.tags) = { is_required: true }]; + // The media type of the data (for external URIs). + string media_type = 4 [(cbor.tags) = { name: "dc:format" }]; + // The size of the data (for external URIs). + int64 size = 5; + // Additional information about the data's type (for external URIs). + repeated AssetType data_types = 6; +}
diff --git a/proto/ingredient_assertion.proto b/proto/ingredient_assertion.proto new file mode 100644 index 0000000..7e8c403 --- /dev/null +++ b/proto/ingredient_assertion.proto
@@ -0,0 +1,134 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/asset_type.proto"; +import "proto/hashed_uri.proto"; +import "proto/validation_status.proto"; + +// Represents an assertion about an ingredient used in the asset. +// Based on `ingredient-map-v2` in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ingredient_schema. +message IngredientAssertionV2 { + // The name of the ingredient. + string title = 1 [(cbor.tags) = { is_required: true, name: "dc:title" }]; + // The media type of the ingredient. + string media_type = 2 + [(cbor.tags) = { is_required: true, name: "dc:format" }]; + // The relationship of this ingredient to the asset it is an ingredient of. + string relationship = 3; + // The value of the ingredient's `xmpMM:DocumentID`. + string document_id = 4 [(cbor.tags) = { name: "documentID" }]; + // Unique identifier, such as the value of the ingredient's + // `xmpMM:InstanceID`. + string instance_id = 5 [(cbor.tags) = { name: "instanceID" }]; + // URI reference to a data box or external data containing the ingredient + // asset itself. + InternalOrExternalHashedUri data = 6; + // Additional information about the data's type. + repeated AssetType data_types = 7; + // URI reference to the C2PA Manifest of the ingredient. + HashedUri c2pa_manifest = 8; + // URI reference to a thumbnail in a data box. + HashedUri thumbnail = 9; + // Validation status of the ingredient (provided by the claim generator). + repeated ValidationStatus validation_status = 14 + [(cbor.tags) = { name: "validationStatus" }]; + // Additional description of the ingredient. + string description = 11; + // URI to an informational page about the ingredient or its data. + string informational_uri = 12 [(cbor.tags) = { name: "informational_URI" }]; + reserved 13; + + reserved 10; +} + +// Represents an assertion about an ingredient used in the asset. +// Based on `ingredient-map` in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ingredient_schema. +message IngredientAssertionV1 { + // The name of the ingredient. + string title = 1 [(cbor.tags) = { is_required: true, name: "dc:title" }]; + // The media type of the ingredient. + string media_type = 2 + [(cbor.tags) = { is_required: true, name: "dc:format" }]; + // The value of the ingredient's `xmpMM:DocumentID`. + string document_id = 3 [(cbor.tags) = { name: "documentID" }]; + // Unique identifier, such as the value of the ingredient's + // `xmpMM:InstanceID`. + string instance_id = 4 + [(cbor.tags) = { is_required: true, name: "instanceID" }]; + // The relationship of this ingredient to the asset it is an ingredient of. + string relationship = 5; + // URI reference to the C2PA Manifest of the ingredient. + HashedUri c2pa_manifest = 6; + // URI reference to a thumbnail in a data box. + HashedUri thumbnail = 7; + // Validation status of the ingredient (provided by the claim generator). + repeated ValidationStatus validation_status = 10 + [(cbor.tags) = { name: "validationStatus" }]; + reserved 9; + + reserved 8; +} + +// Represents an assertion about an ingredient used in the asset. +// Based on `ingredient-map-v3` in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ingredient_schema. +message IngredientAssertionV3 { + string title = 1 [(cbor.tags) = { name: "dc:title" }]; + string media_type = 2 [(cbor.tags) = { name: "dc:format" }]; + string relationship = 3; + IngredientValidationResults validation_results = 4 + [(cbor.tags) = { name: "validationResults" }]; + string instance_id = 5 [(cbor.tags) = { name: "instanceID" }]; + InternalOrExternalHashedUri data = 6; + repeated AssetType data_types = 7 [(cbor.tags) = { name: "dataTypes" }]; + HashedUri active_manifest = 8 [(cbor.tags) = { name: "activeManifest" }]; + HashedUri claim_signature = 9 [(cbor.tags) = { name: "claimSignature" }]; + HashedUri thumbnail = 10; + string description = 11; + string informational_uri = 12 [(cbor.tags) = { name: "informationalURI" }]; + string digital_source_type = 14 [(cbor.tags) = { name: "digitalSourceType" }]; + reserved 13; +} + +// Changes/deltas between the current and previous validation results for each +// ingredient's manifest. +// See `ingredient-delta-validation-result-map` at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results. +message IngredientDeltaValidationResult { + string ingredient_assertion_uri = 1 + [(cbor.tags) = { name: "ingredientAssertionURI", is_required: true }]; + ValidationStatusSet validation_deltas = 2 + [(cbor.tags) = { name: "validationDeltas", is_required: true }]; +} + +// Results from the claim generator performing full validation. +// See `validation-results-map` at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results. +message IngredientValidationResults { + ValidationStatusSet active_manifest = 1 + [(cbor.tags) = { name: "activeManifest" }]; + repeated IngredientDeltaValidationResult ingredient_deltas = 2 + [(cbor.tags) = { name: "ingredientDeltas" }]; + string spec_version = 6 [(cbor.tags) = { name: "specVersion" }]; + string trust_list_uri = 7 [(cbor.tags) = { name: "trustListUri" }]; + reserved 3, 4, 5; +}
diff --git a/proto/ingredient_validation_result.proto b/proto/ingredient_validation_result.proto new file mode 100644 index 0000000..a9ca2e6 --- /dev/null +++ b/proto/ingredient_validation_result.proto
@@ -0,0 +1,142 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "proto/assertion.proto"; +import "proto/bmff_based_hash_assertion.proto"; +import "proto/boxes_hash_assertion.proto"; +import "proto/collection_data_hash_assertion.proto"; +import "proto/data_hash_assertion.proto"; +import "proto/generator_info.proto"; +import "proto/ingredient_assertion.proto"; +import "proto/signature_info.proto"; + +import option "google/protobuf/cpp_features.proto"; + +option features.(pb.cpp).string_type = STRING; +option java_outer_classname = "IngredientValidationResult"; +option objc_class_prefix = "C2PA"; + +// Result of a successful or failed validation of an ingredient asset. +message IngredientValidationResultProto { + // Optional. + // If omitted, the ingredient will be treated as having unknown provenance and + // ingredient_validation_results will be ignored. + bytes manifest_store_bytes = 1; + + // The IANA media type of the asset (e.g. "image/jpeg"), if known. + string media_type = 6; + + // Optional. Full validation results for the ingredient. + // SHOULD BE SET IF `manifest_store_bytes` IS SET. + IngredientValidationResults ingredient_validation_results = 2; + + // Metadata about the ingredient asset for logging/monitoring purposes. + IngredientValidationLoggingMetadata logging_metadata = 3; + + // Optional. Metadata about the ingredient asset for legacy manifests. + LegacyIngredientMetadata legacy_ingredient_metadata = 5; + + // Contains the URI and redaction reason for a redacted assertion. + message RedactedAssertion { + string uri = 1; + string reason = 2; + } + + // DO NOT MANUALLY SET. If set then `manifest_store_bytes` contains the + // redacted ingredient manifest store. Should only be set by the + // ManifestStoreRedactor. + repeated RedactedAssertion redactions = 7; + + // Optional. The summarized digital source type of the ingredient asset, if + // known. Used in cases of unknown provenance or if the ingredient manifest + // store was redacted. + string digital_source_type = 8; + + reserved 4; +} + +// Result of a partial validation of an ingredient manifest store, where the +// content binding has not yet been validated. +message PartialIngredientValidationResultProto { + // Required. Validation results for the ingredient not including hard binding + // validation results. + IngredientValidationResults ingredient_validation_results = 1; + + // Metadata about the ingredient asset for logging/monitoring purposes. + IngredientValidationLoggingMetadata logging_metadata = 3; + + // Contains the relevant hard binding assertion binding the c2pa manifest + // store to the asset. + message HardBindingAssertion { + // Required. + string hard_binding_uri = 1; + // JUMBF URI of the relevant multi-asset hash assertion, if one was found. + // This must be located in the same manifest as the `hard_binding_uri`. + string multi_asset_hash_uri = 7; + + // Required. The relevant hard binding assertion. + oneof hard_binding_assertion { + BmffBasedHashAssertion bmff_based_hash = 2; + DataHashAssertion data_hash = 3; + BoxesHashAssertion boxes_hash = 4; + CollectionDataHashAssertion collection_data_hash = 8; + } + + // All hash assertions for the asset, including the primary hard binding + // assertion as well as (if present) the multi-asset hash assertion and the + // part hash assertions it references. Order is not significant. + repeated Assertion assertions = 6; + + // Optional. If the active manifest is an update manifest then the + // `hard_binding_uri` will point into a different manifest from the + // `active_manifest_uri`. + string active_manifest_uri = 5; + } + + // Optional. The relevant hard binding assertion, if one was found. + HardBindingAssertion hard_binding_assertion = 2; + + // Optional. Metadata about the ingredient asset for legacy manifests. + LegacyIngredientMetadata legacy_ingredient_metadata = 5; + + // Optional. The summarized digital source type of the ingredient asset, if + // known. Currently only set for assets with AI generation usage. + string digital_source_type = 6; +} + +message LegacyIngredientMetadata { + // True if the active manifest is a legacy manifest. + bool is_legacy_ingredient = 1; + // Includes labels of legacy manifests unsupported by this validator. + repeated string unsupported_manifests = 2; + // Includes JUMBF URIs of ingredient assertions that point to legacy manifests + // unsupported by this validator. Assertions may be in any of the ingredient + // manifests or the active manifest. + repeated string unsupported_ingredient_assertion_uris = 3; +} + +// Metadata about an ingredient asset for logging/monitoring purposes. +message IngredientValidationLoggingMetadata { + message ManifestInfo { + GeneratorInfo claim_generator = 1; + SignatureInfo signature = 2; + } + // Information about the active manifest. + ManifestInfo active_manifest = 1; +}
diff --git a/proto/manifest.proto b/proto/manifest.proto new file mode 100644 index 0000000..d3186d3 --- /dev/null +++ b/proto/manifest.proto
@@ -0,0 +1,100 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/assertion.proto"; +import "proto/generator_info.proto"; +import "proto/hashed_uri.proto"; +import "proto/signature_info.proto"; +import "proto/validation_status.proto"; + +import option "google/protobuf/cpp_features.proto"; + +option java_outer_classname = "ManifestOuterClass"; +option features.(pb.cpp).string_type = STRING; + +// Represents a verifiable unit containing a set of assertions and a claim +// about an asset together with a claim signature. +message Manifest { + // Results of validating this manifest. + ValidationStatusSet validation = 15; + + // Whether the manifest is an update manifest. + bool is_update_manifest = 5; + + // Label of the manifest, unique within the containing manifest store. + string label = 1; + + // Container holding all the assertion references about the asset. + Claim claim = 2; + + // A set of assertions associated with the asset, which are referenced by + // the claim in this manifest. These assertions constitute the "assertion + // store" described in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_assertion_store. + repeated Assertion assertions = 3; + + reserved 4; +} + +// Contains all the assertion and redacted assertion references about an asset +// at a given time. +message Claim { + // Uniquely identifies a specific version of an asset + string instance_id = 13 [(cbor.tags) = { name: "instanceID" }]; + + // Information about the claim generator. + GeneratorInfo claim_generator_info = 1; + + // A set of references to the assertions about when, where, and how the asset + // was originated or transformed. + // + // The reference is specified as a JUMBF URI reference. See + // https://www.iso.org/standard/84635.html for more details about JUMBF + // standard. + repeated HashedUri created_assertions = 7; + repeated HashedUri gathered_assertions = 8; + + // A set of references to the assertions that were subject to redaction. + // + // Redaction is a process where an assertion of an asset-embedded manifest is + // removed from that asset's manifest when this asset is used as an + // ingredient. See + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_redaction_of_assertions + // for more details about the redaction process. + // + // The reference is specified as a JUMBF URI reference. See + // https://www.iso.org/standard/84635.html for more details about JUMBF + // standard. + repeated string redacted_assertions = 4; + + // Information about the claim signature. + SignatureInfo signature_info = 5 [(cbor.tags) = { mode: MODE_SKIP }]; + + // Only used by the generator; the validator shouldn't populate this field. + string signature = 11; + + // Default hash algorithm. + string default_algorithm = 10 [(cbor.tags) = { name: "alg" }]; + + // Label of the claim box. + string label = 12; + + reserved 2, 3, 6, 9; +}
diff --git a/proto/metadata_assertion.proto b/proto/metadata_assertion.proto new file mode 100644 index 0000000..ea6e617 --- /dev/null +++ b/proto/metadata_assertion.proto
@@ -0,0 +1,28 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "google/protobuf/struct.proto"; + +// C2PA metadata. +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_metadata. +message MetadataAssertion { + google.protobuf.Struct value = 1 [(cbor.tags) = { mode: MODE_SKIP }]; +}
diff --git a/proto/multi_asset_hash_assertion.proto b/proto/multi_asset_hash_assertion.proto new file mode 100644 index 0000000..b18b22d --- /dev/null +++ b/proto/multi_asset_hash_assertion.proto
@@ -0,0 +1,55 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "proto/hashed_uri.proto"; + +// Based on `multi-asset-hash-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_box_hash. +message MultiAssetHashAssertion { + repeated MultiAssetPart parts = 1 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + + // For internal use in the manifest generator -- do not set or read. + bytes pad = 2 [(cbor.tags) = { is_required: true }]; + // For internal use in the manifest generator -- do not set or read. + bytes pad2 = 3; +} + +// Based on `locator-map`: +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_6. +// Either the `byte_offset` and `length` of a byte range or the `bmff_box` of a +// BMFF box. +message MultiAssetPartLocation { + // These two fields are used to specify a window of bytes within a file. + int64 byte_offset = 1 [(cbor.tags) = { name: "byteOffset", type: TYPE_UINT }]; + int64 length = 2 [(cbor.tags) = { type: TYPE_UINT }]; + + // This field is used to specify a BMFF box. + string bmff_box = 3 [(cbor.tags) = { name: "bmffBox" }]; +} + +message MultiAssetPart { + // The location of the hash part. + MultiAssetPartLocation location = 1; + // The hash of the part. + HashedUri hash_assertion = 2 [(cbor.tags) = { name: "hashAssertion" }]; + // Whether the hash is optional. + bool is_optional = 3 [(cbor.tags) = { name: "optional" }]; +}
diff --git a/proto/redacted_assertion.proto b/proto/redacted_assertion.proto new file mode 100644 index 0000000..cd82a92 --- /dev/null +++ b/proto/redacted_assertion.proto
@@ -0,0 +1,45 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +// A custom assertion that has been redacted by the validator. +// The original assertion label will be replaced with one starting with +// com.google.redacted_custom_assertion. +// This redaction is a separate process from redaction by a claim generator. +message RedactedCustomAssertion {} + +// A gathered assertion that has been redacted by the validator. +// The original assertion label will be replaced with one starting with +// com.google.redacted_gathered_assertion. +// This redaction is a separate process from redaction by a claim generator. +message RedactedGatheredAssertion { + // The original label of the assertion. + string original_label = 1; +} + +// A deprecated standard assertion that has been redacted by the validator. +// The original assertion label will be replaced with one starting with +// com.google.redacted_deprecated_assertion. +// This redaction is a separate process from redaction by a claim generator. +message RedactedDeprecatedAssertion { + // The original label of the assertion. + string original_label = 1; +} + +// An assertion that was redacted by a subsequent claim generator. +message RedactedByGeneratorAssertion {}
diff --git a/proto/repository_receipt_assertion.proto b/proto/repository_receipt_assertion.proto new file mode 100644 index 0000000..c670300 --- /dev/null +++ b/proto/repository_receipt_assertion.proto
@@ -0,0 +1,50 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "google/protobuf/struct.proto"; + +// Schema for Repository Receipt Metadata (c2pa.repository-receipt) +// Matches C2PA 2.4 technical specification. +message RepositoryReceiptAssertion { + message Repository { + // Required. The URI of the repository. + string uri = 1; + + // Required. The unique ID of the ingested manifest. + string manifest_id = 2; + } + + message Anchor { + // Required. A reference URI for the receipt. + string uri = 1; + + // Optional. Parameters used when referencing the ingestion proof. + google.protobuf.Struct parameters = 2 [(cbor.tags) = { mode: MODE_SKIP }]; + + // Required. Cryptographic proof confirming the ingestion. + google.protobuf.Struct proof = 3 [(cbor.tags) = { mode: MODE_SKIP }]; + } + + // Required. Details of the repository. + Repository repository = 1; + + // Required. Details of the anchor/proof. + Anchor anchor = 2; +}
diff --git a/proto/signature_info.proto b/proto/signature_info.proto new file mode 100644 index 0000000..ffdcfe7 --- /dev/null +++ b/proto/signature_info.proto
@@ -0,0 +1,57 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "google/protobuf/timestamp.proto"; +import "proto/assurance_level.proto"; + +// Information about a C2PA claim signature. +message SignatureInfo { + // The serial number of the certificate. + string cert_serial_number = 1; + + // Signer of the claim, a DN in RFC 2253 format (e.g., "CN=C2PA Signer,OU=FOR + // TESTING_ONLY,O=C2PA Test Signing Cert,L=Somewhere,ST=CA,C=US"). + // + // The field name is `issuer` (as in the open-source SDK) because this is the + // entity that issued the content credentials. + string issuer = 2; + + // Issuer of the claim signer's certificate, a DN in RFC 2253 format (e.g., + // "CN=Intermediate CA,OU=FOR TESTING_ONLY,O=C2PA Test Intermediate Root CA, + // L=Somewhere,ST=CA,C=US"). The identities of any other CAs in the trust + // chain are not included. + string certificate_issuer = 4; + + // A time-stamp applied to the signature, produced by a trusted Time-Stamp + // Authority. + // + // NOTE: Assuming claim validation succeeds, the asset itself can be assumed + // to have been created at or before this time. Any display of this time to + // users should take care not to give the impression that the asset was + // created *at* that time -- it could have been created years earlier. + google.protobuf.Timestamp timestamping_time = 3; + + // Assurance levels for the signing cert, matching the C2PA Generator Product + // Security requirements. + AssuranceLevel assurance_level = 5; + + // The string value of the UUID for the conforming products list record + // (e.g., "0197aa34-665b-7811-9bbc-317cfd988d0a"). + string conforming_product_id = 6; +}
diff --git a/proto/soft_binding_assertion.proto b/proto/soft_binding_assertion.proto new file mode 100644 index 0000000..7b614d8 --- /dev/null +++ b/proto/soft_binding_assertion.proto
@@ -0,0 +1,93 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; +import "google/protobuf/struct.proto"; + +message SoftBindingMetadataMap { + // Additional description of the implementation or author of the binding or + // the algorithm + string description = 1; + // Contact information for the implementation or author of the binding or the + // algorithm + string contact = 2; + // A web page containing more details about the implementation or author of + // the binding or the algorithm. + string informational_url = 3 [(cbor.tags) = { name: "informationalUrl" }]; + + // Additional metadata of the soft binding. + // * tstr => any +} + +message SoftBindingTimespanMap { + // Start of the time range (as milliseconds from media start) + int64 start = 1 [(cbor.tags) = { is_required: true, type: TYPE_UINT }]; + // End of the time range (as milliseconds from media start) + int64 end = 2 [(cbor.tags) = { is_required: true, type: TYPE_UINT }]; +} + +message SoftBindingScopeMap { + // CBOR byte string describing, in algorithm specific format, the part of the + // digital content over which the soft binding value has been computed" + bytes extent = 1; + // The time range over which the soft binding value has been computed. + SoftBindingTimespanMap timespan = 2; + // The region of interest over which the soft binding value has been computed. + // RegionOfInterestMap region = 3; +} + +message SoftBindingBlockMap { + SoftBindingScopeMap scope = 1 [(cbor.tags) = { is_required: true }]; + // CBOR byte string describing, in algorithm specific format, the value of the + // soft binding computed over this block of digital content" + bytes value = 2 [(cbor.tags) = { is_required: true }]; +} + +// Schema for Soft Binding Assertion (c2pa.soft-binding) +// Matches C2PA 2.4 technical specification. +message SoftBindingAssertion { + // A string identifying the soft binding algorithm and version of that + // algorithm used to compute the value, taken from the C2PA soft binding + // algorithm list. If this field is absent, the algorithm is taken from the + // `alg_soft` value of the enclosing structure. If both are present, the field + // in this structure is used. If no value is present in any of these places, + // this structure is invalid; there is no default. + string algorithm = 1 [(cbor.tags) = { is_required: true, name: "alg" }]; + repeated SoftBindingBlockMap blocks = 2 + [(cbor.tags) = { is_required: true, disallow_empty: true }]; + // For internal use in the manifest generator -- do not set or read. + bytes pad = 4 [(cbor.tags) = { mode: MODE_WRITE_ONLY }]; + // For internal use in the manifest generator -- do not set or read. + bytes pad2 = 5 [(cbor.tags) = { mode: MODE_WRITE_ONLY }]; + + // A human-readable description of what this hash covers + string name = 6; + + // CBOR byte string describing parameters of the soft binding algorithm. + bytes alg_params = 7 [(cbor.tags) = { name: "alg-params" }]; + + // Additional metadata of the soft binding. Useful for binding-specific + // information. + SoftBindingMetadataMap binding_metadata = 8 + [(cbor.tags) = { name: "bindingMetadata" }]; + + // Additional information about the assertion + // AssertionMetadataMap assertion_metadata = 9 + // [(cbor.tags) = { name: "metadata" }]; +}
diff --git a/proto/thumbnail_assertion.proto b/proto/thumbnail_assertion.proto new file mode 100644 index 0000000..c57c785 --- /dev/null +++ b/proto/thumbnail_assertion.proto
@@ -0,0 +1,30 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +// See +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#thumbnail_assertion +// for more details. +message ThumbnailAssertion { + // The media type of the thumbnail file. Required. + string media_type = 1; + // The bits of the thumbnail file. Required. + bytes data = 2; + // An optional name of the thumbnail file. + string file_name = 3; +}
diff --git a/proto/validation_result.proto b/proto/validation_result.proto new file mode 100644 index 0000000..01f367f --- /dev/null +++ b/proto/validation_result.proto
@@ -0,0 +1,65 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "proto/manifest.proto"; + +option java_outer_classname = "ValidationResult"; + +// Result of a successful or failed validation of content credentials. +message ValidationResultProto { + Manifest active_manifest = 3; + + // Validation results for all ingredient manifests. + repeated Manifest ingredient_manifests = 4; + + // The IANA media type of the asset (e.g. "image/jpeg"), if known. + string media_type = 6; + + // The C2PA spec version of the validator used to validate the manifest. + string spec_version = 7; + + // The trust list URI used by the validator. + string trust_list_uri = 8; + + reserved 1, 2, 5; +} + +// Result of a partial validation of content credentials, where the content +// binding has not yet been validated. +message PartialValidationResultProto { + Manifest active_manifest = 1; + + // Validation results for all ingredient manifests. + repeated Manifest ingredient_manifests = 2; + + // JUMBF URI of the relevant hard binding assertion, if one was found. + // This will be in the active manifest if it is a standard manifest, and + // in an ingredient manifest otherwise. + string hard_binding_uri = 3; + + // JUMBF URI of the relevant multi-asset hash assertion, if one was found. + // This must be located in the same manifest as the `hard_binding_uri`. + string multi_asset_hash_uri = 4; + + // The C2PA spec version of the validator used to validate the manifest. + string spec_version = 5; + + // The trust list URI used by the validator. + string trust_list_uri = 6; +}
diff --git a/proto/validation_status.proto b/proto/validation_status.proto new file mode 100644 index 0000000..b41f12f --- /dev/null +++ b/proto/validation_status.proto
@@ -0,0 +1,51 @@ +// 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. +// + +edition = "2024"; + +package credentio; + +import "cbor/tags.proto"; + +// Result of a single validation check. +// See `status-map` at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results +// for more details. +message ValidationStatus { + // A label-formatted string that describes the status. + // A list of all possible status codes can be found at + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_standard_status_codes. + string code = 1 [(cbor.tags) = { is_required: true }]; + // JPEG Universal Metadata Box Format (JUMBF) URI reference to the relevant + // element of the manifest store, including `self#jumbf=/c2pa/` prefix. + // See https://www.iso.org/standard/84635.html for more details about JUMBF + // standard. + string url = 2; + // A human readable string explaining the status. + string explanation = 3; +} + +// Results of a manifest validation. +// Validation is successful if `failures` is empty. +// See `status-codes-map` at +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results. +message ValidationStatusSet { + repeated ValidationStatus failures = 1 + [(cbor.tags) = { name: "failure", is_required: true }]; + repeated ValidationStatus successes = 2 + [(cbor.tags) = { name: "success", is_required: true }]; + repeated ValidationStatus informationals = 3 + [(cbor.tags) = { name: "informational", is_required: true }]; +}
diff --git a/testing/BUILD b/testing/BUILD new file mode 100644 index 0000000..94b3444 --- /dev/null +++ b/testing/BUILD
@@ -0,0 +1,303 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package( + default_testonly = True, + default_visibility = ["//visibility:public"], +) + +cc_library( + name = "test_string_utils", + hdrs = ["test_string_utils.h"], + deps = [ + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_library( + name = "mock_cose_verifier", + hdrs = ["mock_cose_verifier.h"], + deps = [ + "//cose:verifier", + "//proto:cose_verification_result_cc_proto", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest", + ], +) + +cc_library( + name = "mock_assertion_validator", + hdrs = ["mock_assertion_validator.h"], + deps = [ + "//assertion:validator", + "//jumbf:uri", + "//validator:tracker", + "@abseil-cpp//absl/container:flat_hash_set", + "@googletest//:gtest", + ], +) + +cc_library( + name = "fake_claim_validator", + hdrs = ["fake_claim_validator.h"], + deps = [ + "//claim:validator", + "//constants:status_codes", + "//jumbf:box", + "//validator:tracker", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + ], +) + +cc_test( + name = "fake_claim_validator_test", + srcs = ["fake_claim_validator_test.cc"], + deps = [ + ":fake_claim_validator", + ":proto_test_utils", + ":test_validation_tracker", + "//constants:status_codes", + "//jumbf:box", + "//proto:generator_info_cc_proto", + "//proto:manifest_cc_proto", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "fake_assertion_validator", + hdrs = ["fake_assertion_validator.h"], + deps = [ + "//assertion:validator", + "//constants:status_codes", + "//jumbf:uri", + "//validator:tracker", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + ], +) + +cc_test( + name = "fake_assertion_validator_test", + srcs = ["fake_assertion_validator_test.cc"], + deps = [ + ":fake_assertion_validator", + ":proto_test_utils", + ":test_validation_tracker", + "//constants:status_codes", + "//jumbf:box", + "//jumbf:uri", + "//proto:assertion_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "crypto_testing", + srcs = ["crypto_testing.cc"], + hdrs = ["crypto_testing.h"], + deps = [ + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/time", + "@boringssl//:crypto", + ], +) + +cc_library( + name = "boringssl_utils", + testonly = False, + srcs = ["boringssl_utils.cc"], + hdrs = ["boringssl_utils.h"], + deps = [ + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + ], +) + +cc_library( + name = "cbor_utils", + srcs = ["cbor_utils.cc"], + hdrs = ["cbor_utils.h"], + deps = [ + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@libcppbor", + "@nlohmann_json//:json", + ], +) + +cc_test( + name = "cbor_utils_test", + srcs = ["cbor_utils_test.cc"], + deps = [ + ":cbor_utils", + "//cbor:parse", + "//testing/proto:test_message_cc_proto", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@libcppbor", + "@nlohmann_json//:json", + ], +) + +cc_library( + name = "pem", + srcs = ["pem.cc"], + hdrs = ["pem.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + "@tink_cc//proto:common_cc_proto", + "@tink_cc//tink:cleartext_keyset_handle", + "@tink_cc//tink:keyset_handle", + "@tink_cc//tink:public_key_sign", + "@tink_cc//tink/signature:config_v0", + "@tink_cc//tink/signature:signature_pem_keyset_reader", + ], +) + +cc_test( + name = "pem_test", + srcs = ["pem_test.cc"], + deps = [ + ":pem", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + "@tink_cc//tink/signature:signature_pem_keyset_reader", + ], +) + +cc_library( + name = "cms", + testonly = False, + srcs = ["cms.cc"], + hdrs = ["cms.h"], + deps = [ + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + ], +) + +cc_library( + name = "jumbf_utils", + srcs = ["jumbf_utils.cc"], + hdrs = ["jumbf_utils.h"], + deps = [ + "//assertion:assertion_encoder", + "//constants:labels", + "//crypto:hash", + "//crypto/default:hasher", + "//jumbf:box", + "//jumbf:constants", + "//jumbf:parse", + "//jumbf:test_utils", + "//proto:assertion_cc_proto", + "//utils:byte_readers", + "//utils:byte_writers", + "//uuid", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + ], +) + +cc_library( + name = "test_validation_tracker", + hdrs = ["test_validation_tracker.h"], + deps = [ + "//constants:status_codes", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//validator:tracker", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/strings", + "@googletest//:gtest", + "@protobuf//:protobuf_lite", + ], +) + +cc_library( + name = "test_status_tracker", + hdrs = ["test_status_tracker.h"], + deps = [ + ":test_validation_tracker", + "//constants:status_codes", + "//utils:status_tracker", + "//validator:tracker", + ], +) + +cc_library( + name = "proto_test_utils", + hdrs = ["proto_test_utils.h"], + deps = [ + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest", + "@protobuf", + "@protobuf//:differencer", + ], +) + +cc_library( + name = "test_file_utils", + srcs = ["test_file_utils.cc"], + hdrs = ["test_file_utils.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@bazel_tools//tools/cpp/runfiles", + "@riegeli//riegeli/bytes:cfile_reader", + "@riegeli//riegeli/bytes:reader", + ], +)
diff --git a/testing/boringssl_utils.cc b/testing/boringssl_utils.cc new file mode 100644 index 0000000..b9ecc4e --- /dev/null +++ b/testing/boringssl_utils.cc
@@ -0,0 +1,71 @@ +// 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 "testing/boringssl_utils.h" + +#include <cstdint> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/log/check.h" +#include "absl/log/die_if_null.h" +#include "absl/strings/string_view.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/bytestring.h" +#include "openssl/mem.h" +#include "openssl/obj.h" +#include "openssl/pem.h" +#include "openssl/x509.h" + +namespace credentio { + +bssl::UniquePtr<X509> absl_nullable PemToCertificate(absl::string_view pem) { + bssl::UniquePtr<BIO> bio( + BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size())); + X509* x509 = PEM_read_bio_X509(bio.get(), nullptr /* existing key */, + nullptr /* password callback */, + nullptr /* password */); + return bssl::UniquePtr<X509>(x509); +} + +bssl::UniquePtr<EVP_PKEY> absl_nullable PemToKey(absl::string_view pem) { + bssl::UniquePtr<BIO> bio( + BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size())); + EVP_PKEY* pkey = PEM_read_bio_PrivateKey( + bio.get(), nullptr /* existing key */, nullptr /* password callback */, + nullptr /* password */); + return bssl::UniquePtr<EVP_PKEY>(pkey); +} + +std::string PemToDer(absl::string_view pem) { + bssl::UniquePtr<BIO> bio(ABSL_DIE_IF_NULL( + BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size()))); + + char* name; + char* header; + uint8_t* data; + long len; // NOLINT: Can't use `int64_t` because on ARM that becomes long + // long which is incompatible with `PEM_read_bio(...)`. + CHECK(PEM_read_bio(bio.get(), &name, &header, &data, &len)); + // Smart pointers for clean-up only. + bssl::UniquePtr<char> name_uniq(name); + bssl::UniquePtr<char> header_uniq(header); + bssl::UniquePtr<uint8_t> data_uniq(data); + + return std::string(reinterpret_cast<const char*>(data), len); +} + +} // namespace credentio
diff --git a/testing/boringssl_utils.h b/testing/boringssl_utils.h new file mode 100644 index 0000000..417099e --- /dev/null +++ b/testing/boringssl_utils.h
@@ -0,0 +1,41 @@ +// 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. +// + +// Utilities for performing common BoringSSL operations. +// +// Primarily intended for tests. +#ifndef THIRD_PARTY_CREDENTIO_TESTING_BORINGSSL_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_BORINGSSL_UTILS_H_ + +#include <string> + +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "openssl/base.h" + +namespace credentio { + +// Parses the first X509 certificate in `pem`. Returns `nullptr` on error. +bssl::UniquePtr<X509> absl_nullable PemToCertificate(absl::string_view pem); + +// Parses the private key in `pem`. Returns `nullptr` on error. +bssl::UniquePtr<EVP_PKEY> absl_nullable PemToKey(absl::string_view pem); + +// Parses the first PEM block found in `pem`. CHECK-fails on error. +std::string PemToDer(absl::string_view pem); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_BORINGSSL_UTILS_H_
diff --git a/testing/cbor_utils.cc b/testing/cbor_utils.cc new file mode 100644 index 0000000..bb9ef79 --- /dev/null +++ b/testing/cbor_utils.cc
@@ -0,0 +1,263 @@ +// 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 "testing/cbor_utils.h" + +#include <sys/types.h> + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/check.h" +#include "absl/log/log.h" +#include "absl/strings/escaping.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" +#include "cppbor/cppbor.h" +#include "cppbor/cppbor_parse.h" +#include "nlohmann/json.hpp" + +namespace cbor { +namespace { + +using Json = ::nlohmann::json; + +cppbor::Map B64ToBstr(cppbor::Map* map); +cppbor::Map BstrToB64(cppbor::Map* map); + +constexpr absl::string_view kBase64Prefix = "b64'"; +constexpr absl::string_view kBase64Suffix = "'"; + +class NoExceptionParser + : public nlohmann::detail::json_sax_dom_parser< + Json, nlohmann::detail::iterator_input_adapter<const char*>> { + public: + explicit NoExceptionParser(Json& j) + : nlohmann::detail::json_sax_dom_parser< + Json, nlohmann::detail::iterator_input_adapter<const char*>>( + j, false) {} + + bool parse_error(std::size_t position, absl::string_view last_token, + const Json::exception& ex) { + error_ = ex.what(); + return false; // Stop processing. + } + + std::string error() { return error_; } + + private: + std::string error_; +}; + +std::string DecodePrefixedBase64(absl::string_view encoded) { + auto stripped = absl::StripPrefix(encoded, kBase64Prefix); + if (!absl::EndsWith(stripped, kBase64Suffix)) { + LOG(FATAL) << "The base64-encoded string " << encoded + << " does not end with the suffix " << kBase64Suffix; + } + stripped = absl::StripSuffix(stripped, kBase64Suffix); + std::string decoded; + if (!absl::Base64Unescape(stripped, &decoded)) { + LOG(FATAL) << "Failed to decode base64: " << stripped; + } + return decoded; +} + +std::string EncodePrefixedBase64(absl::string_view raw) { + return absl::StrCat(kBase64Prefix, absl::Base64Escape(raw), kBase64Suffix); +} + +std::string EncodeCbor(cppbor::Item* item) { + std::vector<uint8_t> buf; + buf.resize(item->encodedSize()); + item->encode(buf.data(), buf.data() + buf.size()); + return std::string(reinterpret_cast<const char*>(buf.data()), buf.size()); +} + +// Recursively replaces all base64-encoded Tstr with their decoded values as +// Bstr. +cppbor::Array B64ToBstr(cppbor::Array* array) { + cppbor::Array new_array; + for (auto& item : *array) { + if (auto s = item->asTstr(); + s != nullptr && s->value().starts_with(kBase64Prefix)) { + new_array.add(cppbor::Bstr(DecodePrefixedBase64(s->value()))); + } else if (auto m = item->asMap(); m != nullptr) { + new_array.add(B64ToBstr(m)); + } else if (auto a = item->asArray(); a != nullptr) { + new_array.add(B64ToBstr(a)); + } else { + new_array.add(std::move(item)); + } + } + return new_array; +} + +// Recursively replaces all base64-encoded Tstr with their decoded values as +// Bstr. +cppbor::Map B64ToBstr(cppbor::Map* map) { + cppbor::Map new_map; + for (auto& [key, value] : *map) { + if (auto v = value->asTstr(); + v != nullptr && v->value().starts_with(kBase64Prefix)) { + new_map.add(std::move(key), + cppbor::Bstr(DecodePrefixedBase64(v->value()))); + } else if (auto m = value->asMap(); m != nullptr) { + new_map.add(std::move(key), B64ToBstr(m)); + } else if (auto v = value->asArray(); v != nullptr) { + cppbor::Array new_array = B64ToBstr(v); + new_map.add(std::move(key), std::move(new_array)); + } else { + new_map.add(std::move(key), std::move(value)); + } + } + return new_map; +} + +// Recursively replaces all base64-encoded Tstr with their decoded values as +// Bstr. Supports only arrays and maps. +std::string B64ToBstr(const uint8_t* begin, const uint8_t* end) { + auto [item, new_position, error] = cppbor::parse(begin, end); + if (!error.empty()) { + LOG(FATAL) << "CBOR parsing failed: " << error; + } + if (new_position != end) { + LOG(FATAL) << "Trailing bytes after the parsed CBOR item"; + } + + if (auto map = item->asMap(); map != nullptr) { + auto new_map = B64ToBstr(map); + return EncodeCbor(&new_map); + } else if (auto array = item->asArray(); array != nullptr) { + auto new_array = B64ToBstr(array); + return EncodeCbor(&new_array); + } + LOG(FATAL) << "Input CBOR item is " << item->type() + << "; expected array (128) or map (160)."; +} + +std::unique_ptr<cppbor::Item> UnwrapTag(std::unique_ptr<cppbor::Item> item) { + while (item->asSemanticTag() != nullptr) { + if (auto t = item->asTstr(); t != nullptr) return t->clone(); + if (auto u = item->asUint(); u != nullptr) return u->clone(); + if (auto i = item->asInt(); i != nullptr) return i->clone(); + if (auto b = item->asBool(); b != nullptr) return b->clone(); + if (auto f = item->asFloat(); f != nullptr) return f->clone(); + if (auto d = item->asDouble(); d != nullptr) return d->clone(); + if (auto s = item->asSimple(); s != nullptr) return s->clone(); + break; + } + return item; +} + +// Recursively replaces all Bstr with base64-encoded Tstr. +cppbor::Array BstrToB64(cppbor::Array* array) { + cppbor::Array new_array; + for (auto& item_ref : *array) { + auto item = UnwrapTag(std::move(item_ref)); + if (auto s = item->asBstr(); s != nullptr) { + absl::string_view sv(reinterpret_cast<const char*>(s->value().data()), + s->value().size()); + new_array.add(cppbor::Tstr(EncodePrefixedBase64(sv))); + } else if (auto m = item->asMap(); m != nullptr) { + new_array.add(BstrToB64(m)); + } else if (auto a = item->asArray(); a != nullptr) { + new_array.add(BstrToB64(a)); + } else { + new_array.add(std::move(item)); + } + } + return new_array; +} + +// Recursively replaces all Bstr with base64-encoded Tstr. +cppbor::Map BstrToB64(cppbor::Map* map) { + cppbor::Map new_map; + for (auto& [key_ref, value_ref] : *map) { + auto key = UnwrapTag(std::move(key_ref)); + auto value = UnwrapTag(std::move(value_ref)); + if (auto s = value->asBstr(); s != nullptr) { + absl::string_view sv(reinterpret_cast<const char*>(s->value().data()), + s->value().size()); + new_map.add(std::move(key), cppbor::Tstr(EncodePrefixedBase64(sv))); + } else if (auto m = value->asMap(); m != nullptr) { + new_map.add(std::move(key), BstrToB64(m)); + } else if (auto v = value->asArray(); v != nullptr) { + cppbor::Array new_array = BstrToB64(v); + new_map.add(std::move(key), std::move(new_array)); + } else { + new_map.add(std::move(key), std::move(value)); + } + } + return new_map; +} + +// Recursively replaces all Bstr with base64-encoded Tstr. Supports only arrays +// and maps. +std::string BstrToB64(absl::string_view raw) { + auto [item, new_position, error] = + cppbor::parse(reinterpret_cast<const uint8_t*>(raw.data()), raw.length()); + if (!error.empty()) { + LOG(FATAL) << "CBOR parsing failed: " << error; + } + if (new_position != + reinterpret_cast<const uint8_t*>(raw.data() + raw.length())) { + LOG(FATAL) << "Trailing bytes after the parsed CBOR item"; + } + + if (auto map = item->asMap(); map != nullptr) { + auto new_map = BstrToB64(map); + return EncodeCbor(&new_map); + } else if (auto array = item->asArray(); array != nullptr) { + auto new_array = BstrToB64(array); + return EncodeCbor(&new_array); + } + LOG(FATAL) << "Input CBOR item is " << item->type() + << "; expected array (128) or map (160)."; +} + +} // namespace + +std::string FromJson(absl::string_view json) { + Json result; + NoExceptionParser parser(result); + if (!Json::sax_parse(json, &parser, Json::input_format_t::json)) { + LOG(FATAL) << "JSON parse error: " << parser.error(); + } + std::vector<uint8_t> cbor = Json::to_cbor(result); + return B64ToBstr(cbor.data(), cbor.data() + cbor.size()); +} + +std::string ToJson(absl::string_view cbor) { + std::string encoded = BstrToB64(cbor); + Json result; + NoExceptionParser parser(result); + auto ia = nlohmann::detail::input_adapter(encoded); + nlohmann::detail::binary_reader<Json, decltype(ia), NoExceptionParser> reader( + std::move(ia), Json::input_format_t::cbor); + if (!reader.sax_parse(Json::input_format_t::cbor, &parser, /*strict=*/true, + /*tag_handler=*/Json::cbor_tag_handler_t::ignore)) { + LOG(FATAL) << "CBOR parse error: " << parser.error(); + } + return result.dump(); +} + +} // namespace cbor
diff --git a/testing/cbor_utils.h b/testing/cbor_utils.h new file mode 100644 index 0000000..516be2c --- /dev/null +++ b/testing/cbor_utils.h
@@ -0,0 +1,38 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_CBOR_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_CBOR_UTILS_H_ + +#include <string> + +#include "absl/strings/string_view.h" +namespace cbor { + +// Converts a JSON string to a CBOR string. +// On malformed input, records error via LOG(FATAL). +// This also replaces all base64-encoded strings, like "b64'Zm9v'", with their +// decoded values as byte strings. +std::string FromJson(absl::string_view json); + +// Converts a CBOR string to a JSON string. +// On malformed input, records error via LOG(FATAL). +// +// Byte strings are represented as base64-encoded strings, like "b64'Zm9v'". +std::string ToJson(absl::string_view cbor); + +} // namespace cbor + +#endif // THIRD_PARTY_CREDENTIO_TESTING_CBOR_UTILS_H_
diff --git a/testing/cbor_utils_test.cc b/testing/cbor_utils_test.cc new file mode 100644 index 0000000..51e9b5a --- /dev/null +++ b/testing/cbor_utils_test.cc
@@ -0,0 +1,242 @@ +// 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 "testing/cbor_utils.h" + +#include <string> +#include <utility> + +#include "absl/strings/string_view.h" +#include "cbor/parse.h" +#include "cppbor/cppbor.h" +#include "gtest/gtest.h" +#include "nlohmann/json.hpp" +#include "testing/proto/test_message.pb.h" + +namespace { + +constexpr absl::string_view kTestJsonMap = R"json({ + "padding": "b64'TQ=='", + "foo": "b64'Zm9v'", + "bar": "bar", + "array": ["b64'Zm9v'", "b64'YmFy'", 1, "bar"], + "map": {"foo": "b64'Zm9v'", "bar": "bar"}, + "array_map": [{"foo": "b64'Zm9v'", "bar": "bar"}, {"foo": "b64'Zm9v'", "bar": "bar"}], + "array_array": [["b64'Zm9v'", "b64'YmFy'", 1, "bar"], ["b64'Zm9v'", "b64'YmFy'", 1, "bar"]] + })json"; + +constexpr absl::string_view kTestJsonArray = + R"json(["b64'Zm9v'", "b64'YmFy'", 1, "bar"])json"; + +TEST(CborUtilsTest, JsonToCborToJson_NoB64) { + auto json = R"json({ + "foo": "bar" + })json"; + auto cbor = cbor::FromJson(json); + auto decoded_json = cbor::ToJson(cbor); + EXPECT_EQ(nlohmann::json::parse(decoded_json), nlohmann::json::parse(json)); +} + +TEST(CborUtilsTest, JsonToCborToJson_MapWithB64) { + auto cbor = cbor::FromJson(kTestJsonMap); + EXPECT_EQ(nlohmann::json::parse(cbor::ToJson(cbor)), + nlohmann::json::parse(kTestJsonMap)); +} + +TEST(CborUtilsTest, JsonToCborToJson_ArrayWithB64) { + auto cbor = cbor::FromJson(kTestJsonArray); + EXPECT_EQ(nlohmann::json::parse(cbor::ToJson(cbor)), + nlohmann::json::parse(kTestJsonArray)); +} + +TEST(CborUtilsTest, ToJson_WithSemanticTag) { + // 0xC0 is Tag 0 (RFC 3339 date/time string). + // Construct a CBOR map {"when": 0xC0 "2026-05-19T00:00:00Z"}. + cppbor::Map orig_map; + orig_map.add("when", + cppbor::SemanticTag(0, cppbor::Tstr("2026-05-19T00:00:00Z"))); + auto bytes = orig_map.encode(); + absl::string_view bytes_view(reinterpret_cast<const char*>(bytes.data()), + bytes.size()); + std::string json = cbor::ToJson(bytes_view); + EXPECT_EQ( + nlohmann::json::parse(json), + nlohmann::json::parse(R"json({"when": "2026-05-19T00:00:00Z"})json")); +} + +TEST(CborUtilsTest, JsonToCbor_MapWithB64) { + auto cbor = cbor::FromJson(kTestJsonMap); + auto value1 = cbor::Parse(cbor); + ASSERT_TRUE(value1.ok()); + auto result = std::move(value1).value(); + auto value2 = result->AsMap(); + ASSERT_TRUE(value2.ok()); + auto map = std::move(value2).value(); + auto value3 = map.GetByteString("padding"); + ASSERT_TRUE(value3.ok()); + auto padding = std::move(value3).value(); + EXPECT_EQ(padding, "M"); + auto value4 = map.GetByteString("foo"); + ASSERT_TRUE(value4.ok()); + auto foo = std::move(value4).value(); + EXPECT_EQ(foo, "foo"); + auto value5 = map.GetString("bar"); + ASSERT_TRUE(value5.ok()); + auto bar = std::move(value5).value(); + EXPECT_EQ(bar, "bar"); + + auto value6 = map.GetArray("array"); + ASSERT_TRUE(value6.ok()); + auto array = std::move(value6).value(); + ASSERT_EQ(array.size(), 4); + auto value7 = array.GetByteString(0); + ASSERT_TRUE(value7.ok()); + auto array_foo = std::move(value7).value(); + EXPECT_EQ(array_foo, "foo"); + auto value8 = array.GetByteString(1); + ASSERT_TRUE(value8.ok()); + auto array_bar = std::move(value8).value(); + EXPECT_EQ(array_bar, "bar"); + auto value9 = array.GetUint64(2); + ASSERT_TRUE(value9.ok()); + auto array_1 = std::move(value9).value(); + EXPECT_EQ(array_1, 1); + auto value10 = array.GetString(3); + ASSERT_TRUE(value10.ok()); + auto array_bar_2 = std::move(value10).value(); + EXPECT_EQ(array_bar_2, "bar"); + + auto value11 = map.GetMap("map"); + ASSERT_TRUE(value11.ok()); + auto map_map = std::move(value11).value(); + auto value12 = map_map.GetByteString("foo"); + ASSERT_TRUE(value12.ok()); + auto map_foo = std::move(value12).value(); + EXPECT_EQ(map_foo, "foo"); + auto value13 = map_map.GetString("bar"); + ASSERT_TRUE(value13.ok()); + auto map_bar = std::move(value13).value(); + EXPECT_EQ(map_bar, "bar"); + + auto value14 = map.GetArray("array_map"); + ASSERT_TRUE(value14.ok()); + auto array_map = std::move(value14).value(); + ASSERT_EQ(array_map.size(), 2); + auto value15 = array_map.GetMap(0); + ASSERT_TRUE(value15.ok()); + auto array_map_0 = std::move(value15).value(); + auto value16 = array_map_0.GetByteString("foo"); + ASSERT_TRUE(value16.ok()); + auto array_map_foo_0 = std::move(value16).value(); + EXPECT_EQ(array_map_foo_0, "foo"); + auto value17 = array_map_0.GetString("bar"); + ASSERT_TRUE(value17.ok()); + auto array_map_bar_0 = std::move(value17).value(); + EXPECT_EQ(array_map_bar_0, "bar"); + auto value18 = array_map.GetMap(1); + ASSERT_TRUE(value18.ok()); + auto array_map_1 = std::move(value18).value(); + auto value19 = array_map_1.GetByteString("foo"); + ASSERT_TRUE(value19.ok()); + auto array_map_foo_1 = std::move(value19).value(); + EXPECT_EQ(array_map_foo_1, "foo"); + auto value20 = array_map_1.GetString("bar"); + ASSERT_TRUE(value20.ok()); + auto array_map_bar_1 = std::move(value20).value(); + EXPECT_EQ(array_map_bar_1, "bar"); + + auto value21 = map.GetArray("array_array"); + ASSERT_TRUE(value21.ok()); + auto array_array = std::move(value21).value(); + ASSERT_EQ(array_array.size(), 2); + auto value22 = array_array.GetArray(0); + ASSERT_TRUE(value22.ok()); + auto array_array_0 = std::move(value22).value(); + ASSERT_EQ(array_array_0.size(), 4); + auto value23 = array_array_0.GetByteString(0); + ASSERT_TRUE(value23.ok()); + auto array_array_foo_0 = std::move(value23).value(); + EXPECT_EQ(array_array_foo_0, "foo"); + auto value24 = array_array_0.GetString(3); + ASSERT_TRUE(value24.ok()); + auto array_array_bar_0 = std::move(value24).value(); + EXPECT_EQ(array_array_bar_0, "bar"); + auto value25 = array_array.GetArray(1); + ASSERT_TRUE(value25.ok()); + auto array_array_1 = std::move(value25).value(); + ASSERT_EQ(array_array_1.size(), 4); + auto value26 = array_array_1.GetByteString(0); + ASSERT_TRUE(value26.ok()); + auto array_array_foo_1 = std::move(value26).value(); + EXPECT_EQ(array_array_foo_1, "foo"); + auto value27 = array_array_1.GetString(3); + ASSERT_TRUE(value27.ok()); + auto array_array_bar_1 = std::move(value27).value(); + EXPECT_EQ(array_array_bar_1, "bar"); +} + +TEST(CborUtilsTest, JsonToCbor_ArrayWithB64) { + auto cbor = cbor::FromJson(kTestJsonArray); + auto value28 = cbor::Parse(cbor); + ASSERT_TRUE(value28.ok()); + auto result = std::move(value28).value(); + auto value29 = result->AsArray(); + ASSERT_TRUE(value29.ok()); + auto array = std::move(value29).value(); + ASSERT_EQ(array.size(), 4); + auto value30 = array.GetByteString(0); + ASSERT_TRUE(value30.ok()); + auto array_foo = std::move(value30).value(); + EXPECT_EQ(array_foo, "foo"); + auto value31 = array.GetByteString(1); + ASSERT_TRUE(value31.ok()); + auto array_bar = std::move(value31).value(); + EXPECT_EQ(array_bar, "bar"); + auto value32 = array.GetUint64(2); + ASSERT_TRUE(value32.ok()); + auto array_1 = std::move(value32).value(); + EXPECT_EQ(array_1, 1); + auto value33 = array.GetString(3); + ASSERT_TRUE(value33.ok()); + auto array_bar_2 = std::move(value33).value(); + EXPECT_EQ(array_bar_2, "bar"); +} + +TEST(CborUtilsTest, JsonToCbor_ErrorMalformedBase64Decoding) { + auto json = R"json({ + "foo": "b64'Zm9v`'" + })json"; + EXPECT_DEATH({ cbor::FromJson(json); }, "Failed to decode base64: Zm9v`"); +} + +TEST(CborUtilsTest, JsonToCbor_ErrorMalformedBase64Format) { + auto json = R"json({ + "foo": "b64'Zm9v" + })json"; + EXPECT_DEATH( + { cbor::FromJson(json); }, + "The base64-encoded string b64'Zm9v does not end with the suffix '"); +} + +TEST(CborUtilsDeathTest, JsonToCbor_Malformed) { + EXPECT_DEATH( + { cbor::FromJson("malformed"); }, "JSON parse error.*syntax error"); +} + +TEST(CborUtilsDeathTest, CborToJson_Malformed) { + EXPECT_DEATH({ cbor::ToJson("malformed"); }, "CBOR parsing failed"); +} + +} // namespace
diff --git a/testing/cms.cc b/testing/cms.cc new file mode 100644 index 0000000..b73dd6d --- /dev/null +++ b/testing/cms.cc
@@ -0,0 +1,389 @@ +// 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 "testing/cms.h" + +#include <cstdint> +#include <ctime> +#include <memory> +#include <string> +#include <vector> + +#include "absl/functional/function_ref.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/mem.h" +#include "openssl/obj.h" +#include "openssl/x509.h" + +namespace credentio_testing { + +namespace { + +constexpr absl::string_view kOidMessageDigest = "1.2.840.113549.1.9.4"; +constexpr absl::string_view kOidContentType = "1.2.840.113549.1.9.3"; +constexpr absl::string_view kOidTstInfo = "1.2.840.113549.1.9.16.1.4"; +constexpr absl::string_view kOidData = "1.2.840.113549.1.7.1"; +constexpr absl::string_view kOidSigningTime = "1.2.840.113549.1.9.5"; +constexpr absl::string_view kOidSmimeCaps = "1.2.840.113549.1.9.15"; +constexpr absl::string_view kOidAes256Cbc = "2.16.840.1.101.3.4.1.42"; +constexpr absl::string_view kOidAes192Cbc = "2.16.840.1.101.3.4.1.22"; +constexpr absl::string_view kOidAes128Cbc = "2.16.840.1.101.3.4.1.2"; +constexpr absl::string_view kOidDesEde3Cbc = "1.2.840.113549.3.7"; +constexpr absl::string_view kOidRsaesOaep = "1.2.840.113549.1.1.7"; +constexpr absl::string_view kOidSha256 = "2.16.840.1.101.3.4.2.1"; +constexpr absl::string_view kOidSignedData = "1.2.840.113549.1.7.2"; +constexpr absl::string_view kOidRsaEncryption = "1.2.840.113549.1.1.1"; + +bool SerializeCertificate(const X509& certificate, std::vector<uint8_t>* data) { + int len = i2d_X509(&certificate, nullptr); + if (len <= 0) { + return false; + } + data->resize(len); + uint8_t* dataptr = data->data(); + return i2d_X509(&certificate, &dataptr) == len; +} + +bool SerializeCertificateIssuerName(const X509& certificate, + std::vector<uint8_t>* data) { + int len = i2d_X509_NAME(X509_get_issuer_name(&certificate), nullptr); + if (len <= 0) { + return false; + } + data->resize(len); + uint8_t* dataptr = data->data(); + return i2d_X509_NAME(X509_get_issuer_name(&certificate), &dataptr) == len; +} + +bool SerializeCertificateSerialNumber(const X509& certificate, + std::vector<uint8_t>* data) { + const ASN1_INTEGER* serial_asn1 = X509_get0_serialNumber(&certificate); + if (serial_asn1 == nullptr) { + return false; + } + int len = i2d_ASN1_INTEGER(serial_asn1, nullptr); + if (len <= 0) { + return false; + } + data->resize(len); + uint8_t* dataptr = data->data(); + return i2d_ASN1_INTEGER(serial_asn1, &dataptr) == len; +} + +bool SerializeSigningTime(time_t signing_time, std::vector<uint8_t>* data) { + bssl::UniquePtr<ASN1_TIME> asn1_time(ASN1_TIME_set(nullptr, signing_time)); + if (asn1_time == nullptr) { + return false; + } + int len = i2d_ASN1_TIME(asn1_time.get(), nullptr); + if (len <= 0) { + return false; + } + data->resize(len); + uint8_t* dataptr = data->data(); + return i2d_ASN1_TIME(asn1_time.get(), &dataptr) == len; +} + +bool AddOid(CBB* cbb, absl::string_view oid) { + return CBB_add_asn1_oid_from_text(cbb, oid.data(), oid.length()); +} + +} // namespace + +absl::StatusOr<std::string> CreateSignedCms( + absl::Span<const absl::string_view> contents, + const X509& signer_certificate, + absl::FunctionRef<absl::StatusOr<std::vector<uint8_t>>(absl::string_view)> + sign_callback, + const SignedCmsOptions& options) { + ABSL_ASSIGN_OR_RETURN(auto hasher, + CreateHasher(credentio::HashAlgorithm::kSha256)); + for (const auto& part : contents) { + hasher->Update(part); + } + std::string hash = hasher->Digest(); + + std::vector<uint8_t> signing_time_der, issuer_name, serial_number; + if (!SerializeCertificateIssuerName(signer_certificate, &issuer_name) || + !SerializeCertificateSerialNumber(signer_certificate, &serial_number) || + !SerializeSigningTime(absl::ToUnixSeconds(options.signing_time), + &signing_time_der)) { + return absl::InternalError("Failed to serialize certificate components."); + } + + bssl::ScopedCBB attributes; + CBB att_seq, oid, value_set, value; + if (CBB_init(attributes.get(), 1024) != 1) { + return absl::InternalError("Memory allocation failed for attributes."); + } + + // Message Digest + if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidMessageDigest) || + CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 || + CBB_add_asn1(&value_set, &value, CBS_ASN1_OCTETSTRING) != 1 || + CBB_add_bytes(&value, reinterpret_cast<const uint8_t*>(hash.data()), + hash.size()) != 1) { + return absl::InternalError("Failed to add message digest attribute."); + } + + // Content Type + if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidContentType) || + CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 || + CBB_add_asn1(&value_set, &value, CBS_ASN1_OBJECT) != 1) { + return absl::InternalError("Failed to add content type attribute."); + } + if (options.is_time_stamp_token) { + if (!AddOid(&value, kOidTstInfo)) { + return absl::InternalError("Failed to add TSTInfo OID."); + } + } else { + if (!AddOid(&value, kOidData)) { + return absl::InternalError("Failed to add data OID."); + } + } + + // Signing Time + if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidSigningTime) || + CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 || + CBB_add_bytes(&value_set, signing_time_der.data(), + signing_time_der.size()) != 1) { + return absl::InternalError("Failed to add signing time attribute."); + } + + // SMIME-CAPS + CBB cap_seq, cap_item; + if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidSmimeCaps) || + CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 || + CBB_add_asn1(&value_set, &cap_seq, CBS_ASN1_SEQUENCE) != 1) { + return absl::InternalError("Failed to add SMIME-CAPS attribute."); + } + + // AES-256-CBC + if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidAes256Cbc)) { + return absl::InternalError("Failed to add AES-256-CBC capability."); + } + // AES-192-CBC + if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidAes192Cbc)) { + return absl::InternalError("Failed to add AES-192-CBC capability."); + } + // AES-128-CBC + if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidAes128Cbc)) { + return absl::InternalError("Failed to add AES-128-CBC capability."); + } + // DES-EDE3-CBC + if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidDesEde3Cbc)) { + return absl::InternalError("Failed to add DES-EDE3-CBC capability."); + } + // RSAES-OAEP + if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidRsaesOaep)) { + return absl::InternalError("Failed to add RSAES-OAEP capability."); + } + // SHA256 + if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 || + !AddOid(&oid, kOidSha256)) { + return absl::InternalError("Failed to add SHA256 capability."); + } + + // Extra Signed Attributes + if (!options.extra_signed_attributes.empty()) { + if (CBB_add_bytes(attributes.get(), + reinterpret_cast<const uint8_t*>( + options.extra_signed_attributes.data()), + options.extra_signed_attributes.length()) != 1) { + return absl::InternalError("Failed to add extra signed attributes."); + } + } + + uint8_t* raw_attr_data; + size_t raw_attr_len; + if (CBB_finish(attributes.get(), &raw_attr_data, &raw_attr_len) != 1) { + return absl::InternalError("Failed to finish attributes CBB."); + } + bssl::UniquePtr<uint8_t> raw_attr_data_uniq(raw_attr_data); + + // Sign the attributes. The signature is computed over the SET of attributes. + bssl::ScopedCBB pk_signed_attributes; + CBB pk_attributes_set; + if (CBB_init(pk_signed_attributes.get(), raw_attr_len + 5) != 1 || + CBB_add_asn1(pk_signed_attributes.get(), &pk_attributes_set, + CBS_ASN1_SET) != 1 || + CBB_add_bytes(&pk_attributes_set, raw_attr_data, raw_attr_len) != 1 || + CBB_flush(pk_signed_attributes.get()) != 1) { + return absl::InternalError("Failed to prepare attributes for signing."); + } + + ABSL_ASSIGN_OR_RETURN( + std::vector<uint8_t> signature, + sign_callback(absl::string_view( + reinterpret_cast<const char*>(CBB_data(pk_signed_attributes.get())), + CBB_len(pk_signed_attributes.get())))); + + // Assemble CMS + bssl::ScopedCBB cms; + CBB content_type, signed_data, digest_algs, digest_alg_seq, content_seq, + certs, algo_params, temp, signed_seq; + if (CBB_init(cms.get(), 2048) != 1) { + return absl::InternalError("Memory allocation for CMS failed."); + } + + uint64_t version = options.is_time_stamp_token ? 3 : 1; + + if (CBB_add_asn1(cms.get(), &content_type, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&content_type, &temp, CBS_ASN1_OBJECT) != 1 || + !AddOid(&temp, kOidSignedData) || + CBB_add_asn1(&content_type, &signed_data, + CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1 || + CBB_add_asn1(&signed_data, &signed_seq, CBS_ASN1_SEQUENCE) != 1 || + // Version + CBB_add_asn1_uint64(&signed_seq, version) != 1 || + // Digest algorithms + CBB_add_asn1(&signed_seq, &digest_algs, CBS_ASN1_SET) != 1 || + CBB_add_asn1(&digest_algs, &digest_alg_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&digest_alg_seq, &temp, CBS_ASN1_OBJECT) != 1 || + !AddOid(&temp, kOidSha256) || + CBB_add_asn1(&digest_alg_seq, &algo_params, CBS_ASN1_NULL) != 1 || + // Content + CBB_add_asn1(&signed_seq, &content_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&content_seq, &temp, CBS_ASN1_OBJECT) != 1) { + return absl::InternalError("Failed to build CMS header."); + } + + if (options.is_time_stamp_token) { + if (!AddOid(&temp, kOidTstInfo)) { + return absl::InternalError("Failed to add TSTInfo OID to header."); + } + } else { + if (!AddOid(&temp, kOidData)) { + return absl::InternalError("Failed to add data OID to header."); + } + } + + // Opaque Content + CBB octet_stream; + if (CBB_add_asn1(&content_seq, &octet_stream, + CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1 || + CBB_add_asn1(&octet_stream, &temp, CBS_ASN1_OCTETSTRING) != 1) { + return absl::InternalError("Failed to add opaque content wrapper."); + } + for (const auto& part : contents) { + if (CBB_add_bytes(&temp, reinterpret_cast<const uint8_t*>(part.data()), + part.size()) != 1) { + return absl::InternalError("Failed to add opaque content bytes."); + } + } + + // Certificates + if (options.include_user_certificate || !options.ca_certificates.empty()) { + if (CBB_add_asn1(&signed_seq, &certs, + CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1) { + return absl::InternalError("Failed to add certificates wrapper."); + } + std::vector<uint8_t> der; + for (const X509* cert : options.ca_certificates) { + if (!SerializeCertificate(*cert, &der)) { + return absl::InternalError("Failed to serialize CA certificate."); + } + if (CBB_add_bytes(&certs, der.data(), der.size()) != 1) { + return absl::InternalError("Failed to add CA certificate."); + } + } + if (options.include_user_certificate) { + if (!SerializeCertificate(signer_certificate, &der)) { + return absl::InternalError("Failed to serialize user certificate."); + } + if (CBB_add_bytes(&certs, der.data(), der.size()) != 1) { + return absl::InternalError("Failed to add user certificate."); + } + } + } + + // Signer Info + CBB signer_info_set, signer_info, issuer_and_sn; + if (CBB_add_asn1(&signed_seq, &signer_info_set, CBS_ASN1_SET) != 1 || + CBB_add_asn1(&signer_info_set, &signer_info, CBS_ASN1_SEQUENCE) != 1 || + // Version + CBB_add_asn1_uint64(&signer_info, 1) != 1 || + CBB_add_asn1(&signer_info, &issuer_and_sn, CBS_ASN1_SEQUENCE) != 1 || + // Signer info: Signer identifier: Issuer and serial number + CBB_add_bytes(&issuer_and_sn, issuer_name.data(), issuer_name.size()) != + 1 || + CBB_add_bytes(&issuer_and_sn, serial_number.data(), + serial_number.size()) != 1 || + // Signer info: Digest algorithm + CBB_add_asn1(&signer_info, &digest_alg_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&digest_alg_seq, &temp, CBS_ASN1_OBJECT) != 1 || + !AddOid(&temp, kOidSha256) || + CBB_add_asn1(&digest_alg_seq, &algo_params, CBS_ASN1_NULL) != 1 || + // Signer info: Signed attributes + CBB_add_asn1(&signer_info, &temp, + CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1 || + CBB_add_bytes(&temp, raw_attr_data, raw_attr_len) != 1 || + // Signer info: Signature algorithm + CBB_add_asn1(&signer_info, &digest_alg_seq, CBS_ASN1_SEQUENCE) != 1 || + CBB_add_asn1(&digest_alg_seq, &temp, CBS_ASN1_OBJECT) != 1) { + return absl::InternalError("Failed to build SignerInfo."); + } + + // Hardcode RsaEncryption for now to match simple_cms behavior in tests, as + // TimestampVerifier doesn't strictly check this. + if (!AddOid(&temp, kOidRsaEncryption) || + CBB_add_asn1(&digest_alg_seq, &algo_params, CBS_ASN1_NULL) != 1 || + // Signer info: Signature + CBB_add_asn1(&signer_info, &temp, CBS_ASN1_OCTETSTRING) != 1 || + CBB_add_bytes(&temp, signature.data(), signature.size()) != 1) { + return absl::InternalError("Failed to add signature to SignerInfo."); + } + + uint8_t* cms_data; + size_t cms_len; + if (CBB_finish(cms.get(), &cms_data, &cms_len) != 1) { + return absl::InternalError("Failed to finish CMS CBB."); + } + bssl::UniquePtr<uint8_t> cms_data_uniq(cms_data); + + return std::string(reinterpret_cast<char*>(cms_data), cms_len); +} + +} // namespace credentio_testing
diff --git a/testing/cms.h b/testing/cms.h new file mode 100644 index 0000000..98c944a --- /dev/null +++ b/testing/cms.h
@@ -0,0 +1,52 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_CMS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_CMS_H_ + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/functional/function_ref.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "openssl/base.h" + +namespace credentio_testing { + +struct SignedCmsOptions { + absl::Time signing_time = absl::UnixEpoch(); + bool include_user_certificate = false; + absl::Span<const X509* const> ca_certificates = {}; + absl::string_view extra_signed_attributes = ""; + bool is_time_stamp_token = false; +}; + +// Creates a minimal signed CMS structure (SignedData) wrapping the given +// contents. This is a simplified implementation intended for testing and +// internal use. +absl::StatusOr<std::string> CreateSignedCms( + absl::Span<const absl::string_view> contents, + const X509& signer_certificate, + absl::FunctionRef<absl::StatusOr<std::vector<uint8_t>>(absl::string_view)> + sign_callback, + const SignedCmsOptions& options = {}); + +} // namespace credentio_testing + +#endif // THIRD_PARTY_CREDENTIO_TESTING_CMS_H_
diff --git a/testing/crypto_testing.cc b/testing/crypto_testing.cc new file mode 100644 index 0000000..7df4c10 --- /dev/null +++ b/testing/crypto_testing.cc
@@ -0,0 +1,140 @@ +// 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 "testing/crypto_testing.h" + +#include <cstddef> +#include <string> + +#include "absl/log/check.h" +#include "absl/time/time.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bn.h" +#include "openssl/digest.h" +#include "openssl/ec_key.h" +#include "openssl/evp.h" +#include "openssl/rsa.h" +#include "openssl/x509.h" + +namespace credentio { + +bssl::UniquePtr<EVP_PKEY> CreateTestEcKey(int nid) { + bssl::UniquePtr<EC_KEY> key(EC_KEY_new_by_curve_name(nid)); + CHECK_NE(key, nullptr); + + CHECK(EC_KEY_generate_key(key.get())); + + bssl::UniquePtr<EVP_PKEY> pkey(EVP_PKEY_new()); + CHECK_NE(pkey, nullptr); + CHECK(EVP_PKEY_set1_EC_KEY(pkey.get(), key.get())); + return pkey; +} + +bssl::UniquePtr<EVP_PKEY> CreateTestRsaKey(size_t num_bits) { + bssl::UniquePtr<BIGNUM> rsa_f4(BN_new()); + CHECK_NE(rsa_f4, nullptr); + CHECK(BN_set_word(rsa_f4.get(), RSA_F4)); + + bssl::UniquePtr<RSA> rsa(RSA_new()); + CHECK_NE(rsa, nullptr); + + CHECK(RSA_generate_key_ex(rsa.get(), num_bits, rsa_f4.get(), nullptr)); + + bssl::UniquePtr<EVP_PKEY> pkey(EVP_PKEY_new()); + CHECK_NE(pkey, nullptr); + CHECK(EVP_PKEY_set1_RSA(pkey.get(), rsa.get())); + return pkey; +} + +bssl::UniquePtr<EVP_PKEY> CreateTestEdKey() { + bssl::UniquePtr<EVP_PKEY_CTX> ctx( + EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr)); + CHECK_NE(ctx, nullptr); + CHECK(EVP_PKEY_keygen_init(ctx.get())); + EVP_PKEY* pkey = nullptr; + CHECK(EVP_PKEY_keygen(ctx.get(), &pkey)); + return bssl::UniquePtr<EVP_PKEY>(pkey); +} + +bssl::UniquePtr<X509> CreateTestCertificate( + const TestCertificateOptions& options) { + // Options validation. + CHECK(!options.subject_common_name.empty()); + CHECK(!options.issuer_common_name.empty()); + CHECK(options.signing_key != nullptr); + CHECK(options.private_key != nullptr); + + // Create the X509 object. + bssl::UniquePtr<X509> x509(X509_new()); + CHECK(X509_set_version(x509.get(), 2)); + + // Set the not_before/not_after fields. + bssl::UniquePtr<ASN1_TIME> not_before(ASN1_TIME_new()); + CHECK(ASN1_TIME_set(not_before.get(), absl::ToTimeT(options.not_before))); + CHECK(X509_set1_notBefore(x509.get(), not_before.get())); + + bssl::UniquePtr<ASN1_TIME> not_after(ASN1_TIME_new()); + CHECK(ASN1_TIME_set(not_after.get(), absl::ToTimeT(options.not_after))); + CHECK(X509_set1_notAfter(x509.get(), not_after.get())); + + // Set the subject DN. + bssl::UniquePtr<X509_NAME> subject_name(X509_NAME_new()); + CHECK(X509_NAME_add_entry_by_txt(subject_name.get(), /*field=*/"CN", + MBSTRING_ASC, + reinterpret_cast<const unsigned char*>( + options.subject_common_name.c_str()), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + CHECK(X509_NAME_add_entry_by_txt( + subject_name.get(), /*field=*/"O", MBSTRING_ASC, + reinterpret_cast<const unsigned char*>("Google"), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + CHECK(X509_NAME_add_entry_by_txt( + subject_name.get(), /*field=*/"OU", MBSTRING_ASC, + reinterpret_cast<const unsigned char*>("Google OU"), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + CHECK(X509_set_subject_name(x509.get(), subject_name.get())); + + // Set the issuer DN. + bssl::UniquePtr<X509_NAME> issuer_name(X509_NAME_new()); + CHECK(X509_NAME_add_entry_by_txt(issuer_name.get(), /*field=*/"CN", + MBSTRING_ASC, + reinterpret_cast<const unsigned char*>( + options.issuer_common_name.c_str()), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + CHECK(X509_NAME_add_entry_by_txt( + issuer_name.get(), /*field=*/"O", MBSTRING_ASC, + reinterpret_cast<const unsigned char*>("Google"), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + CHECK(X509_NAME_add_entry_by_txt( + issuer_name.get(), /*field=*/"OU", MBSTRING_ASC, + reinterpret_cast<const unsigned char*>("Google OU"), + /*len=*/-1, /*loc=*/-1, + /*set=*/0)); + CHECK(X509_set_issuer_name(x509.get(), issuer_name.get())); + + // Set the public key and sign the certificate. + CHECK(X509_set_pubkey(x509.get(), options.private_key)); + CHECK(X509_sign(x509.get(), options.signing_key, EVP_sha256())); + + return x509; +} + +} // namespace credentio
diff --git a/testing/crypto_testing.h b/testing/crypto_testing.h new file mode 100644 index 0000000..946cf4e --- /dev/null +++ b/testing/crypto_testing.h
@@ -0,0 +1,50 @@ +// 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. +// + +// Utilities for testing crypto code. +#ifndef THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_ + +#include <cstddef> +#include <string> + +#include "absl/time/time.h" +#include "openssl/base.h" +#include "openssl/x509.h" + +namespace credentio { + +struct TestCertificateOptions { + std::string subject_common_name; + std::string issuer_common_name; + absl::Time not_before = absl::FromUnixSeconds(1); + absl::Time not_after = absl::FromUnixSeconds(86400L * 365 * 100); + EVP_PKEY* signing_key = nullptr; + EVP_PKEY* private_key = nullptr; +}; + +// Generates an X.509 certificate based on `options`. +bssl::UniquePtr<X509> CreateTestCertificate( + const TestCertificateOptions& options); + +bssl::UniquePtr<EVP_PKEY> CreateTestEcKey(int nid); + +bssl::UniquePtr<EVP_PKEY> CreateTestRsaKey(size_t num_bits); + +bssl::UniquePtr<EVP_PKEY> CreateTestEdKey(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_
diff --git a/testing/fake_assertion_validator.h b/testing/fake_assertion_validator.h new file mode 100644 index 0000000..89efa5f --- /dev/null +++ b/testing/fake_assertion_validator.h
@@ -0,0 +1,75 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_FAKE_ASSERTION_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_FAKE_ASSERTION_VALIDATOR_H_ + +#include <string> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "assertion/validator.h" +#include "constants/status_codes.h" +#include "jumbf/uri.h" +#include "validator/tracker.h" + +namespace credentio { + +class FakeAssertionValidator : public AssertionValidator { + public: + FakeAssertionValidator(absl::Span<const FailureStatusCode> failures, + absl::Span<const SuccessStatusCode> successes) + : failures_(failures.begin(), failures.end()), + successes_(successes.begin(), successes.end()) {} + void ValidateClaimAssertions( + const jumbf::UriResolver& uri_resolver, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, + ValidationTracker& validation_tracker) const override { + // Record failures and successes. + for (const auto& failure : failures_) { + validation_tracker.RecordFailure(failure, {}); + } + for (const auto& success : successes_) { + validation_tracker.RecordSuccess(success, {}); + } + // Populate `assertions_out` with assertions for the given manifest path. + std::string manifest_path = absl::StrCat("/c2pa/", manifest.label()); + if (assertions_.contains(manifest_path)) { + for (const auto& assertion : assertions_.at(manifest_path)) { + *manifest.add_assertions() = assertion; + } + } + } + + void SetOutputAssertions(absl::string_view manifest_path, + absl::Span<const Assertion> assertions) { + assertions_[manifest_path] = + std::vector<Assertion>(assertions.begin(), assertions.end()); + } + + private: + std::vector<FailureStatusCode> failures_; + std::vector<SuccessStatusCode> successes_; + absl::flat_hash_map<std::string, std::vector<Assertion>> assertions_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_FAKE_ASSERTION_VALIDATOR_H_
diff --git a/testing/fake_assertion_validator_test.cc b/testing/fake_assertion_validator_test.cc new file mode 100644 index 0000000..2ae0b1c --- /dev/null +++ b/testing/fake_assertion_validator_test.cc
@@ -0,0 +1,74 @@ +// 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 "testing/fake_assertion_validator.h" + +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/uri.h" +#include "proto/assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" + +namespace credentio { +namespace { + +using ::credentio_testing::EqualsProto; +using ::testing::ElementsAre; +using ::testing::IsEmpty; + +TEST(FakeAssertionValidatorTest, TrackerIsPopulated) { + FakeAssertionValidator assertion_validator( + /*failures=*/{FailureStatusCode::kAssertionCborInvalid}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + TestValidationTracker t; + jumbf::SuperBox root_box; + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + Manifest manifest; + assertion_validator.ValidateClaimAssertions(uri_resolver, + /*redacted_assertion_paths=*/{}, + manifest, t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kAssertionCborInvalid)); + EXPECT_THAT(t.GetSuccesses(), + ContainsSuccess(SuccessStatusCode::kClaimSignatureValidated)); +} + +TEST(FakeAssertionValidatorTest, AssertionsArePopulated) { + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + absl::string_view manifest_label = "some_manifest_label"; + TestValidationTracker t; + jumbf::SuperBox root_box; + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + Assertion assertion; + assertion.mutable_ingredient_v2()->set_description("some_description"); + assertion_validator.SetOutputAssertions("/c2pa/some_manifest_label", + {assertion}); + Manifest manifest; + manifest.set_label(manifest_label); + assertion_validator.ValidateClaimAssertions( + uri_resolver, /*redacted_assertion_paths=*/{}, manifest, t.tracker()); + EXPECT_THAT(manifest.assertions(), ElementsAre(EqualsProto(assertion))); + EXPECT_THAT(t.GetFailures(), IsEmpty()); +} + +} // namespace +} // namespace credentio
diff --git a/testing/fake_claim_validator.h b/testing/fake_claim_validator.h new file mode 100644 index 0000000..658cc94 --- /dev/null +++ b/testing/fake_claim_validator.h
@@ -0,0 +1,126 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_FAKE_CLAIM_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_FAKE_CLAIM_VALIDATOR_H_ + +#include <optional> +#include <string> +#include <vector> + +#include "absl/container/flat_hash_map.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "claim/validator.h" +#include "constants/status_codes.h" +#include "jumbf/box.h" +#include "validator/tracker.h" + +namespace credentio { + +class FakeClaimValidator : public ClaimValidator { + public: + // Constructs a `FakeClaimValidator` with a set of failure, informational and + // success codes to be recorded for every `Validate` call of the fake. + FakeClaimValidator(absl::Span<const FailureStatusCode> failures, + absl::Span<const InformationalStatusCode> informationals, + absl::Span<const SuccessStatusCode> successes) + : common_failures_(failures.begin(), failures.end()), + common_informationals_(informationals.begin(), informationals.end()), + common_successes_(successes.begin(), successes.end()) {} + + std::optional<Claim> Validate( + const jumbf::SuperBox& manifest, + ValidationTracker& validation_tracker) const override { + // Record failures and successes. + for (const auto& failure : common_failures_) { + validation_tracker.RecordFailure(failure, {}); + } + for (const auto& informational : common_informationals_) { + validation_tracker.RecordInformational(informational, {}); + } + for (const auto& success : common_successes_) { + validation_tracker.RecordSuccess(success, {}); + } + absl::string_view label = manifest.description.label.value_or(""); + if (auto it = results_.find(label); it != results_.end()) { + if (auto failures = claim_failures_.find(label); + failures != claim_failures_.end()) { + for (const auto& failure : failures->second) { + validation_tracker.RecordFailure(failure, {}); + } + } + if (auto informationals = claim_informationals_.find(label); + informationals != claim_informationals_.end()) { + for (const auto& informational : informationals->second) { + validation_tracker.RecordInformational(informational, {}); + } + } + if (auto successes = claim_successes_.find(label); + successes != claim_successes_.end()) { + for (const auto& success : successes->second) { + validation_tracker.RecordSuccess(success, {}); + } + } + return it->second; + } + return std::nullopt; + } + + void SetClaimResult(absl::string_view label, const Claim& claim) { + results_[label] = claim; + } + + // Specifies a list of failure codes that will be recorded when the claim with + // label provided in the parameter `label` is validated. + void SetClaimFailures(absl::string_view label, + absl::Span<const FailureStatusCode> failures) { + claim_failures_[label] = + std::vector<FailureStatusCode>(failures.begin(), failures.end()); + } + + // Specifies a list of informational codes that will be recorded when the + // claim with label provided in the parameter `label` is validated. + void SetClaimInformationals( + absl::string_view label, + absl::Span<const InformationalStatusCode> informationals) { + claim_informationals_[label] = std::vector<InformationalStatusCode>( + informationals.begin(), informationals.end()); + } + + // Specifies a list of success codes that will be recorded when the claim with + // label provided in the parameter `label` is validated. + void SetClaimSuccesses(absl::string_view label, + absl::Span<const SuccessStatusCode> failures) { + claim_successes_[label] = + std::vector<SuccessStatusCode>(failures.begin(), failures.end()); + } + + private: + absl::flat_hash_map<std::string, Claim> results_; + absl::flat_hash_map<std::string, std::vector<FailureStatusCode>> + claim_failures_; + absl::flat_hash_map<std::string, std::vector<InformationalStatusCode>> + claim_informationals_; + absl::flat_hash_map<std::string, std::vector<SuccessStatusCode>> + claim_successes_; + std::vector<FailureStatusCode> common_failures_; + std::vector<InformationalStatusCode> common_informationals_; + std::vector<SuccessStatusCode> common_successes_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_FAKE_CLAIM_VALIDATOR_H_
diff --git a/testing/fake_claim_validator_test.cc b/testing/fake_claim_validator_test.cc new file mode 100644 index 0000000..719df74 --- /dev/null +++ b/testing/fake_claim_validator_test.cc
@@ -0,0 +1,79 @@ +// 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 "testing/fake_claim_validator.h" + +#include "absl/types/span.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "proto/generator_info.pb.h" +#include "proto/manifest.pb.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" + +namespace credentio { +namespace { + +using ::credentio_testing::EqualsProto; +using ::testing::Optional; + +TEST(FakeClaimValidatorTest, TrackerIsPopulated) { + FakeClaimValidator claim_validator( + /*failures=*/{FailureStatusCode::kClaimCborInvalid}, + /*informationals=*/ + {InformationalStatusCode::kIngredientUnknownProvenance}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + TestValidationTracker t; + jumbf::SuperBox active_manifest; + claim_validator.Validate(active_manifest, t.tracker()); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimCborInvalid)); + EXPECT_THAT(t.GetInformationals(), + ContainsInformational( + InformationalStatusCode::kIngredientUnknownProvenance)); + EXPECT_THAT(t.GetSuccesses(), + ContainsSuccess(SuccessStatusCode::kClaimSignatureValidated)); +} + +TEST(FakeClaimValidatorTest, ClaimIsPopulated) { + Claim claim; + claim.mutable_claim_generator_info()->set_name("some_name"); + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + claim_validator.SetClaimResult("some_label", claim); + claim_validator.SetClaimFailures("some_label", + {FailureStatusCode::kClaimCborInvalid}); + claim_validator.SetClaimInformationals( + "some_label", {InformationalStatusCode::kIngredientUnknownProvenance}); + claim_validator.SetClaimSuccesses( + "some_label", {SuccessStatusCode::kClaimSignatureValidated}); + TestValidationTracker t; + jumbf::SuperBox active_manifest; + active_manifest.description.label = "some_label"; + EXPECT_THAT(claim_validator.Validate(active_manifest, t.tracker()), + Optional(EqualsProto(claim))); + EXPECT_THAT(t.GetFailures(), + ContainsFailure(FailureStatusCode::kClaimCborInvalid)); + EXPECT_THAT(t.GetInformationals(), + ContainsInformational( + InformationalStatusCode::kIngredientUnknownProvenance)); + EXPECT_THAT(t.GetSuccesses(), + ContainsSuccess(SuccessStatusCode::kClaimSignatureValidated)); +} + +} // namespace +} // namespace credentio
diff --git a/testing/jumbf_utils.cc b/testing/jumbf_utils.cc new file mode 100644 index 0000000..d275786 --- /dev/null +++ b/testing/jumbf_utils.cc
@@ -0,0 +1,233 @@ +// 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 "testing/jumbf_utils.h" + +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <vector> + +#include "absl/log/absl_check.h" +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/status_matchers.h" // IWYU pragma: keep +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "assertion/assertion_encoder.h" +#include "constants/labels.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "jumbf/box.h" +#include "jumbf/constants.h" +#include "jumbf/parse.h" +#include "jumbf/test_utils.h" +#include "proto/assertion.pb.h" +#include "utils/byte_readers.h" +#include "utils/byte_writers.h" +#include "uuid/uuid.h" + +namespace credentio { +namespace { + +absl::StatusOr<absl::string_view> TrimJumbfHeader(absl::string_view raw_bytes) { + ABSL_ASSIGN_OR_RETURN(auto lbox, ConsumeUint<uint32_t>(&raw_bytes)); + ABSL_RETURN_IF_ERROR(SkipBytes<uint32_t>(&raw_bytes)); + if (lbox == 1) { + auto xlbox_status = SkipBytes<uint64_t>(&raw_bytes); + ABSL_RETURN_IF_ERROR(xlbox_status); + } + return raw_bytes; +} + +std::string ComputeHashOrDie(absl::string_view algorithm, + absl::string_view contents) { + auto hasher = CreateHasher(algorithm); + ABSL_CHECK_OK(hasher) << "Unsupported algorithm: " << algorithm; + (*hasher)->Update(contents); + return (*hasher)->Digest(); +} + +} // namespace + +jumbf::SuperBox ParseSuperBoxOrDie(const std::string* bytes) { + absl::string_view bytes_view = *bytes; + auto box = jumbf::ConsumeSuperBox(&bytes_view, /*recursion_limit=*/-1); + ABSL_CHECK_OK(box); + ABSL_CHECK_EQ(bytes_view, ""); + return *box; +} + +jumbf::SuperBox CreateManifestStore( + absl::Span<const jumbf::SuperBox> manifests) { + jumbf::SuperBox box = { + .description = + { + .type_uuid = kManifestStoreUuid, + .label = kManifestStoreLabel, + }, + .raw_bytes = "b64'testing'", + }; + for (const auto& m : manifests) { + box.contents.push_back({.payload = m}); + } + return box; +} + +jumbf::SuperBox CreateStandardManifest( + absl::string_view label, absl::Span<const jumbf::SuperBox> boxes) { + jumbf::SuperBox box = { + .description = + { + .type_uuid = kStandardManifestUuid, + .label = label, + }, + .raw_bytes = "b64'testing'", + }; + for (const auto& b : boxes) { + box.contents.push_back({.payload = b}); + } + return box; +} + +jumbf::SuperBox CreateUpdateManifest(absl::string_view label, + absl::Span<const jumbf::SuperBox> boxes) { + jumbf::SuperBox box = { + .description = + { + .type_uuid = kUpdateManifestUuid, + .label = label, + }, + .raw_bytes = "b64'testing'", + }; + for (const auto& b : boxes) { + box.contents.push_back({.payload = b}); + } + return box; +} + +std::string EncodeClaimSignatureBox(absl::string_view cbor) { + return jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel, + {jumbf::EncodeCborBox(cbor)}); +} + +std::string EncodeAssertionBoxOrDie(const Assertion& assertion, + const AssertionEncoder& encoder) { + auto assertion_cbor = encoder.ToCbor(assertion); + ABSL_CHECK_OK(assertion_cbor); + std::string assertion_cbor_box = + jumbf::EncodeCborBox(std::string(*assertion_cbor)); + return jumbf::EncodeSuperBox(jumbf::kCborBoxTypeUuid, assertion.label(), + {assertion_cbor_box}, /*requestable=*/true); +} + +jumbf::SuperBox CreateAssertionStoreBox() { + return { + .description = + { + .type_uuid = kAssertionStoreUuid, + .label = kAssertionStoreLabel, + }, + .raw_bytes = "b64'testing'", + }; +} + +jumbf::SuperBox CreateAssertionStoreBox( + absl::Span<const jumbf::SuperBox> assertions) { + auto assertion_store_box = CreateAssertionStoreBox(); + for (const auto& assertion : assertions) { + assertion_store_box.contents.push_back({.payload = assertion}); + } + return assertion_store_box; +} + +jumbf::SuperBox CreateAssertionBoxFromCbor(absl::string_view label, + absl::string_view cbor) { + return { + .description = {.label = label}, + .contents = {jumbf::ContentBox{.payload = + jumbf::CborBox{.payload = cbor}}}, + .raw_bytes = "b64'testing'", + }; +} + +jumbf::SuperBox CreateAssertionBoxFromJson(absl::string_view label, + absl::string_view json) { + return { + .description = {.label = label}, + .contents = {jumbf::ContentBox{.payload = + jumbf::JsonBox{.payload = json}}}, + .raw_bytes = "b64'testing'", + }; +} + +jumbf::SuperBox CreateAssertionBoxFromEmbeddedFile( + absl::string_view label, absl::string_view media_type, + absl::string_view file_contents, + std::optional<absl::string_view> file_name) { + return { + .description = {.label = label}, + .contents = {jumbf::ContentBox{.payload = + jumbf::EmbeddedFileDescriptionBox{ + .media_type = media_type, + .file_name = file_name}}, + jumbf::ContentBox{ + .payload = + jumbf::BinaryDataBox{.payload = file_contents}}}, + .raw_bytes = "b64'testing'", + }; +} + +jumbf::SuperBox CreateActionsAssertionBoxFromCbor(absl::string_view cbor) { + return CreateAssertionBoxFromCbor(kActionsAssertionV2Label, cbor); +} + +jumbf::SuperBox CreateBmffBasedHashAssertionBoxFromCbor( + absl::string_view cbor) { + return CreateAssertionBoxFromCbor(kBmffBasedHashAssertionV3Label, cbor); +} + +jumbf::SuperBox CreateDataHashAssertionBoxFromCbor(absl::string_view cbor) { + return CreateAssertionBoxFromCbor(kDataHashAssertionLabel, cbor); +} + +std::string GetHash(absl::string_view algorithm, const jumbf::SuperBox& box) { + auto raw_bytes = TrimJumbfHeader(box.raw_bytes); + ABSL_CHECK_OK(raw_bytes); + return ComputeHashOrDie(algorithm, *raw_bytes); +} + +std::string CreateStartOfManifestStorePayload( + absl::Span<const uint8_t> prefix) { + std::vector<uint8_t> bytes; + bytes.reserve(prefix.size() + 40); + for (const auto& byte : prefix) { + bytes.push_back(byte); + } + WriteUint32NetworkOrder(10, &bytes); + WriteUint32NetworkOrder(jumbf::kSuperBoxType, &bytes); + WriteUint32NetworkOrder(10, &bytes); + WriteUint32NetworkOrder(jumbf::kDescriptionBoxType, &bytes); + WriteUint64NetworkOrder(0x6332'7061'0011'0010, &bytes); + WriteUint64NetworkOrder(0x8000'00AA'0038'9B71, &bytes); + bytes.push_back(0b0000'0011); + WriteUint32NetworkOrder('c2pa', &bytes); + bytes.push_back(0x00); + return std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size()); +} + +} // namespace credentio
diff --git a/testing/jumbf_utils.h b/testing/jumbf_utils.h new file mode 100644 index 0000000..e1912ff --- /dev/null +++ b/testing/jumbf_utils.h
@@ -0,0 +1,77 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_JUMBF_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_JUMBF_UTILS_H_ + +#include <cstdint> +#include <optional> +#include <string> + +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "assertion/assertion_encoder.h" +#include "jumbf/box.h" +#include "proto/assertion.pb.h" + +namespace credentio { + +// Parses the given bytes as a JUMBF superbox. The underlying bytes must outlive +// the returned SuperBox. +jumbf::SuperBox ParseSuperBoxOrDie(const std::string* bytes); + +// NOTE: Helpers below returning jumbf::SuperBox set `raw_bytes` to a fixed +// test value. + +jumbf::SuperBox CreateManifestStore( + absl::Span<const jumbf::SuperBox> manifests); +jumbf::SuperBox CreateStandardManifest(absl::string_view label, + absl::Span<const jumbf::SuperBox> boxes); +jumbf::SuperBox CreateUpdateManifest(absl::string_view label, + absl::Span<const jumbf::SuperBox> boxes); +jumbf::SuperBox CreateAssertionStoreBox(); +jumbf::SuperBox CreateAssertionStoreBox( + absl::Span<const jumbf::SuperBox> assertions); +jumbf::SuperBox CreateAssertionBoxFromCbor(absl::string_view label, + absl::string_view cbor); +jumbf::SuperBox CreateAssertionBoxFromEmbeddedFile( + absl::string_view label, absl::string_view media_type, + absl::string_view file_contents, + std::optional<absl::string_view> file_name = std::nullopt); +jumbf::SuperBox CreateActionsAssertionBoxFromCbor(absl::string_view cbor); +jumbf::SuperBox CreateBmffBasedHashAssertionBoxFromCbor(absl::string_view cbor); +jumbf::SuperBox CreateDataHashAssertionBoxFromCbor(absl::string_view cbor); +jumbf::SuperBox CreateAssertionBoxFromJson(absl::string_view label, + absl::string_view json); + +// Returns a serialized claim signature JUMBF box. +std::string EncodeClaimSignatureBox(absl::string_view cbor); + +// Returns a serialized assertion JUMBF box. +std::string EncodeAssertionBoxOrDie(const Assertion& assertion, + const AssertionEncoder& encoder); + +// Returns the hash of the given box per +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hashed_uris. +std::string GetHash(absl::string_view algorithm, const jumbf::SuperBox& box); + +// This creates the start of a manifest store payload, just enough to pass +// MightBeC2paManifestStore(...). +std::string CreateStartOfManifestStorePayload( + absl::Span<const uint8_t> prefix = {}); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_JUMBF_UTILS_H_
diff --git a/testing/mock_assertion_validator.h b/testing/mock_assertion_validator.h new file mode 100644 index 0000000..665835f --- /dev/null +++ b/testing/mock_assertion_validator.h
@@ -0,0 +1,40 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_MOCK_ASSERTION_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_MOCK_ASSERTION_VALIDATOR_H_ + +#include <string> + +#include "absl/container/flat_hash_set.h" +#include "assertion/validator.h" +#include "gmock/gmock.h" +#include "jumbf/uri.h" +#include "validator/tracker.h" + +namespace credentio { + +class MockAssertionValidator : public AssertionValidator { + public: + MOCK_METHOD(void, ValidateClaimAssertions, + (const jumbf::UriResolver& uri_resolver, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, ValidationTracker& validation_tracker), + (const, override)); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_MOCK_ASSERTION_VALIDATOR_H_
diff --git a/testing/mock_cose_verifier.h b/testing/mock_cose_verifier.h new file mode 100644 index 0000000..a3e4762 --- /dev/null +++ b/testing/mock_cose_verifier.h
@@ -0,0 +1,37 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_MOCK_COSE_VERIFIER_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_MOCK_COSE_VERIFIER_H_ + +#include <optional> + +#include "absl/strings/string_view.h" +#include "cose/verifier.h" +#include "gmock/gmock.h" +#include "proto/cose_verification_result.pb.h" + +namespace credentio { + +class MockCoseVerifier : public CoseVerifier { + public: + MOCK_METHOD(CoseVerificationResult, Verify, + (absl::string_view signature, absl::string_view data), + (const, override)); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_MOCK_COSE_VERIFIER_H_
diff --git a/testing/pem.cc b/testing/pem.cc new file mode 100644 index 0000000..9844e16 --- /dev/null +++ b/testing/pem.cc
@@ -0,0 +1,101 @@ +// 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 "testing/pem.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "openssl/bio.h" +#include "openssl/ec.h" +#include "openssl/ec_key.h" +#include "openssl/evp.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/pem.h" +#include "openssl/x509.h" +#include "proto/common.pb.h" +#include "tink/cleartext_keyset_handle.h" +#include "tink/keyset_handle.h" +#include "tink/public_key_sign.h" +#include "tink/signature/config_v0.h" +#include "tink/signature/signature_pem_keyset_reader.h" + +namespace credentio { + +using ::crypto::tink::PemKeyParams; +using ::crypto::tink::PublicKeySign; +using ::crypto::tink::SignaturePemKeysetReaderBuilder; + +absl::StatusOr<std::unique_ptr<crypto::tink::PublicKeySign>> LoadTestKeyFromPem( + absl::string_view pem, const PemKeyParams& key_params) { + crypto::tink::SignaturePemKeysetReaderBuilder builder( + SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_SIGN); + builder.Add({ + .serialized_key = std::string(pem), + .parameters = key_params, + }); + ABSL_ASSIGN_OR_RETURN(auto reader, builder.Build()); + ABSL_ASSIGN_OR_RETURN(auto handle, crypto::tink::CleartextKeysetHandle::Read( + std::move(reader))); + return handle->GetPrimitive<crypto::tink::PublicKeySign>( + crypto::tink::ConfigSignatureV0()); +} + +absl::StatusOr<std::string> LoadOcspResponseFromPem(absl::string_view pem) { + bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(pem.data(), pem.size())); + if (bio == nullptr) { + return absl::InternalError("Failed to create BIO."); + } + + std::string response; + bool found = false; + while (true) { + char* name = nullptr; + char* header = nullptr; + unsigned char* data = nullptr; + int64_t len = 0; + if (!PEM_read_bio(bio.get(), &name, &header, &data, &len)) { + break; + } + bssl::UniquePtr<char> name_ptr(name); + bssl::UniquePtr<char> header_ptr(header); + bssl::UniquePtr<unsigned char> data_ptr(data); + + if (absl::string_view(name) == "OCSP RESPONSE") { + if (found) { + return absl::InvalidArgumentError( + "Multiple OCSP RESPONSE blocks found; expected only one."); + } + response = std::string(reinterpret_cast<char*>(data), len); + found = true; + } + } + + if (!found) { + return absl::InvalidArgumentError("No OCSP RESPONSE block found."); + } + + return response; +} + +} // namespace credentio
diff --git a/testing/pem.h b/testing/pem.h new file mode 100644 index 0000000..234fe7d --- /dev/null +++ b/testing/pem.h
@@ -0,0 +1,40 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_PEM_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_PEM_H_ + +#include <memory> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "tink/public_key_sign.h" +#include "tink/signature/signature_pem_keyset_reader.h" + +namespace credentio { + +// Loads a TEST private key from an unencrypted PEM string, with explicit key +// parameters. +absl::StatusOr<std::unique_ptr<crypto::tink::PublicKeySign>> LoadTestKeyFromPem( + absl::string_view pem, const crypto::tink::PemKeyParams& key_params); + +// Returns a single DER-encoded OCSP response loaded from a PEM string with tag +// "OCSP RESPONSE". Returns an error if multiple blocks are present. +absl::StatusOr<std::string> LoadOcspResponseFromPem(absl::string_view pem); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_PEM_H_
diff --git a/testing/pem_test.cc b/testing/pem_test.cc new file mode 100644 index 0000000..7eaa0c9 --- /dev/null +++ b/testing/pem_test.cc
@@ -0,0 +1,346 @@ +// 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 "testing/pem.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "tink/signature/signature_pem_keyset_reader.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::crypto::tink::PemAlgorithm; +using ::crypto::tink::PemKeyType; +using ::google::crypto::tink::HashType; +using ::testing::HasSubstr; + +constexpr absl::string_view kPemKeyP224 = R"(-----BEGIN PRIVATE KEY----- +MHgCAQAwEAYHKoZIzj0CAQYFK4EEACEEYTBfAgEBBBxaHY9SBldD/sUFJQyS4bOU +2Hf8wXBCEzfEdZ6DoTwDOgAEF3QCwJRkgQxaYZ0cqZTb0FAYQ2alOv3lWlmlNLS1 +80rBbqgzt26PS8ysBhJy8zpTdmkKxdv2SpQ= +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyP256 = R"(-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYUrTw5gDocgm/uqt +9Y0uHuYZ+qUORlv4tsVvsC+RafyhRANCAARhNXddv5HV8eddKtCGlJ2rFUjyDZWK +iVqaBIXOc3nsxuyQuprla45ifAN/nO9vsAWP6OjOVYcgS/MLvjpQHOrm +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyP384 = R"(-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDcgueympOc5A87sQYu +QQqtX50rK0OMz9aowmNXDnEXm4U62eevBJVSUATFyxkZ0qChZANiAASe9I/03xH0 +SB3lclDxJpnHk8tsq1aqZbBl0G5giE3h/KpRd1303+spJAv1UZZhbduuWzycEo1s +EtjDR7qk4+cSrE2ii9ltavHCQJQUsfYkmcesZJh9KpHi8LIhyY50e40= +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyP521 = R"(-----BEGIN PRIVATE KEY----- +MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIAr8855XdpHWs9YBRm +oiy+QZeG5KB14Kl2Rr4DRLgt3258xfm8fuDRHluuJEHZQibrn86u50KkMNVT1qgD +zlqLkwChgYkDgYYABAG/tAXbStwpjnpW6Mla6JboL4VPNfAM+YlgtwTj63j5TN0N +n6krb6JG1yM3fxzEf0BL58wbV/quMxozEQfGxtmyzQEYlJmaWOaXangUvVuY3QAf +v9PLp4egCYvH2iooca4rv64wEhVErXSF8Zre976ITX5p4LUX4Q3DECdnbom2yU2q +qQ== +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyEd25519 = R"(-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIKdWv26hzGxk0up0J6859Q+ELEu/FBMg/PAn7dlxACK8 +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyRsa1024 = R"(-----BEGIN PRIVATE KEY----- +MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBALFa2Jbkosxnt8JT +bry40Mk0kUY/VdFI4Y2RzyzwT6Zx8xY4BqAF8fpjmYjwqezvnA3VwgLDso0TuyVE +OLWcF3rdrxnHSLnuao4CrizKgJJyVOJRpkBtwpGoz4tMzl2lyLv7TI0wL5t5oH8A +7iggeJC0ElTOITVBwcpAlvsZ9pbzAgMBAAECgYABK6DPSh367FpNmYBbFXsnQMYO +qR9XXLxEU2iDGujjp8/bXy5IAxIIPX0HZtMwQZqNitIXc5QrkyxCgDNBm5CaF6bR +CVJW/TlJ4W+l1g5cG5SnahNfVBlEDf0hlIkf3sFZ5XwL77tq7Ft6JQ7caqWTgaw7 +plfL1GpAywUuuStCkQJBAOggRSVjxeLWAPaX3J/PuqxrtjgyjtkkEK8upoz41k+5 +24SCSbBtDcMBLfR2GrcpVKWi/S5rMONVXc9giVQqa0MCQQDDmHxbPBNg8k53zCne +N/8rkGOaUWt05XEjiz+CJ5BJM3w7og+Ky+6fmM3rnVuJ3mKtz+2goX55AqUagz+M +ZHKRAkACrmS+EtH1Vc17C7QDsI2SckgPjB30SiNbMqm/1Rv8ncP4WtzpCclv6DKU +sUyLsxGEPEa6Y4iUB6dmvtG3RCorAkALfw7wHdvVuug5ylo7oXbyDI6OJ3L1b/by +a+DTuZeVdd07dA8NzWa9IrIXwyv6MoQCS1JgOp3AZxNlN4moqs7BAkBdYa/HFdWv +rbAP9L4Sm0BdSH3ZC6Vvn/u3uXAe7teLoAE4lZsbePmOBAGZW2vmlMEK3cnLk/mt +nBJ6QgwSnpno +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyRsa2048 = R"(-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4KLDzI35zPSEW +Tbt2ZdmlKbvghqmJHWchoiptOPzfkCGdn4gzX5VZZHS+knS5fY/iI6a+o1qXdPkp +40/p+yPyyQHbFpfid+wiRgCClZHYtPik6XI8OgVunBGQGBF9EACgEwqIB1Hw4yop +VS9EZigeEXHM7GGuwhG/BpAsRMBbdxKjwtnR0iz8KDeb0Mz6UNi90CtxkBS3wqBv +en6/nQZYJ2WfNt+uArQw8x8uSnuBHqhjTbOi4BLbi+OgfPRfXR0u6kWQWYOqu43D +Kbv3s1N0mjqITFavoYvNLRe6krbbA7s08H1OZ0vZXIdy00loYqwcUNLWzSY1P3pj +D5+OjifnAgMBAAECggEAJmCoe4rei7JmBkldsy9He4Fme0Rk6UOc3ef3EnrpS8e0 +Ea6Qyi/1pkLmh15q/8rYZMaMNbdCJkUEtcN+wGKLW8mi3t7m+RWHup6+9XepBLG2 +DVvu5/kpb6MLj+1MG7Vtg+5j5/QqkdotS57KcmUdoCJ/hjaOpsYfv6DlA6yyL30W +iepLSeEJrwmIeSVZZ2U21jfBPfl8bWR21KhSpu2H6uCbbn3mLNJR2Vd0RFvuNxYe +HvbaGgRqmhnrv9XzW31UrF4yfVVMeRoeerzjiFHSUzLCAq002L8C9udeyVNqab6U +6kSwBVAKpowmhQ7yWkzPN9kmAlsOuhxSX0xr1gTZMQKBgQDwOlH26CaVkHuUknVu +Le3PmHSG8zVCTGBXeovje+c9OYPXC4KbtcbdRGCtMUTkglCHuZ381N1K7uXC7eJ4 +OaCFMrN+ChQnWcoN3QqXGDuzFwPjoIESVHbiZ2X5VkwpYlQ4qnV2JvqghmKu87Wz +wDBfLmwE0/gTCMy+s2Cra9isMQKBgQDEP/tT6zXng3TwI0YynRCQ3+FuO+SE7tIj +JbOoyKaFdF2KkFe9mN9QiLJkkiqW8rpGBMZT6/bBF394b1EujBYYNgriCadP/mHo +U3M8NKTfViwfAHnlzS5vSrk5w5iJHqgrJgcu0R/pf0qJ3qfhrd96gyz7dNylYp5E +XgcfzS9HlwKBgQCbEQpXlwNRCDK9dMzYkk0v8/lEIzn90c9eqHwkG5bornesGIH2 +gSAdNEYzhdoypuvUsLyKt90gwZUOfv/xG0HCuTej5F2eCRekW0q6vanLsM+R38lx +S5RyEMyCZnJDRHWZHm80FXUyqYyJ4kcoAKc4yX5wn2NC5itD7gY4oST8AQKBgGvk +OQoz39i93sY+vlMJIphjXQ47gzDKE2EbUyBR36qzBNIu9O3ZY7xkyp6b9Sozm9Du +wxJUQb56aqwNlvYeMe9xlp4abI9/7NfLOSk6UAIw3+TTYDDEkujAjgCB1hRcdFv0 +djJ46jB2uMX/URCzhI9YGpzL7JpIE3bGsuf66ClVAoGAfIfimDz5JdUyqDNN3Sqi +MUBPmZywDxymF9gl6iSB2scenRYmFz9b0mGrs1+45lPDKxlTmd4E/YrTwDh1VSCs +cQLS9rLBwTyXY+j17DUfYfRUczaF0aZrrpjcT9jkF0AKVTBRaw3+jtd3GV6OtopW +uPwS26qT7r9KF5Df73qDGkI= +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyRsa3072 = R"(-----BEGIN PRIVATE KEY----- +MIIG/gIBADANBgkqhkiG9w0BAQEFAASCBugwggbkAgEAAoIBgQCIlb4z+JN2A114 +bbXyS0R/EtvnmQUtf0nfU2aGB2Ej3SJfNIIAAMophWHeI3tqRYntboU+jKn31/JO +O17NhGBbzc4F5T8wQ6JbIxf3XJGqQLbTQiSjpdRQife6gt3fXrgCqUdYpCz+HAAB +0RbjS0as04sveDrTDZ4EwjZHFYsc/fk4cR6jKYk98hqe39fNeQjkA4/MlDOR7yBa +IomPXh7QlnjEHNfsXWW5pQQTyyY6wOSqCdzMTd7sk6R5Vr/iGtVENxypdtTpA3ld +qC2zNolb/0+5eAw96nGpXuvUFGTnFChSpG3NQ/0GvBz5KcrC+/s6ImOkRF052leK +FYxxGacS8VeJ0O0xvQwKBjH6Z0KS+OR2vZOeOMSWbea/EP6SGzr7Ntlwjx9oyZG7 +Wf7EUpSSbknF36j+cn2YBPl8MWEjQZQ7OQnfcryWNzri4/kbkpgk4mwgB7MkgTI4 +qO6FoJsMz55F++U88uq0MDz7Qec8WKinryhEQUZCxnSa4MQznI8CAwEAAQKCAYAf +/Ron5975c75Pdzp2vcQUEuGn80GwLzCo+kp3t4C7uo3TSGPuBkFSyidqtFqyQAkW +r5zLUnx4TH42blc8wPUkayYGYXOUJTMAL07soySGgFORVNtp7Gu2VSnM59t49iEY +lnbcPs8+0GX8BvFaWIX78iuV+Y2lYnoQ2T+HFHeZWa4l1vB6/D5lYK91aC2NtPwt +Etx56HfPhPbJxW78QenrpmIwZCT5+cHaYdh7nkec2RJ09Ixe2RoNxGJv/K5CEVBr +64m8p9PZ57c0P+n96ovxYpiyXj6jhmxLAktjoA+giiq6C+mtdMQLaDrSR6XLjG8L +F6aYxjGHCCQdzEbDuBKfraovXJeQNNBh19tu93N8VINh2ANQXwJHSpktCvefjTDy +vxt5xSOnbhYVD1bBPDyd1i2rRl25VmHjHHFkk7d9NvTecXCbdZlfyFA+BUKjc8zN +j6AyyJFo8+SIXl707/d3mB86JKDeJfvD5iaBzlXwr5CsHthRUZ4lrrMVt978Y8EC +gcEAu6ew8PA+jjOVO7ddFsUCueL1XQ0btx/lqOiQ+1mnjns6iisvSQIvSGHMir7o +h1c0MEbDM2/iJbk8rU8O3o6yz65oqLNt2mH8xKKEqBslBeUqZeiVVXB85PXZ0Pvt +ZCuCEDjDO3OWvccDL/n3AAy0ELwlBJCHSmiBOVCtH0GV3Z2Gp7Jf1ZwHtJwMDc9z +T7JV6pABq7kgWQIorBF9S2Q9a4gMNFoGdp+bgGR5bPIPXYytv1/60chOTFmswFF1 +ST8vAoHBALpUc3CU2xM30FGn/faMjDA4XyX6zyPJMu5KHdZrmG0h1tAm20GNSGDl +4IJkAslO1uQei+b8GqfkAjqnF4yob6wD1xhgC9ylFhChhEdCDPnnrvOr0+5P6paV +x9pqc1MMpsGHUI0gg4E8AY7FQYPggcET+MymZOgGQ0MyHQMalxo8U82hEMwKfxYd +m/i7DC5Fwq4yoWMIIuw7Nsb346d/qD2huLEuq73sAOXxDO0EAoRj6pFSWxgCQlgd +Nf4JM1MgoQKBwQCXtt7GHOXmX+viSL/5HbSzhruqzGNA7YfmxIgavoRKHHTaqklj +dGG32mHdNrWWXjlyYnOEzYr/xsA4ufcilioY4D/7TH2fFqOecmpf4vpOxtU9aQn6 +9qPzFJVScaue1dIkvU+SbEROEG0SR7ybjKmz8fjXtOW8kbsQFJqy73pzntz0DbGP +aMOOEK9qJvNqVptBtcW0CUsOy0EIxGcRIezKWEbNuI8LLsUwy4qkOxuTHZ6HX9jP +7GrpLYMORY1Wd78CgcEAmMUHBSY23HHljh2jftLLWfnOIVCbJucep/sCrbv39HpL +KszA9eUFk9TGF4IyOqU2UUCuSgsnO8IZBcfb6gprzOg1IhoVf6mnBqm2z8na48bO +TaDzGTBS0XYqruyac1wrGP99zsf6qintyQX36k1XdVuPoRNIJmiEG4LqAJJ7kxol +R7IoGHL+sb5DFFMf8IxnyvVeFtkj8PW3urih4cIryaRfzn3Kd+QY9G6Hv4iLG8rk +FpQkdfHcPWxtyUP33c8hAoHAbS7AWBzBcjj2t4fDdT4WlNl9i0BWRkGRMaRzEZx3 +E/S4aCbLw+HMf+mE2YrTAdEqWw2ax8UBXScb3pgpYoNYsQQ6+2FQJNQF/ijHQP4l +sAsuTISiAHYwhZoDG67uwpsdmVNhXJOp2Et+1xDuzVek1iaW+mtjJhwj208C9Lmj +YDghz5pVeMk3WBJFGiXQn1QhjZzTVog6rSoLj0kEgT9whx9C65ZxbEc1GKSAYun/ +dVMvgoGxsF6YKmXFRRLcNcRS +-----END PRIVATE KEY-----)"; + +constexpr absl::string_view kPemKeyRsa4096 = R"(-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQC2NVmEcVNKJcLB +K9A/JNzQDqIJoxbWmF1gi6QK9bD208oE5PzGeCp/MCpr1rlE+WNfMfnEHUkWB7R2 +Hp+ZsHlXYpfpgXQFNCa6cxj2R2D4eZrot9neoaqq4TWKQpqLc+rL7uSqY3lLspKl +F7I11vHP1JFOh+PTtmSrFq46fALZBzl08VznEk4nq3XXr+H0T4++KukXg+yifCrU +/kZjtm99ibGvxqi+bADGTvgjfa9eissC5U3Um/Dpwbgjw09hEQ1KMi8mhL2EC0K8 +5mN43Un+M8DkFU/kTJofkUH42FSJPrjlMjaLtU47TgIYmSvqJNj4V157Bl4gDmWW +1BOc4sZE1qC4WmKxk7exooSC0XwQnEFcMkHhvJjuemTKh3qPWuu8J3wu7+wR+Dmf +QRO+9zk3cbjkTaDNNkCtYrVQrQiVZ8tcmrmOxac7dKEKpQTzCutjv7bmqR2XYSTR +nUyJXSEFgsufaHB5YPBbPoupO5YbBPmf4iJthvbgwm+KX8sfECLkbDMaVjs2ftpE +m3m+uStsFSX2IWKmkq8dZPtjqZRz1G3FukTzqeSxvqE1En6EpwjU06T2u5bRXC/b +eURu6AxnIToOkPKTELqRNX2Y9Dovbqh2oPYSd0QW/IL4liaOHL9bZ+t1PVXP3OlS +jKxfelege/2GdFgIkwoSL7BYhpwSsQIDAQABAoICAAeB4LeWhMBCGIllgP3NCsUO +yUnc3KDIY9aJwl9KOrmcxc7YUXrq8hNN/H13eig3n0rJkBkuvLdp51tTjS9jqEg7 +sXcnRIy/VpYpjl59V+c8+W87vgAQiKvkPhS9hEkZJFQ1Oloz4YhHKggf1xGlUs7F +mxwReMddFT5AX7rcp7Bh/hHNrQ5/QFWdmKwjnMt4aLuE4Jg9sQcL1ybx+Bv9JRBW +10mfBY5c/FYvZEVwFQTv8ofiw0duimlTOD9O+YsODQ5gzXUIlJdncF9AndoZwgX1 +g23SW/mT12jUDxQN88O44BzfagRtGrVSVD9Htlm+HWt0DBpFC6VF+pURlWJKLLGR +Gd7GrYf+2S0Lxbg7XAJBT4z8Zd5iaKttmW4zguTUYW/Wf8B+kvZ2hb+S+qQ/5yhv +HHJ9kZjXN6XQLGH4ML20GoXk/TkL7B+fUlhFIbixKI0i/8n4OBo8lTycMtMQyflL ++vYYC/ly4fLuN9PjfT/9DxmEl5fokw6eNq0aT01ijOJ2pFzg6aBcEnFUwKAY5Moi +jQ1/7ab7C0f6w3fOh+Dz9PV3Q1yq4G17MvtbpriWMdTlzjtlKiIK0B6EO5BDBvuz +gGHgWBFCTi2lYX6vygiHZxxdOFPQCRlvNWrUbrqcH0L+g6mXdwFaAq7zWHu50W0T +BDPKflh/02rwnvdjsM59AoIBAQDdJICtBjj9lxbohJToMtue9GE2bjjs/mlfBOld +Z/CjkjG1qAp3mxJ0SS0sBoU54DF4Cu34THL/TzzdomK9Gd21+Ed5zrC5yHCaVSRY +PbJFsQo5KujFso5zYt2ZieDvYzUyYHiocOz4LQ80skOzEQ/ti0B8ebYT4B9C9oTH +zBp+AvyRswBodRosHxbddWaOVweiPdYJzKe6CRWQP5ceeIEqAhiUKM7P1kOZhlQN +0wISoMkE4/jlUgc4IQ1hp62gJ7OYVnrsKCvoEe3Wsdk0PBqcUfKgtjDLKDlCK9KC +p4kQtVIQoOKFNDT1C2Npy2x1KS0B2NRsPVMXdmRgX1lxDUx9AoIBAQDS7ch3FE6l +bBqNv120K/jgNrJR2nVsaFv7ptA5KV1kfNvprb8jikf46OxQJIzDBWt8sGTUB4td +jdG2hTpwU8p1Dlw1UDYtsy1opp7LcKQFILleo/sLHH4FM3D/tq/0exX6KwIPLbIb +sf0q74wi/jUh5hr58ub6l/tgBEMNlp4xuo6FHs38nEyPhURVwhbYGTH0v+AZj/vj +gLLPN1MY05zJelrcIVFE/RINdFKzppTQbSgqUv9dGprimkfoiJXmAbC3N65nq3Z1 +Vs0slS6HEHedlr6/1ZnSg33x0OZVVVkzleNYUb6gR0Kv7ZSwQ7Tq2742o5BtlMvL +FGBrtTSWW1lFAoIBACKahqQMeDh+PvBtZpua8bDCzr9kqfWzratXqmiaThZaVvr0 +64ZhtpaJqM7dylUPFs6Q70HOPKRkssJ/mXG7rWy/B/gwdojrq2sK7Z/9L3smQ9JE +O55L3WyCyOgwogjzfix+2gKJgRsoedumFV/cyKBEDPgtV2dux5WD260j6/VR5FGs +L+EoicrjpoFK0JxvU9t4T5jC9G/w4fXrbWswMg3vvAbbB9mPzo4kbdyn6wgdLrdc +MOM5XbXTvjNMuKd57ulkhuGTO6OZwpy4waMh0IwraorpV2NCzQrkrpERRCpgJRJD +Co8LZ0n82TaBxVvj8I38bujtA6aFORm3cdh120UCggEAcm0eoEL6+1CvAxC6o8Br +Di9+A1uw8fOXWui+8xOWn6oAnz9rfrtImia59Cwv1Kh/DDChMkk5eLIz/4E9F6zN +Gz2EFkQCcD5QtwIVCSZ9RG7hQQ9SZE+7qdSjS4Xu5IFVhdaJEnTmt5GSVxWG/4DK +kC5Fmi0E0xNaAletjJSWE0IyiNMSSiT8f6t7S860F91rNWxWBWgP7PZninRn9wLt +fnuLCPpmHTfw8C2Xo/ANsRhWfsdx0y9YsO+jbOmT8CgglvR0/7aL19IUEkiH6iur +yGj76BPXk9VToLiEC+z2LD73C3WlJUwjSE9pp0nDvtkg0oFfG6Np/yx5tlSiR6Mc +xQKCAQAJn4pRIayA2JIOob4OblB7rPijcKdvbYgeCx6op/v2mXOdziPzRrOVUvYZ +WStKzZzYxwat0DjCc96xThMVSaBGl8m16l6lFl2wDJI6ir7nLq+uQcMHrSi24zuS +6gIjbZCoOGqeQXAbAGsFVsme0GdVZGt96sooTOycy8JQ3x7JkQdv1VASQdothWHS +sRPe6OLdCHZBpB9glp4TLXZ1/U4JEQhueiiTxRLAOSITpAKt1kgRmJnjX1tSOq2k +D0478tD3di3bxvGxzM/I5XHRiIikPzsemA4nGDhkSqumceRtdJPCiH68puUiBorf +C4Yc1VEuRRkvHG4duH9NL6soWRHT +-----END PRIVATE KEY-----)"; + +TEST(LoadTestKeyFromPemTest, FailsOnInvalidPem) { + EXPECT_THAT( + LoadTestKeyFromPem("not pem data", {.key_type = PemKeyType::PEM_EC, + .algorithm = PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = 256, + .hash_type = HashType::SHA256}), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(LoadTestKeyFromPemTest, FailsOnP224) { + // P224 is not supported by Tink. + EXPECT_THAT( + LoadTestKeyFromPem(kPemKeyP224, {.key_type = PemKeyType::PEM_EC, + .algorithm = PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = 224, + .hash_type = HashType::SHA256}), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(LoadTestKeyFromPemTest, P256) { + auto signer = + LoadTestKeyFromPem(kPemKeyP256, {.key_type = PemKeyType::PEM_EC, + .algorithm = PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = 256, + .hash_type = HashType::SHA256}); + ABSL_ASSERT_OK(signer); + EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); +} + +TEST(LoadTestKeyFromPemTest, P384) { + auto signer = + LoadTestKeyFromPem(kPemKeyP384, {.key_type = PemKeyType::PEM_EC, + .algorithm = PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = 384, + .hash_type = HashType::SHA384}); + ABSL_ASSERT_OK(signer); + EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); +} + +TEST(LoadTestKeyFromPemTest, P521) { + auto signer = + LoadTestKeyFromPem(kPemKeyP521, {.key_type = PemKeyType::PEM_EC, + .algorithm = PemAlgorithm::ECDSA_IEEE, + .key_size_in_bits = 521, + .hash_type = HashType::SHA512}); + ABSL_ASSERT_OK(signer); + EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); +} + +TEST(LoadTestKeyFromPemTest, FailsOnRSA1024) { + // RSA1024 not supported by Tink. + EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa1024, + { + .key_type = PemKeyType::PEM_RSA, + .algorithm = PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = 1024, + .hash_type = HashType::SHA256, + }), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(LoadTestKeyFromPemTest, RSA2048) { + auto signer = LoadTestKeyFromPem(kPemKeyRsa2048, + { + .key_type = PemKeyType::PEM_RSA, + .algorithm = PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = 2048, + .hash_type = HashType::SHA256, + }); + ABSL_ASSERT_OK(signer); + EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); +} + +TEST(LoadTestKeyFromPemTest, RSA3072) { + auto signer = LoadTestKeyFromPem(kPemKeyRsa3072, + { + .key_type = PemKeyType::PEM_RSA, + .algorithm = PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = 3072, + .hash_type = HashType::SHA256, + }); + ABSL_ASSERT_OK(signer); + EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); +} + +TEST(LoadTestKeyFromPemTest, RSA4096) { + auto signer = LoadTestKeyFromPem(kPemKeyRsa4096, + { + .key_type = PemKeyType::PEM_RSA, + .algorithm = PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = 4096, + .hash_type = HashType::SHA256, + }); + ABSL_ASSERT_OK(signer); + EXPECT_THAT((*signer)->Sign("data to sign"), IsOk()); +} + +TEST(LoadTestKeyFromPemTest, WrongKeySize_Fails) { + EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa4096, + { + .key_type = PemKeyType::PEM_RSA, + .algorithm = PemAlgorithm::RSASSA_PSS, + .key_size_in_bits = 3072, + .hash_type = HashType::SHA256, + }), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(LoadOcspResponseFromPemTest, FailsOnInvalidPem) { + EXPECT_THAT(LoadOcspResponseFromPem("not pem data"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(LoadOcspResponseFromPemTest, Succeeds) { + constexpr absl::string_view kPemOcsp = R"(-----BEGIN OCSP RESPONSE----- +ZmFrZSBvY3NwIHJlc3BvbnNl +-----END OCSP RESPONSE-----)"; + auto der = LoadOcspResponseFromPem(kPemOcsp); + ABSL_ASSERT_OK(der); + EXPECT_EQ(*der, "fake ocsp response"); +} + +TEST(LoadOcspResponseFromPemTest, FailsOnMultipleBlocks) { + constexpr absl::string_view kPemOcspMultiple = + R"(-----BEGIN OCSP RESPONSE----- +ZmFrZSBvY3NwIHJlc3BvbnNlIDE= +-----END OCSP RESPONSE----- +-----BEGIN OCSP RESPONSE----- +ZmFrZSBvY3NwIHJlc3BvbnNlIDI= +-----END OCSP RESPONSE-----)"; + EXPECT_THAT(LoadOcspResponseFromPem(kPemOcspMultiple), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("Multiple OCSP RESPONSE blocks found"))); +} + +} // namespace +} // namespace credentio
diff --git a/testing/proto/BUILD b/testing/proto/BUILD new file mode 100644 index 0000000..83d707b --- /dev/null +++ b/testing/proto/BUILD
@@ -0,0 +1,31 @@ +# 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. +# + +load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library") +load("@protobuf//bazel:proto_library.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "test_message_proto", + srcs = ["test_message.proto"], + deps = [ + ], +) + +cc_proto_library( + name = "test_message_cc_proto", + deps = [":test_message_proto"], +)
diff --git a/testing/proto/test_message.proto b/testing/proto/test_message.proto new file mode 100644 index 0000000..78a3bb4 --- /dev/null +++ b/testing/proto/test_message.proto
@@ -0,0 +1,155 @@ +// 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. +// + +edition = "2024"; + +package credentio_testing; + +message Message { + message NestedMessage { + message NestedNestedMessage { + string name = 1; + uint64 version = 2; + } + + string required_name = 1; + + uint64 version = 2; + NestedNestedMessage nested_nested_message = 3; + } + + NestedMessage required_nested_message = 1; + + uint64 required_uint64 = 2; + + repeated NestedMessage repeated_nested_message = 3; + + repeated NestedMessage required_repeated_nested_message = 4; + + repeated NestedMessage required_non_empty_repeated_nested_message = 5; +} + +message MessageContainingMessage { + Message msg = 1; +} + +message Message2 { + message NestedMessage2 { + string name = 1; + uint64 version = 2; + } + + NestedMessage2 nested_message = 1; +} + +message MessageWithWriteOnlyField { + string name = 1; + uint64 version = 2; + bytes pad = 3; +} + +message MessageOfBools { + bool required_bool = 1; + + bool optional_bool = 2; + + repeated bool repeated_bools = 3; + + repeated bool required_repeated_bools = 4; + + repeated bool required_non_empty_repeated_bools = 5; +} + +message MessageOfFloats { + float required_float = 1; + + float optional_float = 2; + + repeated float repeated_floats = 3; + + repeated float required_repeated_floats = 4; + + repeated float required_non_empty_repeated_floats = 5; +} + +message MessageOfDoubles { + double required_double = 1; + + double optional_double = 2; + + repeated double repeated_doubles = 3; + + repeated double required_repeated_doubles = 4; + + repeated double required_non_empty_repeated_doubles = 5; +} + +message MessageOfInt64s { + int64 required_int64 = 1; + + int64 optional_int64 = 2; + + repeated int64 repeated_int64s = 3; + + repeated int64 required_repeated_int64s = 4; + + repeated int64 required_non_empty_repeated_int64s = 5; +} + +message MessageOfStrings { + string required_string = 1; + + string optional_string = 2; + + repeated string repeated_strings = 3; + + repeated string required_repeated_strings = 5; + + repeated string required_non_empty_repeated_strings = 6; +} + +message MessageOfUint64s { + uint64 required_uint64 = 1; + + uint64 optional_uint64 = 2; + + repeated uint64 repeated_uint64s = 3; + + repeated uint64 required_repeated_uint64s = 4; + + repeated uint64 required_non_empty_repeated_uint64s = 5; +} + +message MessageOfByteStrings { + bytes required_byte_string = 1; + + bytes optional_byte_string = 2; + + repeated bytes repeated_byte_strings = 3; + + repeated bytes required_repeated_byte_strings = 4; + + repeated bytes required_non_empty_repeated_byte_strings = 5; +} + +// Meant to fail conversion to CBOR. +message MessageOfInt32 { + int32 required_int32 = 1; +} + +// Meant to fail conversion to CBOR. +message MessageOfRepeatedInt32 { + repeated int32 repeated_int32s = 1; +}
diff --git a/testing/proto_test_utils.h b/testing/proto_test_utils.h new file mode 100644 index 0000000..bf8ba88 --- /dev/null +++ b/testing/proto_test_utils.h
@@ -0,0 +1,46 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_ + +#include "absl/log/check.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "google/protobuf/text_format.h" +#include "google/protobuf/util/message_differencer.h" + +namespace credentio_testing { + +MATCHER_P(EqualsProto, expected, "") { + return google::protobuf::util::MessageDifferencer::Equals(arg, expected); +} + +MATCHER_P(PartiallyEqualsProto, expected, "") { + google::protobuf::util::MessageDifferencer differencer; + differencer.set_scope(google::protobuf::util::MessageDifferencer::PARTIAL); + return differencer.Compare(expected, arg); +} + +template <typename T> +T ParseTextProtoOrDie(absl::string_view text_proto) { + T proto; + CHECK(google::protobuf::TextFormat::ParseFromString(text_proto, &proto)); + return proto; +} + +} // namespace credentio_testing + +#endif // THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_
diff --git a/testing/test_file_utils.cc b/testing/test_file_utils.cc new file mode 100644 index 0000000..4168b58 --- /dev/null +++ b/testing/test_file_utils.cc
@@ -0,0 +1,72 @@ +// 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 "testing/test_file_utils.h" + +#include <fstream> +#include <memory> +#include <sstream> +#include <string> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/cfile_reader.h" +#include "riegeli/bytes/reader.h" +#include "tools/cpp/runfiles/runfiles.h" + +namespace credentio_testing { +namespace { + +std::string GetAbsoluteFilePath(absl::string_view file_path) { + std::string error; + std::unique_ptr<bazel::tools::cpp::runfiles::Runfiles> runfiles( + bazel::tools::cpp::runfiles::Runfiles::CreateForTest(&error)); + + if (runfiles != nullptr) { + return runfiles->Rlocation(std::string(file_path)); + } + return std::string(file_path); +} + +} // namespace + +absl::StatusOr<std::string> GetContents(absl::string_view file_path) { + std::string actual_path = GetAbsoluteFilePath(file_path); + + std::ifstream f(actual_path, std::ios::binary); + if (!f.is_open()) { + return absl::NotFoundError("Failed to open file: " + actual_path); + } + + std::stringstream buffer; + buffer << f.rdbuf(); + return buffer.str(); +} + +absl::StatusOr<std::unique_ptr<riegeli::Reader>> GetFileReader( + absl::string_view file_path) { + std::string actual_path = GetAbsoluteFilePath(file_path); + + std::unique_ptr<riegeli::CFileReader<>> reader = + std::make_unique<riegeli::CFileReader<>>(actual_path); + if (!reader->ok()) { + return reader->status(); + } + return std::move(reader); +} + +} // namespace credentio_testing
diff --git a/testing/test_file_utils.h b/testing/test_file_utils.h new file mode 100644 index 0000000..89c55e8 --- /dev/null +++ b/testing/test_file_utils.h
@@ -0,0 +1,37 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_TEST_FILE_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_TEST_FILE_UTILS_H_ + +#include <memory> +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/reader.h" + +namespace credentio_testing { + +// Reads file into a buffer or returns error. +absl::StatusOr<std::string> GetContents(absl::string_view file_path); + +// Creates a file reader for the given file path or returns error. +absl::StatusOr<std::unique_ptr<riegeli::Reader>> GetFileReader( + absl::string_view file_path); + +} // namespace credentio_testing + +#endif // THIRD_PARTY_CREDENTIO_TESTING_TEST_FILE_UTILS_H_
diff --git a/testing/test_status_tracker.h b/testing/test_status_tracker.h new file mode 100644 index 0000000..8a1a997 --- /dev/null +++ b/testing/test_status_tracker.h
@@ -0,0 +1,51 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_TEST_STATUS_TRACKER_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_TEST_STATUS_TRACKER_H_ + +#include "constants/status_codes.h" +#include "testing/test_validation_tracker.h" +#include "utils/status_tracker.h" +#include "validator/tracker.h" + +namespace credentio { + +class TestStatusTracker : public StatusTracker { + public: + void RecordSuccess(SuccessStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.tracker().RecordSuccess(code, options); + } + void RecordFailure(FailureStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.tracker().RecordFailure(code, options); + } + void RecordInformational(InformationalStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.tracker().RecordInformational(code, options); + } + + const ValidationStatusSet& GetStatusSet() const { + return tracker_.GetStatusSet(); + } + + private: + TestValidationTracker tracker_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_TEST_STATUS_TRACKER_H_
diff --git a/testing/test_string_utils.h b/testing/test_string_utils.h new file mode 100644 index 0000000..3f8fa8b --- /dev/null +++ b/testing/test_string_utils.h
@@ -0,0 +1,56 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_TEST_STRING_UTILS_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_TEST_STRING_UTILS_H_ + +#include <cstdint> +#include <string> + +#include "riegeli/endian/endian_writing.h" + +namespace credentio_testing { + +inline std::string Uint8Str(uint8_t value) { + std::string s; + s.resize(sizeof(value)); + s[0] = value; + return s; +} + +inline std::string Uint16Str(uint16_t value) { + std::string s; + s.resize(sizeof(value)); + riegeli::WriteBigEndian<uint16_t>(value, s.data()); + return s; +} + +inline std::string Uint32Str(uint32_t value) { + std::string s; + s.resize(sizeof(value)); + riegeli::WriteBigEndian<uint32_t>(value, s.data()); + return s; +} + +inline std::string Uint64Str(uint64_t value) { + std::string s; + s.resize(sizeof(value)); + riegeli::WriteBigEndian<uint64_t>(value, s.data()); + return s; +} + +} // namespace credentio_testing + +#endif // THIRD_PARTY_CREDENTIO_TESTING_TEST_STRING_UTILS_H_
diff --git a/testing/test_validation_tracker.h b/testing/test_validation_tracker.h new file mode 100644 index 0000000..ff938ce --- /dev/null +++ b/testing/test_validation_tracker.h
@@ -0,0 +1,122 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TESTING_TEST_VALIDATION_TRACKER_H_ +#define THIRD_PARTY_CREDENTIO_TESTING_TEST_VALIDATION_TRACKER_H_ + +#include <string> + +#include "absl/container/flat_hash_set.h" +#include "absl/strings/str_cat.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "google/protobuf/repeated_ptr_field.h" +#include "gtest/gtest.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +class TestValidationTracker { + public: + TestValidationTracker() + : tracker_("urn:uuid:1234", manifest_.mutable_validation()) {} + + ValidationTracker& tracker() { return tracker_; } + + absl::flat_hash_set<std::string> GetFailures() const { + return tracker_.GetFailures(); + } + + absl::flat_hash_set<std::string> GetSuccesses() const { + return tracker_.GetSuccesses(); + } + + absl::flat_hash_set<std::string> GetInformationals() const { + return tracker_.GetInformationals(); + } + + google::protobuf::RepeatedPtrField<ValidationStatus> GetFailureStatuses() + const { + return tracker_.GetFailureStatuses(); + } + + google::protobuf::RepeatedPtrField<ValidationStatus> GetSuccessStatuses() + const { + return tracker_.GetSuccessStatuses(); + } + + google::protobuf::RepeatedPtrField<ValidationStatus> + GetInformationalStatuses() const { + return tracker_.GetInformationalStatuses(); + } + + // Dump all statuses for test debugging. + const ValidationStatusSet& GetStatusSet() const { + return manifest_.validation(); + } + + private: + Manifest manifest_; + ValidationTracker tracker_; +}; + +// Matcher for use with the result of `GetFailures`. +inline auto ContainsFailure(FailureStatusCode code) { + return testing::Contains(absl::StrCat(code)); +} + +// Matcher for use with the result of `GetSuccesses`. +inline auto ContainsSuccess(SuccessStatusCode code) { + return testing::Contains(absl::StrCat(code)); +} + +// Matcher for use with the result of `GetInformationals`. +inline auto ContainsInformational(InformationalStatusCode code) { + return testing::Contains(absl::StrCat(code)); +} + +// Matcher that applies to raw status protos (results of `GetFailureStatuses`, +// `GetSuccessStatuses`, or `GetInformationalStatuses`). Note that despite +// acting on protos, this is not a proto matcher, and so cannot be combined with +// proto-matching modifiers such as `IgnoringRepeatedFieldOrdering`, etc. +template <typename StatusCode, typename ExplanationMatcher, typename UrlMatcher> +auto C2paStatusIs(StatusCode code, ExplanationMatcher explanation_matcher, + UrlMatcher url_matcher) { + return testing::AllOf( + testing::Property("code", &ValidationStatus::code, + testing::Eq(absl::StrCat(code))), + testing::Property("explanation", &ValidationStatus::explanation, + explanation_matcher), + testing::Property("url", &ValidationStatus::url, url_matcher)); +} +template <typename StatusCode, typename ExplanationMatcher> +auto C2paStatusIs(StatusCode code, ExplanationMatcher explanation_matcher) { + return testing::AllOf( + testing::Property("code", &ValidationStatus::code, + testing::Eq(absl::StrCat(code))), + testing::Property("explanation", &ValidationStatus::explanation, + explanation_matcher)); +} +template <typename StatusCode> +auto C2paStatusIs(StatusCode code) { + return testing::Property("code", &ValidationStatus::code, + testing::Eq(absl::StrCat(code))); +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TESTING_TEST_VALIDATION_TRACKER_H_
diff --git a/testing/testdata/keys/BUILD b/testing/testdata/keys/BUILD new file mode 100644 index 0000000..1f64fa1 --- /dev/null +++ b/testing/testdata/keys/BUILD
@@ -0,0 +1,24 @@ +# 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. +# + +package( + default_testonly = True, + default_visibility = ["//visibility:public"], +) + +filegroup( + name = "keys", + srcs = glob(["*.pem"]), +)
diff --git a/testing/testdata/keys/test_ca/BUILD b/testing/testdata/keys/test_ca/BUILD new file mode 100644 index 0000000..f4eea36 --- /dev/null +++ b/testing/testdata/keys/test_ca/BUILD
@@ -0,0 +1,29 @@ +# 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. +# + +package( + default_testonly = True, + default_visibility = ["//visibility:public"], +) + +filegroup( + name = "certificates", + srcs = glob(["*.pem"]), +) + +filegroup( + name = "keys", + srcs = glob(["*.key"]), +)
diff --git a/testing/testdata/keys/test_ca/tsa.key b/testing/testdata/keys/test_ca/tsa.key new file mode 100644 index 0000000..7a6ca05 --- /dev/null +++ b/testing/testdata/keys/test_ca/tsa.key
@@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3SbKDokKKMUKAcCs +c9tVRvPpbiwgNgLrpOmB1kX06fuhRANCAAS5S7LLvDCXQwkt1FeAj3CNZeJZ0O2O +le4wGgF07e8G0ZzfJW43DJn82OJqoXOhzLRWKmI+FHju7bY5L8yNj3xp +-----END PRIVATE KEY-----
diff --git a/testing/testdata/keys/test_ca/tsa.pem b/testing/testdata/keys/test_ca/tsa.pem new file mode 100644 index 0000000..89bee90 --- /dev/null +++ b/testing/testdata/keys/test_ca/tsa.pem
@@ -0,0 +1,51 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 69:43:37:b5:65:6e:71:3c:ef:55:65:0d:63:41:e3:14:a0:40:12:4c + Signature Algorithm: ecdsa-with-SHA256 + Issuer: CN=Google C2PA Testing Root CA + Validity + Not Before: Feb 4 21:42:14 2025 GMT + Not After : Jan 11 21:42:14 2125 GMT + Subject: CN=Google C2PA Testing TSA + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:b9:4b:b2:cb:bc:30:97:43:09:2d:d4:57:80:8f: + 70:8d:65:e2:59:d0:ed:8e:95:ee:30:1a:01:74:ed: + ef:06:d1:9c:df:25:6e:37:0c:99:fc:d8:e2:6a:a1: + 73:a1:cc:b4:56:2a:62:3e:14:78:ee:ed:b6:39:2f: + cc:8d:8f:7c:69 + ASN1 OID: prime256v1 + NIST CURVE: P-256 + X509v3 extensions: + X509v3 Key Usage: critical + Digital Signature + X509v3 Extended Key Usage: critical + Time Stamping + X509v3 Subject Key Identifier: + 79:6C:92:AC:5E:6D:29:CD:82:C1:EE:C0:CE:4C:40:2D:75:57:C8:8C + X509v3 Authority Key Identifier: + D3:30:EA:D0:AF:69:F2:F6:06:32:CB:03:C6:51:91:B1:FC:A1:0D:B7 + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:66:02:31:00:fc:02:28:a1:8c:ca:66:15:f2:9d:a0:5c:30: + f2:25:23:1b:1c:99:df:1e:ef:c5:bd:f7:9a:59:81:7a:6b:ab: + 70:08:2b:7b:20:57:a1:25:66:f2:83:f7:f5:a7:fd:d0:ed:02: + 31:00:ad:32:2c:8e:8b:fc:54:d0:3d:16:81:de:76:41:9b:83: + 9c:c3:d4:19:d7:bd:6a:8a:c0:ca:77:5c:16:f3:79:a3:98:50: + 29:f5:a3:45:94:5b:f6:c9:0b:7d:03:53:5e:11 +-----BEGIN CERTIFICATE----- +MIIB1zCCAVygAwIBAgIUaUM3tWVucTzvVWUNY0HjFKBAEkwwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw +NDIxNDIxNFoYDzIxMjUwMTExMjE0MjE0WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ +QSBUZXN0aW5nIFRTQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLlLssu8MJdD +CS3UV4CPcI1l4lnQ7Y6V7jAaAXTt7wbRnN8lbjcMmfzY4mqhc6HMtFYqYj4UeO7t +tjkvzI2PfGmjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEF +BQcDCDAdBgNVHQ4EFgQUeWySrF5tKc2Cwe7AzkxALXVXyIwwHwYDVR0jBBgwFoAU +0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaQAwZgIxAPwCKKGMymYV +8p2gXDDyJSMbHJnfHu/FvfeaWYF6a6twCCt7IFehJWbyg/f1p/3Q7QIxAK0yLI6L +/FTQPRaB3nZBm4Ocw9QZ171qisDKd1wW83mjmFAp9aNFlFv2yQt9A1NeEQ== +-----END CERTIFICATE-----
diff --git a/testing/testdata/keys/trust_anchors.pem b/testing/testdata/keys/trust_anchors.pem new file mode 100644 index 0000000..7ad0efb --- /dev/null +++ b/testing/testdata/keys/trust_anchors.pem
@@ -0,0 +1,54 @@ +-----BEGIN CERTIFICATE----- +MIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw +dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx +GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO +R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw +NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT +b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG +T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI +KoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye +PcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh +eBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA +MEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9 +jgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGezCCBC+gAwIBAgIUDAG5+sfGspprX+hlkn1SuB2f5VQwQQYJKoZIhvcNAQEK +MDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF +AKIDAgEgMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t +ZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S +IFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MjVa +Fw0zMjA2MDcxODQ2MjVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG +A1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG +A1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ +KoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglg +hkgBZQMEAgEFAKIDAgEgA4ICDwAwggIKAoICAQC4q3t327HRHDs7Y9NR+ZqernwU +bZ1EiEBR8vKTZ9StXmSfkzgSnvVfsFanvrKuZvFIWq909t/gH2z0klI2ZtChwLi6 +TFYXQjzQt+x5CpRcdWnB9zfUhOpdUHAhRd03Q14H2MyAiI98mqcVreQOiLDydlhP +Dla7Ign4PqedXBH+NwUCEcbQIEr2LvkZ5fzX1GzBtqymClT/Gqz75VO7zM1oV4gq +ElFHLsTLgzv5PR7pydcHauoTvFWhZNgz5s3olXJDKG/n3h0M3vIsjn11OXkcwq99 +Ne5Nm9At2tC1w0Huu4iVdyTLNLIAfM368ookf7CJeNrVJuYdERwLwICpetYvOnid +VTLSDt/YK131pR32XCkzGnrIuuYBm/k6IYgNoWqUhojGJai6o5hI1odAzFIWr9T0 +sa9f66P6RKl4SUqa/9A/uSS8Bx1gSbTPBruOVm6IKMbRZkSNN/O8dgDa1OftYCHD +blCCQh9DtOSh6jlp9I6iOUruLls7d4wPDrstPefi0PuwsfWAg4NzBtQ3uGdzl/lm +yusq6g94FVVq4RXHN/4QJcitE9VPpzVuP41aKWVRM3X/q11IH80rtaEQt54QMJwi +sIv4eEYW3TYY9iQtq7Q7H9mcz60ClJGYQJvd1DR7lA9LtUrnQJIjNY9v6OuHVXEX +EFoDH0viraraHozMdwIDAQABo2MwYTAdBgNVHQ4EFgQURW8b4nQuZgIteSw5+foy +TZQrGVAwHwYDVR0jBBgwFoAURW8b4nQuZgIteSw5+foyTZQrGVAwDwYDVR0TAQH/ +BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB +ZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIDAgEgA4ICAQBB +WnUOG/EeQoisgC964H5+ns4SDIYFOsNeksJM3WAd0yG2L3CEjUksUYugQzB5hgh4 +BpsxOajrkKIRxXN97hgvoWwbA7aySGHLgfqH1vsGibOlA5tvRQX0WoQ+GMnuliVM +pLjpHdYE2148DfgaDyIlGnHpc4gcXl7YHDYcvTN9NV5Y4P4x/2W/Lh11NC/VOSM9 +aT+jnFE7s7VoiRVfMN2iWssh2aihecdE9rs2w+Wt/E/sCrVClCQ1xaAO1+i4+mBS +a7hW+9lrQKSx2bN9c8K/CyXgAcUtutcIh5rgLm2UWOaB9It3iw0NVaxwyAgWXC9F +qYJsnia4D3AP0TJL4PbpNUaA4f2H76NODtynMfEoXSoG3TYYpOYKZ65lZy3mb26w +fvBfrlASJMClqdiEFHfGhP/dTAZ9eC2cf40iY3ta84qSJybSYnqst8Vb/Gn+dYI9 +qQm0yVHtJtvkbZtgBK5Vg6f5q7I7DhVINQJUVlWzRo6/Vx+/VBz5tC5aVDdqtBAs +q6ZcYS50ECvK/oGnVxjpeOafGvaV2UroZoGy7p7bEoJhqOPrW2yZ4JVNp9K6CCRg +zR6jFN/gUe42P1lIOfcjLZAM1GHixtjP5gLAp6sJS8X05O8xQRBtnOsEwNLj5w0y +MAdtwAzT/Vfv7b08qfx4FfQPFmtjvdu4s82gNatxSA== +-----END CERTIFICATE----- + +
diff --git a/testing/testdata/keys/trust_anchors_info.txt b/testing/testdata/keys/trust_anchors_info.txt new file mode 100644 index 0000000..f2317f8 --- /dev/null +++ b/testing/testdata/keys/trust_anchors_info.txt
@@ -0,0 +1,5 @@ +`trust_anchors.pem` contains the trust list used by the `c2patool`. + +The trust list will be used to validate test assets created by the `c2patool` in the validator tests only. + +The original file can be found at https://github.com/contentauth/c2patool/blob/main/sample/trust_anchors.pem
diff --git a/tools/BUILD b/tools/BUILD new file mode 100644 index 0000000..7322fe9 --- /dev/null +++ b/tools/BUILD
@@ -0,0 +1,41 @@ +# 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. +# + +load("@rules_cc//cc:defs.bzl", "cc_binary") + +package(default_visibility = ["//visibility:public"]) + +cc_binary( + name = "c2pa_validate", + srcs = ["asset_validator_main.cc"], + deps = [ + "//crypto:crypto_read_handler", + "//crypto/default:default_crypto_read_handler", + "//utils:media_type", + "//validator:asset_validator_impl", + "//validator:result", + "//validator:validator_options", + "@abseil-cpp//absl/flags:flag", + "@abseil-cpp//absl/flags:parse", + "@abseil-cpp//absl/flags:usage", + "@abseil-cpp//absl/log:initialize", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@protobuf", + "@riegeli//riegeli/bytes:cfile_reader", + ], +)
diff --git a/tools/asset_validator_main.cc b/tools/asset_validator_main.cc new file mode 100644 index 0000000..700cd54 --- /dev/null +++ b/tools/asset_validator_main.cc
@@ -0,0 +1,165 @@ +// 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 <fstream> +#include <iostream> +#include <memory> +#include <optional> +#include <sstream> +#include <string> +#include <utility> + +#include "absl/flags/flag.h" +#include "absl/flags/parse.h" +#include "absl/flags/usage.h" +#include "absl/log/initialize.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "crypto/crypto_read_handler.h" +#include "crypto/default/default_crypto_read_handler.h" +#include "google/protobuf/text_format.h" +#include "riegeli/bytes/cfile_reader.h" +#include "utils/media_type.h" +#include "validator/asset_validator_impl.h" +#include "validator/result.h" +#include "validator/validator_options.h" + +ABSL_FLAG(std::string, asset, "", + "Path to the asset file to validate (Required)."); +ABSL_FLAG( + std::string, claim_signer_trust, "", + "Path to PEM file containing claim signer trust anchors (for C2PA " + "2.2+ strict EKU compliance). If not provided, then the validator will " + "default to skip trust checks."); +ABSL_FLAG(std::string, tsa_trust, "", + "Path to PEM file containing TSA trust anchors. If not provided, " + "then the validator will default to skip TSA trust checks."); + +namespace { + +absl::StatusOr<std::string> GetContents(absl::string_view file_path) { + std::ifstream f(std::string(file_path), std::ios::binary); + if (!f.is_open()) { + return absl::NotFoundError( + absl::StrCat("Failed to open file: ", file_path)); + } + std::stringstream buffer; + buffer << f.rdbuf(); + return buffer.str(); +} + +} // namespace + +int main(int argc, char* argv[]) { + absl::SetProgramUsageMessage( + "Validates C2PA asset files and prints validation results.\n" + "Usage:\n c2pa_validate --asset=<path_to_asset> " + "[--claim_signer_trust=<pem_path>] [--tsa_trust=<pem_path>]"); + absl::InitializeLog(); + absl::ParseCommandLine(argc, argv); + + const std::string asset_path = absl::GetFlag(FLAGS_asset); + if (asset_path.empty()) { + std::cerr << "Error: --asset flag is required.\n"; + return 1; + } + + const std::string claim_signer_trust_path = + absl::GetFlag(FLAGS_claim_signer_trust); + const std::string tsa_trust_path = absl::GetFlag(FLAGS_tsa_trust); + + credentio::DefaultCryptoReadHandlerOptions crypto_options; + std::string claim_signer_trust_pem; + std::string tsa_trust_pem; + + if (!claim_signer_trust_path.empty()) { + absl::StatusOr<std::string> pem = GetContents(claim_signer_trust_path); + if (!pem.ok()) { + std::cerr << "Error reading claim signer trust file: " << pem.status() + << "\n"; + return 1; + } + claim_signer_trust_pem = *std::move(pem); + crypto_options.claim_signer_trust_anchors_pem = claim_signer_trust_pem; + } else { + std::cerr << "WARNING: --claim_signer_trust is not provided. Skipping " + "claim signer trust checks.\n"; + crypto_options.skip_claim_signer_trust_checks_for_test = true; + } + + if (!tsa_trust_path.empty()) { + absl::StatusOr<std::string> pem = GetContents(tsa_trust_path); + if (!pem.ok()) { + std::cerr << "Error reading TSA trust file: " << pem.status() << "\n"; + return 1; + } + tsa_trust_pem = *std::move(pem); + crypto_options.tsa_trust_anchors_pem = tsa_trust_pem; + } else { + std::cerr + << "WARNING: --tsa_trust is not provided. Skipping TSA trust checks.\n"; + crypto_options.skip_tsa_trust_checks_for_test = true; + } + + absl::StatusOr<std::unique_ptr<credentio::CryptoReadHandler>> + crypto_read_handler = + credentio::CreateDefaultCryptoReadHandler(crypto_options); + if (!crypto_read_handler.ok()) { + std::cerr << "Error creating crypto read handler: " + << crypto_read_handler.status() << "\n"; + return 1; + } + + credentio::AssetValidatorImpl validator(credentio::ValidatorOptions{ + .crypto_read_handler = *std::move(crypto_read_handler), + }); + + riegeli::CFileReader<> reader(asset_path); + if (!reader.ok()) { + std::cerr << "Error opening asset file: " << reader.status() << "\n"; + return 1; + } + + std::optional<absl::string_view> media_type_opt; + const absl::StatusOr<std::string> media_type = + credentio::MediaType(asset_path); + if (media_type.ok()) { + media_type_opt = *media_type; + } else { + std::cerr << "Error determining media type: " << media_type.status() + << "\n"; + } + + const absl::StatusOr<std::unique_ptr<credentio::ValidationResult>> result = + validator.Validate(reader, media_type_opt); + if (!result.ok()) { + std::cerr << "Validation failed: " << result.status() << "\n"; + return 1; + } + + std::cout << "Validation successful!\n"; + std::string text_format; + if (google::protobuf::TextFormat::PrintToString((*result)->proto(), + &text_format)) { + std::cout << "Validation Result:\n" << text_format << "\n"; + } else { + std::cerr << "Failed to convert result proto to text format.\n"; + return 1; + } + + return 0; +}
diff --git a/tsp/BUILD b/tsp/BUILD new file mode 100644 index 0000000..626b70e --- /dev/null +++ b/tsp/BUILD
@@ -0,0 +1,224 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "status_codes", + srcs = ["status_codes.cc"], + hdrs = ["status_codes.h"], + deps = [ + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + ], +) + +cc_test( + name = "status_codes_test", + srcs = ["status_codes_test.cc"], + deps = [ + ":status_codes", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "verified_timestamp", + hdrs = ["verified_timestamp.h"], + deps = [ + "//crypto:algorithms", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + ], +) + +cc_library( + name = "parsed_timestamp_token", + hdrs = ["parsed_timestamp_token.h"], + deps = [ + "//crypto:algorithms", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "cms_parser", + hdrs = ["cms_parser.h"], + deps = [ + ":parsed_timestamp_token", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "constants", + srcs = ["constants.cc"], + hdrs = ["constants.h"], + deps = [ + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/strings:string_view", + "@boringssl//:crypto", + ], +) + +cc_library( + name = "timestamp_parsing", + srcs = ["timestamp_parsing.cc"], + hdrs = ["timestamp_parsing.h"], + deps = [ + ":constants", + ":status_codes", + "//crypto:algorithms", + "//crypto:cbs_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@boringssl//:crypto", + ], +) + +cc_library( + name = "test_helpers", + srcs = ["test_helpers.cc"], + hdrs = ["test_helpers.h"], + deps = [ + ":constants", + ":status_codes", + "//crypto:algorithms", + "//crypto:cbs_utils", + "//crypto:hash", + "//crypto/default:hasher", + "//testing:boringssl_utils", + "//testing:cms", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@tink_cc//proto:common_cc_proto", + "@tink_cc//tink:cleartext_keyset_handle", + "@tink_cc//tink:keyset_handle", + "@tink_cc//tink:public_key_sign", + "@tink_cc//tink/signature:config_v0", + "@tink_cc//tink/signature:signature_pem_keyset_reader", + ], +) + +cc_test( + name = "timestamp_parsing_test", + srcs = ["timestamp_parsing_test.cc"], + data = [ + "//testing/testdata/keys/test_ca:certificates", + "//testing/testdata/keys/test_ca:keys", + ], + deps = [ + ":constants", + ":status_codes", + ":test_helpers", + ":timestamp_parsing", + "//crypto:algorithms", + "//crypto:cbs_utils", + "//testing:test_file_utils", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@boringssl//:crypto", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "timestamp_verifier", + srcs = ["timestamp_verifier.cc"], + hdrs = ["timestamp_verifier.h"], + deps = [ + ":parsed_timestamp_token", + ":timestamp_parsing", + ":verified_timestamp", + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "@abseil-cpp//absl/base:core_headers", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:absl_log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@boringssl//:pki", + ], +) + +cc_test( + name = "timestamp_verifier_test", + srcs = ["timestamp_verifier_test.cc"], + deps = [ + ":constants", + ":parsed_timestamp_token", + ":test_helpers", + ":timestamp_verifier", + ":verified_timestamp", + "//crypto:algorithms", + "//crypto:cbs_utils", + "//crypto/default:trust_store", + "//proto:validation_result_cc_proto", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/time", + "@abseil-cpp//absl/types:span", + "@boringssl//:crypto", + "@boringssl//:pki", + "@googletest//:gtest_main", + ], +)
diff --git a/tsp/cms_parser.h b/tsp/cms_parser.h new file mode 100644 index 0000000..4abba39 --- /dev/null +++ b/tsp/cms_parser.h
@@ -0,0 +1,39 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TSP_CMS_PARSER_H_ +#define THIRD_PARTY_CREDENTIO_TSP_CMS_PARSER_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "tsp/parsed_timestamp_token.h" + +namespace credentio { + +// Interface for parsing CMS TimestampTokens. +class CmsParser { + public: + virtual ~CmsParser() = default; + + // Parses a CMS token and ensures its content type is id-ct-TSTInfo. + virtual absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> + ParseTimestampToken(absl::string_view timestamp_token) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_CMS_PARSER_H_
diff --git a/tsp/constants.cc b/tsp/constants.cc new file mode 100644 index 0000000..bd247fb --- /dev/null +++ b/tsp/constants.cc
@@ -0,0 +1,30 @@ +// 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 "tsp/constants.h" + +#include "absl/log/die_if_null.h" +#include "openssl/base.h" +#include "openssl/obj.h" + +namespace credentio { + +const ASN1_OBJECT* IdAaSigningCertificateV2() { + static const ASN1_OBJECT* singleton = ABSL_DIE_IF_NULL( + OBJ_txt2obj("1.2.840.113549.1.9.16.2.47", /*dont_search_names=*/true)); + return singleton; +} + +} // namespace credentio
diff --git a/tsp/constants.h b/tsp/constants.h new file mode 100644 index 0000000..538470e --- /dev/null +++ b/tsp/constants.h
@@ -0,0 +1,32 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TSP_CONSTANTS_H_ +#define THIRD_PARTY_CREDENTIO_TSP_CONSTANTS_H_ + +#include "absl/strings/string_view.h" +#include "openssl/base.h" + +namespace credentio { +// Format string for GenTime values. Compatible with `absl::FormatTime` and +// `absl::ParseTime`. +constexpr inline absl::string_view kGenTimeFormat = "%E4Y%m%d%H%M%E*SZ"; + +// The OID `id-aa-signingCertificateV2` as defined by RFC 5035. +const ASN1_OBJECT* IdAaSigningCertificateV2(); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_CONSTANTS_H_
diff --git a/tsp/parsed_timestamp_token.h b/tsp/parsed_timestamp_token.h new file mode 100644 index 0000000..7ab82eb --- /dev/null +++ b/tsp/parsed_timestamp_token.h
@@ -0,0 +1,64 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TSP_PARSED_TIMESTAMP_TOKEN_H_ +#define THIRD_PARTY_CREDENTIO_TSP_PARSED_TIMESTAMP_TOKEN_H_ + +#include <string> +#include <vector> + +#include "absl/base/attributes.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "crypto/algorithms.h" + +namespace credentio { + +// Represents a successfully parsed CMS TimestampToken ready for extraction. +class ParsedTimestampToken { + public: + virtual ~ParsedTimestampToken() = default; + + // Verifies the signature of this token. Note that this does not perform trust + // checks. + virtual absl::Status VerifySignature() const = 0; + + // Returns the hash algorithm used to calculate the message imprint. + virtual absl::StatusOr<HashAlgorithm> GetMessageImprintHashAlgorithm() + const = 0; + + // Returns the certificate chain of the TSA certificate. The first element + // is the leaf certificate, followed by any intermediate certificates. + // The certificates are DER encoded. + virtual absl::StatusOr<std::vector<std::string>> GetCertificateChain() + const = 0; + + // Extracts the DER-encoded SigningCertificateV2 attribute from the signer. + // The lifetime of the returned absl::string_view is tied to this + // ParsedTimestampToken instance. + virtual absl::StatusOr<absl::string_view> GetSigningCertificateV2Bytes() const + ABSL_ATTRIBUTE_LIFETIME_BOUND = 0; + + // Gets the raw DER-encoded TstInfo content. + // The lifetime of the returned absl::string_view is tied to this + // ParsedTimestampToken instance. + virtual absl::StatusOr<absl::string_view> GetTstInfoBytes() const + ABSL_ATTRIBUTE_LIFETIME_BOUND = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_PARSED_TIMESTAMP_TOKEN_H_
diff --git a/tsp/status_codes.cc b/tsp/status_codes.cc new file mode 100644 index 0000000..29b40f0 --- /dev/null +++ b/tsp/status_codes.cc
@@ -0,0 +1,50 @@ +// 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 "tsp/status_codes.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" + +namespace credentio { + +absl::StatusOr<TspPkiStatus> GetTspPkiStatus(int64_t status_code) { + switch (status_code) { + case 0: + return TspPkiStatus::kGranted; + case 1: + return TspPkiStatus::kGrantedWithMods; + case 2: + return TspPkiStatus::kRejection; + case 3: + return TspPkiStatus::kWaiting; + case 4: + return TspPkiStatus::kRevocationWarning; + case 5: + return TspPkiStatus::kRevocationNotification; + } + return absl::InvalidArgumentError( + absl::StrCat("unknown TspPkiStatus code: ", status_code)); +} + +bool IsTspPkiStatusOk(TspPkiStatus status_code) { + return status_code == TspPkiStatus::kGranted || + status_code == TspPkiStatus::kGrantedWithMods; +} + +} // namespace credentio
diff --git a/tsp/status_codes.h b/tsp/status_codes.h new file mode 100644 index 0000000..8c49fbf --- /dev/null +++ b/tsp/status_codes.h
@@ -0,0 +1,72 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TSP_STATUS_CODES_H_ +#define THIRD_PARTY_CREDENTIO_TSP_STATUS_CODES_H_ + +#include <cstdint> + +#include "absl/log/log.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" + +namespace credentio { + +// The status codes as defined by RFC 3161 section 2.4.2. +enum class TspPkiStatus { + kGranted = 0, + kGrantedWithMods = 1, + kRejection = 2, + kWaiting = 3, + kRevocationWarning = 4, + kRevocationNotification = 5, +}; + +template <typename Sink> +inline void AbslStringify(Sink& sink, TspPkiStatus status) { + switch (status) { + case TspPkiStatus::kGranted: + sink.Append("granted"); + return; + case TspPkiStatus::kGrantedWithMods: + sink.Append("grantedWithMods"); + return; + case TspPkiStatus::kRejection: + sink.Append("rejection"); + return; + case TspPkiStatus::kWaiting: + sink.Append("waiting"); + return; + case TspPkiStatus::kRevocationWarning: + sink.Append("revocationWarning"); + return; + case TspPkiStatus::kRevocationNotification: + sink.Append("revocationNotification"); + return; + } + LOG(DFATAL) << "unknown TspPkiStatus enum: " << static_cast<int>(status); + sink.Append( + absl::StrCat("UNKNOWN_TSP_PKI_STATUS_", static_cast<int>(status))); +} + +absl::StatusOr<TspPkiStatus> GetTspPkiStatus(int64_t status_code); + +// Returns whether `status_code` would be considered successful if received in a +// TimeStampResp. +bool IsTspPkiStatusOk(TspPkiStatus status_code); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_STATUS_CODES_H_
diff --git a/tsp/status_codes_test.cc b/tsp/status_codes_test.cc new file mode 100644 index 0000000..7d60a3c --- /dev/null +++ b/tsp/status_codes_test.cc
@@ -0,0 +1,66 @@ +// 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 "tsp/status_codes.h" + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; + +TEST(StatusCodesTest, GetTspPkiStatusTest) { + EXPECT_THAT(GetTspPkiStatus(0), IsOkAndHolds(TspPkiStatus::kGranted)); + EXPECT_THAT(GetTspPkiStatus(1), IsOkAndHolds(TspPkiStatus::kGrantedWithMods)); + EXPECT_THAT(GetTspPkiStatus(2), IsOkAndHolds(TspPkiStatus::kRejection)); + EXPECT_THAT(GetTspPkiStatus(3), IsOkAndHolds(TspPkiStatus::kWaiting)); + EXPECT_THAT(GetTspPkiStatus(4), + IsOkAndHolds(TspPkiStatus::kRevocationWarning)); + EXPECT_THAT(GetTspPkiStatus(5), + IsOkAndHolds(TspPkiStatus::kRevocationNotification)); + + EXPECT_THAT(GetTspPkiStatus(6), StatusIs(absl::StatusCode::kInvalidArgument, + "unknown TspPkiStatus code: 6")); + EXPECT_THAT(GetTspPkiStatus(-1), StatusIs(absl::StatusCode::kInvalidArgument, + "unknown TspPkiStatus code: -1")); +} + +TEST(StatusCodesTest, AbslStringifyTspPkiStatus) { + EXPECT_EQ(absl::StrCat(TspPkiStatus::kGranted), "granted"); + EXPECT_EQ(absl::StrCat(TspPkiStatus::kGrantedWithMods), "grantedWithMods"); + EXPECT_EQ(absl::StrCat(TspPkiStatus::kRejection), "rejection"); + EXPECT_EQ(absl::StrCat(TspPkiStatus::kWaiting), "waiting"); + EXPECT_EQ(absl::StrCat(TspPkiStatus::kRevocationWarning), + "revocationWarning"); + EXPECT_EQ(absl::StrCat(TspPkiStatus::kRevocationNotification), + "revocationNotification"); +} + +TEST(StatusCodesTest, AbslStringifyInvalidTspPkiStatus) { + EXPECT_DEBUG_DEATH( + { std::string result = absl::StrCat(static_cast<TspPkiStatus>(100)); }, + "unknown TspPkiStatus enum: 100"); +} + +} // namespace +} // namespace credentio
diff --git a/tsp/test_helpers.cc b/tsp/test_helpers.cc new file mode 100644 index 0000000..07f3432 --- /dev/null +++ b/tsp/test_helpers.cc
@@ -0,0 +1,462 @@ +// 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 "tsp/test_helpers.h" + +#include <cstddef> +#include <cstdint> +#include <memory> +#include <optional> +#include <string> +#include <utility> +#include <variant> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/log/absl_check.h" +#include "absl/log/check.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "crypto/cbs_utils.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/bytestring.h" +#include "openssl/digest.h" +#include "openssl/err.h" +#include "openssl/evp.h" +#include "openssl/mem.h" +#include "openssl/obj.h" +#include "openssl/pem.h" // IWYU pragma: keep +#include "openssl/x509.h" +#include "proto/common.pb.h" +#include "testing/boringssl_utils.h" +#include "testing/cms.h" +#include "tink/cleartext_keyset_handle.h" +#include "tink/keyset_handle.h" +#include "tink/public_key_sign.h" +#include "tink/signature/config_v0.h" +#include "tink/signature/signature_pem_keyset_reader.h" +#include "tsp/constants.h" +#include "tsp/status_codes.h" + +namespace credentio { +namespace { + +using ::crypto::tink::KeysetHandle; +using ::crypto::tink::PublicKeySign; +using ::crypto::tink::SignaturePemKeysetReaderBuilder; + +absl::StatusOr<std::unique_ptr<crypto::tink::KeysetHandle>> +LoadEcdsaPrivateKeyFromPem(absl::string_view pem_data) { + crypto::tink::SignaturePemKeysetReaderBuilder builder( + SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_SIGN); + builder.Add({.serialized_key = std::string(pem_data), + .parameters = { + .key_type = crypto::tink::PemKeyType::PEM_EC, + .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER, + .key_size_in_bits = 256, + .hash_type = google::crypto::tink::HashType::SHA256, + }}); + ABSL_ASSIGN_OR_RETURN(auto reader, builder.Build()); + return crypto::tink::CleartextKeysetHandle::Read(std::move(reader)); +} + +absl::StatusOr<std::vector<uint8_t>> SignWithBoringSsl( + const EVP_PKEY& private_key, absl::string_view data) { + if (EVP_PKEY_id(&private_key) != EVP_PKEY_RSA) { + return absl::UnimplementedError("Only RSA keys are supported."); + } + bssl::ScopedEVP_MD_CTX md_context; + EVP_PKEY_CTX* pkey_ctx = nullptr; + if (EVP_DigestSignInit(md_context.get(), &pkey_ctx, EVP_sha256(), nullptr, + const_cast<EVP_PKEY*>(&private_key)) != 1 || + EVP_DigestSignUpdate(md_context.get(), data.data(), data.length()) != 1) { + return absl::InternalError("EVP_DigestSignInit/Update failed."); + } + size_t sig_len = 0; + if (EVP_DigestSignFinal(md_context.get(), nullptr, &sig_len) != 1) { + return absl::InternalError("EVP_DigestSignFinal failed."); + } + std::vector<uint8_t> signature(sig_len, 0); + if (EVP_DigestSignFinal(md_context.get(), signature.data(), &sig_len) != 1) { + return absl::InternalError("EVP_DigestSignFinal failed."); + } + return signature; +} + +} // namespace + +std::string WrapDerSequence(absl::string_view payload) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, payload.size() + 5)); + CBB seq_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &seq_cbb, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_bytes(&seq_cbb, + reinterpret_cast<const uint8_t*>(payload.data()), + payload.size())); + uint8_t* data; + size_t len; + ABSL_CHECK(CBB_finish(&cbb, &data, &len)); + bssl::UniquePtr<uint8_t> data_uniq(data); + return std::string(reinterpret_cast<char*>(data), len); +} + +std::string EncodeDerInteger(uint64_t n) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 10)); + ABSL_CHECK(CBB_add_asn1_int64(&cbb, n)); + uint8_t* cbb_data; + size_t cbb_len; + ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); + return std::string(reinterpret_cast<char*>(cbb_data), cbb_len); +} + +std::string CreateTimeStampResp(absl::string_view ts_token) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 40)); + CBB ts_resp_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &ts_resp_cbb, CBS_ASN1_SEQUENCE)); + + CBB pki_status_cbb; + ABSL_CHECK(CBB_add_asn1(&ts_resp_cbb, &pki_status_cbb, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBB_add_asn1_int64(&pki_status_cbb, + static_cast<int64_t>(TspPkiStatus::kGranted))); + + ABSL_CHECK(CBB_add_bytes(&ts_resp_cbb, + reinterpret_cast<const uint8_t*>(ts_token.data()), + ts_token.size())); + + uint8_t* cbb_data_ptr; + size_t cbb_len; + ABSL_CHECK(CBB_finish(&cbb, &cbb_data_ptr, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_uniq(cbb_data_ptr); + + std::string cbb_str(cbb_data_ptr, cbb_data_ptr + cbb_len); + return cbb_str; +} + +std::string CreateFailedTimeStampResp( + TspPkiStatus status, absl::Span<const absl::string_view> status_string, + absl::Span<const int32_t> failure_info) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 512)); + CBB resp; + ABSL_CHECK(CBB_add_asn1(&cbb, &resp, CBS_ASN1_SEQUENCE)); + CBB pki_status_info; + ABSL_CHECK(CBB_add_asn1(&resp, &pki_status_info, CBS_ASN1_SEQUENCE)); + ABSL_CHECK( + CBB_add_asn1_int64(&pki_status_info, static_cast<int64_t>(status))); + if (!status_string.empty()) { + CBB status_string_cbb; + ABSL_CHECK( + CBB_add_asn1(&pki_status_info, &status_string_cbb, CBS_ASN1_SEQUENCE)); + for (absl::string_view entry : status_string) { + CBB utf8_string; + ABSL_CHECK( + CBB_add_asn1(&status_string_cbb, &utf8_string, CBS_ASN1_UTF8STRING)); + ABSL_CHECK(CBB_add_bytes(&utf8_string, + reinterpret_cast<const uint8_t*>(entry.data()), + entry.length())); + ABSL_CHECK(CBB_flush(&status_string_cbb)); + } + ABSL_CHECK(CBB_flush(&pki_status_info)); + } + if (!failure_info.empty()) { + CBB failure_info_cbb; + ABSL_CHECK( + CBB_add_asn1(&pki_status_info, &failure_info_cbb, CBS_ASN1_BITSTRING)); + ABSL_CHECK(CBB_add_u8(&failure_info_cbb, 0 /* no padding bits */)); + int32_t max_bit = 0; + for (int32_t entry : failure_info) { + if (entry > max_bit) { + max_bit = entry; + } + } + int32_t bytes = (max_bit / 8) + 1; + std::string data(bytes, 0); + for (int32_t entry : failure_info) { + data[entry >> 3] |= 1 << (7 - (entry & 7)); + } + ABSL_CHECK(CBB_add_bytes(&failure_info_cbb, + reinterpret_cast<const uint8_t*>(data.data()), + data.size())); + ABSL_CHECK(CBB_flush(&pki_status_info)); + } + + uint8_t* cbb_data; + size_t cbb_len; + ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); + + return std::string(reinterpret_cast<char*>(cbb_data), cbb_len); +} + +std::string CreateTimeStampTokenRsa( + absl::Span<const absl::string_view> der_data, absl::string_view key_pem, + absl::string_view cert_pem, bool embed_cert, + std::variant<absl::string_view, bool> signing_certificate_attribute) { + bssl::UniquePtr<EVP_PKEY> key(PemToKey(key_pem)); + ABSL_CHECK(key != nullptr); + bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem)); + ABSL_CHECK(cert != nullptr); + std::string extra_attrs; + if (std::holds_alternative<absl::string_view>( + signing_certificate_attribute)) { + extra_attrs = std::get<absl::string_view>(signing_certificate_attribute); + } else { + if (std::get<bool>(signing_certificate_attribute)) { + X509* certs[] = {cert.get()}; + extra_attrs = + CreateSigningCertificateV2Attribute(certs, HashAlgorithm::kSha256); + } + } + + auto cms_or = credentio_testing::CreateSignedCms( + der_data, *cert, + [&key](absl::string_view data) { return SignWithBoringSsl(*key, data); }, + {.signing_time = absl::FromUnixSeconds(1427738483), + .include_user_certificate = embed_cert, + .extra_signed_attributes = extra_attrs, + .is_time_stamp_token = true}); + ABSL_CHECK_OK(cms_or.status()); + return *cms_or; +} + +std::string CreateTimeStampTokenEcdsa( + absl::Span<const absl::string_view> der_data, + const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem, + bool embed_cert, + std::variant<absl::string_view, bool> signing_certificate_attribute) { + bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem)); + ABSL_CHECK(cert != nullptr); + std::string extra_attrs; + if (std::holds_alternative<absl::string_view>( + signing_certificate_attribute)) { + extra_attrs = std::get<absl::string_view>(signing_certificate_attribute); + } else { + if (std::get<bool>(signing_certificate_attribute)) { + X509* certs[] = {cert.get()}; + extra_attrs = + CreateSigningCertificateV2Attribute(certs, HashAlgorithm::kSha256); + } + } + + auto cms_or = credentio_testing::CreateSignedCms( + der_data, *cert, + [&signer]( + absl::string_view data) -> absl::StatusOr<std::vector<uint8_t>> { + ABSL_ASSIGN_OR_RETURN(auto signature, signer.Sign(data)); + return std::vector<uint8_t>(signature.begin(), signature.end()); + }, + {.signing_time = absl::FromUnixSeconds(1427738483), + .include_user_certificate = embed_cert, + .extra_signed_attributes = extra_attrs, + .is_time_stamp_token = true}); + ABSL_CHECK_OK(cms_or.status()); + return *cms_or; +} + +std::string CreateTimeStampTokenEcdsa( + absl::Span<const absl::string_view> der_data, absl::string_view key_pem, + absl::string_view cert_pem, bool embed_cert, + std::optional<std::vector<const X509*>> extra_certs, + std::variant<absl::string_view, bool> signing_certificate_attribute, + bool use_wrong_e_content_type) { + auto keyset_handle = LoadEcdsaPrivateKeyFromPem(key_pem); + ABSL_CHECK_OK(keyset_handle); + auto signer = (*keyset_handle) + ->GetPrimitive<crypto::tink::PublicKeySign>( + crypto::tink::ConfigSignatureV0()); + ABSL_CHECK_OK(signer); + std::unique_ptr<crypto::tink::PublicKeySign> signer_ptr = *std::move(signer); + + bssl::UniquePtr<EVP_PKEY> key(PemToKey(key_pem)); + ABSL_CHECK(key != nullptr); + bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem)); + ABSL_CHECK(cert != nullptr); + std::string extra_attrs; + if (std::holds_alternative<absl::string_view>( + signing_certificate_attribute)) { + extra_attrs = std::get<absl::string_view>(signing_certificate_attribute); + } else { + if (std::get<bool>(signing_certificate_attribute)) { + std::vector<X509*> certs; + certs.push_back(cert.get()); + if (extra_certs.has_value()) { + for (const X509* extra_cert : *extra_certs) { + certs.push_back(const_cast<X509*>(extra_cert)); + } + } + extra_attrs = CreateSigningCertificateV2Attribute(absl::MakeSpan(certs), + HashAlgorithm::kSha256); + } + } + + std::vector<const X509*> ca_certs; + if (extra_certs.has_value()) { + ca_certs = *extra_certs; + } + + auto cms_or = credentio_testing::CreateSignedCms( + der_data, *cert, + [&signer_ptr]( + absl::string_view data) -> absl::StatusOr<std::vector<uint8_t>> { + ABSL_ASSIGN_OR_RETURN(auto signature, signer_ptr->Sign(data)); + return std::vector<uint8_t>(signature.begin(), signature.end()); + }, + {.signing_time = absl::FromUnixSeconds(1427738483), + .include_user_certificate = embed_cert, + .ca_certificates = ca_certs, + .extra_signed_attributes = extra_attrs, + .is_time_stamp_token = !use_wrong_e_content_type}); + ABSL_CHECK_OK(cms_or.status()); + return *cms_or; +} + +std::string CreateTstInfo(absl::string_view gen_time, + absl::string_view message_imprint_hash, + absl::string_view message_imprint_algorithm_txt, + std::optional<absl::string_view> nonce) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 30)); + + CBB sequence_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &sequence_cbb, CBS_ASN1_SEQUENCE)); + // version + ABSL_CHECK(CBB_add_asn1_int64(&sequence_cbb, 1)); + + // policy + CBB policy_cbb; + ABSL_CHECK(CBB_add_asn1(&sequence_cbb, &policy_cbb, CBS_ASN1_OBJECT)); + ABSL_CHECK( + CBB_add_asn1_oid_from_text(&policy_cbb, "1.2.840.113554.4.1.72585", 24)); + ABSL_CHECK(CBB_flush(&sequence_cbb)); + + // messageImprint + CBB mi_cbb; + ABSL_CHECK(CBB_add_asn1(&sequence_cbb, &mi_cbb, CBS_ASN1_SEQUENCE)); + // messageImprint.hashAlgorithm + CBB algorithm_identifier_cbb; + ABSL_CHECK( + CBB_add_asn1(&mi_cbb, &algorithm_identifier_cbb, CBS_ASN1_SEQUENCE)); + // messageImprint.hashAlgorithm.algorithm + CBB algorithm_cbb; + ABSL_CHECK( + CBB_add_asn1(&algorithm_identifier_cbb, &algorithm_cbb, CBS_ASN1_OBJECT)); + ABSL_CHECK(CBB_add_asn1_oid_from_text( + &algorithm_cbb, message_imprint_algorithm_txt.data(), + message_imprint_algorithm_txt.length())); + ABSL_CHECK(CBB_flush(&mi_cbb)); + + // messageImprint.hashedMessage + ABSL_CHECK(CBB_add_asn1_octet_string( + &mi_cbb, reinterpret_cast<const uint8_t*>(message_imprint_hash.data()), + message_imprint_hash.length())); + ABSL_CHECK(CBB_flush(&sequence_cbb)); + + // serial_number + ABSL_CHECK(CBB_add_asn1_int64(&sequence_cbb, 1)); + + // genTime + CBB gen_time_cbb; + ABSL_CHECK( + CBB_add_asn1(&sequence_cbb, &gen_time_cbb, CBS_ASN1_GENERALIZEDTIME)); + ABSL_CHECK(CBB_add_bytes(&gen_time_cbb, + reinterpret_cast<const uint8_t*>(gen_time.data()), + gen_time.length())); + ABSL_CHECK(CBB_flush(&sequence_cbb)); + + // accuracy (this is an empty SEQUENCE because currently code under test will + // never actually read it) + CBB accuracy_cbb; + ABSL_CHECK(CBB_add_asn1(&sequence_cbb, &accuracy_cbb, CBS_ASN1_SEQUENCE)); + + // ordering + ABSL_CHECK(CBB_add_asn1_bool(&sequence_cbb, true)); + + // nonce + if (nonce.has_value()) { + ABSL_CHECK(CBB_add_bytes(&sequence_cbb, + reinterpret_cast<const uint8_t*>(nonce->data()), + nonce->length())); + } + + uint8_t* der_data; + size_t der_len; + ABSL_CHECK(CBB_finish(&cbb, &der_data, &der_len)); + + std::string der(der_data, der_data + der_len); + + OPENSSL_free(der_data); + return der; +} + +std::string CreateSigningCertificateV2Attribute( + absl::Span<X509* absl_nonnull> certs, HashAlgorithm hash_algorithm) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 1000)); + CBB attr_seq_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &attr_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB oid_cbb; + ABSL_CHECK(CBB_add_asn1(&attr_seq_cbb, &oid_cbb, CBS_ASN1_OBJECT)); + ABSL_CHECK(CBB_add_bytes(&oid_cbb, OBJ_get0_data(IdAaSigningCertificateV2()), + OBJ_length(IdAaSigningCertificateV2()))); + ABSL_CHECK(CBB_flush(&attr_seq_cbb)); + + CBB value_set_cbb; + ABSL_CHECK(CBB_add_asn1(&attr_seq_cbb, &value_set_cbb, CBS_ASN1_SET)); + CBB value_seq_cbb; + ABSL_CHECK(CBB_add_asn1(&value_set_cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + for (X509* x509 : certs) { + uint8_t* der_ptr = nullptr; + size_t der_len = i2d_X509(x509, &der_ptr); + bssl::UniquePtr<uint8_t> der(der_ptr); + + CBS der_cbs; + CBS_init(&der_cbs, der_ptr, der_len); + auto hasher = CreateHasher(hash_algorithm); + ABSL_CHECK_OK(hasher); + (*hasher)->Update(ToStringView(der_cbs)); + std::string hash = (*hasher)->Digest(); + CBS hash_cbs = FromStringView(hash); + + CBB certs_entry_cbb; + ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + if (hash_algorithm != HashAlgorithm::kSha256) { + ABSL_CHECK( + OBJ_nid2cbb(&certs_entry_cbb, static_cast<int>(hash_algorithm))); + } + ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs), + CBS_len(&hash_cbs))); + ABSL_CHECK(CBB_flush(&certs_cbb)); + } + + auto result = FinishToString(&cbb); + ABSL_CHECK_OK(result); + return *result; +} + +} // namespace credentio
diff --git a/tsp/test_helpers.h b/tsp/test_helpers.h new file mode 100644 index 0000000..5d4590d --- /dev/null +++ b/tsp/test_helpers.h
@@ -0,0 +1,89 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TSP_TEST_HELPERS_H_ +#define THIRD_PARTY_CREDENTIO_TSP_TEST_HELPERS_H_ + +#include <cstdint> +#include <optional> +#include <string> +#include <variant> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "openssl/base.h" +#include "tink/public_key_sign.h" +#include "tsp/status_codes.h" + +namespace credentio { + +// Wraps arbitrary data in a DER SEQUENCE. +std::string WrapDerSequence(absl::string_view payload); + +// Encodes a DER INTEGER (with tag and length, no wrapping structure) +std::string EncodeDerInteger(uint64_t n); + +std::string CreateTimeStampResp(absl::string_view ts_token); + +std::string CreateFailedTimeStampResp( + TspPkiStatus status, absl::Span<const absl::string_view> status_string = {}, + absl::Span<const int32_t> failure_info = {}); + +// If `signing_certificate_attribute` holds a string, it will be taken as the +// raw attribute value. If it holds `true` the value will be generated from +// `cert_pem`. If it holds `false`, the attribute will be omitted. +std::string CreateTimeStampTokenRsa( + absl::Span<const absl::string_view> der_data, absl::string_view key_pem, + absl::string_view cert_pem, bool embed_cert = true, + std::variant<absl::string_view, bool> signing_certificate_attribute = true); + +// If `signing_certificate_attribute` holds a string, it will be taken as the +// raw attribute value. If it holds `true` the value will be generated from +// `cert_pem`. If it holds `false`, the attribute will be omitted. +std::string CreateTimeStampTokenEcdsa( + absl::Span<const absl::string_view> der_data, + const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem, + bool embed_cert, + std::variant<absl::string_view, bool> signing_certificate_attribute = true); + +// If `signing_certificate_attribute` holds a string, it will be taken as the +// raw attribute value. If it holds `true` the value will be generated from +// `cert_pem` and `extra_certs. If it holds `false`, the attribute will be +// omitted. +// If `use_wrong_e_content_type` is true, the eContentType will be set to +// id-data instead of id-ct-tst-info. +std::string CreateTimeStampTokenEcdsa( + absl::Span<const absl::string_view> der_data, absl::string_view key_pem, + absl::string_view cert_pem, bool embed_cert = true, + std::optional<std::vector<const X509*>> extra_certs = std::nullopt, + std::variant<absl::string_view, bool> signing_certificate_attribute = true, + bool use_wrong_e_content_type = false); + +// If supplied, `nonce` will be inserted directly into the DER SEQUENCE, so it +// must contain its own headers. +std::string CreateTstInfo( + absl::string_view gen_time, absl::string_view message_imprint_hash, + absl::string_view message_imprint_algorithm_txt, + std::optional<absl::string_view> nonce = std::nullopt); + +std::string CreateSigningCertificateV2Attribute( + absl::Span<X509* absl_nonnull> certs, HashAlgorithm hash_algorithm); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_TEST_HELPERS_H_
diff --git a/tsp/timestamp_parsing.cc b/tsp/timestamp_parsing.cc new file mode 100644 index 0000000..d9c7bc0 --- /dev/null +++ b/tsp/timestamp_parsing.cc
@@ -0,0 +1,250 @@ +// 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 "tsp/timestamp_parsing.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "crypto/cbs_utils.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/obj.h" +#include "tsp/constants.h" +#include "tsp/status_codes.h" + +namespace credentio { +namespace { + +// See RFC 3161, section 2.4.2. +struct FailureInfo { + int value; + absl::string_view description; +}; + +constexpr FailureInfo kFailureInfo[] = { + {0, R"((badAlg) unrecognized or unsupported Algorithm Identifier)"}, + {2, R"((badRequest) transaction not permitted or supported)"}, + {5, R"((badDataFormat) the data submitted has the wrong format)"}, + {14, R"((timeNotAvailable) the TSA's time source is not available)"}, + {15, + R"((unacceptedPolicy) the requested TSA policy is not supported by the TSA)"}, + {16, + R"((unacceptedExtension) the requested extension is not supported by the TSA)"}, + {17, + R"((addInfoNotAvailable) the additional information requested could not be understood or is not available)"}, + {25, + R"((systemFailure) the request cannot be handled due to system failure)"}, +}; + +// Converts a CBS containing a FailureInfo bitstring to a human-readable string. +std::string FailureInfoString(const CBS& failure_info) { + std::vector<absl::string_view> elements; + for (const auto& info : kFailureInfo) { + if (CBS_asn1_bitstring_has_bit(&failure_info, info.value)) { + elements.push_back(info.description); + } + } + return absl::StrJoin(elements, "; "); +} + +} // namespace + +absl::StatusOr<absl::string_view> ParseTimestampResp( + absl::string_view timestamp_resp) { + CBS timestamp_resp_cbs; + CBS_init(×tamp_resp_cbs, + reinterpret_cast<const uint8_t*>(timestamp_resp.data()), + timestamp_resp.length()); + CBS seq; + if (!CBS_get_asn1(×tamp_resp_cbs, &seq, CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError( + "cannot parse top-level DER SEQUENCE while parsing TimeStampResp"); + } + CBS pki_status_info; + if (!CBS_get_asn1(&seq, &pki_status_info, CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError("cannot parse TimeStampResp.status"); + } + + int64_t raw_status_code; + if (!CBS_get_asn1_int64(&pki_status_info, &raw_status_code)) { + return absl::InvalidArgumentError( + "cannot parse TimeStampResp.status.status"); + } + + ABSL_ASSIGN_OR_RETURN(TspPkiStatus status_code, + GetTspPkiStatus(raw_status_code)); + + if (!IsTspPkiStatusOk(status_code)) { + CBS status_string; + std::string status_string_joined; + if (CBS_peek_asn1_tag(&pki_status_info, CBS_ASN1_SEQUENCE) && + CBS_get_asn1(&pki_status_info, &status_string, CBS_ASN1_SEQUENCE)) { + CBS status_string_element; + std::vector<std::string> status_string_elements; + while (CBS_get_asn1(&status_string, &status_string_element, + CBS_ASN1_UTF8STRING)) { + status_string_elements.push_back(std::string( + reinterpret_cast<const char*>(CBS_data(&status_string_element)), + CBS_len(&status_string_element))); + } + status_string_joined = absl::StrJoin(status_string_elements, "; "); + } + + CBS failure_info; + std::string failure_info_string; + if (CBS_get_asn1(&pki_status_info, &failure_info, CBS_ASN1_BITSTRING)) { + failure_info_string = FailureInfoString(failure_info); + } + + std::string error_message = absl::StrCat( + "TimestampResp.status contains an error; status=", status_code); + if (!failure_info_string.empty()) { + absl::StrAppend(&error_message, "; ", failure_info_string); + } + if (!status_string_joined.empty()) { + absl::StrAppend(&error_message, "; ", status_string_joined); + } + return absl::InvalidArgumentError(error_message); + } + // We extract the entire TimeStampToken element (including the header + // bytes) because the CMS library expects a complete DER construct. + CBS ts_token; + if (!CBS_get_asn1_element(&seq, &ts_token, CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError( + "cannot extract TimeStampToken from TimeStampResp"); + } + return absl::string_view(reinterpret_cast<const char*>(ts_token.data), + ts_token.len); +} + +absl::Status ParseTstInfo(CBS cbs, absl::Time* time, + std::string* message_imprint_hash, + HashAlgorithm* hash_algorithm, std::string* nonce) { + CBS sequence_cbs; + if (!CBS_get_asn1(&cbs, /*out=*/&sequence_cbs, CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError("could not read top-level sequence"); + } + if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_INTEGER)) { + return absl::InvalidArgumentError("could not skip `version`"); + } + if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_OBJECT)) { + return absl::InvalidArgumentError("could not skip `policy`"); + } + + // Format of MessageImprint defined by RFC 3161 section 2.4.1 + CBS messageimprint_cbs; + if (!CBS_get_asn1(&sequence_cbs, /*out=*/&messageimprint_cbs, + CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError("could not read `messageImprint`"); + } + + // Format of AlgorithmIdentifier defined by RFC 5280 section 4.1.1.2 + CBS algorithm_identifier_cbs; + if (!CBS_get_asn1(&messageimprint_cbs, /*out=*/&algorithm_identifier_cbs, + CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError( + "could not read `messageImprint.hashAlgorithm`"); + } + + CBS oid_cbs; + if (!CBS_get_asn1(&algorithm_identifier_cbs, /*out=*/&oid_cbs, + CBS_ASN1_OBJECT)) { + return absl::InvalidArgumentError( + "could not read `messageImprint.hashAlgorithm.algorithm`"); + } + + ABSL_ASSIGN_OR_RETURN( + *hash_algorithm, NidToHashAlgorithm(OBJ_cbs2nid(&oid_cbs)), + _.SetPrepend() << "unsupported message imprint hash algorithm: "); + + CBS hash_cbs; + if (!CBS_get_asn1(&messageimprint_cbs, &hash_cbs, CBS_ASN1_OCTETSTRING)) { + return absl::InvalidArgumentError( + "could not read `messageImprint.hashedMessage`"); + } + + *message_imprint_hash = ToStringView(hash_cbs); + + if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_INTEGER)) { + return absl::InvalidArgumentError("could not skip `serialNumber`"); + } + + // Note that we can't use the OpenSSL function to decode the time value + // because it enforces the limits imposed in RFC 5280, which is more + // restrictive than RFC 3161. + CBS gentime_cbs; + if (!CBS_get_asn1(&sequence_cbs, /*out=*/&gentime_cbs, + CBS_ASN1_GENERALIZEDTIME)) { + return absl::InvalidArgumentError("could not read `genTime`"); + } + absl::string_view gentime_view( + reinterpret_cast<const char*>(gentime_cbs.data), gentime_cbs.len); + if (!absl::EndsWith(gentime_view, "Z")) { + return absl::InvalidArgumentError( + "`genTime` value is not specified in UTC (needs \"Z\" suffix)"); + } + if (gentime_view.find_first_of('Z') != gentime_view.length() - 1) { + return absl::InvalidArgumentError( + "`genTime` is not a valid timestamp (contains \"Z\" that is not at " + "end)"); + } + std::string error; + if (!absl::ParseTime(kGenTimeFormat, gentime_view, time, &error)) { + return absl::InvalidArgumentError( + absl::StrCat("could not parse `genTime` value: ", error)); + } + + // Skip over `accuracy` if exists. + if (CBS_peek_asn1_tag(&sequence_cbs, CBS_ASN1_SEQUENCE)) { + if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError("could not skip `accuracy`"); + } + } + // Skip over `ordering` if exists. + if (CBS_peek_asn1_tag(&sequence_cbs, CBS_ASN1_BOOLEAN)) { + int ordering; + if (!CBS_get_asn1_bool(&sequence_cbs, &ordering)) { + return absl::InvalidArgumentError("could not skip `ordering`"); + } + } + + // Retrieve `nonce` if exists. + if (CBS_peek_asn1_tag(&sequence_cbs, CBS_ASN1_INTEGER)) { + CBS nonce_cbs; + if (CBS_get_asn1_element(&sequence_cbs, &nonce_cbs, CBS_ASN1_INTEGER)) { + *nonce = std::string(reinterpret_cast<const char*>(CBS_data(&nonce_cbs)), + CBS_len(&nonce_cbs)); + } else { + return absl::InvalidArgumentError("could not read `nonce`"); + } + } else { + nonce->clear(); + } + + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/tsp/timestamp_parsing.h b/tsp/timestamp_parsing.h new file mode 100644 index 0000000..fbbe90e --- /dev/null +++ b/tsp/timestamp_parsing.h
@@ -0,0 +1,50 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_PARSING_H_ +#define THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_PARSING_H_ + +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "openssl/base.h" + +namespace credentio { + +// Pulls the CMS timestamp token out of TimeStampResp and checks the response's +// status. See RFC 3161 section 2.4.2. +// `timestamp_resp` must be the full DER SEQUENCE, including the header bytes. +// Any TSA status codes other than "granted" or "grantedWithMods" in the +// response's `status` field will result in INVALID_ARGUMENT (as will any DER +// parsing errors). +absl::StatusOr<absl::string_view> ParseTimestampResp( + absl::string_view timestamp_resp); + +// Extracts the `messageImprint`, `genTime`, and `nonce` values from a `TSTInfo` +// DER sequence, defined by RFC 3161. The array backing `cbs` will not be +// modified. All errors can be treated as `timestamp.malformed`. If the TSTInfo +// does not contain a `nonce` value, the provided `nonce` string will be left +// empty. +absl::Status ParseTstInfo(CBS cbs, absl::Time* time, + std::string* message_imprint_hash, + HashAlgorithm* hash_algorithm, std::string* nonce); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_PARSING_H_
diff --git a/tsp/timestamp_parsing_test.cc b/tsp/timestamp_parsing_test.cc new file mode 100644 index 0000000..46c985e --- /dev/null +++ b/tsp/timestamp_parsing_test.cc
@@ -0,0 +1,338 @@ +// 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 "tsp/timestamp_parsing.h" + +#include <cstddef> +#include <cstdint> +#include <string> + +#include "absl/base/no_destructor.h" +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "crypto/algorithms.h" +#include "crypto/cbs_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/mem.h" // IWYU pragma: keep, `bssl::UniquePtr` in base.h is incomplete +#include "testing/test_file_utils.h" +#include "tsp/constants.h" +#include "tsp/status_codes.h" +#include "tsp/test_helpers.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::Test; +using ::testing::TestWithParam; +using ::testing::ValuesIn; + +constexpr absl::string_view kSha384OidTxt = "2.16.840.1.101.3.4.2.2"; +constexpr absl::string_view kSha256OidTxt = "2.16.840.1.101.3.4.2.1"; + +constexpr absl::string_view kMessageImprintHash = "fake message imprint"; + +constexpr absl::string_view kTestKeysDir = "c2pa/testing/testdata/keys/"; + +absl::string_view ValidNonce() { + static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe)); + return *nonce; +} + +std::string LoadTestCryptoAsset(absl::string_view short_path) { + std::string file_path = absl::StrCat(kTestKeysDir, short_path); + auto contents = credentio_testing::GetContents(file_path); + CHECK_OK(contents.status()); + return *contents; +} + +absl::Time ParseGenTime(absl::string_view timestamp) { + absl::Time time; + std::string parse_error; + CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error)) + << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp + << "\"): " << parse_error; + return time; +} + +class ParseTimestampRespTest : public Test { + public: + static void SetUpTestSuite() { + *time_stamp_token_ = CreateTimeStampTokenEcdsa( + {"NOT DER NOT ANYTHING WHATEVER DO NOT CARE THIS IS JUST JUNK FOR " + "TESTING"}, + LoadTestCryptoAsset("test_ca/tsa.key"), + LoadTestCryptoAsset("test_ca/tsa.pem")); + } + + protected: + absl::string_view time_stamp_token() { return *time_stamp_token_; } + + private: + static inline absl::NoDestructor<std::string> time_stamp_token_; +}; + +TEST_F(ParseTimestampRespTest, ValidTimestampResp) { + std::string timestamp_resp = CreateTimeStampResp(time_stamp_token()); + + EXPECT_THAT(ParseTimestampResp(timestamp_resp), + IsOkAndHolds(time_stamp_token())); +} + +TEST_F(ParseTimestampRespTest, FailedRespWithNoExplanation) { + std::string timestamp_resp = + CreateFailedTimeStampResp(TspPkiStatus::kRejection); + + EXPECT_THAT( + ParseTimestampResp(timestamp_resp), + StatusIs(absl::StatusCode::kInvalidArgument, + "TimestampResp.status contains an error; status=rejection")); +} + +TEST_F(ParseTimestampRespTest, FailedRespWithStatusString) { + std::string timestamp_resp = CreateFailedTimeStampResp( + TspPkiStatus::kRejection, + /*status_string=*/{"some explanation", "some other explanation"}); + + EXPECT_THAT( + ParseTimestampResp(timestamp_resp), + StatusIs( + absl::StatusCode::kInvalidArgument, + R"(TimestampResp.status contains an error; status=rejection; some explanation; some other explanation)")); +} + +TEST_F(ParseTimestampRespTest, FailedRespWithFailureInfo) { + std::string timestamp_resp = CreateFailedTimeStampResp( + TspPkiStatus::kRejection, {}, + {5 /* badDataFormat */, 14 /* timeNotAvailable */}); + EXPECT_THAT( + ParseTimestampResp(timestamp_resp), + StatusIs( + absl::StatusCode::kInvalidArgument, + R"(TimestampResp.status contains an error; status=rejection; (badDataFormat) the data submitted has the wrong format; (timeNotAvailable) the TSA's time source is not available)")); +} + +TEST(ParseTimeStampRespTest, NoSequence) { + constexpr absl::string_view kNotSequence = "invalid"; + + EXPECT_THAT( + ParseTimestampResp(kNotSequence), + StatusIs( + absl::StatusCode::kInvalidArgument, + "cannot parse top-level DER SEQUENCE while parsing TimeStampResp")); +} + +TEST(ParseTimeStampRespTest, InvalidPkiStatusInfo) { + CBB cbb; + CHECK(CBB_init(&cbb, 1024)); + CBB req; + CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); + CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5)); + uint8_t* cbb_data; + size_t cbb_len; + CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); + + EXPECT_THAT(ParseTimestampResp(absl::string_view( + reinterpret_cast<char*>(cbb_data), cbb_len)), + StatusIs(absl::StatusCode::kInvalidArgument, + "cannot parse TimeStampResp.status")); +} + +TEST(ParseTimeStampRespTest, InvalidStatusCode) { + CBB cbb; + CHECK(CBB_init(&cbb, 1024)); + CBB req; + CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); + CBB pki_status_info; + CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE)); + CHECK(CBB_add_bytes(&pki_status_info, + reinterpret_cast<const uint8_t*>("12345"), 5)); + uint8_t* cbb_data; + size_t cbb_len; + CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); + + EXPECT_THAT(ParseTimestampResp(absl::string_view( + reinterpret_cast<char*>(cbb_data), cbb_len)), + StatusIs(absl::StatusCode::kInvalidArgument, + "cannot parse TimeStampResp.status.status")); +} + +TEST(ParseTimeStampRespTest, InvalidTimestampToken) { + CBB cbb; + CHECK(CBB_init(&cbb, 1024)); + CBB req; + CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE)); + CBB pki_status_info; + CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE)); + CHECK(CBB_add_asn1_int64( + &pki_status_info, static_cast<int64_t>(TspPkiStatus::kGrantedWithMods))); + CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5)); + uint8_t* cbb_data; + size_t cbb_len; + CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); + + EXPECT_THAT(ParseTimestampResp(absl::string_view( + reinterpret_cast<char*>(cbb_data), cbb_len)), + StatusIs(absl::StatusCode::kInvalidArgument, + "cannot extract TimeStampToken from TimeStampResp")); +} + +struct InvalidTimeFormatTestCase { + absl::string_view header_key; + absl::string_view timestamp; + absl::string_view expected_error_substr; +}; + +constexpr InvalidTimeFormatTestCase kInvalidFormatTestCases[] = { + {.timestamp = "6666666666666Z", + .expected_error_substr = "could not parse `genTime` value"}, + {.timestamp = "20240815094500.9999+0000Z", + .expected_error_substr = "could not parse `genTime` value"}, + {.timestamp = "20200521000000Z19920521000000Z19980521000000Z", + .expected_error_substr = + R"(`genTime` is not a valid timestamp (contains "Z" that is not at end))"}, + {.timestamp = "66666666666666", + .expected_error_substr = + "`genTime` value is not specified in UTC (needs \"Z\" suffix)"}, + {.timestamp = "6666666666666Z", + .expected_error_substr = "could not parse `genTime` value"}, + {.timestamp = "20240815094500.9999+0000Z", + .expected_error_substr = "could not parse `genTime` value"}, + {.timestamp = "20200521000000Z19920521000000Z19980521000000Z", + .expected_error_substr = + R"(`genTime` is not a valid timestamp (contains "Z" that is not at end))"}, + {.timestamp = "66666666666666", + .expected_error_substr = + "`genTime` value is not specified in UTC (needs \"Z\" suffix)"}, +}; + +using InvalidTimeFormatTest = TestWithParam<InvalidTimeFormatTestCase>; + +TEST_P(InvalidTimeFormatTest, RejectsInvalidFormats) { + std::string tst_info = + CreateTstInfo(GetParam().timestamp, kMessageImprintHash, kSha256OidTxt); + absl::Time time; + std::string message_imprint_hash; + HashAlgorithm hash_algorithm; + std::string nonce; + + EXPECT_THAT(ParseTstInfo(FromStringView(tst_info), &time, + &message_imprint_hash, &hash_algorithm, &nonce), + StatusIs(absl::StatusCode::kInvalidArgument, + testing::HasSubstr(GetParam().expected_error_substr))); +} + +INSTANTIATE_TEST_SUITE_P(ParseTstInfoTest, InvalidTimeFormatTest, + ValuesIn(kInvalidFormatTestCases)); + +struct ValidTimeFormatTestCase { + absl::string_view timestamp; +}; + +constexpr ValidTimeFormatTestCase kValidTimestampFormatTestCases[] = { + {.timestamp = "20230521091011.1337Z"}, + {.timestamp = "20230521000000Z"}, + {.timestamp = "20240101000000Z"}, + {.timestamp = "20251231235959.9999Z"}}; + +using ValidTimeFormatTest = TestWithParam<ValidTimeFormatTestCase>; + +TEST_P(ValidTimeFormatTest, AcceptsValidFormats) { + std::string tst_info = CreateTstInfo( + GetParam().timestamp, kMessageImprintHash, kSha384OidTxt, ValidNonce()); + + absl::Time time; + std::string message_imprint_hash; + HashAlgorithm hash_algorithm; + std::string nonce; + + EXPECT_THAT(ParseTstInfo(FromStringView(tst_info), &time, + &message_imprint_hash, &hash_algorithm, &nonce), + IsOk()); + EXPECT_EQ(time, ParseGenTime(GetParam().timestamp)); + EXPECT_EQ(message_imprint_hash, kMessageImprintHash); + EXPECT_EQ(hash_algorithm, HashAlgorithm::kSha384); + EXPECT_EQ(nonce, ValidNonce()); +} + +INSTANTIATE_TEST_SUITE_P(ParseTstInfoTest, ValidTimeFormatTest, + ValuesIn(kValidTimestampFormatTestCases)); + +TEST(ParseTstInfoTest, InvalidVersion) { + CBB cbb; + CHECK(CBB_init(&cbb, 1024)); + CBB tst_info; + CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE)); + // Version - should be INTEGER + CHECK(CBB_add_asn1_octet_string(&tst_info, nullptr, 0)); + + uint8_t* cbb_data; + size_t cbb_len; + CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); + + absl::Time time; + std::string message_imprint_hash; + HashAlgorithm hash_algorithm; + std::string nonce; + + EXPECT_THAT( + ParseTstInfo(FromStringView(absl::string_view( + reinterpret_cast<char*>(cbb_data), cbb_len)), + &time, &message_imprint_hash, &hash_algorithm, &nonce), + StatusIs(absl::StatusCode::kInvalidArgument, "could not skip `version`")); +} + +TEST(ParseTstInfoTest, InvalidPolicy) { + CBB cbb; + CHECK(CBB_init(&cbb, 1024)); + CBB tst_info; + CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE)); + // Version + CHECK(CBB_add_asn1_int64(&tst_info, 1)); + // Policy - should be OBJECT IDENTIFIER + CHECK(CBB_add_asn1_int64(&tst_info, 12345)); + + uint8_t* cbb_data; + size_t cbb_len; + CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len)); + bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data); + + absl::Time time; + std::string message_imprint_hash; + HashAlgorithm hash_algorithm; + std::string nonce; + + EXPECT_THAT( + ParseTstInfo(FromStringView(absl::string_view( + reinterpret_cast<char*>(cbb_data), cbb_len)), + &time, &message_imprint_hash, &hash_algorithm, &nonce), + StatusIs(absl::StatusCode::kInvalidArgument, "could not skip `policy`")); +} + +} // namespace +} // namespace credentio
diff --git a/tsp/timestamp_verifier.cc b/tsp/timestamp_verifier.cc new file mode 100644 index 0000000..86df41b --- /dev/null +++ b/tsp/timestamp_verifier.cc
@@ -0,0 +1,263 @@ +// 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 "tsp/timestamp_verifier.h" + +#include <cstdint> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include "absl/log/absl_log.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "openssl/asn1.h" +#include "openssl/base.h" +#include "openssl/bytestring.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/pki/verify.h" +#include "openssl/pki/verify_error.h" +#include "openssl/stack.h" +#include "openssl/x509.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/timestamp_parsing.h" +#include "tsp/verified_timestamp.h" +namespace credentio { + +namespace { + +// Represents the `EssCertIdV2` SEQUENCE, part of the `SigningCertificateV2` +// attribute as defined in RFC 5035. +struct EssCertIdV2 { + HashAlgorithm hash_algorithm; + std::string cert_hash; +}; + +absl::Status CertMatchesEssCertId(absl::string_view cert, EssCertIdV2 id) { + auto hasher = CreateHasher(id.hash_algorithm); + if (!hasher.ok()) { + return absl::Status( + absl::StatusCode::kFailedPrecondition, + absl::StrCat("Invalid HashAlgorithm value: ", id.hash_algorithm, "; ", + hasher.status().message())); + } + (*hasher)->Update(cert); + if ((*hasher)->Digest() == id.cert_hash) { + return absl::OkStatus(); + } else { + return absl::UnauthenticatedError( + R"(certificate does not match EssCertIDV2 value)"); + } +} + +absl::Status CheckTsaCertificateTrust( + const bssl::VerifyTrustStore& tsa_roots, + const VerifiedTimestamp& verified_timestamp, + absl::Span<const EssCertIdV2> ess_cert_ids) { + if (ess_cert_ids.empty()) { + return absl::InvalidArgumentError( + R"(SigningCertificateV2 signed attribute is empty; at minimum, TSAs must record their own leaf certificate in this attribute)"); + } + absl::Status status = CertMatchesEssCertId( + verified_timestamp.tsa_certificate(), ess_cert_ids[0]); + if (!status.ok()) { + return absl::Status( + status.code(), + absl::StrCat("TSA cert does not match SigningCertificateV2 attribute: ", + status.message())); + } + + auto intermediate_certs_der = + verified_timestamp.certificate_chain().subspan(1); + if (intermediate_certs_der.size() > 2) { + return absl::UnauthenticatedError( + absl::StrCat("excessive timestamp certificate chain length: ", + intermediate_certs_der.size() + 1)); + } + + bssl::CertificateVerifyOptions opts; + + opts.key_purpose = + bssl::CertificateVerifyOptions::KeyPurpose::C2PA_TIMESTAMPING; + opts.leaf_cert = verified_timestamp.tsa_certificate(); + opts.intermediates.insert(opts.intermediates.end(), + intermediate_certs_der.begin(), + intermediate_certs_der.end()); + // Timestamp cert chain validity periods are evaluated with respect to the + // attested time, per + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_time_stamp: + // "Validate that the attested time, as found in the genTime field (in the + // timeStampToken), falls within the validity period of the TSA’s signing + // certificate." + opts.time = absl::ToTimeT(verified_timestamp.asserted_time()); + opts.trust_store = &tsa_roots; + bssl::VerifyError error; + if (!bssl::CertificateVerify(opts, &error)) { + if (error.Code() == bssl::VerifyError::StatusCode::CERTIFICATE_EXPIRED || + error.Code() == + bssl::VerifyError::StatusCode::CERTIFICATE_NOT_YET_VALID) { + return absl::OutOfRangeError(error.DiagnosticString()); + } + return absl::UnauthenticatedError( + absl::StrCat("timestamp certificate chain could not be validated: ", + error.DiagnosticString())); + } + return absl::OkStatus(); +} + +// Extracts the `certs` field of the `SigningCertificateV2` attribute (attribute +// defined in RFC 5035). +absl::StatusOr<std::vector<EssCertIdV2>> GetEssCertIds( + absl::string_view signing_certificate_v2_bytes) { + std::vector<EssCertIdV2> out; + CBS signing_certificate_cbs; + CBS_init( + &signing_certificate_cbs, + reinterpret_cast<const uint8_t*>(signing_certificate_v2_bytes.data()), + signing_certificate_v2_bytes.size()); + + CBS signing_certificate_sequence; + if (!CBS_get_asn1(&signing_certificate_cbs, &signing_certificate_sequence, + CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError( + R"(could not parse DER SEQUENCE for SigningCertificateV2 attribute value)"); + } + + CBS certs_sequence; + if (!CBS_get_asn1(&signing_certificate_sequence, &certs_sequence, + CBS_ASN1_SEQUENCE)) { + return absl::InvalidArgumentError( + R"(could not parse `certs` field (DER SEQUENCE) of SigningCertificateV2 attribute value)"); + } + + CBS ess_cert_id_cbs; + while (CBS_get_asn1(&certs_sequence, &ess_cert_id_cbs, CBS_ASN1_SEQUENCE)) { + // The hash algorithm is an optional field, if it is not present it defaults + // to SHA-256. + int algorithm_nid = NID_sha256; + CBS original = ess_cert_id_cbs; + CBS oid_cbs; + if (CBS_get_asn1(&ess_cert_id_cbs, &oid_cbs, CBS_ASN1_OBJECT)) { + algorithm_nid = OBJ_cbs2nid(&oid_cbs); + if (algorithm_nid == NID_undef) { + bssl::UniquePtr<char> oid_txt_uniq(CBS_asn1_oid_to_text(&oid_cbs)); + absl::string_view oid_txt; + if (oid_txt_uniq != nullptr) { + oid_txt = oid_txt_uniq.get(); + } else { + oid_txt = "<unprintable OID>"; + } + return absl::InvalidArgumentError(absl::StrCat( + R"(unrecognized OID in `certs.hashAlgorithm` field of SigningCertificateV2 attribute value; OID value: )", + oid_txt)); + } + } else { + ess_cert_id_cbs = original; + } + auto hash_algorithm = NidToHashAlgorithm(algorithm_nid); + if (!hash_algorithm.ok()) { + return absl::Status( + hash_algorithm.status().code(), + absl::StrCat( + "unsupported hash algorithm in `certs.hashAlgorithm` field of " + "SigningCertificateV2 attribute value: ", + hash_algorithm.status().message())); + } + + CBS hash_cbs; + if (!CBS_get_asn1(&ess_cert_id_cbs, &hash_cbs, CBS_ASN1_OCTETSTRING)) { + return absl::InvalidArgumentError( + R"(could not parse `certs.hash` field (DER OCTETSTRING) of SigningCertificateV2 attribute value)"); + } + out.push_back(EssCertIdV2{ + .hash_algorithm = *hash_algorithm, + .cert_hash = + std::string(reinterpret_cast<const char*>(CBS_data(&hash_cbs)), + CBS_len(&hash_cbs))}); + } + + return out; +} + +} // namespace + +absl::StatusOr<VerifiedTimestamp> TimestampVerifier::VerifyTimestampToken( + const ParsedTimestampToken& parsed_token) const { + ABSL_ASSIGN_OR_RETURN(auto chain, parsed_token.GetCertificateChain()); + + // CMS supports more digest algorithms than C2PA, so we need to check that the + // digest algorithm is supported. + ABSL_RETURN_IF_ERROR(parsed_token.GetMessageImprintHashAlgorithm().status()); + + if (chain.empty()) { + // This would indicate a bug in the CMS library. + return absl::InternalError("TSA leaf cert missing"); + } + + absl::Status verify_status = parsed_token.VerifySignature(); + if (!verify_status.ok()) { + return absl::UnauthenticatedError( + absl::StrCat("the timestamp signature could not be verified: ", + verify_status.message())); + } + + ABSL_ASSIGN_OR_RETURN(auto signing_certificate_v2_bytes, + parsed_token.GetSigningCertificateV2Bytes()); + + ABSL_ASSIGN_OR_RETURN(auto ess_cert_ids, + GetEssCertIds(signing_certificate_v2_bytes)); + + ABSL_ASSIGN_OR_RETURN(auto tst_info_bytes, parsed_token.GetTstInfoBytes()); + CBS cbs; + CBS_init(&cbs, reinterpret_cast<const uint8_t*>(tst_info_bytes.data()), + tst_info_bytes.size()); + + absl::Time time; + std::string message_imprint_hash; + HashAlgorithm hash_algorithm; + std::string nonce; + ABSL_RETURN_IF_ERROR( + ParseTstInfo(cbs, &time, &message_imprint_hash, &hash_algorithm, &nonce)); + + ABSL_ASSIGN_OR_RETURN( + VerifiedTimestamp verified_timestamp, + VerifiedTimestamp::Create(time, std::move(chain), + std::move(message_imprint_hash), hash_algorithm, + nonce)); + + if (tsa_roots_ == nullptr) { + ABSL_LOG_EVERY_N_SEC(INFO, 60) + << "Bypassing C2PA TSA certificate trust check."; + } else { + ABSL_RETURN_IF_ERROR(CheckTsaCertificateTrust( + *tsa_roots_, verified_timestamp, ess_cert_ids)); + } + ABSL_VLOG(1) << "Verified timestamp: " << verified_timestamp.asserted_time(); + return verified_timestamp; +} + +} // namespace credentio
diff --git a/tsp/timestamp_verifier.h b/tsp/timestamp_verifier.h new file mode 100644 index 0000000..9eaaa7e --- /dev/null +++ b/tsp/timestamp_verifier.h
@@ -0,0 +1,52 @@ +// 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. +// + +// Support for RFC 3161 TimestampToken verification. +#ifndef THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_VERIFIER_H_ +#define THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_VERIFIER_H_ + +#include "absl/base/attributes.h" +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "openssl/pki/verify.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { + +// Verifier for RFC 3161 TimestampTokens, implemented using SimpleCMS. +class TimestampVerifier { + public: + // If `tsa_roots` is `nullptr`, the verifier will skip trust checks (only for + // use in tests). + explicit TimestampVerifier(const bssl::VerifyTrustStore* absl_nullable + tsa_roots ABSL_ATTRIBUTE_LIFETIME_BOUND) + : tsa_roots_(tsa_roots) {} + + // Verifies the signature of an RFC 3161 TimestampToken, and + // checks that the TSA is trusted. Error codes reported include + // kInvalidArgument: Malformed timestamp token + // kUnauthenticated: Signature verification errors, missing/untrusted certs + // kOutOfRange: A certificate was not within its validity period + absl::StatusOr<VerifiedTimestamp> VerifyTimestampToken( + const ParsedTimestampToken& parsed_token) const; + + private: + const bssl::VerifyTrustStore* tsa_roots_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_VERIFIER_H_
diff --git a/tsp/timestamp_verifier_test.cc b/tsp/timestamp_verifier_test.cc new file mode 100644 index 0000000..001717e --- /dev/null +++ b/tsp/timestamp_verifier_test.cc
@@ -0,0 +1,756 @@ +// 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 "tsp/timestamp_verifier.h" + +#include <cstddef> +#include <cstdint> +#include <iterator> +#include <memory> +#include <optional> +#include <string> +#include <vector> + +#include "absl/algorithm/container.h" +#include "absl/base/no_destructor.h" +#include "absl/log/absl_check.h" +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" +#include "crypto/cbs_utils.h" +#include "crypto/default/trust_store.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "openssl/base.h" +#include "openssl/bio.h" +#include "openssl/bytestring.h" +#include "openssl/digest.h" +#include "openssl/mem.h" +#include "openssl/nid.h" +#include "openssl/obj.h" +#include "openssl/pem.h" +#include "openssl/pki/verify.h" +#include "openssl/x509.h" +#include "proto/validation_result.pb.h" +#include "tsp/constants.h" +#include "tsp/parsed_timestamp_token.h" +#include "tsp/test_helpers.h" +#include "tsp/verified_timestamp.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::Return; + +constexpr absl::string_view kCaPem = R"(-----BEGIN CERTIFICATE----- +MIIB3zCCAWagAwIBAgIUXJz+6VKGMVOVs+xupRS3Cne3or0wCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw +NDE1MTMyMFoYDzIxMjUwMTExMTUxMzIwWjAmMSQwIgYDVQQDDBtHb29nbGUgQzJQ +QSBUZXN0aW5nIFJvb3QgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATWa/wOwQMD +5zNmXVNGbPJuptbvXrNBhgKZI6SHRzngJ4bV+SShgLDqFdtnDfwAO/nxHyACTNl6 +sJ9ue407+HW6W1WEODD9a/jVYizAVkzUaEDElDEA5PPkegHqtVJuf8CjUzBRMB0G +A1UdDgQWBBTTMOrQr2ny9gYyywPGUZGx/KENtzAfBgNVHSMEGDAWgBTTMOrQr2ny +9gYyywPGUZGx/KENtzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQC +MCsAxaLyEkp2nyQ2BtBoSuu52k3qfWLNAOHiaifoaxhQzDo1juNYuITM8I88PwVO +QAIwJBRVRNysxvouMf7cQ2VSYTPc32BY/m38wnWS3K94l7eS3sANM9U++e8dgEtG +U2Tv +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kTsaPem = R"(-----BEGIN CERTIFICATE----- +MIIB1zCCAVygAwIBAgIUaUM3tWVucTzvVWUNY0HjFKBAEkwwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw +NDIxNDIxNFoYDzIxMjUwMTExMjE0MjE0WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ +QSBUZXN0aW5nIFRTQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLlLssu8MJdD +CS3UV4CPcI1l4lnQ7Y6V7jAaAXTt7wbRnN8lbjcMmfzY4mqhc6HMtFYqYj4UeO7t +tjkvzI2PfGmjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEF +BQcDCDAdBgNVHQ4EFgQUeWySrF5tKc2Cwe7AzkxALXVXyIwwHwYDVR0jBBgwFoAU +0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaQAwZgIxAPwCKKGMymYV +8p2gXDDyJSMbHJnfHu/FvfeaWYF6a6twCCt7IFehJWbyg/f1p/3Q7QIxAK0yLI6L +/FTQPRaB3nZBm4Ocw9QZ171qisDKd1wW83mjmFAp9aNFlFv2yQt9A1NeEQ== +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kTsaRsaPem = R"(-----BEGIN CERTIFICATE----- +MIIDoDCCAyegAwIBAgIUDDONK2J0GuK8OSkZYI4sN4GGJaYwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw +NTAwNDU1N1oYDzIxMjUwMTEyMDA0NTU3WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ +QSBUZXN0aW5nIFRTQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO5N +FYAVihiznVBVhnpoOIvz790xxqdpZXRYrCXkj9L2o07sjUza8cotpvDzd9W8EkPg +CJSl0dJ4VeZ7wq4PH0azLgpWDppTVtUtzbYGWacvgWsgLQ96SqAmPYnuHwD+b+Wv +q/p35JB3csFDpg2F9SKFHZH50KQJ4z+WLAKf9Gexfs0y8DsN3DtQgzaVF2vS7/SL +jfFbnS1aHXoeKvPiu475pltkuw0velbXi8/cHIq3VuQOwxgyCIE4FuWJYYPMMu5j +HJ0+bBtDI+Nn8rWmN7hpOV1Lo268gzwR2HtGmdM1LABj+RY8nBdyXJmfAY9kqm/D +VuEVrsGdIOYiAw7iHLvjc4CfNaKs6l9bhTppsVydLZE1fKuMFvmSs5Gc9yIermeu +jmoPB6QSVJh76mKOug7BmEzLHipFRxfDOmdr+eP/5w3L1ZOA+nEG2XWVICbIH/ND +rteeFCGJsDV7wDx6Yhpfvjh8A5qP9Z41MW8CtqpzNakivtVaDKmULZ/dyKeI8BZ5 +DvEg/6mpwN6Eu06czep0tO4LQ/l3935w3d/lkpn1yKDu9nm02uvVBEqwZW2lR5BT +MeqxQTMrrbmoi0mjsyX+5XmsUzAzBFlZ9HJi14p6vd70FJOlRXttGxCxiLC/QkmN +krFnFH7v1bhbRGGtKqttUK4yogdnxaOhZ6gr/s45AgMBAAGjajBoMA4GA1UdDwEB +/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAdBgNVHQ4EFgQU+rL+AIZ5 +EsQVhIN6SfX34LSTS7YwHwYDVR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcw +CgYIKoZIzj0EAwIDZwAwZAIwDrCJbyS2Z4yA+82o7slKap3VpBwqBW5xrYjIJyV3 +IGzPI3i55bQb0wKRf6SMBHmCAjAfrVYauA+rpNxEYEIoM/ziaJ0xEbF/tw/JQyVE +0JW905o/hzXHO5LV3z/x0SYRPiY= +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kTsaWithIntermediatePem = + R"(-----BEGIN CERTIFICATE----- +MIIBvzCCAWSgAwIBAgIUImopbS3q7uF8Wz88f39/wfxAIYowCgYIKoZIzj0EAwIw +LjEsMCoGA1UEAwwjR29vZ2xlIEMyUEEgVGVzdGluZyBJbnRlcm1lZGlhdGUgQ0Ew +IBcNMjUwMzEzMTM1NzI2WhgPMjEyNTAyMTcxMzU3MjZaMCIxIDAeBgNVBAMMF0dv +b2dsZSBDMlBBIFRlc3RpbmcgVFNBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +Z9YEmeJodxdGQdhJbSjIeccIbxjIQY5rPp/c6zqF4/mRr8VOdMur3r/r0Gyxju8k +7R+6/j5IjQwzci01Qs7W06NqMGgwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM +MAoGCCsGAQUFBwMIMB0GA1UdDgQWBBQGI2mf9lUe+oNSZE/WRQlwVGMgoTAfBgNV +HSMEGDAWgBRmRE2zkZe58xIqg//9Iw76XyOLKjAKBggqhkjOPQQDAgNJADBGAiEA +onFfvfflN96uF0ECnej2aZ7ikuyz+OsDEQVDQZNBTHMCIQCo5YiNm7EeeMexhDzM +vVdDSKzUFmzJXGZAO/kkEQyNXw== +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kIntermediatePem = R"(-----BEGIN CERTIFICATE----- +MIIByjCCAVGgAwIBAgIUVhW73Ry+Zn7eKwhnxTiyLDJ3cKowCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDMx +MzEzMzc0MVoYDzIxMjUwMjE3MTMzNzQxWjAuMSwwKgYDVQQDDCNHb29nbGUgQzJQ +QSBUZXN0aW5nIEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABJKYZuxD1F5Eqkgl+fqnubgtLr4MHlAh2p/7n8E/BOZuemrAAXVw7p27D1Oy +n6RNyobJVRZHcm61i09hfTKw/NajUzBRMB0GA1UdDgQWBBRmRE2zkZe58xIqg//9 +Iw76XyOLKjAfBgNVHSMEGDAWgBTTMOrQr2ny9gYyywPGUZGx/KENtzAPBgNVHRMB +Af8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQCMBqHAcfcOv21PmRMWchgZ37ZRoor +4+XlKPnh+L0gBBRykqI49VvvpgxtCC2jVA2BnQIwZf0DOnk6c4lUvSzrEJ7+ToR1 +dJMoNuufOTVoMQdojCLwnH4GrwID0qsdXJm8X0XY +-----END CERTIFICATE-----)"; + +constexpr absl::string_view kClaimSignerPem = R"(-----BEGIN CERTIFICATE----- +MIICDzCCAZWgAwIBAgIUR8DAqLjMOExo0KJS3DGC4Qsy79kwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIy +MzA1MDMwMFoYDzIxMjUwMTMwMDUwMzAwWjBbMSYwJAYDVQQDDB1Hb29nbGUgVGVz +dCBDMlBBIENsYWltIFNpZ25lcjEdMBsGA1UECgwUR29vZ2xlIEMyUEEgVGVzdCBP +cmcxEjAQBgNVBAsMCVRlc3QgVW5pdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA +BKw1/4qPpbSgEWNUy7B5R48dxnsx92d5lqh7HY204TpvmxQgukcwavpW8XBxSdYr +F+XEgNStkprWpnkoKWNpShyjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E +DDAKBggrBgEFBQcDBDAdBgNVHQ4EFgQU6wle6tElLcBSIwsoBICb7gInPiQwHwYD +VR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaAAwZQIw +YNgGpaaZxI2km45t/Wd+njvQlF0JJ4+9GvZ0bDQWH7kjAQTmKrkxuNMT77cA2IMW +AjEArZqFNmE/5yv8tzHbPP73c3n8BPUmjIpZR5A2nzjfhPzpClFp70lkO+mTu82d +ULnv +-----END CERTIFICATE-----)"; + +class MockParsedTimestampToken : public ParsedTimestampToken { + public: + MOCK_METHOD(absl::Status, VerifySignature, (), (const, override)); + MOCK_METHOD(absl::StatusOr<HashAlgorithm>, GetMessageImprintHashAlgorithm, (), + (const, override)); + MOCK_METHOD(absl::StatusOr<std::vector<std::string>>, GetCertificateChain, (), + (const, override)); + MOCK_METHOD(absl::StatusOr<absl::string_view>, GetSigningCertificateV2Bytes, + (), (const, override)); + MOCK_METHOD(absl::StatusOr<absl::string_view>, GetTstInfoBytes, (), + (const, override)); +}; + +std::string GetSigningCertificateV2Value(absl::string_view attribute_bytes) { + CBS cbs = FromStringView(attribute_bytes); + CBS seq, oid, set; + ABSL_CHECK(CBS_get_asn1(&cbs, &seq, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(CBS_get_asn1(&seq, &oid, CBS_ASN1_OBJECT)); + ABSL_CHECK(CBS_get_asn1(&seq, &set, CBS_ASN1_SET)); + CBS val_elem; + ABSL_CHECK(CBS_get_any_asn1_element(&set, &val_elem, nullptr, nullptr)); + return std::string(ToStringView(val_elem)); +} + +constexpr absl::string_view kSha384OidTxt = "2.16.840.1.101.3.4.2.2"; + +constexpr absl::string_view kMessageImprintHash = "fake message imprint"; +constexpr absl::string_view kValidTimestamp = "20240101000000Z"; + +absl::Time ValidTimestampTime() { + static absl::Time time = []() { + absl::Time time; + std::string err; + ABSL_CHECK(absl::ParseTime(kGenTimeFormat, kValidTimestamp, &time, &err)) + << err; + return time; + }(); + + return time; +} + +bssl::UniquePtr<X509> ParseCert(absl::string_view pem) { + CBS cbs = FromStringView(pem); + bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(CBS_data(&cbs), CBS_len(&cbs))); + ABSL_CHECK(bio != nullptr); + bssl::UniquePtr<X509> x509( + PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr)); + ABSL_CHECK(x509 != nullptr); + return x509; +} + +std::string RawDer(X509* x509) { + uint8_t* der_ptr = nullptr; + size_t der_len = i2d_X509(x509, &der_ptr); + bssl::UniquePtr<uint8_t> der(der_ptr); + + return std::string(reinterpret_cast<char*>(der_ptr), der_len); +} + +std::string RawDer(absl::string_view pem) { + return RawDer(ParseCert(pem).get()); +} + +absl::Time ParseGenTime(absl::string_view timestamp) { + absl::Time time; + std::string parse_error; + ABSL_CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error)) + << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp + << "\"): " << parse_error; + return time; +} + +absl::string_view ValidNonce() { + static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe)); + return *nonce; +} + +std::string SigningCertificateV2AttributeFromPem( + absl::Span<const absl::string_view> pems) { + std::vector<bssl::UniquePtr<X509>> certs; + for (absl::string_view pem : pems) { + certs.push_back(ParseCert(pem)); + } + std::vector<X509*> ptr_vec; + absl::c_transform(certs, std::back_inserter(ptr_vec), + [](const auto& cert) { return cert.get(); }); + return CreateSigningCertificateV2Attribute(absl::MakeSpan(ptr_vec), + HashAlgorithm::kSha256); +} + +class TimestampVerifierTest : public testing::Test { + protected: + TimestampVerifierTest() : no_trust_checks_(/*tsa_roots=*/nullptr) {} + + void SetupMockDefaults(MockParsedTimestampToken& mock_token, + const std::vector<std::string>& cert_chain, + absl::string_view v2_val, absl::string_view tst_info) { + ON_CALL(mock_token, GetCertificateChain()) + .WillByDefault(Return(cert_chain)); + ON_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillByDefault(Return(HashAlgorithm::kSha384)); + ON_CALL(mock_token, VerifySignature()) + .WillByDefault(Return(absl::OkStatus())); + ON_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillByDefault(Return(v2_val)); + ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info)); + } + + TimestampVerifier no_trust_checks_; +}; + +TEST_F(TimestampVerifierTest, AcceptsRsaTimestamp) { + std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash, + kSha384OidTxt, ValidNonce()); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaRsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token); + ABSL_ASSERT_OK(timestamp); + EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime()); + EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash)); + EXPECT_THAT(timestamp->message_imprint_hash_algorithm(), + Eq(HashAlgorithm::kSha384)); + EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce())); + EXPECT_THAT(timestamp->tsa_certificate(), Eq(RawDer(kTsaRsaPem))); + EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(RawDer(kTsaRsaPem))); +} + +TEST_F(TimestampVerifierTest, AcceptsEcdsaTimestamp) { + std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash, + kSha384OidTxt, ValidNonce()); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token); + ABSL_ASSERT_OK(timestamp); + EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime()); + EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash)); + EXPECT_THAT(timestamp->message_imprint_hash_algorithm(), + Eq(HashAlgorithm::kSha384)); + EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce())); + EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der)); + EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(cert_der)); +} + +TEST_F(TimestampVerifierTest, AcceptsTimestampWithIntermediateCertificate) { + std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash, + kSha384OidTxt, ValidNonce()); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + std::string intermediate_der = RawDer(kIntermediatePem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info); + + auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token); + ABSL_ASSERT_OK(timestamp); + EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime()); + EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash)); + EXPECT_THAT(timestamp->message_imprint_hash_algorithm(), + Eq(HashAlgorithm::kSha384)); + EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce())); + EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der)); + EXPECT_THAT(timestamp->certificate_chain(), + ElementsAre(cert_der, intermediate_der)); +} + +TEST_F(TimestampVerifierTest, GetSigningCertificateV2BytesFails) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(absl::InvalidArgumentError("Missing attribute"))); + + EXPECT_THAT( + no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, "Missing attribute")); +} + +TEST_F(TimestampVerifierTest, InvalidTstInfoFails) { + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {RawDer(kTsaRsaPem)}, v2_val, + "THIS IS NOT BER OR DER OR CER OR AER OR ..."); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("could not read top-level sequence"))); +} + +TEST_F(TimestampVerifierTest, SignatureVerificationFails) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()) + .WillOnce(Return(absl::UnauthenticatedError("Signature failed"))); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("Signature failed"))); +} + +TEST_F(TimestampVerifierTest, EmptyChainFails) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInternal, + HasSubstr("TSA leaf cert missing"))); +} + +TEST_F(TimestampVerifierTest, InvalidSigningCertificateAttribute) { + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return("THIS IS NOT A DER SEQUENCE")); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "could not parse DER SEQUENCE for SigningCertificateV2 " + "attribute value")); +} + +TEST_F(TimestampVerifierTest, UnrecognizedSiginingCertificateHashAlgorithm) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + constexpr absl::string_view kFakeOid = "1.2.840.113549.1.800.888.8888"; + CBS hash_cbs = FromStringView( + "thisisnotarealhashbutthetestwontcarebecauseitwillfailduetothebadoidanywa" + "ys"); + + CBB certs_entry_cbb; + ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + CBB obj_cbb; + ABSL_CHECK(CBB_add_asn1(&certs_entry_cbb, &obj_cbb, CBS_ASN1_OBJECT)); + ABSL_CHECK( + CBB_add_asn1_oid_from_text(&obj_cbb, kFakeOid.data(), kFakeOid.length())); + ABSL_CHECK(CBB_flush(&certs_entry_cbb)); + + ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs), + CBS_len(&hash_cbs))); + + auto value_bytes = FinishToString(&cbb); + ABSL_ASSERT_OK(value_bytes); + + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(*value_bytes)); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "unrecognized OID in `certs.hashAlgorithm` field of " + "SigningCertificateV2 attribute value; OID value: " + "1.2.840.113549.1.800.888.8888")); +} + +TEST_F(TimestampVerifierTest, NoSigningCertificateHash) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + CBB certs_entry_cbb; + ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha384)); + ABSL_CHECK(CBB_flush(&certs_entry_cbb)); + + auto value_bytes = FinishToString(&cbb); + ABSL_ASSERT_OK(value_bytes); + + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(*value_bytes)); + + EXPECT_THAT( + no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "could not parse `certs.hash` field (DER OCTETSTRING) of " + "SigningCertificateV2 attribute value")); +} + +TEST_F(TimestampVerifierTest, UnsupportedSigningCertificateHashAlgorithm) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + CBS hash_cbs = FromStringView("Not a real hash"); + + CBB certs_entry_cbb; + ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha1)); + ABSL_CHECK(CBB_flush(&certs_entry_cbb)); + + ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs), + CBS_len(&hash_cbs))); + + auto value_bytes = FinishToString(&cbb); + ABSL_ASSERT_OK(value_bytes); + + MockParsedTimestampToken mock_token; + EXPECT_CALL(mock_token, GetCertificateChain()) + .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)})); + EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillOnce(Return(HashAlgorithm::kSha384)); + EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus())); + EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillOnce(Return(*value_bytes)); + + EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kInvalidArgument, + "unsupported hash algorithm in `certs.hashAlgorithm` " + "field of SigningCertificateV2 attribute value: hash " + "algorithm NID is not on C2PA allowlist: 64")); +} + +class TrustChecksTest : public ::testing::Test { + protected: + TrustChecksTest() + : tsa_roots_(LoadTestRoots().value()), verifier_(tsa_roots_.get()) {} + + absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTestRoots() { + return LoadTrustStore(kCaPem); + } + + void SetupMockDefaults(MockParsedTimestampToken& mock_token, + const std::vector<std::string>& cert_chain, + absl::string_view v2_val, absl::string_view tst_info) { + ON_CALL(mock_token, GetCertificateChain()) + .WillByDefault(Return(cert_chain)); + ON_CALL(mock_token, GetMessageImprintHashAlgorithm()) + .WillByDefault(Return(HashAlgorithm::kSha384)); + ON_CALL(mock_token, VerifySignature()) + .WillByDefault(Return(absl::OkStatus())); + ON_CALL(mock_token, GetSigningCertificateV2Bytes()) + .WillByDefault(Return(v2_val)); + ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info)); + } + + const std::unique_ptr<bssl::VerifyTrustStore> tsa_roots_; + TimestampVerifier verifier_; +}; + +TEST_F(TrustChecksTest, Trusted) { + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token); + ABSL_ASSERT_OK(verified_timestamp); + EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime))); +} + +TEST_F(TrustChecksTest, TrustedWithIntermediate) { + constexpr absl::string_view kTime = "20250514000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + std::string intermediate_der = RawDer(kIntermediatePem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info); + + auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token); + ABSL_ASSERT_OK(verified_timestamp); + EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime))); +} + +TEST_F(TrustChecksTest, TooManyIntermediateCerts) { + constexpr absl::string_view kTime = "20250514000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + std::string intermediate_der = RawDer(kIntermediatePem); + + MockParsedTimestampToken mock_token; + // Chain length 4 (1 leaf + 3 intermediates) + SetupMockDefaults( + mock_token, + {cert_der, intermediate_der, intermediate_der, intermediate_der}, v2_val, + tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("excessive timestamp certificate chain length"))); +} + +TEST_F(TrustChecksTest, TrustedMissingIntermediate) { + constexpr absl::string_view kTime = "20250514000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = + SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaWithIntermediatePem); + + MockParsedTimestampToken mock_token; + // Missing intermediate! + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("No matching issuer found"))); +} + +TEST_F(TrustChecksTest, Untrusted) { + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + + std::string dummy_cert = "DUMMY_CERT_DER"; + // Compute SHA256 of dummy_cert using internal OpenSSL without hasher.h + uint8_t hash[32]; + unsigned int hash_len; + ABSL_CHECK(EVP_Digest(dummy_cert.data(), dummy_cert.length(), hash, &hash_len, + EVP_sha256(), nullptr)); + std::string digest(reinterpret_cast<char*>(hash), hash_len); + + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_entry_cbb; + ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE)); + ABSL_CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha256)); + ABSL_CHECK(CBB_flush(&certs_entry_cbb)); + CBS hash_cbs = FromStringView(digest); + ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs), + CBS_len(&hash_cbs))); + auto v2_val = FinishToString(&cbb); + ABSL_ASSERT_OK(v2_val); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {dummy_cert}, *v2_val, tst_info); + + EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("timestamp certificate chain"))); +} + +TEST_F(TrustChecksTest, InvalidEku) { + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kClaimSignerPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kClaimSignerPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + // The certificate has Email Protection EKU, which is not allowed for TSAs. + EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kUnauthenticated, + HasSubstr("prohibited key usage"))); +} + +TEST_F(TrustChecksTest, SigningCertificateV2AttributeLeafMismatch) { + constexpr absl::string_view kTime = "21250204161300Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs( + absl::StatusCode::kUnauthenticated, + HasSubstr("TSA cert does not match SigningCertificateV2 attribute: " + "certificate does not match EssCertIDV2 value"))); +} + +TEST_F(TrustChecksTest, EmptySigningCertificateAttribute) { + CBB cbb; + ABSL_CHECK(CBB_init(&cbb, 1000)); + CBB value_seq_cbb; + ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE)); + CBB certs_cbb; + ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE)); + + auto value_bytes = FinishToString(&cbb); + ABSL_ASSERT_OK(value_bytes); + + constexpr absl::string_view kTime = "20250205000000Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string cert_der = RawDer(kTsaRsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, *value_bytes, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs( + absl::StatusCode::kInvalidArgument, + "SigningCertificateV2 signed attribute is empty; at minimum, " + "TSAs must record their own leaf certificate in this attribute")); +} + +TEST_F(TrustChecksTest, OutsideTsaCertValidityWindow) { + // This time is outside the validity window of the test TSA certificate, + // but inside the validity window of the test CA certificate. + constexpr absl::string_view kTime = "20250204161300Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("before notBefore"))); +} + +TEST_F(TrustChecksTest, OutsideCaCertValidityWindow) { + // This time is inside the validity window of the test TSA certificate, + // but outside the validity window of the test CA certificate. + constexpr absl::string_view kTime = "21250204161300Z"; + std::string tst_info = + CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt); + std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem}); + std::string v2_val = GetSigningCertificateV2Value(v2_attr); + std::string cert_der = RawDer(kTsaPem); + + MockParsedTimestampToken mock_token; + SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info); + + EXPECT_THAT( + verifier_.VerifyTimestampToken(mock_token), + StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("after notAfter"))); +} + +} // namespace +} // namespace credentio
diff --git a/tsp/verified_timestamp.h b/tsp/verified_timestamp.h new file mode 100644 index 0000000..c9d7004 --- /dev/null +++ b/tsp/verified_timestamp.h
@@ -0,0 +1,99 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_TSP_VERIFIED_TIMESTAMP_H_ +#define THIRD_PARTY_CREDENTIO_TSP_VERIFIED_TIMESTAMP_H_ + +#include <string> +#include <utility> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" +#include "absl/types/span.h" +#include "crypto/algorithms.h" + +namespace credentio { + +// Represents a TimeStampToken (RFC 3161 section 2.4.2) that has passed +// signature checks. The trustworthiness of signatures is not checked, callers +// should verify this before use. +class VerifiedTimestamp final { + public: + static absl::StatusOr<VerifiedTimestamp> Create( + absl::Time asserted_time, std::vector<std::string> certificate_chain, + std::string message_imprint_hash, + HashAlgorithm message_imprint_hash_algorithm, std::string nonce) { + if (certificate_chain.empty()) { + return absl::InvalidArgumentError("certificate_chain must not be empty"); + } + return VerifiedTimestamp(asserted_time, std::move(certificate_chain), + std::move(message_imprint_hash), + message_imprint_hash_algorithm, std::move(nonce)); + } + + // The time at which the timestamp was generated (genTime in RFC 3161). + absl::Time asserted_time() const { return asserted_time_; } + + // The signing certificate of the Time Stamping Authority (TSA), DER-encoded. + absl::string_view tsa_certificate() const { + return certificate_chain_.front(); + } + + // The full certificate chain of the TSA, starting with the signing + // certificate, DER-encoded. + absl::Span<const std::string> certificate_chain() const { + return certificate_chain_; + } + + // The hash value of the data that was timestamped (hashedMessage in RFC + // 3161). + absl::string_view message_imprint_hash() const { + return message_imprint_hash_; + } + + // The hash algorithm used to calculate the message imprint. + HashAlgorithm message_imprint_hash_algorithm() const { + return message_imprint_hash_algorithm_; + } + + // The ASN.1 / DER INTEGER element, with tag / length prefix. May be empty if + // the field was absent in the source TimeStampToken. + absl::string_view nonce() const { return nonce_; } + + private: + VerifiedTimestamp(absl::Time asserted_time, + std::vector<std::string>&& certificate_chain, + std::string&& message_imprint_hash, + HashAlgorithm message_imprint_hash_algorithm, + std::string nonce) + : asserted_time_(asserted_time), + certificate_chain_(std::move(certificate_chain)), + message_imprint_hash_(std::move(message_imprint_hash)), + message_imprint_hash_algorithm_(message_imprint_hash_algorithm), + nonce_(std::move(nonce)) {} + + absl::Time asserted_time_; + std::vector<std::string> certificate_chain_; + std::string message_imprint_hash_; + HashAlgorithm message_imprint_hash_algorithm_; + std::string nonce_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_TSP_VERIFIED_TIMESTAMP_H_
diff --git a/utils/BUILD b/utils/BUILD new file mode 100644 index 0000000..0330b49 --- /dev/null +++ b/utils/BUILD
@@ -0,0 +1,290 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "riegeli", + srcs = ["riegeli.cc"], + hdrs = ["riegeli.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@riegeli//riegeli/base:arithmetic", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "riegeli_test", + srcs = ["riegeli_test.cc"], + deps = [ + ":riegeli", + "//testing:test_string_utils", + "@abseil-cpp//absl/functional:function_ref", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "distinguished_name", + srcs = ["distinguished_name.cc"], + hdrs = ["distinguished_name.h"], + deps = [ + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "distinguished_name_test", + srcs = ["distinguished_name_test.cc"], + deps = [ + ":distinguished_name", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "byte_readers", + hdrs = ["byte_readers.h"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:str_format", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/endian:endian_reading", + ], +) + +cc_test( + name = "byte_readers_test", + srcs = ["byte_readers_test.cc"], + deps = [ + ":byte_readers", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "byte_writers", + srcs = ["byte_writers.cc"], + hdrs = ["byte_writers.h"], + deps = [ + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "byte_writers_test", + srcs = ["byte_writers_test.cc"], + deps = [ + ":byte_writers", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "uri", + srcs = ["uri.cc"], + hdrs = ["uri.h"], + deps = [ + "//constants:labels", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "uri_test", + srcs = ["uri_test.cc"], + deps = [ + ":uri", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "byte_instruction", + srcs = ["byte_instruction.cc"], + hdrs = ["byte_instruction.h"], + deps = [ + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@riegeli//riegeli/base:chain", + "@riegeli//riegeli/bytes:chain_reader", + "@riegeli//riegeli/bytes:copy_all", + "@riegeli//riegeli/bytes:reader", + "@riegeli//riegeli/bytes:writer", + ], +) + +cc_test( + name = "byte_instruction_test", + srcs = ["byte_instruction_test.cc"], + deps = [ + ":byte_instruction", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + "@riegeli//riegeli/bytes:string_reader", + "@riegeli//riegeli/bytes:string_writer", + ], +) + +cc_library( + name = "media_type", + srcs = ["media_type.cc"], + hdrs = ["media_type.h"], + deps = [ + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "media_type_test", + srcs = ["media_type_test.cc"], + deps = [ + ":media_type", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "status_tracker", + hdrs = ["status_tracker.h"], + deps = [ + "//constants:status_codes", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//validator:tracker", + ], +) + +cc_library( + name = "two_stage_status_tracker", + srcs = ["two_stage_status_tracker.cc"], + hdrs = ["two_stage_status_tracker.h"], + deps = [ + ":status_tracker", + "//constants:status_codes", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//validator:tracker", + ], +) + +cc_library( + name = "dual_status_tracker", + srcs = ["dual_status_tracker.cc"], + hdrs = ["dual_status_tracker.h"], + deps = [ + ":status_tracker", + "//constants:status_codes", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//validator:result", + "//validator:tracker", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/memory", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + ], +) + +cc_test( + name = "two_stage_status_tracker_test", + srcs = ["two_stage_status_tracker_test.cc"], + deps = [ + ":status_tracker", + ":two_stage_status_tracker", + "//constants:status_codes", + "//proto:assertion_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "//testing:test_validation_tracker", + "//validator:tracker", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "dual_status_tracker_test", + srcs = ["dual_status_tracker_test.cc"], + deps = [ + ":dual_status_tracker", + "//constants:status_codes", + "//proto:assertion_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/strings", + "@googletest//:gtest_main", + ], +)
diff --git a/utils/byte_instruction.cc b/utils/byte_instruction.cc new file mode 100644 index 0000000..c04b707 --- /dev/null +++ b/utils/byte_instruction.cc
@@ -0,0 +1,286 @@ +// 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 "utils/byte_instruction.h" + +#include <algorithm> +#include <cstdint> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "riegeli/base/chain.h" +#include "riegeli/bytes/chain_reader.h" +#include "riegeli/bytes/copy_all.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +namespace { + +constexpr uint64_t kBufferChunkSize = 1024 * 1024; // 1 MiB + +absl::Status ReadDataToBuffer(riegeli::Writer& writer, int64_t length, + riegeli::Chain& buffer) { + // Flush the writer to make sure the data is available for reading. + if (!writer.Flush()) { + return writer.status(); + } + + // Create a reader at the current position of the writer. + riegeli::Reader* reader = writer.ReadMode(writer.pos()); + if (reader == nullptr) { + return writer.status(); + } + + // Read data from the reader to the buffer. + if (!reader->ReadAndAppend(length, buffer)) { + return reader->StatusOrAnnotate( + absl::DataLossError("Failed to read data from buffer")); + } + + // Close the reader. + if (!reader->Close()) { + return reader->StatusOrAnnotate( + absl::DataLossError("Failed to close reader")); + } + + return absl::OkStatus(); +} + +absl::Status WriteDataFromBuffer(riegeli::Chain& buffer, int64_t length, + riegeli::Writer& writer) { + // Create a reader for the buffer. + riegeli::ChainReader cr(&buffer); + + // Read data from the buffer. + absl::string_view data; + if (!cr.Read(length, data)) { + return cr.StatusOrAnnotate( + absl::DataLossError("Failed to read data from buffer")); + } + + // Write the data to the writer. + if (!writer.Write(data)) { + return writer.status(); + } + + // Close the reader. + if (!cr.Close()) { + return cr.StatusOrAnnotate(absl::DataLossError("Failed to close reader")); + } + + // Remove data from the buffer, done after writing as the absl::string_view + // data is a ref into the buffer. + buffer.RemovePrefix(length); + + return absl::OkStatus(); +} + +absl::Status MoveDataThroughBuffer(riegeli::Chain& buffer, int64_t length, + riegeli::Writer& writer) { + ABSL_RETURN_IF_ERROR(ReadDataToBuffer(writer, length, buffer)); + return WriteDataFromBuffer(buffer, length, writer); +} + +absl::Status MoveDataThroughBuffer(riegeli::Chain& buffer, + absl::string_view data, + riegeli::Writer& writer) { + buffer.Append(data); + return WriteDataFromBuffer(buffer, data.size(), writer); +} + +absl::Status PushData(riegeli::Writer& writer, riegeli::Chain& buffer, + int64_t end_position) { + if (buffer.empty()) { + // We didn't insert anything yet, so just seek + if (!writer.Seek(end_position)) { + return writer.StatusOrAnnotate( + absl::DataLossError("Failed to seek writer")); + } + return absl::OkStatus(); + } + + uint64_t remaining_byte_count = end_position - writer.pos(); + + while (remaining_byte_count > 0) { + uint64_t count_to_move = std::min(kBufferChunkSize, remaining_byte_count); + + ABSL_RETURN_IF_ERROR(MoveDataThroughBuffer(buffer, count_to_move, writer)); + + remaining_byte_count -= count_to_move; + } + return absl::OkStatus(); +} + +} // namespace + +absl::Status ApplyByteInstructions( + riegeli::Reader* absl_nonnull source, + absl::Span<const ByteInstruction> instructions, + riegeli::Writer* absl_nonnull destination) { + if (!source->Seek(0) || !destination->Seek(0)) { + return absl::DataLossError("failed to seek to start of file"); + } + + if (instructions.empty()) { + // No instructions, just copy the source to the destination. + return riegeli::CopyAll(*source, *destination); + } + + if (!source->SupportsSize() || !source->Size().has_value()) { + return absl::InvalidArgumentError("Source does not have a size."); + } + uint64_t file_size = source->Size().value_or(0); + uint64_t instruction_index = 0; + + while (source->pos() < file_size && instruction_index < instructions.size()) { + const ByteInstruction& ins = instructions[instruction_index]; + + if (ins.offset < source->pos()) { + return absl::InvalidArgumentError( + "Byte instruction offsets must be in ascending order"); + } + if (ins.offset >= file_size) { + // Next instruction is at or past the end of the file. + break; + } + + // Copy enough data from the source to reach the instruction offset. + if (source->pos() < ins.offset) { + if (!source->Copy(ins.offset - source->pos(), *destination)) { + ABSL_RETURN_IF_ERROR(source->status()); + ABSL_RETURN_IF_ERROR(destination->status()); + return absl::DataLossError("Unexpected EOF while copying source data"); + } + } + + // Write the data + if (!destination->Write(ins.bytes)) { + return destination->status(); + } + + if (ins.operation == ByteInstruction::Operation::kReplace) { + uint64_t bytes_to_skip = + std::min<uint64_t>(ins.bytes.size(), file_size - source->pos()); + if (!source->Skip(bytes_to_skip)) { + ABSL_RETURN_IF_ERROR(source->status()); + return absl::DataLossError("Unexpected EOF while skipping source data"); + } + } + + ++instruction_index; + } + + // Copy any remaining data from the source to the destination. + if (source->pos() < file_size) { + ABSL_RETURN_IF_ERROR(riegeli::CopyAll(*source, *destination)); + } + + // Write any remaining instructions. + while (instruction_index < instructions.size()) { + const ByteInstruction& ins = instructions[instruction_index]; + if (!destination->Write(ins.bytes)) { + return destination->status(); + } + ++instruction_index; + } + + return absl::OkStatus(); +} + +absl::Status ApplyByteInstructions( + absl::Span<const credentio::ByteInstruction> instructions, + riegeli::Writer* absl_nonnull writer) { + if (!writer->Seek(0)) { + return absl::DataLossError("failed to seek to start of file"); + } + + if (instructions.empty()) { + return absl::OkStatus(); + } + + if (!writer->Size().has_value()) { + return absl::InvalidArgumentError("Writer does not have a size."); + } + + uint64_t file_size = writer->Size().value(); + riegeli::Chain buffer; + + uint64_t instruction_index = 0; + + while (writer->pos() < file_size && instruction_index < instructions.size()) { + const ByteInstruction& ins = instructions[instruction_index]; + + if (ins.offset < writer->pos()) { + return absl::InvalidArgumentError( + "Byte instruction offsets must be in ascending order"); + } + if (ins.offset >= file_size) { + // Next instruction is at or past the end of the file. + break; + } + + // Process enough data to reach the instruction offset. + ABSL_RETURN_IF_ERROR(PushData(*writer, buffer, ins.offset)); + + if (ins.operation == credentio::ByteInstruction::Operation::kInsert) { + // Insert operations do not impact the current offset, just add to the + // buffer. + buffer.Append(ins.bytes); + } else if (ins.operation == + credentio::ByteInstruction::Operation::kReplace) { + if (buffer.empty()) { + // If the buffer is empty, we can write the data directly to the writer. + if (!writer->Write(ins.bytes)) { + return writer->status(); + } + } else { + // If the buffer is not empty, we need to move the data through the + // buffer. + ABSL_RETURN_IF_ERROR(MoveDataThroughBuffer(buffer, ins.bytes, *writer)); + } + } + + ++instruction_index; + } + + // Process all remaining data up to the end of the file. + if (writer->pos() < file_size) { + ABSL_RETURN_IF_ERROR(PushData(*writer, buffer, file_size)); + } + if (!buffer.empty()) { + // Write any remaining data in the buffer. + if (!writer->Write(buffer)) { + return writer->status(); + } + } + + // Write any remaining instructions. + while (instruction_index < instructions.size()) { + if (!writer->Write(instructions[instruction_index].bytes)) { + return writer->status(); + } + ++instruction_index; + } + + return absl::OkStatus(); +} + +} // namespace credentio
diff --git a/utils/byte_instruction.h b/utils/byte_instruction.h new file mode 100644 index 0000000..d72f3e5 --- /dev/null +++ b/utils/byte_instruction.h
@@ -0,0 +1,78 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_BYTE_INSTRUCTION_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_BYTE_INSTRUCTION_H_ + +#include <cstdint> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/types/span.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" + +namespace credentio { + +// A ByteInstruction is a single instruction for modifying a target asset. +// The instructions are applied in order and are based on the original asset. +struct ByteInstruction { + enum class Operation { + kInsert, // Add the bytes in this instruction to the destination. + kReplace, // Overwrite the bytes in the destination with the bytes in this + // instruction. + }; + + Operation operation; + // The offset of the destination in the target asset, based on the start of + // the asset before any adjustments are made. + uint64_t offset; + std::string bytes; + + bool operator==(const ByteInstruction& other) const { + return operation == other.operation && offset == other.offset && + bytes == other.bytes; + } +}; + +// Applies a series of byte instructions to a source reader, writing the +// modified content to a destination writer. +// +// Bytes are copied from `source` to `destination` based on instructions: +// - If `instruction.operation` is `kInsert`, `instruction.bytes` are inserted +// at `instruction.offset`. +// - If `instruction.operation` is `kReplace`, `instruction.bytes` replace +// `instruction.bytes.size()` bytes in `source` starting at +// `instruction.offset`. +// +// After all instructions are applied, any remaining bytes in `source` are +// copied to `destination`. +// +// Returns an error if `source` or `destination` fail or if the wrong number of +// bytes are copied or if the instructions are not in ascending order. +absl::Status ApplyByteInstructions( + riegeli::Reader* absl_nonnull source, + absl::Span<const ByteInstruction> instructions, + riegeli::Writer* absl_nonnull destination); +// Does the same as above, but applies the instructions in place to the given +// writer. +absl::Status ApplyByteInstructions( + absl::Span<const credentio::ByteInstruction> instructions, + riegeli::Writer* absl_nonnull writer); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_BYTE_INSTRUCTION_H_
diff --git a/utils/byte_instruction_test.cc b/utils/byte_instruction_test.cc new file mode 100644 index 0000000..5d0c0b0 --- /dev/null +++ b/utils/byte_instruction_test.cc
@@ -0,0 +1,400 @@ +// 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 "utils/byte_instruction.h" + +#include <cstdint> +#include <string> +#include <vector> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +struct SuccessfulApplicationTestCase { + std::string name; + std::string input; + std::vector<ByteInstruction> instructions; + std::string expected; +}; + +using SuccessfulApplicationTest = + testing::TestWithParam<SuccessfulApplicationTestCase>; + +INSTANTIATE_TEST_SUITE_P( + ParameterizedTests, SuccessfulApplicationTest, + testing::ValuesIn<SuccessfulApplicationTestCase>({ + { + .name = "NoInstructions", + .input = "hello world", + .instructions = {}, + .expected = "hello world", + }, + { + .name = "EmptyInsert", + .input = "", + .instructions = + { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 0, + .bytes = "foo"}, + }, + .expected = "foo", + }, + { + .name = "EmptyReplace", + .input = "", + .instructions = + { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 0, + .bytes = "foo"}, + }, + .expected = "foo", + }, + { + .name = "EmptyInstruction", + .input = "bar", + .instructions = + { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 0, + .bytes = ""}, + }, + .expected = "bar", + }, + { + .name = "InsertAtStart", + .input = "bar", + .instructions = + { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 0, + .bytes = "foo"}, + }, + .expected = "foobar", + }, + { + .name = "InsertAtEnd", + .input = "bar", + .instructions = + { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 3, + .bytes = "foo"}, + }, + .expected = "barfoo", + }, + { + .name = "ReplaceAtStart", + .input = "bar", + .instructions = + { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 0, + .bytes = "foo"}, + }, + .expected = "foo", + }, + { + .name = "ReplaceAtEnd", + .input = "bar", + .instructions = + { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 3, + .bytes = "foo"}, + }, + .expected = "barfoo", + }, + { + .name = "InsertAtMiddle", + .input = "bar", + .instructions = + { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 1, + .bytes = "foo"}, + }, + .expected = "bfooar", + }, + { + .name = "ReplaceAtMiddle", + .input = "bar", + .instructions = + { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 1, + .bytes = "foo"}, + }, + .expected = "bfoo", + }, + { + .name = "ReplaceInsertReplace", + .input = "0_________0_________0_________0_________", + .instructions = + { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 10, + .bytes = "replace"}, + {.operation = ByteInstruction::Operation::kInsert, + .offset = 17, + .bytes = "insert"}, + {.operation = ByteInstruction::Operation::kReplace, + .offset = 30, + .bytes = "second"}, + }, + .expected = "0_________replaceinsert___0_________second____", + }, + { + .name = "InsertPastEndOfInput", + .input = "hello world", + .instructions = + { + {.operation = ByteInstruction::Operation::kInsert, + .offset = 30, + .bytes = " goodnight"}, + }, + .expected = "hello world goodnight", + }, + { + .name = "ReplacePastEndOfInput", + .input = "hello world", + .instructions = + { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 30, + .bytes = " goodnight"}, + }, + .expected = "hello world goodnight", + }, + { + .name = "ReplacePastEndOfInputWithTrailingInstructions", + .input = "hello world", + .instructions = + { + {.operation = ByteInstruction::Operation::kReplace, + .offset = 6, + .bytes = "world goodnight"}, + {.operation = ByteInstruction::Operation::kInsert, + .offset = 30, + .bytes = " and goodbye"}, + }, + .expected = "hello world goodnight and goodbye", + }, + }), + [](const testing::TestParamInfo<SuccessfulApplicationTest::ParamType>& + info) { return info.param.name; }); + +TEST_P(SuccessfulApplicationTest, AppliesByteInstructions) { + riegeli::StringReader<std::string> reader(GetParam().input); + ASSERT_THAT(reader.status(), IsOk()); + riegeli::StringWriter<std::string> writer; + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT(ApplyByteInstructions(&reader, GetParam().instructions, &writer), + IsOk()); + + ASSERT_TRUE(reader.VerifyEndAndClose()); + ASSERT_TRUE(writer.Close()); + + EXPECT_EQ(writer.dest(), GetParam().expected); +} + +TEST_P(SuccessfulApplicationTest, AppliesByteInstructionsInPlace) { + riegeli::StringWriter<std::string> writer( + GetParam().input, riegeli::StringWriterBase::Options().set_append(true)); + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT(ApplyByteInstructions(GetParam().instructions, &writer), IsOk()); + + ASSERT_TRUE(writer.Close()); + + EXPECT_EQ(writer.dest(), GetParam().expected); +} + +TEST(SuccessfulApplicationTest, ReaderNotAtStart) { + riegeli::StringReader<> reader("hello world"); + ASSERT_THAT(reader.status(), IsOk()); + ASSERT_TRUE(reader.Seek(8)); + + riegeli::StringWriter<std::string> writer; + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT( + ApplyByteInstructions(&reader, + {{.operation = ByteInstruction::Operation::kReplace, + .offset = 6, + .bytes = "W"}}, + &writer), + IsOk()); + + ASSERT_TRUE(reader.VerifyEndAndClose()); + ASSERT_TRUE(writer.Close()); + + EXPECT_EQ(writer.dest(), "hello World"); +} + +TEST(SuccessfulApplicationTest, WriterNotAtStart) { + riegeli::StringWriter<std::string> writer( + "hello world", riegeli::StringWriterBase::Options().set_append(true)); + ASSERT_THAT(writer.status(), IsOk()); + ASSERT_TRUE(writer.Seek(8)); + + EXPECT_THAT( + ApplyByteInstructions({{.operation = ByteInstruction::Operation::kReplace, + .offset = 6, + .bytes = "W"}}, + &writer), + IsOk()); + + ASSERT_TRUE(writer.Close()); + + EXPECT_EQ(writer.dest(), "hello World"); +} + +TEST(FailedApplicationTest, ReaderClosed) { + riegeli::StringReader<> reader("hello world"); + ASSERT_THAT(reader.status(), IsOk()); + reader.Close(); + + riegeli::StringWriter<std::string> writer; + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer), + StatusIs(absl::StatusCode::kFailedPrecondition, + HasSubstr("Object closed"))); +} + +TEST(FailedApplicationTest, WriterClosed) { + riegeli::StringReader<> reader("hello world"); + ASSERT_THAT(reader.status(), IsOk()); + + riegeli::StringWriter<std::string> writer; + ASSERT_THAT(writer.status(), IsOk()); + writer.Close(); + + EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer), + StatusIs(absl::StatusCode::kFailedPrecondition, + HasSubstr("Object closed"))); +} + +TEST(FailedApplicationTest, InstructionOutOfOrder) { + riegeli::StringReader<> reader("hello world"); + ASSERT_THAT(reader.status(), IsOk()); + + riegeli::StringWriter<std::string> writer; + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT( + ApplyByteInstructions(&reader, + {{.operation = ByteInstruction::Operation::kReplace, + .offset = 5, + .bytes = "da"}, + {.operation = ByteInstruction::Operation::kReplace, + .offset = 2, + .bytes = "da"}}, + &writer), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must be in ascending order"))); +} + +class PrematureEofReader : public riegeli::StringReader<> { + public: + PrematureEofReader(absl::string_view data, uint64_t fake_size) + : riegeli::StringReader<>(data), fake_size_(fake_size) {} + + std::optional<riegeli::Position> SizeImpl() override { return fake_size_; } + + private: + uint64_t fake_size_; +}; + +TEST(FailedApplicationTest, UnexpectedEofDuringCopy) { + PrematureEofReader reader("hello", 100); + ASSERT_THAT(reader.status(), IsOk()); + + riegeli::StringWriter<std::string> writer; + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT( + ApplyByteInstructions(&reader, + {{.operation = ByteInstruction::Operation::kReplace, + .offset = 10, + .bytes = "x"}}, + &writer), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Unexpected EOF while copying source data"))); +} + +TEST(FailedApplicationTest, UnexpectedEofDuringSkip) { + PrematureEofReader reader("hello", 100); + ASSERT_THAT(reader.status(), IsOk()); + + riegeli::StringWriter<std::string> writer; + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT( + ApplyByteInstructions(&reader, + {{.operation = ByteInstruction::Operation::kReplace, + .offset = 0, + .bytes = "xxxxxxxxxx"}}, + &writer), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("Unexpected EOF while skipping source data"))); +} + +TEST(FailedApplicationInPlaceTest, WriterClosed) { + riegeli::StringWriter<std::string> writer( + "hello world", riegeli::StringWriterBase::Options().set_append(true)); + ASSERT_THAT(writer.status(), IsOk()); + writer.Close(); + + EXPECT_THAT(ApplyByteInstructions({}, &writer), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("failed to seek to start of file"))); +} + +TEST(FailedApplicationInPlaceTest, InstructionOutOfOrder) { + riegeli::StringWriter<std::string> writer( + "hello world", riegeli::StringWriterBase::Options().set_append(true)); + ASSERT_THAT(writer.status(), IsOk()); + + EXPECT_THAT( + ApplyByteInstructions({{.operation = ByteInstruction::Operation::kReplace, + .offset = 5, + .bytes = "da"}, + {.operation = ByteInstruction::Operation::kReplace, + .offset = 2, + .bytes = "da"}}, + &writer), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("must be in ascending order"))); +} + +} // namespace +} // namespace credentio
diff --git a/utils/byte_readers.h b/utils/byte_readers.h new file mode 100644 index 0000000..970a2ec --- /dev/null +++ b/utils/byte_readers.h
@@ -0,0 +1,74 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_BYTE_READERS_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_BYTE_READERS_H_ + +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "riegeli/endian/endian_reading.h" + +namespace credentio { + +// Reads a big-endian unsigned integer of type `N` from the supplied segment. +// Returns an error if the segment is not large enough to contain the integer. +template <typename N> +absl::StatusOr<N> ReadUint(absl::string_view segment) { + if (segment.size() < sizeof(N)) { + return absl::InvalidArgumentError(absl::StrFormat( + "Segment of length %d is not large enough to contain a %d byte " + "unsigned integer", + segment.size(), sizeof(N))); + } + return riegeli::ReadBigEndian<N>(segment.data()); +} + +// Reads a big-endian unsigned integer of type `N` from the supplied segment and +// removes the bytes from the segment. Returns an error if the segment is not +// large enough to contain the integer and does not remove any bytes from the +// segment. +template <typename N> +absl::StatusOr<N> ConsumeUint(absl::string_view* segment) { + if (segment == nullptr) { + return absl::InvalidArgumentError("Segment is null"); + } + ABSL_ASSIGN_OR_RETURN(auto result, ReadUint<N>(*segment)); + segment->remove_prefix(sizeof(N)); + return result; +} + +// Skips `sizeof(N)` bytes from the supplied segment. Returns an error if the +// segment is not large enough to contain `sizeof(N)` bytes and does not remove +// any bytes from the segment. +template <typename N> +absl::Status SkipBytes(absl::string_view* segment) { + if (segment == nullptr) { + return absl::InvalidArgumentError("Segment is null"); + } + if (segment->size() < sizeof(N)) { + return absl::InvalidArgumentError(absl::StrFormat( + "Segment of length %d is not large enough to skip %d bytes", + segment->size(), sizeof(N))); + } + segment->remove_prefix(sizeof(N)); + return absl::OkStatus(); +} + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_BYTE_READERS_H_
diff --git a/utils/byte_readers_test.cc b/utils/byte_readers_test.cc new file mode 100644 index 0000000..5a3773c --- /dev/null +++ b/utils/byte_readers_test.cc
@@ -0,0 +1,96 @@ +// 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 "utils/byte_readers.h" + +#include <cstdint> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { + +namespace { + +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; + +TEST(ByteReadersTest, ReadTooSmall) { + EXPECT_THAT(ReadUint<uint16_t>("\x12"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ByteReadersTest, ReadUint) { + EXPECT_THAT(ReadUint<uint16_t>("\x12\x34"), IsOkAndHolds(0x1234)); +} + +TEST(ByteReadersTest, ReadUintWithRemainder) { + EXPECT_THAT(ReadUint<uint16_t>("\x12\x34\x56\x78"), IsOkAndHolds(0x1234)); +} + +TEST(ByteReadersTest, ConsumeNull) { + EXPECT_THAT(ConsumeUint<uint16_t>(nullptr), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ByteReadersTest, ConsumeTooSmall) { + absl::string_view input = "\x12"; + EXPECT_THAT(ConsumeUint<uint16_t>(&input), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_EQ(input, "\x12"); +} + +TEST(ByteReadersTest, ConsumeUint) { + absl::string_view input = "\x12\x34"; + EXPECT_THAT(ConsumeUint<uint16_t>(&input), IsOkAndHolds(0x1234)); + EXPECT_EQ(input, ""); +} + +TEST(ByteReadersTest, ConsumeUintWithRemainder) { + absl::string_view input = "\x12\x34\x56\x78"; + EXPECT_THAT(ConsumeUint<uint16_t>(&input), IsOkAndHolds(0x1234)); + EXPECT_EQ(input, "\x56\x78"); +} + +TEST(ByteReadersTest, SkipBytesNull) { + EXPECT_THAT(SkipBytes<uint16_t>(nullptr), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ByteReadersTest, SkipBytes) { + absl::string_view input = "\x12\x34"; + EXPECT_THAT(SkipBytes<uint8_t>(&input), IsOk()); + EXPECT_EQ(input, "\x34"); +} + +TEST(ByteReadersTest, SkipBytesAll) { + absl::string_view input = "\x12\x34"; + EXPECT_THAT(SkipBytes<uint16_t>(&input), IsOk()); + EXPECT_EQ(input, ""); +} + +TEST(ByteReadersTest, SkipBytesNotEnough) { + absl::string_view input = "\x12\x34"; + EXPECT_THAT(SkipBytes<uint32_t>(&input), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_EQ(input, "\x12\x34"); +} + +} // namespace +} // namespace credentio
diff --git a/utils/byte_writers.cc b/utils/byte_writers.cc new file mode 100644 index 0000000..a194884 --- /dev/null +++ b/utils/byte_writers.cc
@@ -0,0 +1,49 @@ +// 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 "utils/byte_writers.h" + +#include <cstddef> +#include <cstdint> +#include <vector> + +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +namespace { +// Best to specify template parameter explicitly when calling this. +template <class T> +void WriteNetworkOrder(T value, std::vector<uint8_t>* bytes) { + size_t n = bytes->size(); + bytes->resize(n + sizeof(T)); + riegeli::WriteBigEndian<T>(value, reinterpret_cast<char*>(bytes->data() + n)); +} + +} // namespace + +void WriteUint16NetworkOrder(uint16_t value, std::vector<uint8_t>* bytes) { + WriteNetworkOrder<uint16_t>(value, bytes); +} + +void WriteUint32NetworkOrder(uint32_t value, std::vector<uint8_t>* bytes) { + WriteNetworkOrder<uint32_t>(value, bytes); +} + +void WriteUint64NetworkOrder(uint64_t value, std::vector<uint8_t>* bytes) { + WriteNetworkOrder<uint64_t>(value, bytes); +} + +} // namespace credentio
diff --git a/utils/byte_writers.h b/utils/byte_writers.h new file mode 100644 index 0000000..419f0dd --- /dev/null +++ b/utils/byte_writers.h
@@ -0,0 +1,38 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_BYTE_WRITERS_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_BYTE_WRITERS_H_ + +#include <cstdint> +#include <vector> + +namespace credentio { + +// Writes the given unsigned 16-bit integer to the given vector in big endian +// format. +void WriteUint16NetworkOrder(uint16_t value, std::vector<uint8_t>* bytes); + +// Writes the given unsigned 32-bit integer to the given vector in big endian +// format. +void WriteUint32NetworkOrder(uint32_t value, std::vector<uint8_t>* bytes); + +// Writes the given unsigned 64-bit integer to the given vector in big endian +// format. +void WriteUint64NetworkOrder(uint64_t value, std::vector<uint8_t>* bytes); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_BYTE_WRITERS_H_
diff --git a/utils/byte_writers_test.cc b/utils/byte_writers_test.cc new file mode 100644 index 0000000..d9331f7 --- /dev/null +++ b/utils/byte_writers_test.cc
@@ -0,0 +1,69 @@ +// 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 "utils/byte_writers.h" + +#include <cstdint> +#include <vector> + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { + +namespace { + +using ::testing::ElementsAre; + +TEST(ByteWritersTest, Write64Bit) { + std::vector<uint8_t> bytes; + WriteUint64NetworkOrder(0x1122334455667788, &bytes); + EXPECT_THAT(bytes, + ElementsAre(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88)); +} + +TEST(ByteWritersTest, Write32Bit) { + std::vector<uint8_t> bytes; + WriteUint32NetworkOrder(0x11223344, &bytes); + EXPECT_THAT(bytes, ElementsAre(0x11, 0x22, 0x33, 0x44)); +} + +TEST(ByteWritersTest, Write16Bit) { + std::vector<uint8_t> bytes; + WriteUint16NetworkOrder(0x1234, &bytes); + EXPECT_THAT(bytes, ElementsAre(0x12, 0x34)); +} + +TEST(ByteWritersTest, Write64BitToNonEmptyVector) { + std::vector<uint8_t> bytes = {0xaa, 0xbb}; + WriteUint64NetworkOrder(0x1122334455667788, &bytes); + EXPECT_THAT(bytes, ElementsAre(0xaa, 0xbb, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88)); +} + +TEST(ByteWritersTest, Write32BitToNonEmptyVector) { + std::vector<uint8_t> bytes = {0xaa, 0xbb}; + WriteUint32NetworkOrder(0x11223344, &bytes); + EXPECT_THAT(bytes, ElementsAre(0xaa, 0xbb, 0x11, 0x22, 0x33, 0x44)); +} + +TEST(ByteWritersTest, Write16BitToNonEmptyVector) { + std::vector<uint8_t> bytes = {0xaa, 0xbb}; + WriteUint16NetworkOrder(0x1234, &bytes); + EXPECT_THAT(bytes, ElementsAre(0xaa, 0xbb, 0x12, 0x34)); +} + +} // namespace +} // namespace credentio
diff --git a/utils/distinguished_name.cc b/utils/distinguished_name.cc new file mode 100644 index 0000000..9a4dfab --- /dev/null +++ b/utils/distinguished_name.cc
@@ -0,0 +1,234 @@ +// 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 "utils/distinguished_name.h" + +#include <cstddef> +#include <string> +#include <utility> + +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/escaping.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" + +namespace credentio { +namespace { + +// Consumes an OID. +// +// oid = 1*DIGIT *("." 1*DIGIT) +// (but we only enforce that it starts with a digit and contains digits and ".") +absl::StatusOr<std::string> ConsumeOid(absl::string_view* s) { + if (s->empty() || !absl::ascii_isdigit(s->front())) { + return absl::InvalidArgumentError("expected OID value starting with digit"); + } + size_t len = s->find_first_not_of("0123456789."); + std::string oid = std::string(s->substr(0, len)); + s->remove_prefix(oid.size()); + return oid; +} + +// Consumes an attribute type (e.g., "CN" or "1.2.3"). +// +// attributeType = (ALPHA 1*keychar) / oid +// keychar = ALPHA / DIGIT / "-" +absl::StatusOr<std::string> ConsumeAttr(absl::string_view* s) { + if (s->empty()) { + return absl::InvalidArgumentError("empty attribute name"); + } + if (absl::ascii_isdigit(s->front())) { + return ConsumeOid(s); + } + if (!absl::ascii_isalpha(s->front())) { + return absl::InvalidArgumentError( + "expected attribute name starting with alpha"); + } + std::string value; + while (!s->empty()) { + if (absl::ascii_isalpha(s->front()) || absl::ascii_isdigit(s->front()) || + s->front() == '-') { + value.push_back(s->front()); + s->remove_prefix(1); + } else { + break; + } + } + return value; +} + +// Consumes a #aabbccddee string, returning decoded value. +absl::StatusOr<std::string> ConsumeHexStringValue(absl::string_view* s) { + absl::string_view temp = *s; + if (!absl::ConsumePrefix(&temp, "#")) { + return absl::InvalidArgumentError("expected hex string starting with #"); + } + + absl::string_view digits = + temp.substr(0, temp.find_first_not_of("0123456789abcdefABCDEF")); + temp.remove_prefix(digits.length()); + std::string value; + if (!absl::HexStringToBytes(digits, &value)) { + return absl::InvalidArgumentError("cannot parse hex string"); + } + *s = temp; + return value; +} + +// Consumes a backslash escape, returning decoded character. +// +// pair = "\" ( special / "\" / QUOTATION / hexpair ) +// stringchar = <any character except one of special, "\" or QUOTATION > +// +// hexstring = 1*hexpair +// hexpair = hexchar hexchar +// +// hexchar = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" +// / "a" / "b" / "c" / "d" / "e" / "f" +// special = "," / "=" / "+" / "<" / ">" / "#" / ";" +absl::StatusOr<std::string> ConsumeBackslashEscape(absl::string_view* s) { + absl::string_view temp = *s; + if (!absl::ConsumePrefix(&temp, "\\")) { + return absl::InvalidArgumentError( + "expected backslash escape starting with \\"); + } + if (temp.empty()) { + return absl::InvalidArgumentError("backslash at end of input"); + } + if (absl::ascii_isxdigit(absl::ascii_toupper(temp.front()))) { + std::string decoded_hex; + if (temp.length() < 2 || + !absl::HexStringToBytes(temp.substr(0, 2), &decoded_hex)) { + return absl::InvalidArgumentError("cannot decode hex backslash escape"); + } + temp.remove_prefix(2); + *s = temp; + return decoded_hex; + } + if (!absl::StrContains(",=+<>#;\"\\", temp.front())) { + return absl::InvalidArgumentError("invalid backslash escape"); + } + std::string value = std::string(temp.substr(0, 1)); + temp.remove_prefix(1); + *s = temp; + return value; +} + +// Consumes a double-quoted string, returning decoded value. +// string = ... / QUOTATION *( quotechar / pair ) QUOTATION ; only from v2 +// quotechar = <any character except "\" or QUOTATION > +absl::StatusOr<std::string> ConsumeQuotedStringValue(absl::string_view* s) { + absl::string_view temp = *s; + if (!absl::ConsumePrefix(&temp, "\"")) { + return absl::InvalidArgumentError( + "expected quoted string starting with \""); + } + std::string value; + while (!temp.empty()) { + if (absl::ConsumePrefix(&temp, "\"")) { + *s = temp; + return value; + } + if (temp.front() == '\\') { + ABSL_ASSIGN_OR_RETURN(auto unescaped, ConsumeBackslashEscape(&temp)); + absl::StrAppend(&value, unescaped); + } else { + value.push_back(temp.front()); + temp.remove_prefix(1); + } + } + return absl::InvalidArgumentError("closing '\"' not found"); +} + +// Consumes an attribute value and returns the decoded value. +// +// attributeValue = string +// string = *( stringchar / pair ) +// / "#" hexstring +// / QUOTATION *( quotechar / pair ) QUOTATION ; only from v2 +absl::StatusOr<std::string> ConsumeValue(absl::string_view* s) { + if (!s->empty() && s->front() == '#') { + return ConsumeHexStringValue(s); + } + if (!s->empty() && s->front() == '"') { + return ConsumeQuotedStringValue(s); + } + absl::string_view temp = *s; + std::string value; + while (!temp.empty() && !absl::StrContains(",=+<>#;\"", temp.front())) { + if (temp.front() == '\\') { + ABSL_ASSIGN_OR_RETURN(auto unescaped, ConsumeBackslashEscape(&temp)); + absl::StrAppend(&value, unescaped); + } else { + value.push_back(temp.front()); + temp.remove_prefix(1); + } + } + *s = temp; + return value; +} + +// Consumes an attribute/value (e.g., "FOO=Bar") and returns the pair. +absl::StatusOr<std::pair<std::string, std::string>> ConsumeAttrAndValue( + absl::string_view* s) { + absl::string_view temp = *s; + ABSL_ASSIGN_OR_RETURN(auto attr, ConsumeAttr(&temp)); + if (!absl::ConsumePrefix(&temp, "=")) { + return absl::InvalidArgumentError("expected '=' after attribute name"); + } + ABSL_ASSIGN_OR_RETURN(auto value, ConsumeValue(&temp)); + *s = temp; + return std::make_pair(attr, value); +} + +} // namespace + +absl::StatusOr<absl::flat_hash_map<std::string, std::string>> +ParseDistinguishedName(absl::string_view distinguished_name) { + absl::flat_hash_map<std::string, std::string> attrs; + while (!distinguished_name.empty()) { + auto attr_and_value = ConsumeAttrAndValue(&distinguished_name); + if (!attr_and_value.ok()) { + return absl::Status(attr_and_value.status().code(), + absl::StrCat("cannot parse RFC2253 DN: ", + attr_and_value.status().message())); + } + const auto& [attr, value] = *attr_and_value; + for (const auto& [existing_attr, _] : attrs) { + if (absl::EqualsIgnoreCase(existing_attr, attr)) { + return absl::InvalidArgumentError("Duplicate attribute in RFC2253 DN"); + } + } + attrs[attr] = value; + if (distinguished_name.empty()) { + break; + } + if (absl::ConsumePrefix(&distinguished_name, "+")) { + return absl::UnimplementedError("multi-valued RDN not supported"); + } + if (!absl::ConsumePrefix(&distinguished_name, ",")) { + return absl::InvalidArgumentError("Cannot parse RFC2253 DN"); + } + } + return attrs; +} + +} // namespace credentio
diff --git a/utils/distinguished_name.h b/utils/distinguished_name.h new file mode 100644 index 0000000..6d566ba --- /dev/null +++ b/utils/distinguished_name.h
@@ -0,0 +1,34 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_DISTINGUISHED_NAME_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_DISTINGUISHED_NAME_H_ + +#include <string> + +#include "absl/container/flat_hash_map.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +// Parses an RFC 2253 DN value (e.g., "CN=SnazzyCamera 2.0,OU=Snazzy +// Cams,O=SnazzyCo\, LLC,L=Somewhere,ST=MD,C=US"). +absl::StatusOr<absl::flat_hash_map<std::string, std::string>> +ParseDistinguishedName(absl::string_view distinguished_name); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_DISTINGUISHED_NAME_H_
diff --git a/utils/distinguished_name_test.cc b/utils/distinguished_name_test.cc new file mode 100644 index 0000000..f35b236 --- /dev/null +++ b/utils/distinguished_name_test.cc
@@ -0,0 +1,136 @@ +// 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 "utils/distinguished_name.h" + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio::ParseDistinguishedName; +using ::testing::IsEmpty; +using ::testing::Pair; +using ::testing::UnorderedElementsAre; + +TEST(ParseDn, EmptyString) { + EXPECT_THAT(ParseDistinguishedName(""), IsOkAndHolds(IsEmpty())); +} + +TEST(ParseDn, SingleValue) { + EXPECT_THAT(ParseDistinguishedName("CN=Foo"), + IsOkAndHolds(UnorderedElementsAre(Pair("CN", "Foo")))); +} + +TEST(ParseDn, MultipleValues) { + EXPECT_THAT(ParseDistinguishedName("CN=Foo,O=Gizmotron,OU=Widgets Division"), + IsOkAndHolds(UnorderedElementsAre( + Pair("CN", "Foo"), Pair("O", "Gizmotron"), + Pair("OU", "Widgets Division")))); +} + +TEST(ParseDn, BackslashEscapeSequences) { + EXPECT_THAT(ParseDistinguishedName( + R"(CN=Foo\2fBar\2FBaz,O=P\=NP?,OU=\57idgets Division)"), + IsOkAndHolds(UnorderedElementsAre( + Pair("CN", R"(Foo/Bar/Baz)"), Pair("O", R"(P=NP?)"), + Pair("OU", R"(Widgets Division)")))); +} + +TEST(ParseDn, TrailingBackslash) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo\)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, InvalidBackslashHex) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=F\9j)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, UnrecognizedBackslashEscape) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=Fo\o)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, MissingEquals) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo,OU)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, DuplicateAttribute) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo,CN=Bar)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, DuplicateAttributeCaseInsensitive) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo,cn=Bar)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, QuotedString) { + EXPECT_THAT( + ParseDistinguishedName( + R"(CN="This &= That, Inc.",O="A,B,C,\58\2c\59\2c\5A\+")"), + IsOkAndHolds(UnorderedElementsAre(Pair("CN", R"(This &= That, Inc.)"), + Pair("O", R"(A,B,C,X,Y,Z+)")))); +} + +TEST(ParseDn, MissingCloseQuote) { + EXPECT_THAT(ParseDistinguishedName(R"(CN="Foo)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, OidAttribute) { + EXPECT_THAT(ParseDistinguishedName("CN=Foo,1.2.345=Bar"), + IsOkAndHolds(UnorderedElementsAre(Pair("CN", "Foo"), + Pair("1.2.345", "Bar")))); +} + +TEST(ParseDn, InvalidAttributeName) { + EXPECT_THAT(ParseDistinguishedName(".CN=Foo"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, HexString) { + EXPECT_THAT(ParseDistinguishedName("CN=#466f6F"), + IsOkAndHolds(UnorderedElementsAre(Pair("CN", "Foo")))); +} + +TEST(ParseDn, HexStringOddLength) { + EXPECT_THAT(ParseDistinguishedName("CN=#466f6"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, EmptyAttributeValues) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=,OU="",O=#)"), + IsOkAndHolds(UnorderedElementsAre(Pair("CN", ""), Pair("OU", ""), + Pair("O", "")))); +} + +TEST(ParseDn, InvalidSeparator) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo>O=Bar)"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(ParseDn, MultiValueUnsupported) { + EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo+O=Bar)"), + StatusIs(absl::StatusCode::kUnimplemented)); +} + +} // namespace
diff --git a/utils/dual_status_tracker.cc b/utils/dual_status_tracker.cc new file mode 100644 index 0000000..d8a9b74 --- /dev/null +++ b/utils/dual_status_tracker.cc
@@ -0,0 +1,135 @@ +// 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 "utils/dual_status_tracker.h" + +#include <memory> + +#include "absl/base/nullability.h" +#include "absl/log/log.h" +#include "absl/memory/memory.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "constants/status_codes.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "validator/result.h" +#include "validator/tracker.h" + +namespace credentio { + +namespace { + +SuccessStatusCode ConvertIngredientCodeToActiveCode(SuccessStatusCode code) { + switch (code) { + case SuccessStatusCode::kAssertionBmffHashMatch: + return SuccessStatusCode::kGoogleAssertionBmffHashMatch; + case SuccessStatusCode::kAssertionBoxesHashMatch: + return SuccessStatusCode::kGoogleAssertionBoxesHashMatch; + case SuccessStatusCode::kAssertionDataHashMatch: + return SuccessStatusCode::kGoogleAssertionDataHashMatch; + case SuccessStatusCode::kAssertionMultiAssetHashMatch: + return SuccessStatusCode::kGoogleAssertionMultiAssetHashMatch; + default: + LOG(DFATAL) << "Unexpected success code: " << code; + return code; + } +} + +FailureStatusCode ConvertIngredientCodeToActiveCode(FailureStatusCode code) { + switch (code) { + case FailureStatusCode::kAssertionBmffHashMismatch: + return FailureStatusCode::kGoogleAssertionBmffHashMismatch; + case FailureStatusCode::kAssertionBoxesHashMismatch: + return FailureStatusCode::kGoogleAssertionBoxesHashMismatch; + case FailureStatusCode::kAssertionDataHashMismatch: + return FailureStatusCode::kGoogleAssertionDataHashMismatch; + case FailureStatusCode::kAssertionBmffHashMalformed: + return FailureStatusCode::kGoogleAssertionBmffHashMalformed; + case FailureStatusCode::kAssertionBoxesHashUnknownBox: + return FailureStatusCode::kGoogleAssertionBoxesHashUnknownBox; + case FailureStatusCode::kAssertionDataHashMalformed: + return FailureStatusCode::kGoogleAssertionDataHashMalformed; + case FailureStatusCode::kAssertionBoxesHashMalformed: + return FailureStatusCode::kGoogleAssertionBoxesHashMalformed; + case FailureStatusCode::kAssertionMultiAssetHashMismatch: + return FailureStatusCode::kGoogleAssertionMultiAssetHashMismatch; + case FailureStatusCode::kAssertionMultiAssetHashMalformed: + return FailureStatusCode::kGoogleAssertionMultiAssetHashMalformed; + case FailureStatusCode::kAssertionMultiAssetHashMissingPart: + return FailureStatusCode::kGoogleAssertionMultiAssetHashMissingPart; + default: + return code; + } +} + +InformationalStatusCode ConvertIngredientCodeToActiveCode( + InformationalStatusCode code) { + switch (code) { + default: + return code; + } +} + +} // namespace + +absl::StatusOr<std::unique_ptr<DualStatusTracker>> +DualStatusTracker::FromPartialValidationResult( + PartialValidationResultProto* absl_nonnull partial_validation_result) { + Manifest* absl_nullable assertion_manifest = GetMutableManifestForAssertion( + partial_validation_result, partial_validation_result->hard_binding_uri()); + if (assertion_manifest == nullptr) { + return absl::InvalidArgumentError( + absl::StrCat("missing manifest for assertion: ", + partial_validation_result->hard_binding_uri())); + } + Manifest* active_manifest = + partial_validation_result->mutable_active_manifest(); + return absl::WrapUnique(new DualStatusTracker( + assertion_manifest, + assertion_manifest == active_manifest ? nullptr : active_manifest)); +} + +void DualStatusTracker::RecordSuccess( + SuccessStatusCode code, ValidationTracker::RecordOptions options) { + assertion_tracker_->RecordSuccess(code, options); + if (active_tracker_ != nullptr) { + active_tracker_->RecordSuccess(ConvertIngredientCodeToActiveCode(code), + options); + } +} + +void DualStatusTracker::RecordFailure( + FailureStatusCode code, ValidationTracker::RecordOptions options) { + assertion_tracker_->RecordFailure(code, options); + if (active_tracker_ != nullptr) { + active_tracker_->RecordFailure(ConvertIngredientCodeToActiveCode(code), + options); + } +} + +void DualStatusTracker::RecordInformational( + InformationalStatusCode code, ValidationTracker::RecordOptions options) { + assertion_tracker_->RecordInformational(code, options); + if (active_tracker_ != nullptr) { + active_tracker_->RecordInformational( + ConvertIngredientCodeToActiveCode(code), options); + } +} + +} // namespace credentio
diff --git a/utils/dual_status_tracker.h b/utils/dual_status_tracker.h new file mode 100644 index 0000000..e70e593 --- /dev/null +++ b/utils/dual_status_tracker.h
@@ -0,0 +1,86 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_DUAL_STATUS_TRACKER_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_DUAL_STATUS_TRACKER_H_ + +#include <memory> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "constants/status_codes.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "utils/status_tracker.h" +#include "validator/tracker.h" + +namespace credentio { + +// A tracker for writing hard binding success and failure codes to the manifest +// containing the hard binding assertion and optionally the active manifest. +// +// The success and failure codes are written to the manifest containing the +// assertion using the provided ValidationTracker and, if the manifest +// containing the assertion is not the active manifest, optionally to the active +// manifest using the optional ValidationTracker. +// +// The success and failure codes are written to the active manifest using a +// google-code prefixed version of the c2pa success/failure code. This is +// necessary because the summarizer only reads from the active manifest and +// needs the success/failure codes to know which hard binding assertions were +// evaluated. +class DualStatusTracker : public StatusTracker { + public: + static absl::StatusOr<std::unique_ptr<DualStatusTracker>> + FromPartialValidationResult( + PartialValidationResultProto* absl_nonnull partial_validation_result); + + void RecordSuccess(SuccessStatusCode code, + ValidationTracker::RecordOptions options) override; + void RecordFailure(FailureStatusCode code, + ValidationTracker::RecordOptions options) override; + void RecordInformational(InformationalStatusCode code, + ValidationTracker::RecordOptions options) override; + + bool WritesToIngredientManifest() const { return active_tracker_ != nullptr; } + + private: + // The tracker for the manifest containing the hard binding assertion. + std::unique_ptr<ValidationTracker> assertion_tracker_; + // The tracker for the active manifest if the assertion is not in the active + // manifest. + std::unique_ptr<ValidationTracker> active_tracker_; + + explicit DualStatusTracker(Manifest* absl_nonnull assertion_manifest, + Manifest* absl_nullable active_manifest) + : assertion_tracker_( + std::make_unique<ValidationTracker>(assertion_manifest)), + active_tracker_( + active_manifest == nullptr + ? nullptr + : std::make_unique<ValidationTracker>(active_manifest)) {}; + // This constructor should only be used by the static factory methods. + explicit DualStatusTracker( + std::unique_ptr<ValidationTracker> absl_nonnull assertion_tracker, + std::unique_ptr<ValidationTracker> absl_nullable active_tracker = nullptr) + : assertion_tracker_(std::move(assertion_tracker)), + active_tracker_(std::move(active_tracker)) {}; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_DUAL_STATUS_TRACKER_H_
diff --git a/utils/dual_status_tracker_test.cc b/utils/dual_status_tracker_test.cc new file mode 100644 index 0000000..0c8c308 --- /dev/null +++ b/utils/dual_status_tracker_test.cc
@@ -0,0 +1,364 @@ +// 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 "utils/dual_status_tracker.h" + +#include <string> + +#include "absl/container/flat_hash_set.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; +using ::testing::IsEmpty; +using ::testing::TestParamInfo; +using ::testing::TestWithParam; + +struct TrackerTestCase { + std::string test_name; + credentio::SuccessStatusCode success_code; + credentio::FailureStatusCode failure_code; + std::string expected_success_codes; + std::string expected_failure_codes; +}; +using TrackerTest = TestWithParam<TrackerTestCase>; + +absl::flat_hash_set<std::string> GetFailures(ValidationStatusSet validation) { + absl::flat_hash_set<std::string> failures; + for (const auto& failure : validation.failures()) { + failures.insert(std::string(failure.code())); + } + return failures; +} + +absl::flat_hash_set<std::string> GetSuccesses(ValidationStatusSet validation) { + absl::flat_hash_set<std::string> successes; + for (const auto& success : validation.successes()) { + successes.insert(std::string(success.code())); + } + return successes; +} + +TEST(DualStatusTrackerTest, ManifestNotFound) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + hard_binding_uri: "self#jumbf=/c2pa/1234/c2pa.assertions/c2pa.hash.boxes" + )pb"); + EXPECT_THAT(DualStatusTracker::FromPartialValidationResult(&result), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(DualStatusTrackerTest, NoHardBindingUri) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + )pb"); + EXPECT_THAT(DualStatusTracker::FromPartialValidationResult(&result), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +TEST(DualStatusTrackerTest, WritesStatusCodesToSingleTracker) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + hard_binding_uri: "self#jumbf=/c2pa/a/c2pa.assertions/c2pa.hash.boxes" + )pb"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + const auto& active = result.active_manifest(); + + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + + (*tracker)->RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre("assertion.bmffHash.match")); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + + (*tracker)->RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre("assertion.bmffHash.match")); + EXPECT_THAT(GetFailures(active.validation()), + ElementsAre("assertion.bmffHash.mismatch")); +} + +TEST(DualStatusTrackerTest, WritesDifferentSuccessCode) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + ingredient_manifests { label: "i" } + hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes" + )pb"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + + const auto& active = result.active_manifest(); + const auto& ingredient = result.ingredient_manifests(0); + + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty()); + + (*tracker)->RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre("com.google.assertion.bmffHash.match")); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + EXPECT_THAT(GetSuccesses(ingredient.validation()), + ElementsAre("assertion.bmffHash.match")); + EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty()); +} + +TEST(DualStatusTrackerTest, WritesSameFailureCode) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + ingredient_manifests { label: "i" } + hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes" + )pb"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + + const auto& active = result.active_manifest(); + const auto& ingredient = result.ingredient_manifests(0); + + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty()); + + (*tracker)->RecordFailure(FailureStatusCode::kClaimCborInvalid, {}); + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), + ElementsAre("claim.cbor.invalid")); + EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(ingredient.validation()), + ElementsAre("claim.cbor.invalid")); +} + +TEST(DualStatusTrackerTest, WritesDifferentFailureCode) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + ingredient_manifests { label: "i" } + hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes" + )pb"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + + const auto& active = result.active_manifest(); + const auto& ingredient = result.ingredient_manifests(0); + + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty()); + + (*tracker)->RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, {}); + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), + ElementsAre("com.google.assertion.bmffHash.mismatch")); + EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(ingredient.validation()), + ElementsAre("assertion.bmffHash.mismatch")); +} + +TEST(DualStatusTrackerTest, StatusUrls) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + ingredient_manifests { label: "i" } + hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes" + )pb"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + + const auto& active = result.active_manifest(); + const auto& ingredient = result.ingredient_manifests(0); + + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty()); + + (*tracker)->RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, {}); + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), + ElementsAre("com.google.assertion.bmffHash.mismatch")); + EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(ingredient.validation()), + ElementsAre("assertion.bmffHash.mismatch")); + + EXPECT_THAT(active.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb( + failures { + code: "com.google.assertion.bmffHash.mismatch" + url: "self#jumbf=/c2pa/a" + } + )pb"))); + EXPECT_THAT(ingredient.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb( + failures { + code: "assertion.bmffHash.mismatch" + url: "self#jumbf=/c2pa/i" + } + )pb"))); +} + +TEST(DualStatusTrackerTest, WritesUnexpectedSuccessCode) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "a" } + hard_binding_uri: "self#jumbf=/c2pa/a/c2pa.assertions/c2pa.hash.boxes" + )pb"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + const auto& active = result.active_manifest(); + + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + + (*tracker)->RecordSuccess(SuccessStatusCode::kClaimSignatureValidated, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre("claimSignature.validated")); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); +} + +// Parametrized tests for success and failure codes. +TEST_P(TrackerTest, WritesStatusCodesToSingleTrackerAssertionsInActive) { + const TrackerTestCase& test_case = GetParam(); + + PartialValidationResultProto result; + result.mutable_active_manifest()->set_label("active_manifest"); + result.mutable_active_manifest()->add_assertions()->set_label("assertion1"); + result.set_hard_binding_uri( + "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion1"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + const auto& active = result.active_manifest(); + + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + + (*tracker)->RecordSuccess(test_case.success_code, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre(test_case.expected_success_codes)); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + + (*tracker)->RecordFailure(test_case.failure_code, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre(test_case.expected_success_codes)); + EXPECT_THAT(GetFailures(active.validation()), + ElementsAre(test_case.expected_failure_codes)); +} + +TEST_P(TrackerTest, WritesStatusCodesToSingleTrackerAssertionsInIngredient) { + const TrackerTestCase& test_case = GetParam(); + + PartialValidationResultProto result; + result.mutable_active_manifest()->set_label("active_manifest"); + result.add_ingredient_manifests()->set_label("ingredient_manifest"); + result.mutable_ingredient_manifests(0)->add_assertions()->set_label( + "assertion1"); + result.set_hard_binding_uri( + "self#jumbf=/c2pa/ingredient_manifest/c2pa.assertions/assertion1"); + + auto tracker = DualStatusTracker::FromPartialValidationResult(&result); + ASSERT_TRUE(tracker.ok()); + const auto& active = result.active_manifest(); + + // It is expected to have "com.google." prefixes here. + std::string expected_success_codes = + absl::StrCat("com.google.", test_case.expected_success_codes); + std::string expected_failure_codes = + absl::StrCat("com.google.", test_case.expected_failure_codes); + EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty()); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + + (*tracker)->RecordSuccess(test_case.success_code, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre(expected_success_codes)); + EXPECT_THAT(GetFailures(active.validation()), IsEmpty()); + + (*tracker)->RecordFailure(test_case.failure_code, {}); + EXPECT_THAT(GetSuccesses(active.validation()), + ElementsAre(expected_success_codes)); + EXPECT_THAT(GetFailures(active.validation()), + ElementsAre(expected_failure_codes)); +} + +INSTANTIATE_TEST_SUITE_P( + TrackerTest, TrackerTest, + testing::ValuesIn<TrackerTestCase>({ + {"BmffWithMismatch", SuccessStatusCode::kAssertionBmffHashMatch, + FailureStatusCode::kAssertionBmffHashMismatch, + "assertion.bmffHash.match", "assertion.bmffHash.mismatch"}, + {"BmffWithMalformed", SuccessStatusCode::kAssertionBmffHashMatch, + FailureStatusCode::kAssertionBmffHashMalformed, + "assertion.bmffHash.match", "assertion.bmffHash.malformed"}, + {"BoxesWithMismatch", SuccessStatusCode::kAssertionBoxesHashMatch, + FailureStatusCode::kAssertionBoxesHashMismatch, + "assertion.boxesHash.match", "assertion.boxesHash.mismatch"}, + {"BoxesWithUnknown", SuccessStatusCode::kAssertionBoxesHashMatch, + FailureStatusCode::kAssertionBoxesHashUnknownBox, + "assertion.boxesHash.match", "assertion.boxesHash.unknownBox"}, + {"BoxesWithMalformed", SuccessStatusCode::kAssertionBoxesHashMatch, + FailureStatusCode::kAssertionBoxesHashMalformed, + "assertion.boxesHash.match", "assertion.boxesHash.malformed"}, + {"DataWithMismatch", SuccessStatusCode::kAssertionDataHashMatch, + FailureStatusCode::kAssertionDataHashMismatch, + "assertion.dataHash.match", "assertion.dataHash.mismatch"}, + {"DataWithMalformed", SuccessStatusCode::kAssertionDataHashMatch, + FailureStatusCode::kAssertionDataHashMalformed, + "assertion.dataHash.match", "assertion.dataHash.malformed"}, + {"MultiAssetWithMalformed", + SuccessStatusCode::kAssertionMultiAssetHashMatch, + FailureStatusCode::kAssertionMultiAssetHashMalformed, + "assertion.multiAssetHash.match", + "assertion.multiAssetHash.malformed"}, + {"MultiAssetWithMismatch", + SuccessStatusCode::kAssertionMultiAssetHashMatch, + FailureStatusCode::kAssertionMultiAssetHashMismatch, + "assertion.multiAssetHash.match", "assertion.multiAssetHash.mismatch"}, + {"MultiAssetWithMissingPart", + SuccessStatusCode::kAssertionMultiAssetHashMatch, + FailureStatusCode::kAssertionMultiAssetHashMissingPart, + "assertion.multiAssetHash.match", + "assertion.multiAssetHash.missingPart"}, + }), + [](const TestParamInfo<TrackerTest::ParamType>& info) { + return info.param.test_name; + }); + +} // namespace +} // namespace credentio
diff --git a/utils/media_type.cc b/utils/media_type.cc new file mode 100644 index 0000000..5ecd6be --- /dev/null +++ b/utils/media_type.cc
@@ -0,0 +1,81 @@ +// 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 "utils/media_type.h" + +#include <string> + +#include "absl/base/no_destructor.h" +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" + +namespace credentio { +namespace { + +// Returns the part of the filename after the final ".", or an empty string if +// the filename does not have a ".". +absl::string_view GetFileExtension(absl::string_view filename) { + auto dot_pos = filename.rfind('.'); + if (dot_pos == absl::string_view::npos) return ""; + return filename.substr(dot_pos + 1); +} + +} // namespace + +absl::StatusOr<std::string> MediaType(absl::string_view filename) { + static absl::NoDestructor<absl::flat_hash_map<std::string, std::string>> + kExtensionToMediaType({ + {"avif", "image/avif"}, + {"heic", "image/heic"}, + {"heif", "image/heif"}, + {"jpeg", "image/jpeg"}, + {"jpg", "image/jpeg"}, + {"m4a", "audio/mp4"}, + {"mp4", "video/mp4"}, + {"pdf", "application/pdf"}, + {"png", "image/png"}, + {"webp", "image/webp"}, + {"mov", "video/quicktime"}, + {"dng", "image/x-adobe-dng"}, + {"tif", "image/tiff"}, + {"tiff", "image/tiff"}, + {"wav", "audio/wav"}, // Not formally registered. + {"gif", "image/gif"}, + {"mp3", "audio/mpeg"}, + {"flac", "audio/flac"}, + {"pptx", + "application/" + "vnd.openxmlformats-officedocument.presentationml.presentation"}, + {"docx", + "application/" + "vnd.openxmlformats-officedocument.wordprocessingml.document"}, + {"xlsx", + "application/" + "vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + }); + std::string extension = absl::AsciiStrToLower(GetFileExtension(filename)); + auto it = kExtensionToMediaType->find(extension); + if (it != kExtensionToMediaType->end()) { + return it->second; + } + return absl::InvalidArgumentError( + absl::StrCat("Unsupported file extension: \"", extension, "\"")); +} + +} // namespace credentio
diff --git a/utils/media_type.h b/utils/media_type.h new file mode 100644 index 0000000..1f4e96f --- /dev/null +++ b/utils/media_type.h
@@ -0,0 +1,31 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_MEDIA_TYPE_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_MEDIA_TYPE_H_ + +#include <string> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +// Returns the media type of the given filename. +absl::StatusOr<std::string> MediaType(absl::string_view filename); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_MEDIA_TYPE_H_
diff --git a/utils/media_type_test.cc b/utils/media_type_test.cc new file mode 100644 index 0000000..9b75dda --- /dev/null +++ b/utils/media_type_test.cc
@@ -0,0 +1,73 @@ +// 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 "utils/media_type.h" + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; + +TEST(MediaTypeTest, ReturnsMediaType) { + EXPECT_THAT(MediaType("foo.avif"), IsOkAndHolds("image/avif")); + EXPECT_THAT(MediaType("foo.heic"), IsOkAndHolds("image/heic")); + EXPECT_THAT(MediaType("foo.heif"), IsOkAndHolds("image/heif")); + EXPECT_THAT(MediaType("foo.jpeg"), IsOkAndHolds("image/jpeg")); + EXPECT_THAT(MediaType("foo.jpg"), IsOkAndHolds("image/jpeg")); + EXPECT_THAT(MediaType("foo.mp4"), IsOkAndHolds("video/mp4")); + EXPECT_THAT(MediaType("foo.pdf"), IsOkAndHolds("application/pdf")); + EXPECT_THAT(MediaType("foo.png"), IsOkAndHolds("image/png")); + EXPECT_THAT(MediaType("foo.webp"), IsOkAndHolds("image/webp")); + EXPECT_THAT(MediaType("foo.mov"), IsOkAndHolds("video/quicktime")); + EXPECT_THAT(MediaType("foo.dng"), IsOkAndHolds("image/x-adobe-dng")); + EXPECT_THAT(MediaType("foo.tif"), IsOkAndHolds("image/tiff")); + EXPECT_THAT(MediaType("foo.tiff"), IsOkAndHolds("image/tiff")); + EXPECT_THAT(MediaType("foo.wav"), IsOkAndHolds("audio/wav")); + EXPECT_THAT(MediaType("foo.gif"), IsOkAndHolds("image/gif")); + EXPECT_THAT(MediaType("foo.m4a"), IsOkAndHolds("audio/mp4")); + EXPECT_THAT(MediaType("foo.mp3"), IsOkAndHolds("audio/mpeg")); + EXPECT_THAT(MediaType("foo.flac"), IsOkAndHolds("audio/flac")); + EXPECT_THAT( + MediaType("foo.pptx"), + IsOkAndHolds( + "application/" + "vnd.openxmlformats-officedocument.presentationml.presentation")); + EXPECT_THAT( + MediaType("foo.docx"), + IsOkAndHolds( + "application/" + "vnd.openxmlformats-officedocument.wordprocessingml.document")); + EXPECT_THAT( + MediaType("foo.xlsx"), + IsOkAndHolds("application/" + "vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + + EXPECT_THAT(MediaType("FOO.AVIF"), IsOkAndHolds("image/avif")); + + EXPECT_THAT(MediaType("no_extension"), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(MediaType(""), StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(MediaType("foo.txt"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +} // namespace +} // namespace credentio
diff --git a/utils/riegeli.cc b/utils/riegeli.cc new file mode 100644 index 0000000..7e4325b --- /dev/null +++ b/utils/riegeli.cc
@@ -0,0 +1,54 @@ +// 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 "utils/riegeli.h" + +#include <algorithm> +#include <cstring> +#include <string> + +#include "riegeli/base/arithmetic.h" +#include "riegeli/bytes/reader.h" + +namespace credentio { + +bool ReadNullTerminatedString(riegeli::Reader& reader, size_t max_length, + std::string& output) { + reader.Pull(max_length); + if (reader.available() == 0) { + // The data ends. Avoid undefined behavior in `std::memchr(nullptr, _, _)` + // if `reader.cursor() == nullptr`. + return false; + } + const char* const terminator = static_cast<const char*>(std::memchr( + reader.cursor(), '\0', std::min(reader.available(), max_length))); + if (terminator == nullptr) { + // No null terminator found within the max_length. + return false; + } + + size_t length = riegeli::PtrDistance(reader.cursor(), terminator); + if (!reader.Read(length, output)) { + // Failed to read the string. + return false; + } + if (!reader.Skip(1)) { + // Failed to skip the null terminator. + return false; + } + return true; +} + +} // namespace credentio
diff --git a/utils/riegeli.h b/utils/riegeli.h new file mode 100644 index 0000000..f36bfec --- /dev/null +++ b/utils/riegeli.h
@@ -0,0 +1,69 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_RIEGELI_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_RIEGELI_H_ + +#include <cstddef> +#include <cstdint> +#include <limits> +#include <string> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "riegeli/bytes/reader.h" +#include "riegeli/bytes/writer.h" +#include "riegeli/endian/endian_reading.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +// Reads a single value of type `T` from the source in big endian order, adds +// the adjustment value and writes it to the destination. Returns the adjusted +// number. An error is returned if the adjusted value is too large to fit in a +// `T` or if either reading or writing fails. +template <typename T> +absl::StatusOr<T> CopyBigEndian(riegeli::Reader& source, + riegeli::Writer& destination, + uint64_t adjustment = 0) { + T offset; + if (!riegeli::ReadBigEndian<T>(source, offset)) { + return source.StatusOrAnnotate(absl::DataLossError( + absl::StrCat("Failed to read unsigned integer of size: ", sizeof(T)))); + } + + T space_remaining = std::numeric_limits<T>::max() - offset; + if (space_remaining < adjustment) { + return absl::DataLossError("The adjusted offset is too large to fit"); + } + + offset += adjustment; + + if (!riegeli::WriteBigEndian<T>(offset, destination)) { + return destination.status(); + } + return offset; +} + +// Reads a null terminated string from the source into the output string. +// Returns false if the read or seek fails or if no null terminator was +// encountered within the given max_length. Returns true otherwise. +bool ReadNullTerminatedString(riegeli::Reader& reader, size_t max_length, + std::string& output); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_RIEGELI_H_
diff --git a/utils/riegeli_test.cc b/utils/riegeli_test.cc new file mode 100644 index 0000000..640d730 --- /dev/null +++ b/utils/riegeli_test.cc
@@ -0,0 +1,234 @@ +// 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 "utils/riegeli.h" + +#include <cstdint> +#include <limits> +#include <string> +#include <type_traits> + +#include "absl/functional/function_ref.h" +#include "absl/log/log.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "riegeli/bytes/string_reader.h" +#include "riegeli/bytes/string_writer.h" +#include "testing/test_string_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +template <typename T> +using CreateStringFunction = absl::FunctionRef<std::string(T)>; + +template <typename T> +absl::StatusOr<CreateStringFunction<T>> GetCreateStringFunction() { + if (!std::is_unsigned_v<T>) { + return absl::InvalidArgumentError("Type must be unsigned"); + } + + switch (sizeof(T)) { + case 1: + return credentio_testing::Uint8Str; + case 2: + return credentio_testing::Uint16Str; + case 4: + return credentio_testing::Uint32Str; + case 8: + return credentio_testing::Uint64Str; + } + + return absl::InvalidArgumentError("Unsupported type size"); +} + +template <typename T> +using CopyBigEndianTests = testing::Test; + +TYPED_TEST_SUITE_P(CopyBigEndianTests); + +TYPED_TEST_P(CopyBigEndianTests, FailsWhenSourceClosed) { + std::string source_str = ""; + riegeli::StringReader<> source_reader(source_str); + + std::string result; + riegeli::StringWriter<> destination(&result); + + source_reader.Close(); + + EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination, 0), + StatusIs(absl::StatusCode::kFailedPrecondition, + HasSubstr("Object closed"))); + + destination.Close(); + EXPECT_EQ(result, ""); +} + +TYPED_TEST_P(CopyBigEndianTests, FailsWhenDestinationClosed) { + auto create_string = GetCreateStringFunction<TypeParam>(); + ASSERT_TRUE(create_string.ok()); + + std::string source_str = (*create_string)(10); + riegeli::StringReader<> source_reader(source_str); + + std::string result; + riegeli::StringWriter<> destination(&result); + + destination.Close(); + + EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination, 0), + StatusIs(absl::StatusCode::kFailedPrecondition, + HasSubstr("Object closed"))); +} + +TYPED_TEST_P(CopyBigEndianTests, FailsWhenNoSource) { + std::string source_str = ""; + riegeli::StringReader<> source_reader(source_str); + + std::string result; + riegeli::StringWriter<> destination(&result); + + EXPECT_THAT( + CopyBigEndian<TypeParam>(source_reader, destination, 0), + StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read"))); + + destination.Close(); + EXPECT_EQ(result, ""); +} + +TYPED_TEST_P(CopyBigEndianTests, FailsWhenTooLarge) { + auto create_string = GetCreateStringFunction<TypeParam>(); + ASSERT_TRUE(create_string.ok()); + + std::string source_str = + (*create_string)(std::numeric_limits<TypeParam>().max() - 10); + riegeli::StringReader<> source_reader(source_str); + + std::string result; + riegeli::StringWriter<> destination(&result); + + EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination, + std::numeric_limits<int64_t>().max()), + StatusIs(absl::StatusCode::kDataLoss, + HasSubstr("adjusted offset is too large"))); + + destination.Close(); + EXPECT_EQ(result, ""); +} + +TYPED_TEST_P(CopyBigEndianTests, Succeeds) { + auto create_string = GetCreateStringFunction<TypeParam>(); + ASSERT_TRUE(create_string.ok()); + + TypeParam source = 10; + std::string source_str = (*create_string)(source); + riegeli::StringReader<> source_reader(source_str); + + int64_t adjustment = 10; + + TypeParam expected = source + adjustment; + std::string expected_str = (*create_string)(expected); + + std::string result; + riegeli::StringWriter<> destination(&result); + + EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination, adjustment), + IsOkAndHolds(expected)); + + EXPECT_TRUE(destination.Close()); + EXPECT_EQ(result, expected_str); +} + +TYPED_TEST_P(CopyBigEndianTests, SucceedsWithZeroAdjustment) { + auto create_string = GetCreateStringFunction<TypeParam>(); + ASSERT_TRUE(create_string.ok()); + + TypeParam source = 42; + std::string source_str = (*create_string)(source); + riegeli::StringReader<> source_reader(source_str); + + std::string result; + riegeli::StringWriter<> destination(&result); + + EXPECT_THAT( + credentio::CopyBigEndian<TypeParam>(source_reader, destination, 0), + IsOkAndHolds(source)); + + EXPECT_TRUE(destination.Close()); + EXPECT_EQ(result, source_str); +} + +TEST(ReadNullTerminatedStringTests, SucceedsWithNullTerminator) { + std::string source_str("hello\0world", 11); + riegeli::StringReader<> reader(source_str); + std::string output; + EXPECT_TRUE(ReadNullTerminatedString(reader, 10, output)); + EXPECT_EQ(output, "hello"); + EXPECT_EQ(reader.pos(), 6); +} + +TEST(ReadNullTerminatedStringTests, SucceedsWithNullTerminatorAtStart) { + std::string source_str("\0helloworld", 11); + riegeli::StringReader<> reader(source_str); + std::string output; + EXPECT_TRUE(ReadNullTerminatedString(reader, 6, output)); + EXPECT_EQ(output, ""); + EXPECT_EQ(reader.pos(), 1); +} + +TEST(ReadNullTerminatedStringTests, SucceedsWithMultipleNulls) { + std::string source_str("abc\0def\0ghi", 11); + riegeli::StringReader<> reader(source_str); + std::string output; + EXPECT_TRUE(ReadNullTerminatedString(reader, 8, output)); + EXPECT_EQ(output, "abc"); + EXPECT_EQ(reader.pos(), 4); +} + +TEST(ReadNullTerminatedStringTests, FailsWithoutNullTerminatorWithinBounds) { + std::string source_str = "abcdefghij"; + riegeli::StringReader<> reader(source_str); + std::string output; + EXPECT_FALSE(ReadNullTerminatedString(reader, 5, output)); + EXPECT_EQ(output, ""); + EXPECT_EQ(reader.pos(), 0); +} + +TEST(ReadNullTerminatedStringTests, FailsIfInsufficientBytesToRead) { + std::string source_str = "abc"; + riegeli::StringReader<> reader(source_str); + std::string output; + EXPECT_FALSE(ReadNullTerminatedString(reader, 5, output)); +} + +REGISTER_TYPED_TEST_SUITE_P(CopyBigEndianTests, FailsWhenSourceClosed, + FailsWhenDestinationClosed, FailsWhenNoSource, + FailsWhenTooLarge, Succeeds, + SucceedsWithZeroAdjustment); + +using CopyBigEndianTypes = + ::testing::Types<uint8_t, uint16_t, uint32_t, uint64_t>; + +INSTANTIATE_TYPED_TEST_SUITE_P(_, CopyBigEndianTests, CopyBigEndianTypes); + +} // namespace +} // namespace credentio
diff --git a/utils/status_tracker.h b/utils/status_tracker.h new file mode 100644 index 0000000..d32a606 --- /dev/null +++ b/utils/status_tracker.h
@@ -0,0 +1,55 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_STATUS_TRACKER_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_STATUS_TRACKER_H_ + +#include "constants/status_codes.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "validator/tracker.h" + +namespace credentio { + +// A tracker for writing hard binding success and failure codes to the manifest +// containing the hard binding assertion and optionally the active manifest. +// +// The success and failure codes are written to the manifest containing the +// assertion using the provided ValidationTracker and, if the manifest +// containing the assertion is not the active manifest, optionally to the active +// manifest using the optional ValidationTracker. +// +// The success and failure codes are written to the active manifest using a +// google-code prefixed version of the c2pa success/failure code. This is +// necessary because the summarizer only reads from the active manifest and +// needs the success/failure codes to know which hard binding assertions were +// evaluated. +class StatusTracker { + public: + virtual ~StatusTracker() = default; + + virtual void RecordSuccess(SuccessStatusCode code, + ValidationTracker::RecordOptions options) = 0; + virtual void RecordFailure(FailureStatusCode code, + ValidationTracker::RecordOptions options) = 0; + virtual void RecordInformational( + InformationalStatusCode code, + ValidationTracker::RecordOptions options) = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_STATUS_TRACKER_H_
diff --git a/utils/two_stage_status_tracker.cc b/utils/two_stage_status_tracker.cc new file mode 100644 index 0000000..ef13557 --- /dev/null +++ b/utils/two_stage_status_tracker.cc
@@ -0,0 +1,67 @@ +// 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 "utils/two_stage_status_tracker.h" + +#include <utility> + +#include "constants/status_codes.h" +#include "validator/tracker.h" + +namespace credentio { + +void TwoStageStatusTracker::RecordSuccess( + SuccessStatusCode code, ValidationTracker::RecordOptions options) { + stage_1_success_.push_back(std::make_pair(code, SavedRecordOptions(options))); +} + +void TwoStageStatusTracker::RecordFailure( + FailureStatusCode code, ValidationTracker::RecordOptions options) { + stage_1_failure_.push_back(std::make_pair(code, SavedRecordOptions(options))); +} + +void TwoStageStatusTracker::RecordInformational( + InformationalStatusCode code, ValidationTracker::RecordOptions options) { + stage_1_informational_.push_back( + std::make_pair(code, SavedRecordOptions(options))); +} + +void TwoStageStatusTracker::WriteStage1() { + for (const auto& [code, options] : stage_1_success_) { + hash_tracker_.RecordSuccess(code, options.Get()); + } + for (const auto& [code, options] : stage_1_failure_) { + hash_tracker_.RecordFailure(code, options.Get()); + } + for (const auto& [code, options] : stage_1_informational_) { + hash_tracker_.RecordInformational(code, options.Get()); + } + ClearStage1(); +} + +void TwoStageStatusTracker::WriteStage2() { + for (const auto& [code, options] : stage_2_success_) { + hash_tracker_.RecordSuccess(code, options.Get()); + } + for (const auto& [code, options] : stage_2_failure_) { + hash_tracker_.RecordFailure(code, options.Get()); + } + for (const auto& [code, options] : stage_2_informational_) { + hash_tracker_.RecordInformational(code, options.Get()); + } + ClearStage2(); +} + +} // namespace credentio
diff --git a/utils/two_stage_status_tracker.h b/utils/two_stage_status_tracker.h new file mode 100644 index 0000000..3ff3165 --- /dev/null +++ b/utils/two_stage_status_tracker.h
@@ -0,0 +1,129 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_TWO_STAGE_STATUS_TRACKER_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_TWO_STAGE_STATUS_TRACKER_H_ + +#include <string> +#include <utility> +#include <vector> + +#include "constants/status_codes.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "utils/status_tracker.h" +#include "validator/tracker.h" + +namespace credentio { + +// A StatusTracker for recording validation status codes and failure codes to +// another StatusTracker. +// +// This implementation is intended to be used when there are outside conditions +// that determine if the status codes should be written to the contained +// StatusTracker. +// +// There are two holding stages for status codes. The three Record* methods will +// store the codes into stage 1. These status codes can either be written to the +// underlying StatusTracker by calling WriteStage1() or they can be moved to +// stage 2 by calling MoveStage1ToStage2(). +// +// Once in stage 2, the codes can be written to the underlying StatusTracker by +// calling WriteStage2(). +class TwoStageStatusTracker : public StatusTracker { + public: + explicit TwoStageStatusTracker(StatusTracker& hash_tracker) + : hash_tracker_(hash_tracker) {} + + void RecordSuccess(SuccessStatusCode code, + ValidationTracker::RecordOptions options) override; + + void RecordFailure(FailureStatusCode code, + ValidationTracker::RecordOptions options) override; + + void RecordInformational(InformationalStatusCode code, + ValidationTracker::RecordOptions options) override; + + // Writes all the status codes in stage 1 to the underlying StatusTracker. + // Afterwards stage 1 will be cleared. + void WriteStage1(); + + // Writes all the status codes in stage 2 to the underlying StatusTracker. + // Afterwards stage 2 will be cleared. + void WriteStage2(); + + // Clears all the status codes in stage 1. + void ClearStage1() { + stage_1_success_.clear(); + stage_1_failure_.clear(); + stage_1_informational_.clear(); + } + + // Clears all the status codes in stage 2. + void ClearStage2() { + stage_2_success_.clear(); + stage_2_failure_.clear(); + stage_2_informational_.clear(); + } + + // Moves all the status codes in stage 1 to stage 2. Afterwards stage 1 will + // be cleared. + void MoveStage1ToStage2() { + stage_2_success_.insert(stage_2_success_.end(), stage_1_success_.begin(), + stage_1_success_.end()); + stage_2_failure_.insert(stage_2_failure_.end(), stage_1_failure_.begin(), + stage_1_failure_.end()); + stage_2_informational_.insert(stage_2_informational_.end(), + stage_1_informational_.begin(), + stage_1_informational_.end()); + ClearStage1(); + } + + bool HasFailuresInStage1() const { return !stage_1_failure_.empty(); } + + private: + // A saved copy of ValidationTracker::RecordOptions (using std::string members + // instead of absl::string_view). + struct SavedRecordOptions { + explicit SavedRecordOptions(ValidationTracker::RecordOptions options) + : url(options.url), explanation(options.explanation) {} + + ValidationTracker::RecordOptions Get() const { + return ValidationTracker::RecordOptions{ + .url = url, + .explanation = explanation, + }; + } + std::string url; + std::string explanation; + }; + template <typename T> + using TrackerRecord = std::pair<T, SavedRecordOptions>; + + StatusTracker& hash_tracker_; + + std::vector<TrackerRecord<SuccessStatusCode>> stage_1_success_; + std::vector<TrackerRecord<FailureStatusCode>> stage_1_failure_; + std::vector<TrackerRecord<InformationalStatusCode>> stage_1_informational_; + + std::vector<TrackerRecord<SuccessStatusCode>> stage_2_success_; + std::vector<TrackerRecord<FailureStatusCode>> stage_2_failure_; + std::vector<TrackerRecord<InformationalStatusCode>> stage_2_informational_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_TWO_STAGE_STATUS_TRACKER_H_
diff --git a/utils/two_stage_status_tracker_test.cc b/utils/two_stage_status_tracker_test.cc new file mode 100644 index 0000000..86427b9 --- /dev/null +++ b/utils/two_stage_status_tracker_test.cc
@@ -0,0 +1,280 @@ +// 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 "utils/two_stage_status_tracker.h" + +#include <string> + +#include "absl/strings/str_cat.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/proto_test_utils.h" +#include "testing/test_validation_tracker.h" +#include "utils/status_tracker.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; +using ::testing::IsEmpty; + +class RecordingStatusTracker : public StatusTracker { + public: + explicit RecordingStatusTracker(ValidationTracker& tracker) + : tracker_(tracker) {} + + void RecordSuccess(SuccessStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.RecordSuccess(code, options); + } + void RecordFailure(FailureStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.RecordFailure(code, options); + } + void RecordInformational(InformationalStatusCode code, + ValidationTracker::RecordOptions options) override { + tracker_.RecordInformational(code, options); + } + + ValidationTracker& tracker_; +}; + +TEST(TwoStageStatusTrackerTest, DoesNotWriteOnRecord) { + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + TwoStageStatusTracker pending_tracker(tracker); + + pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {}); + pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {}); + pending_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, {}); + + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT(t.GetInformationals(), IsEmpty()); +} + +TEST(TwoStageStatusTrackerTest, WritesStage1OnlyOnce) { + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + TwoStageStatusTracker pending_tracker(tracker); + + pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {}); + pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {}); + pending_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, {}); + + pending_tracker.WriteStage1(); + EXPECT_THAT( + t.GetSuccesses(), + ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch))); + EXPECT_THAT( + t.GetFailures(), + ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch))); + EXPECT_THAT(t.GetInformationals(), + ElementsAre(absl::StrCat( + InformationalStatusCode::kIngredientUnknownProvenance))); + + pending_tracker.WriteStage1(); + EXPECT_THAT( + t.GetSuccesses(), + ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch))); + EXPECT_THAT( + t.GetFailures(), + ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch))); + EXPECT_THAT(t.GetInformationals(), + ElementsAre(absl::StrCat( + InformationalStatusCode::kIngredientUnknownProvenance))); +} + +TEST(TwoStageStatusTrackerTest, ClearsStage1AfterMovingToStage2) { + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + TwoStageStatusTracker pending_tracker(tracker); + + pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {}); + pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {}); + pending_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, {}); + + pending_tracker.MoveStage1ToStage2(); + + pending_tracker.WriteStage1(); + + EXPECT_THAT(t.GetSuccesses(), IsEmpty()); + EXPECT_THAT(t.GetFailures(), IsEmpty()); + EXPECT_THAT(t.GetInformationals(), IsEmpty()); +} + +TEST(TwoStageStatusTrackerTest, + CallingMoveStage1ToStage2TwiceDoesNotDuplicateEntries) { + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + TwoStageStatusTracker pending_tracker(tracker); + + pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {}); + pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {}); + pending_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, {}); + + pending_tracker.MoveStage1ToStage2(); + pending_tracker.MoveStage1ToStage2(); + + pending_tracker.WriteStage2(); + + EXPECT_THAT( + t.GetSuccesses(), + ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch))); + EXPECT_THAT( + t.GetFailures(), + ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch))); + EXPECT_THAT(t.GetInformationals(), + ElementsAre(absl::StrCat( + InformationalStatusCode::kIngredientUnknownProvenance))); +} + +TEST(TwoStageStatusTrackerTest, WritesStage2OnlyOnce) { + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + TwoStageStatusTracker pending_tracker(tracker); + + pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {}); + pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {}); + pending_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, {}); + + pending_tracker.MoveStage1ToStage2(); + + pending_tracker.WriteStage2(); + EXPECT_THAT( + t.GetSuccesses(), + ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch))); + EXPECT_THAT( + t.GetFailures(), + ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch))); + EXPECT_THAT(t.GetInformationals(), + ElementsAre(absl::StrCat( + InformationalStatusCode::kIngredientUnknownProvenance))); + + pending_tracker.WriteStage2(); + EXPECT_THAT( + t.GetSuccesses(), + ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch))); + EXPECT_THAT( + t.GetFailures(), + ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch))); + EXPECT_THAT(t.GetInformationals(), + ElementsAre(absl::StrCat( + InformationalStatusCode::kIngredientUnknownProvenance))); +} + +TEST(TwoStageStatusTrackerTest, SavesStringOptionsForWriteStage1) { + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + TwoStageStatusTracker pending_tracker(tracker); + + { + std::string url = "self#jumbf=/c2pa/blah"; + std::string explanation = "some explanation"; + pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, + {.url = url, .explanation = explanation}); + pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {.url = url, .explanation = explanation}); + pending_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, + {.url = url, .explanation = explanation}); + } + pending_tracker.WriteStage1(); + EXPECT_THAT( + t.GetSuccessStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.bmffHash.match" + url: "self#jumbf=/c2pa/blah" + explanation: "some explanation" + )pb")))); + EXPECT_THAT( + t.GetFailureStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.bmffHash.mismatch" + url: "self#jumbf=/c2pa/blah" + explanation: "some explanation" + )pb")))); + EXPECT_THAT( + t.GetInformationalStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/blah" + explanation: "some explanation" + )pb")))); +} + +TEST(TwoStageStatusTrackerTest, SavesStringOptionsForWriteStage2) { + TestValidationTracker t; + RecordingStatusTracker tracker(t.tracker()); + TwoStageStatusTracker pending_tracker(tracker); + + { + std::string url = "self#jumbf=/c2pa/blah"; + std::string explanation = "some explanation"; + pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, + {.url = url, .explanation = explanation}); + pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, + {.url = url, .explanation = explanation}); + pending_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, + {.url = url, .explanation = explanation}); + } + pending_tracker.MoveStage1ToStage2(); + + pending_tracker.WriteStage2(); + EXPECT_THAT( + t.GetSuccessStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.bmffHash.match" + url: "self#jumbf=/c2pa/blah" + explanation: "some explanation" + )pb")))); + EXPECT_THAT( + t.GetFailureStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.bmffHash.mismatch" + url: "self#jumbf=/c2pa/blah" + explanation: "some explanation" + )pb")))); + EXPECT_THAT( + t.GetInformationalStatuses(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/blah" + explanation: "some explanation" + )pb")))); +} + +} // namespace +} // namespace credentio
diff --git a/utils/uri.cc b/utils/uri.cc new file mode 100644 index 0000000..566c758 --- /dev/null +++ b/utils/uri.cc
@@ -0,0 +1,43 @@ +// 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 "utils/uri.h" + +#include <cstddef> + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" +#include "constants/labels.h" + +namespace credentio { + +absl::StatusOr<absl::string_view> GetManifestLabelFromAbsoluteUri( + absl::string_view uri) { + if (!absl::ConsumePrefix(&uri, "self#jumbf=/c2pa/")) { + return absl::InvalidArgumentError(absl::StrCat("invalid URI: ", uri)); + } + size_t pos = uri.find(kManifestLabelDelimiter); + absl::string_view label = uri.substr(0, pos); + if (label.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("empty manifest label in URI: ", uri)); + } + return label; +} + +} // namespace credentio
diff --git a/utils/uri.h b/utils/uri.h new file mode 100644 index 0000000..09f145e --- /dev/null +++ b/utils/uri.h
@@ -0,0 +1,35 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_UTILS_URI_H_ +#define THIRD_PARTY_CREDENTIO_UTILS_URI_H_ + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +// Extracts the C2PA manifest label from the given absolute URI. +// +// The URI must be a JUMBF URI that starts with `self#jumbf=/c2pa/`. +// +// Returns an error if the URI is not a valid JUMBF URI or does not +// start with `self#jumbf=/c2pa/`. +absl::StatusOr<absl::string_view> GetManifestLabelFromAbsoluteUri( + absl::string_view uri); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UTILS_URI_H_
diff --git a/utils/uri_test.cc b/utils/uri_test.cc new file mode 100644 index 0000000..d135f46 --- /dev/null +++ b/utils/uri_test.cc
@@ -0,0 +1,49 @@ +// 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 "utils/uri.h" + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace { + +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::credentio::GetManifestLabelFromAbsoluteUri; + +TEST(GetManifestLabelFromAbsoluteUriTest, Success) { + EXPECT_THAT(GetManifestLabelFromAbsoluteUri( + "self#jumbf=/c2pa/foo.bar/c2pa.assertions/blah"), + IsOkAndHolds("foo.bar")); + EXPECT_THAT(GetManifestLabelFromAbsoluteUri("self#jumbf=/c2pa/foo.bar"), + IsOkAndHolds("foo.bar")); +} + +TEST(GetManifestLabelFromUriTest, Failure) { + EXPECT_THAT(GetManifestLabelFromAbsoluteUri(""), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(GetManifestLabelFromAbsoluteUri("/c2pa/foo.bar"), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT(GetManifestLabelFromAbsoluteUri("self#jumbf=/c2pa/"), + StatusIs(absl::StatusCode::kInvalidArgument)); + EXPECT_THAT( + GetManifestLabelFromAbsoluteUri("self#jumbf=/c2pa//c2pa.assertions/"), + StatusIs(absl::StatusCode::kInvalidArgument)); +} + +} // namespace
diff --git a/uuid/BUILD b/uuid/BUILD new file mode 100644 index 0000000..70b5149 --- /dev/null +++ b/uuid/BUILD
@@ -0,0 +1,55 @@ +# 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. +# + +load("@rules_cc//cc:cc_test.bzl", "cc_test") +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "uuid", + srcs = ["uuid.cc"], + hdrs = ["uuid.h"], + deps = [ + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/numeric:int128", + "@abseil-cpp//absl/random", + "@abseil-cpp//absl/random:distributions", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/endian:endian_reading", + "@riegeli//riegeli/endian:endian_writing", + ], +) + +cc_test( + name = "uuid_test", + srcs = ["uuid_test.cc"], + deps = [ + ":uuid", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/numeric:int128", + "@abseil-cpp//absl/random", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +)
diff --git a/uuid/uuid.cc b/uuid/uuid.cc new file mode 100644 index 0000000..4235c8d --- /dev/null +++ b/uuid/uuid.cc
@@ -0,0 +1,167 @@ +// 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 "uuid/uuid.h" + +#include <net/if.h> +#include <netinet/in.h> +#include <sys/ioctl.h> +#include <sys/socket.h> +#include <unistd.h> + +#include <cstdint> +#include <string> + +#include "absl/base/no_destructor.h" +#include "absl/log/check.h" +#include "absl/log/log.h" +#include "absl/random/distributions.h" +#include "absl/random/random.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "riegeli/endian/endian_reading.h" +#include "riegeli/endian/endian_writing.h" + +namespace credentio { + +namespace { +class UuidGeneratorImpl : public UuidGenerator { + public: + // Generates a V4 UUID as documented in + // https://tools.ietf.org/html/rfc4122#section-4.4 + Uuid Generate() const override { + absl::BitGen gen; + const auto lo = absl::Uniform<uint64_t>(gen); + const auto hi = absl::Uniform<uint64_t>(gen); + return Uuid((lo & 0x0fffffffffffffffUL) | 0x4000000000000000UL, + (hi & 0xffffffffffffbfffUL) | 0x0000000000008000UL); + }; +}; +} // namespace + +//////////////////////////////////////////////////////////////////////// +// Uuid and related helpers + +// Canonical Uids. +const Uuid Uuid::kInvalid; + +// We reserve the node 00:00:00:00:00:00, time [0, 999] for invalid +// Uuids (constants) +const Uuid Uuid::kValidMin(0, ValidMinLo()); +const Uuid Uuid::kValidMax(~0ULL, ~0ULL); + +std::string Uuid::ToString() const { + if (*this == kInvalid) return std::string(kInvalidRepr); + + auto to_hex = [](uint64_t v, int num_chars, char* out) { + static constexpr char hex_char[] = {'0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; + for (int i = num_chars - 1; i >= 0; --i) { + *out++ = hex_char[(v >> (i * 4)) & 0xf]; + } + }; + uint32_t time_low = low64() & 0xffffffff; + uint32_t time_mid = (low64() >> 32) & 0xffff; + uint32_t time_high = (low64() >> 48) & 0xffff; + uint32_t sequence = high64() & 0xffff; + uint64_t node = (high64() >> 16) & 0xffffffffffffULL; + char buf[36]; + char* ptr = buf; + to_hex(time_low, 8, ptr); + ptr += 8; + *ptr++ = '-'; + to_hex(time_mid, 4, ptr); + ptr += 4; + *ptr++ = '-'; + to_hex(time_high, 4, ptr); + ptr += 4; + *ptr++ = '-'; + to_hex(sequence, 4, ptr); + ptr += 4; + *ptr++ = '-'; + to_hex(node, 12, ptr); + return std::string(buf, 36); +} + +absl::StatusOr<Uuid> Uuid::FromString(absl::string_view s) { + Uuid uuid; + if (!ParseFromString(s, &uuid)) { + return absl::InvalidArgumentError( + absl::StrCat("Syntax error: string '", s, "' is not a Uuid")); + } + return uuid; +} + +std::string Uuid::ToProtoBytes() const { + std::string s; + ToProtoBytes(&s); + return s; +} + +// The UUid byte format is a mixed-endian format specified by RFC 4122. +void Uuid::ToProtoBytes(std::string* s) const { + // Special case for kInvalidUID, which is represented as the empty string. + if (*this == kInvalid) { + s->assign(""); + return; + } + + char bytes[16]; + uint32_t time_low = low64() & 0xffffffff; + uint16_t time_mid = (low64() >> 32) & 0xffff; + uint16_t time_hi_and_version = (low64() >> 48) & 0xffff; + uint16_t clock_seq = high64() & 0xffff; + uint16_t node_low = (high64() >> 16) & 0xffff; + uint32_t node_hi = (high64() >> 32) & 0xffffffff; + + riegeli::WriteBigEndian<uint32_t>(time_low, &bytes[0]); + riegeli::WriteBigEndian<uint16_t>(time_mid, &bytes[4]); + riegeli::WriteBigEndian<uint16_t>(time_hi_and_version, &bytes[6]); + riegeli::WriteBigEndian<uint16_t>(clock_seq, &bytes[8]); + riegeli::WriteBigEndian<uint32_t>(node_hi, &bytes[10]); + riegeli::WriteBigEndian<uint16_t>(node_low, &bytes[14]); + s->assign(bytes, 16); +} + +absl::StatusOr<Uuid> Uuid::FromProtoBytes(absl::string_view bytes) { + if (bytes.empty()) { + return Uuid(kInvalid); + } else if (bytes.size() == 16) { + const char* data = bytes.data(); + uint64_t time_low = riegeli::ReadBigEndian<uint32_t>(&data[0]); + uint64_t time_mid = riegeli::ReadBigEndian<uint16_t>(&data[4]); + uint64_t time_hi_and_version = riegeli::ReadBigEndian<uint16_t>(&data[6]); + uint64_t clock_seq = riegeli::ReadBigEndian<uint16_t>(&data[8]); + uint64_t node_hi = riegeli::ReadBigEndian<uint32_t>(&data[10]); + uint64_t node_low = riegeli::ReadBigEndian<uint16_t>(&data[14]); + uint64_t low64 = (time_hi_and_version << 48) | (time_mid << 32) | time_low; + uint64_t high64 = (node_hi << 32) | (node_low << 16) | clock_seq; + return Uuid(high64, low64); + } + return ::absl::InvalidArgumentError("Syntax error: bytes are not a Uuid"); +} + +bool Uuid::IsValid() const { return kValidMin <= *this && *this <= kValidMax; } + +bool Uuid::IsMagic() const { return high_ == 0 && low_ < ValidMinLo(); } + +const UuidGenerator& UuidGenerator::Default() { + static const absl::NoDestructor<UuidGeneratorImpl> kDefaultUuidGenerator; + return *kDefaultUuidGenerator; +} + +} // namespace credentio
diff --git a/uuid/uuid.h b/uuid/uuid.h new file mode 100644 index 0000000..0561b69 --- /dev/null +++ b/uuid/uuid.h
@@ -0,0 +1,221 @@ +// 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. +// + +// A Uuid is an identifier, that is unique over all space and time. The default +// implementation is based on UUID (DCE version), which uses the Ethernet MAC +// address of the machine, and a timestamp (which we replace with a sequence +// number). +// +// http://en.wikipedia.org/wiki/Universally_unique_identifier +// http://tools.ietf.org/html/rfc4122.html +// +// The reason to use DCE is that it is based on the physical +// address, which may aid debugging. + +#ifndef THIRD_PARTY_CREDENTIO_UUID_UUID_H_ +#define THIRD_PARTY_CREDENTIO_UUID_UUID_H_ + +#include <cstdint> +#include <ostream> +#include <string> + +#include "absl/log/check.h" +#include "absl/numeric/int128.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" + +namespace credentio { + +// Uuid is the type of unique identifiers. +class Uuid { + public: + // In addition to the following constructors, the default copy constructor and + // assignment operator are also allowed. + // Uuids are not POD, because the default constructor initializes them to + // kInvalid. However, the Google Style Guide allows static Uuid constants + // because Uuid constructors are all constexpr, and the type has no + // destructors. + constexpr Uuid() : high_(0), low_(InvalidMinLo()) {} // equal to kInvalid + explicit constexpr Uuid(uint64_t low) : high_(0), low_(low) {} + constexpr Uuid(uint64_t high, uint64_t low) : high_(high), low_(low) {} + constexpr explicit Uuid(absl::uint128 raw) + : Uuid(absl::Uint128High64(raw), absl::Uint128Low64(raw)) {} + + bool IsValid() const; + + // Conversion to/from the ASCII form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. The + // format uses the following ABNF, where each numerical component is + // represented in hex, with leading zeros (although the FromString variants + // will accept input without leading zeros). The 'x's above show the maximum + // length of each of those components. + // + // UUID = time-low "-" time-mid "-" time-high-and-version "-" + // clock-seq-and-reserved clock-seq-low "-" node + // + // FromString returns INVALID_ARGUMENT, and FromStringOrDie CHECK-fails if + // there is a syntax error. + std::string ToString() const; + static absl::StatusOr<Uuid> FromString(absl::string_view s); + static constexpr Uuid FromStringOrDie(absl::string_view s) { + Uuid uuid; + CHECK(Uuid::ParseFromString(s, &uuid)) + << "Syntax error: string '" << s << "' is not a Uuid"; + return uuid; + } + + absl::uint128 ToRawNumber() const { return absl::MakeUint128(high_, low_); } + uint64_t high64() const { return high_; } + uint64_t low64() const { return low_; } + + // Conversion to/from 16-byte binary strings. Layout and byte order + // are specified by RFC 4122. + std::string ToProtoBytes() const; + void ToProtoBytes(std::string* s) const; + + // Convert proto string to Uuid. Returns INVALID_ARGUMENT if the Uuid could + // not be parsed correctly. + static absl::StatusOr<Uuid> FromProtoBytes(absl::string_view bytes); + + // Canonical Uuids + static const Uuid kInvalid; // Represents errors. + static constexpr absl::string_view kInvalidRepr = + "Uid::kInvalid"; // String representation of kInvalid. + + // We reserve 999 in invalid Uuids (in addition to kInvalid) that can be + // used as special sentinel values that will be different from any id returned + // by a Uuid generator. Magic values can be declared as static compile-time + // constants. + template <uint64_t k> + static constexpr Uuid Magic() { + static_assert(k < ValidMinLo(), "Argument too large"); + static_assert(k > InvalidMinLo(), "Argument too small"); + return Uuid(k); + } + bool IsMagic() const; + + // Range of valid Uuids + static const Uuid kValidMin; + static const Uuid kValidMax; + + template <typename H> + friend H AbslHashValue(H h, const Uuid& uuid) { + return H::combine(std::move(h), uuid.high_, uuid.low_); + } + + private: + // Underlying parsing function for ASCII form conversions. + static constexpr bool ParseFromString(absl::string_view s, Uuid* uuid) { + auto consume_hex = [](int max_chars, absl::string_view* input, + uint64_t* res) { + *res = 0; + int count = 0; + while (count < max_chars && !input->empty()) { + char c = input->front(); + uint32_t v = 0; + if (c >= '0' && c <= '9') { + v = c - '0'; + } else if (c >= 'a' && c <= 'f') { + v = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + v = c - 'A' + 10; + } else { + break; + } + *res = (*res << 4) + v; + input->remove_prefix(1); + ++count; + } + return count > 0; + }; + auto consume_char = [](char check_char, absl::string_view* input) { + if (input->empty() || input->front() != check_char) return false; + input->remove_prefix(1); + return true; + }; + + if (s == kInvalidRepr) { + // Equal to kInvalid (canonical value cannot be used here due to constexpr + // restrictions). + *uuid = Uuid(); + return true; + } + + absl::string_view input(s); + uint64_t time_low = 0, time_mid = 0, time_high = 0, sequence = 0, node = 0; + bool valid = true; + valid &= consume_hex(8, &input, &time_low); + valid &= consume_char('-', &input); + valid &= consume_hex(4, &input, &time_mid); + valid &= consume_char('-', &input); + valid &= consume_hex(4, &input, &time_high); + valid &= consume_char('-', &input); + valid &= consume_hex(4, &input, &sequence); + valid &= consume_char('-', &input); + valid &= consume_hex(12, &input, &node); + valid &= input.empty(); + + if (!valid) { + return false; + } + + const uint64_t high64 = + ((node & 0xffffffffffffULL) << 16) | (sequence & 0xffffULL); + const uint64_t low64 = ((time_high & 0xffffULL) << 48) | + ((time_mid & 0xffffULL) << 32) | + (time_low & 0xffffffffULL); + *uuid = Uuid(high64, low64); + return true; + } + + // We reserve the node 00:00:00:00:00:00, time [0, 999] for invalid + // Uuids (constants) + static constexpr uint64_t ValidMinLo() { return 1000; } + static constexpr uint64_t InvalidMinLo() { return 0; } + + uint64_t high_{0}; + uint64_t low_{0}; +}; + +// Comparison operators. +inline bool operator==(Uuid a, Uuid b) { + return a.high64() == b.high64() && a.low64() == b.low64(); +} +inline bool operator!=(Uuid a, Uuid b) { return !(a == b); } +inline bool operator<(Uuid a, Uuid b) { + return a.high64() < b.high64() || + (a.high64() == b.high64() && a.low64() < b.low64()); +} +inline bool operator<=(Uuid a, Uuid b) { + return a.high64() < b.high64() || + (a.high64() == b.high64() && a.low64() <= b.low64()); +} + +// Formatting. +inline std::ostream& operator<<(std::ostream& o, Uuid id) { + return o << id.ToString(); +} + +class UuidGenerator { + public: + virtual ~UuidGenerator() = default; + virtual Uuid Generate() const = 0; + // Generates a V4 UUID as documented in + // https://tools.ietf.org/html/rfc4122#section-4.4 + static const UuidGenerator& Default(); +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_UUID_UUID_H_
diff --git a/uuid/uuid_test.cc b/uuid/uuid_test.cc new file mode 100644 index 0000000..935b705 --- /dev/null +++ b/uuid/uuid_test.cc
@@ -0,0 +1,319 @@ +// 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 "uuid/uuid.h" + +#include <cstdint> +#include <string> +#include <type_traits> + +#include "absl/log/check.h" +#include "absl/numeric/int128.h" +#include "absl/random/random.h" +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace credentio { +namespace { + +using ::absl::StatusCode::kInvalidArgument; +using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; +using ::absl_testing::StatusIs; +using ::testing::HasSubstr; + +TEST(UuidTest, IsTriviallyDestructible) { + // The type is not POD because it has a default constructor that initializes + // the value to kInvalid. Without such as constructor, Uuids declared on the + // stack would have an indeterminate value. However, the type is trivially + // destructible, which means the Google Style Guide allows static constants + // of type Uuid. + static_assert(std::is_trivially_destructible_v<Uuid>, + "Uuid is not trivially destructible"); +} + +TEST(UuidTest, ConstexprFromStringOrDie) { + constexpr auto kUuid = + Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a"); + EXPECT_FALSE(kUuid.IsMagic()); +} + +TEST(UuidTest, CompareData) { + const Uuid id0_0(0, 0); + const Uuid id0_1(0, 1); + const Uuid id1_0(1, 0); + + EXPECT_TRUE(id0_0 == id0_0); + EXPECT_FALSE(id0_0 != id0_0); + EXPECT_FALSE(id0_0 < id0_0); + EXPECT_TRUE(id0_0 <= id0_0); + + EXPECT_FALSE(id0_0 == id0_1); + EXPECT_TRUE(id0_0 != id0_1); + EXPECT_TRUE(id0_0 < id0_1); + EXPECT_TRUE(id0_0 <= id0_1); + + EXPECT_FALSE(id0_0 == id1_0); + EXPECT_TRUE(id0_0 != id1_0); + EXPECT_TRUE(id0_0 < id1_0); + EXPECT_TRUE(id0_0 <= id1_0); + + EXPECT_FALSE(id0_1 == id0_0); + EXPECT_TRUE(id0_1 != id0_0); + EXPECT_FALSE(id0_1 < id0_0); + EXPECT_FALSE(id0_1 <= id0_0); + + EXPECT_FALSE(id1_0 == id0_0); + EXPECT_TRUE(id1_0 != id0_0); + EXPECT_FALSE(id1_0 < id0_0); + EXPECT_FALSE(id1_0 <= id0_0); +} + +TEST(UuidTest, Invalid) { + Uuid id; + EXPECT_EQ(Uuid::kInvalid, id); +} + +TEST(UuidTest, IsValid) { + EXPECT_FALSE(Uuid(0).IsValid()); + EXPECT_FALSE(Uuid(1).IsValid()); + + absl::uint128 i = Uuid(Uuid::kValidMin).ToRawNumber(); + i--; + EXPECT_FALSE(Uuid(i).IsValid()); + + i++; + EXPECT_TRUE(Uuid(i).IsValid()); + EXPECT_TRUE(Uuid(Uuid::kValidMax).IsValid()); +} + +TEST(UuidTest, Magic) { + // This test will not compile unless 0 < key < 1000. + static constexpr uint64_t key = 27; + + // Four different ways of specifying the same static constant. + static const Uuid m1 = Uuid::Magic<key>(); + static const Uuid m2 = Uuid(key); + static const Uuid m3 = Uuid(0, key); + static const Uuid m4 = Uuid(absl::uint128(key)); + + EXPECT_TRUE(m1 == m2); + EXPECT_TRUE(m2 == m3); + EXPECT_TRUE(m3 == m4); +} + +TEST(UuidTest, IsMagic) { + // This test will not compile unless 0 < magic_key < 1000. + static constexpr uint64_t magic_key = 27; + + // Four different ways of specifying the same static constant. + EXPECT_TRUE(Uuid::Magic<magic_key>().IsMagic()); + EXPECT_TRUE(Uuid(magic_key).IsMagic()); + EXPECT_TRUE(Uuid(0, magic_key).IsMagic()); + EXPECT_TRUE(Uuid(absl::uint128(magic_key)).IsMagic()); + + EXPECT_FALSE(Uuid(1001).IsMagic()); + EXPECT_FALSE(Uuid(0, 1001).IsMagic()); + EXPECT_FALSE(Uuid(absl::uint128(1001)).IsMagic()); +} + +TEST(UuidTest, Number) { + absl::uint128 i1 = absl::MakeUint128(0, 1); + Uuid id1(1); + EXPECT_EQ(i1, id1.ToRawNumber()); + EXPECT_EQ(0, id1.high64()); + EXPECT_EQ(1, id1.low64()); + + absl::uint128 i2 = absl::MakeUint128(0, 1275); + Uuid id2(0, 1275); + EXPECT_EQ(i2, id2.ToRawNumber()); + EXPECT_EQ(0, id2.high64()); + EXPECT_EQ(1275, id2.low64()); + + absl::uint128 i3 = absl::MakeUint128(1, 0); + Uuid id3(1, 0); + EXPECT_EQ(i3, id3.ToRawNumber()); + EXPECT_EQ(1, id3.high64()); + EXPECT_EQ(0, id3.low64()); + + absl::uint128 i4 = absl::MakeUint128(2, 14); + Uuid id4(2, 14); + EXPECT_EQ(i4, id4.ToRawNumber()); + EXPECT_EQ(2, id4.high64()); + EXPECT_EQ(14, id4.low64()); + + absl::BitGen rng; + const auto rand64 = [&rng]() { return absl::Uniform<uint64_t>(rng); }; + + for (int i = 0; i != 100; i++) { + uint64_t high = rand64(); + uint64_t low = rand64(); + absl::uint128 j = absl::MakeUint128(high, low); + Uuid id(high, low); + EXPECT_EQ(j, id.ToRawNumber()); + EXPECT_EQ(high, id.high64()); + EXPECT_EQ(low, id.low64()); + } +} + +TEST(UuidTest, Compare) { + EXPECT_TRUE(Uuid(1) == Uuid(1)); + EXPECT_FALSE(Uuid(1) == Uuid(2)); + EXPECT_FALSE(Uuid(1) != Uuid(1)); + EXPECT_TRUE(Uuid(1) != Uuid(2)); + EXPECT_TRUE(Uuid(1) < Uuid(2)); + EXPECT_FALSE(Uuid(2) < Uuid(1)); + + absl::BitGen rng; + const auto rand64 = [&rng]() { return absl::Uniform<uint64_t>(rng); }; + + for (int i = 0; i != 100; i++) { + absl::uint128 i1 = absl::MakeUint128(rand64(), rand64()); + absl::uint128 i2 = absl::MakeUint128(rand64(), rand64()); + Uuid id1(i1); + Uuid id2(i2); + const Uuid sid1(absl::Uint128High64(i1), absl::Uint128Low64(i1)); + const Uuid sid2(absl::Uint128High64(i2), absl::Uint128Low64(i2)); + EXPECT_EQ(id1, id1); + EXPECT_EQ(id1, sid1); + EXPECT_EQ(sid1, id1); + if (i1 < i2) { + EXPECT_TRUE(id1 < id2); + EXPECT_FALSE(id2 < id1); + EXPECT_FALSE(id1 == id2); + + EXPECT_TRUE(id1 < sid2); + EXPECT_FALSE(id2 < sid1); + EXPECT_FALSE(id1 == sid2); + + EXPECT_TRUE(sid1 < id2); + EXPECT_FALSE(sid2 < id1); + EXPECT_FALSE(sid1 == id2); + } else if (i2 < i1) { + EXPECT_TRUE(id2 < id1); + EXPECT_FALSE(id1 < id2); + EXPECT_FALSE(id1 == id2); + + EXPECT_TRUE(id2 < sid1); + EXPECT_FALSE(id1 < sid2); + EXPECT_FALSE(id1 == sid2); + + EXPECT_TRUE(sid2 < id1); + EXPECT_FALSE(sid1 < id2); + EXPECT_FALSE(sid1 == id2); + } else { + EXPECT_EQ(id1, id2); + EXPECT_EQ(id1, sid2); + EXPECT_EQ(sid1, id2); + } + } +} + +TEST(UuidTest, String) { + Uuid id(0x0f0e0d0c0b0a0908ULL, 0x0706050403020100ULL); + EXPECT_EQ("03020100-0504-0706-0908-0f0e0d0c0b0a", id.ToString()); + EXPECT_EQ(id, Uuid::FromStringOrDie("3020100-504-706-908-f0e0d0c0b0a")); + EXPECT_EQ(id, Uuid::FromStringOrDie("3020100-504-706-908-F0E0D0C0B0A")); + EXPECT_EQ(id, Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a")); +} + +TEST(UuidTest, StringSuccess) { + Uuid id(0x0f0e0d0c0b0a0908ULL, 0x0706050403020100ULL); + EXPECT_EQ("03020100-0504-0706-0908-0f0e0d0c0b0a", id.ToString()); + EXPECT_THAT(Uuid::FromString("3020100-504-706-908-f0e0d0c0b0a"), + IsOkAndHolds(id)); + EXPECT_THAT(Uuid::FromString("3020100-504-706-908-F0E0D0C0B0A"), + IsOkAndHolds(id)); +} + +TEST(UuidTest, StringFail) { + auto is_invalid = StatusIs(kInvalidArgument, HasSubstr("Syntax error")); + EXPECT_THAT(Uuid::FromString("I am not a Uuid"), is_invalid); + EXPECT_THAT(Uuid::FromString(""), is_invalid); + EXPECT_THAT(Uuid::FromString("00-00-00"), is_invalid); + + // Individual components too long. + EXPECT_THAT(Uuid::FromString("111111118-1111-1111-1111-111111111111"), + is_invalid); + EXPECT_THAT(Uuid::FromString("11111111-11118-1111-1111-111111111111"), + is_invalid); + EXPECT_THAT(Uuid::FromString("11111111-1111-11118-1111-111111111111"), + is_invalid); + EXPECT_THAT(Uuid::FromString("11111111-1111-1111-11118-111111111111"), + is_invalid); + EXPECT_THAT(Uuid::FromString("11111111-1111-1111-1111-1111111111118"), + is_invalid); + + // Missing components + EXPECT_THAT(Uuid::FromString("-1111-1111-1111-111111111111"), is_invalid); + EXPECT_THAT(Uuid::FromString("11111111-1111--1111-111111111111"), is_invalid); + EXPECT_THAT(Uuid::FromString("11111111-1111-1111-1111-"), is_invalid); + EXPECT_THAT(Uuid::FromString("----"), is_invalid); + + // Extra component + EXPECT_THAT(Uuid::FromString("11111111-1111-1111-1111-111111111111-1"), + is_invalid); +} + +TEST(UuidTest, StringDeath) { + EXPECT_DEATH(Uuid::FromStringOrDie("wrong"), "Syntax error"); + EXPECT_DEATH(Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0Z"), + "Syntax error"); + EXPECT_DEATH(Uuid::FromStringOrDie("Z3020100-0504-0706-0908-0f0e0d0c0b0a"), + "Syntax error"); +} + +TEST(UuidTest, Bytes) { + Uuid id(0x0f0e0d0c0b0a0908ULL, 0x0706050403020100ULL); + EXPECT_EQ("03020100-0504-0706-0908-0f0e0d0c0b0a", id.ToString()); + std::string s; + s.assign("\x03\x02\x01\x00\x05\x04\x07\x06\x09\x08\x0f\x0e\x0d\x0c\x0b\x0a", + 16); + EXPECT_EQ(s, id.ToProtoBytes()); + + auto id2 = Uuid::FromProtoBytes(s); + EXPECT_THAT(id2.status(), IsOk()); + EXPECT_EQ(id, id2.value()); +} + +TEST(UuidTest, InvalidBytes) { + // Uuid::kInvalid is represented with the empty string. + Uuid id; + EXPECT_EQ(Uuid::kInvalid, id); + EXPECT_EQ("", id.ToProtoBytes()); + auto id2 = Uuid::FromProtoBytes(""); + EXPECT_THAT(id2.status(), IsOk()); + EXPECT_EQ(Uuid::kInvalid, id2.value()); +} + +TEST(UuidTest, ParseError) { + auto is_invalid = StatusIs(kInvalidArgument, HasSubstr("Syntax error")); + std::string s; + s.resize(15); + EXPECT_THAT(Uuid::FromProtoBytes(s), is_invalid); + + s.resize(17); + EXPECT_THAT(Uuid::FromProtoBytes(s), is_invalid); + + s.resize(16); + EXPECT_THAT(Uuid::FromProtoBytes(s).status(), IsOk()); +} + +} // namespace +} // namespace credentio
diff --git a/validator/BUILD b/validator/BUILD new file mode 100644 index 0000000..fcdc5cc --- /dev/null +++ b/validator/BUILD
@@ -0,0 +1,317 @@ +# 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. +# + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "tracker", + srcs = ["tracker.cc"], + hdrs = ["tracker.h"], + deps = [ + "//constants:labels", + "//constants:status_codes", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/strings", + "@protobuf//:protobuf_lite", + ], +) + +cc_test( + name = "tracker_test", + srcs = ["tracker_test.cc"], + deps = [ + ":tracker", + "//constants:status_codes", + "//proto:manifest_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "validator_metrics", + hdrs = ["validator_metrics.h"], + deps = [ + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "@abseil-cpp//absl/strings", + ], +) + +cc_library( + name = "result", + srcs = ["result.cc"], + hdrs = ["result.h"], + deps = [ + "//proto:assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "result_test", + srcs = ["result_test.cc"], + deps = [ + ":result", + "//proto:assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//testing:proto_test_utils", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "graph_internals", + srcs = ["graph_internals.cc"], + hdrs = ["graph_internals.h"], + deps = [ + "//proto:assertion_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "graph_internals_test", + srcs = ["graph_internals_test.cc"], + deps = [ + ":graph_internals", + "//proto:ingredient_assertion_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "validation_result_internal", + srcs = ["validation_result_internal.cc"], + hdrs = ["validation_result_internal.h"], + deps = [ + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "@abseil-cpp//absl/algorithm:container", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + ], +) + +cc_library( + name = "validator_options", + hdrs = ["validator_options.h"], + deps = [ + ":validator_metrics", + "//constants:spec_version", + "//crypto:crypto_read_handler", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/time:clock_interface", + ], +) + +cc_test( + name = "validation_result_internal_test", + srcs = ["validation_result_internal_test.cc"], + deps = [ + ":validation_result_internal", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//testing:proto_test_utils", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_matchers", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "graph", + srcs = ["graph.cc"], + hdrs = ["graph.h"], + deps = [ + ":graph_internals", + ":tracker", + ":validator_options", + "//assertion:hashed_uri_validator", + "//assertion:validator", + "//claim:validator", + "//constants:ingredient_relationships", + "//constants:labels", + "//constants:status_codes", + "//crypto:hash", + "//jumbf:box", + "//jumbf:uri", + "//proto:assertion_cc_proto", + "//proto:bmff_based_hash_assertion_cc_proto", + "//proto:boxes_hash_assertion_cc_proto", + "//proto:collection_data_hash_assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:generator_info_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//proto:validation_status_cc_proto", + "//uuid", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/log:check", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_test( + name = "graph_test", + srcs = ["graph_test.cc"], + deps = [ + ":graph", + ":tracker", + ":validator_options", + "//assertion:validator", + "//constants:status_codes", + "//crypto:algorithms", + "//crypto:hash", + "//crypto/default:hasher", + "//jumbf:box", + "//jumbf:uri", + "//proto:assertion_cc_proto", + "//proto:data_hash_assertion_cc_proto", + "//proto:hashed_uri_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:manifest_cc_proto", + "//proto:validation_result_cc_proto", + "//testing:fake_assertion_validator", + "//testing:fake_claim_validator", + "//testing:jumbf_utils", + "//testing:mock_assertion_validator", + "//testing:proto_test_utils", + "@abseil-cpp//absl/container:flat_hash_set", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:status_matchers", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/types:span", + "@googletest//:gtest_main", + ], +) + +cc_library( + name = "manifest_store_validator", + hdrs = ["manifest_store_validator.h"], + deps = [ + "//proto:ingredient_validation_result_cc_proto", + "//proto:validation_result_cc_proto", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "manifest_store_validator_impl", + srcs = ["manifest_store_validator_impl.cc"], + hdrs = ["manifest_store_validator_impl.h"], + deps = [ + ":graph", + ":manifest_store_validator", + ":result", + ":validator_metrics", + ":validator_options", + "//assertion:validator", + "//claim:validator", + "//constants:labels", + "//constants:status_codes", + "//cose:verifier", + "//crypto:hash", + "//crypto/default:hasher", + "//jumbf:box", + "//jumbf:parse", + "//jumbf:uri", + "//proto:assertion_cc_proto", + "//proto:ingredient_assertion_cc_proto", + "//proto:ingredient_validation_result_cc_proto", + "//proto:validation_result_cc_proto", + "//utils:dual_status_tracker", + "//uuid", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + ], +) + +cc_library( + name = "asset_validator", + hdrs = ["asset_validator.h"], + deps = [ + ":result", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +) + +cc_library( + name = "asset_validator_impl", + srcs = ["asset_validator_impl.cc"], + hdrs = ["asset_validator_impl.h"], + deps = [ + ":asset_validator", + ":manifest_store_validator", + ":manifest_store_validator_impl", + ":result", + ":validation_result_internal", + ":validator_metrics", + ":validator_options", + "//bindings:validator", + "//formats:core_registry", + "//formats:extractor", + "//formats:format", + "//formats:registry", + "//proto:validation_result_cc_proto", + "@abseil-cpp//absl/base:nullability", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:status_macros", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@riegeli//riegeli/bytes:reader", + ], +)
diff --git a/validator/asset_validator.h b/validator/asset_validator.h new file mode 100644 index 0000000..0faeb98 --- /dev/null +++ b/validator/asset_validator.h
@@ -0,0 +1,64 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_H_ + +#include <memory> +#include <optional> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "riegeli/bytes/reader.h" +#include "validator/result.h" + +namespace credentio { + +// A validator of C2PA metadata embedded in media files (assets). +class AssetValidator { + public: + virtual ~AssetValidator() = default; + + // Extracts and verifies content credentials from an asset of a supported + // type. Returns an error if validation could not be performed (e.g., due to + // internal error) or no C2PA metadata is present. + // + // The `input` is a riegeli::Reader over the asset. It must support backwards + // seeks (e.g., riegeli::FileReader, riegeli::StringReader). + // The `media_type` is the IANA media type of the asset (e.g. "image/jpeg"). + // If not provided, the media type will be determined from the input. + // + // Example: + // File input: + // ABSL_ASSIGN_OR_RETURN(std::string media_type, + // credentio::MediaType(file_path)); + // riegeli::FileReader reader(file_path); + // ABSL_RETURN_IF_ERROR(reader.status()); + // absl::StatusOr<std::unique_ptr<ValidationResult>> result = + // validator->Validate(reader, *media_type); + // + // String input: + // std::string contents = "contents"; + // riegeli::StringReader reader(contents); + // absl::StatusOr<std::unique_ptr<ValidationResult>> result = + // validator->Validate(reader, "image/jpeg"); + virtual absl::StatusOr<std::unique_ptr<ValidationResult>> Validate( + riegeli::Reader& input, + std::optional<absl::string_view> media_type) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_H_
diff --git a/validator/asset_validator_impl.cc b/validator/asset_validator_impl.cc new file mode 100644 index 0000000..2e44de8 --- /dev/null +++ b/validator/asset_validator_impl.cc
@@ -0,0 +1,127 @@ +// 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 "validator/asset_validator_impl.h" + +#include <memory> +#include <optional> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "bindings/validator.h" +#include "formats/core_registry.h" +#include "formats/extractor.h" +#include "formats/format.h" +#include "formats/registry.h" +#include "proto/validation_result.pb.h" +#include "riegeli/bytes/reader.h" +#include "validator/manifest_store_validator.h" +#include "validator/manifest_store_validator_impl.h" +#include "validator/result.h" +#include "validator/validation_result_internal.h" +#include "validator/validator_metrics.h" +#include "validator/validator_options.h" + +namespace credentio { +namespace { + +absl::StatusOr<std::unique_ptr<ValidationResult>> FinalizePartialResult( + std::unique_ptr<PartialValidationResultProto> partial_validation_result, + riegeli::Reader& input, const Format& format, + std::optional<absl::string_view> media_type, + ValidatorMetrics* absl_nullable metrics, std::string manifest_store_bytes) { + std::unique_ptr<ValidationResultProto> validation_result; + if (partial_validation_result->has_hard_binding_uri()) { + if (!input.Seek(0)) { + return absl::InternalError( + "Failed to seek to the beginning of the input."); + } + ABSL_ASSIGN_OR_RETURN( + validation_result, + ContentBindingValidator().Validate( + input, format, std::move(partial_validation_result))); + } else { + ABSL_ASSIGN_OR_RETURN( + validation_result, + MakeFullValidationResult(std::move(partial_validation_result))); + } + if (media_type.has_value()) { + validation_result->set_media_type(*media_type); + } + + if (metrics != nullptr) { + metrics->RecordValidationResult( + media_type.has_value() ? *media_type : format.mime_types()[0], + *validation_result); + } + + return std::make_unique<ValidationResult>(std::move(validation_result), + std::move(manifest_store_bytes)); +} + +ValidatorOptions OptionsWithoutMetrics(ValidatorOptions options) { + options.metrics = nullptr; + return options; +} + +} // namespace + +absl::StatusOr<std::unique_ptr<ValidationResult>> AssetValidatorImpl::Validate( + riegeli::Reader& input, std::optional<absl::string_view> media_type) const { + if (!input.SupportsSize() || !input.SupportsRandomAccess()) { + return absl::InvalidArgumentError( + "The input does not support backwards seeks, it is required."); + } + + if (!input.Seek(0)) { + return absl::InternalError("Failed to seek to the beginning of the input."); + } + const Format* format; + if (media_type.has_value()) { + ABSL_ASSIGN_OR_RETURN(format, + format_registry_->GetFormat(*media_type, input)); + } else { + ABSL_ASSIGN_OR_RETURN(format, format_registry_->GetFormat(input)); + } + + if (!input.Seek(0)) { + return absl::InternalError("Failed to seek to the beginning of the input."); + } + ABSL_ASSIGN_OR_RETURN(auto manifest_store, + format->extractor()->ExtractManifestStore(input)); + + absl::string_view manifest_str = manifest_store; + + ABSL_ASSIGN_OR_RETURN( + std::unique_ptr<PartialValidationResultProto> partial_validation_result, + manifest_store_validator_->Validate(manifest_str)); + + return FinalizePartialResult(std::move(partial_validation_result), input, + *format, media_type, options_.metrics, + std::move(manifest_store)); +} + +AssetValidatorImpl::AssetValidatorImpl(ValidatorOptions options) + : options_(options), + format_registry_(CreateCoreFormatRegistry()), + manifest_store_validator_(std::make_unique<ManifestStoreValidatorImpl>( + OptionsWithoutMetrics(std::move(options)))) {} + +} // namespace credentio
diff --git a/validator/asset_validator_impl.h b/validator/asset_validator_impl.h new file mode 100644 index 0000000..ea54c3c --- /dev/null +++ b/validator/asset_validator_impl.h
@@ -0,0 +1,60 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_IMPL_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_IMPL_H_ + +#include <cstdint> +#include <memory> +#include <optional> + +#include "absl/base/nullability.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "formats/registry.h" +#include "riegeli/bytes/reader.h" +#include "validator/asset_validator.h" +#include "validator/manifest_store_validator.h" +#include "validator/result.h" +#include "validator/validator_metrics.h" +#include "validator/validator_options.h" + +namespace credentio { + +class AssetValidatorImpl : public AssetValidator { + public: + explicit AssetValidatorImpl(ValidatorOptions options); + + absl::StatusOr<std::unique_ptr<ValidationResult>> Validate( + riegeli::Reader& input, + std::optional<absl::string_view> media_type) const override; + + private: + struct Options { + int64_t max_chunk_bytes; + ValidatorMetrics* absl_nullable metrics; + + explicit Options(const ValidatorOptions& options) + : max_chunk_bytes(options.max_chunk_bytes), metrics(options.metrics) {} + }; + + Options options_; + std::unique_ptr<FormatRegistry> format_registry_; + std::unique_ptr<ManifestStoreValidator> manifest_store_validator_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_IMPL_H_
diff --git a/validator/graph.cc b/validator/graph.cc new file mode 100644 index 0000000..d04ca47 --- /dev/null +++ b/validator/graph.cc
@@ -0,0 +1,545 @@ +// 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 "validator/graph.h" + +#include <cstddef> +#include <memory> +#include <optional> +#include <stack> +#include <string> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_set.h" +#include "absl/log/check.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "assertion/hashed_uri_validator.h" +#include "constants/ingredient_relationships.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "jumbf/box.h" +#include "proto/assertion.pb.h" +#include "proto/bmff_based_hash_assertion.pb.h" +#include "proto/boxes_hash_assertion.pb.h" +#include "proto/collection_data_hash_assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/generator_info.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "uuid/uuid.h" +#include "validator/graph_internals.h" +#include "validator/tracker.h" + +namespace credentio { +namespace { + +template <typename T> +const Manifest* GetManifest(const T* result, absl::string_view label) { + if (result->active_manifest().label() == label) { + return &result->active_manifest(); + } + for (const auto& ingredient_manifest : result->ingredient_manifests()) { + if (ingredient_manifest.label() == label) { + return &ingredient_manifest; + } + } + return nullptr; +} + +bool IsIngredientAssertion(const Assertion& assertion) { + return assertion.has_ingredient_v3(); +} + +// Returns true if the given label is a hard binding assertion label. +// Does not match multi-asset hash assertions or the "part hash" assertions they +// reference. + +// Returns true if the given label is for a hard binding assertion, a +// multi-asset hash assertion, or a "part hash" assertion. + +std::optional<HashedUri> GetIngredientManifestHashedUri( + const Assertion& assertion) { + if (assertion.ingredient_v3().has_active_manifest()) { + return assertion.ingredient_v3().active_manifest(); + } + return std::nullopt; +} + +absl::string_view GetIngredientRelationship(const Assertion& assertion) { + return assertion.ingredient_v3().relationship(); +} + +std::string GetAssertionUri(absl::string_view manifest_label, + const Assertion& assertion) { + return absl::StrJoin({absl::StrCat(kManifestStoreUrlPrefix, manifest_label), + kAssertionStoreLabel, assertion.label()}, + kManifestLabelDelimiter); +} + +// No failures are recorded in the tracker for this method as these failures +// are recorded during the ValidateNode call. +std::optional<std::string> GetHardBindingUri( + const PartialValidationResultProto& result, + absl::string_view manifest_label, ValidationTracker& tracker) { + if (manifest_label.empty()) { + return std::nullopt; + } + auto* manifest_with_content_bindings = GetManifest(&result, manifest_label); + if (manifest_with_content_bindings == nullptr) { + return std::nullopt; + } + + std::optional<std::string> uri; + for (const auto& assertion : manifest_with_content_bindings->assertions()) { + if ((assertion.has_bmff_based_hash() || assertion.has_boxes_hash() || + assertion.has_data_hash() || assertion.has_collection_data_hash()) && + !absl::StrContains(assertion.label(), ".part")) { + uri = GetAssertionUri(manifest_label, assertion); + break; + } + } + return uri; +} + +// No failures are recorded in the tracker for this method as these failures +// are recorded during the ValidateNode call. +std::optional<std::string> GetMultiAssetHashUri( + const PartialValidationResultProto& result, + absl::string_view manifest_label, ValidationTracker& tracker) { + if (manifest_label.empty()) { + return std::nullopt; + } + auto* manifest_with_content_bindings = GetManifest(&result, manifest_label); + if (manifest_with_content_bindings == nullptr) { + return std::nullopt; + } + + std::optional<std::string> uri; + for (const auto& assertion : manifest_with_content_bindings->assertions()) { + if (assertion.has_multi_asset_hash()) { + uri = GetAssertionUri(manifest_label, assertion); + break; + } + } + return uri; +} + +void SetHardBindingUri(absl::string_view manifest_with_content_bindings_label, + PartialValidationResultProto& result) { + ValidationTracker tracker(result.mutable_active_manifest()); + + if (result.active_manifest().label().empty()) { + return; + } + if (auto hard_binding_uri = GetHardBindingUri( + result, manifest_with_content_bindings_label, tracker); + hard_binding_uri.has_value()) { + result.set_hard_binding_uri(*hard_binding_uri); + } + if (auto multi_asset_hash_uri = GetMultiAssetHashUri( + result, manifest_with_content_bindings_label, tracker); + multi_asset_hash_uri.has_value()) { + result.set_multi_asset_hash_uri(*multi_asset_hash_uri); + } +} + +// Returns the containing manifest path from the given assertion path, or +// nullopt if the assertion path is invalid. +std::optional<absl::string_view> ManifestPathFromAssertionPath( + absl::string_view path) { + // Absolute assertion path should look like `/c2pa/<manifest + // label>/c2pa.assertions/...`. Find the position of the third slash to + // extract the manifest path. + if (path.empty() || path[0] != '/') { + return std::nullopt; + } + size_t second_slash = path.find('/', 1); + if (second_slash == std::string::npos) { + return std::nullopt; + } + size_t third_slash = path.find('/', second_slash + 1); + if (third_slash == std::string::npos) { + return std::nullopt; + } + return path.substr(0, third_slash); +} + +absl::string_view ManifestLabel(const jumbf::SuperBox& node) { + return node.description.label.value_or(""); +} + +std::string ManifestPath(const jumbf::SuperBox& node) { + return absl::StrCat("/c2pa/", ManifestLabel(node)); +} + +// Applies checks based on the manifest type, returning true if processing +// should continue. +bool CheckManifestType(Manifest& manifest, credentio::Uuid type_uuid, + std::string* manifest_with_content_bindings_label, + ValidationTracker& tracker) { + if (type_uuid == kStandardManifestUuid) { + if (manifest_with_content_bindings_label->empty()) { + // This is either the active manifest or the first standard manifest + // found by following the chain of parent ingredients from the active + // manifest. + *manifest_with_content_bindings_label = manifest.label(); + } + return true; + } + if (type_uuid == kUpdateManifestUuid) { + // Update manifest checks are performed in the assertion validator. + manifest.set_is_update_manifest(true); + return true; + } + if (type_uuid == kCompressedManifestUuid) { + tracker.RecordFailure( + FailureStatusCode::kGoogleUnsupportedManifestType, + {.explanation = "Compressed manifests not supported"}); + return false; + } + if (type_uuid == kTimestampManifestUuid) { + // No need to support time-stamp manifests (deprecated in C2PA 2.2). + tracker.RecordFailure(FailureStatusCode::kGoogleUnsupportedManifestType, + {.explanation = "Timestamp manifests not supported"}); + return false; + } + tracker.RecordFailure( + FailureStatusCode::kGoogleUnsupportedManifestType, + {.explanation = + absl::StrCat("Unrecognized manifest type: ", type_uuid.ToString())}); + return false; +} + +// Returns true if the manifest label uses a legacy (pre-C2PA-2.1) format. +bool IsLegacyManifestLabel(absl::string_view manifest_label) { + return !absl::StartsWith(manifest_label, "urn:c2pa"); +} + +} // namespace + +absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> +ManifestGraph::Validate() { + ABSL_RETURN_IF_ERROR(ValidateGraph()); + ABSL_ASSIGN_OR_RETURN(auto result, ValidatePostProcess()); + // At this point, the set of redacted assertions and the set of ingredient + // manifests should be fully populated. We can perform the second pass of the + // ingredient validation algorithm in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation. + return ValidateWithRedactions(std::move(result)); +} + +absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> +ManifestGraph::ValidatePostProcess() { + auto result = std::make_unique<PartialValidationResultProto>(); + for (const auto& ingredient_manifest_path : ingredient_manifest_paths_) { + ABSL_ASSIGN_OR_RETURN(auto manifest_state, + GetValidatedManifestState(ingredient_manifest_path)); + absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>> + existing_failures; + existing_failures.reserve( + manifest_state->manifest.validation().failures_size()); + for (const auto& failure : + manifest_state->manifest.validation().failures()) { + existing_failures.insert(std::make_pair(failure.code(), failure.url())); + } + for (const IngredientAssertion& assertion : + manifest_state->referencing_ingredient_assertions) { + PropagateFailuresFromIngredientAssertion( + assertion.assertion->ingredient_v3(), + *manifest_state->manifest.mutable_validation(), existing_failures); + } + *result->add_ingredient_manifests() = std::move(manifest_state->manifest); + } + ABSL_ASSIGN_OR_RETURN( + auto active_manifest_state, + GetValidatedManifestState(ManifestPath(active_manifest_))); + *result->mutable_active_manifest() = + std::move(active_manifest_state->manifest); + SetHardBindingUri(manifest_with_content_bindings_label_, *result); + return result; +} + +std::optional<std::string> ManifestGraph::ProcessIngredientAssertion( + const Assertion& assertion, absl::string_view manifest_path, + absl::string_view default_algorithm, ValidationTracker& tracker) { + const std::string assertion_url = absl::StrCat( + kAssertionStoreLabel, kManifestLabelDelimiter, assertion.label()); + std::optional<HashedUri> manifest_hashed_uri = + GetIngredientManifestHashedUri(assertion); + if (!manifest_hashed_uri.has_value()) { + // Record informational code if the ingredient assertion does not have + // an `active_manifest` field, unless relationship is `inputTo` (Step + // 7-a-iii in the algorithm). + if (GetIngredientRelationship(assertion) != + kIngredientRelationshipInputTo) { + tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, + {.url = assertion_url}); + } + return std::nullopt; + } + auto absolute_path = uri_resolver_.GetAbsolutePathFromUri( + manifest_hashed_uri->url(), manifest_path); + if (!absolute_path.ok()) { + tracker.RecordFailure(FailureStatusCode::kIngredientManifestMissing, + {.url = assertion_url}); + return std::nullopt; + } + if (assertion.ingredient_v3().has_claim_signature()) { + // Validate the claim signature hash. + const auto& claim_signature = assertion.ingredient_v3().claim_signature(); + HashedUriValidator claim_signature_hash_validator( + std::string(manifest_path), uri_resolver_, &hash_checker_factory_); + claim_signature_hash_validator.SetErrorCodes(HashedUriValidator::Codes{ + .missing = FailureStatusCode::kIngredientClaimSignatureMissing, + .mismatch = FailureStatusCode::kIngredientClaimSignatureMismatch}); + claim_signature_hash_validator.SetDefaultAlgorithm( + std::string(default_algorithm)); + auto path = claim_signature_hash_validator.Validate(claim_signature, + assertion_url, tracker); + if (!path.has_value()) { + return std::nullopt; // Validation failure status has been recorded. + } + tracker.RecordSuccess(SuccessStatusCode::kIngredientClaimSignatureValidated, + {.url = assertion_url}); + } else { + // Validate the manifest hash. + HashedUriValidator manifest_hash_validator( + std::string(manifest_path), uri_resolver_, &hash_checker_factory_); + manifest_hash_validator.SetErrorCodes(HashedUriValidator::Codes{ + .missing = FailureStatusCode::kIngredientManifestMissing, + .mismatch = FailureStatusCode::kIngredientManifestMismatch}); + manifest_hash_validator.SetDefaultAlgorithm(std::string(default_algorithm)); + auto path = manifest_hash_validator.Validate(*manifest_hashed_uri, + assertion_url, tracker); + if (!path.has_value()) { + return std::nullopt; // Validation failure status has been recorded. + } + tracker.RecordSuccess(SuccessStatusCode::kIngredientManifestValidated, + {.url = assertion_url}); + } + return *absolute_path; +} + +ManifestGraph::ManifestState& ManifestGraph::GetOrCreateManifestState( + absl::string_view path) { + auto [it, inserted] = manifests_.insert({std::string(path), nullptr}); + if (inserted) { + it->second = std::make_unique<ManifestState>(); + } + return *it->second; +} + +absl::StatusOr<ManifestGraph::ManifestState* absl_nonnull> +ManifestGraph::GetValidatedManifestState(absl::string_view path) { + auto it = manifests_.find(path); + if (it == manifests_.end()) { + return absl::InternalError( + absl::StrCat("manifest state not found: ", path)); + } + if (it->second->validation_state != InternalValidationState::kValidated) { + return absl::InternalError( + absl::StrCat("manifest is not yet validated: ", path)); + } + return it->second.get(); +} + +absl::Status ManifestGraph::ValidateGraph() { + std::stack<const jumbf::SuperBox*> to_visit; + to_visit.push(&active_manifest_); + bool is_active_manifest = true; + while (!to_visit.empty()) { + const jumbf::SuperBox* node = to_visit.top(); + to_visit.pop(); + ABSL_RETURN_IF_ERROR(ValidateNode(*node, + /*is_active_manifest=*/is_active_manifest, + to_visit)); + is_active_manifest = false; + } + return absl::OkStatus(); +} + +// Validates the claim and assertions for the given "node" that represents a +// manifest in the manifest graph. Calling this function for a node is similar +// to jumping to Step 3 in the ingredient validation algorithm in +// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation +// for a target manifest represented by this node in the manifest graph. +// +// The method: +// * Locates and validates the claim (Step 4 in the algorithm). +// * Updates the set of redacted assertions (Step 5 in the algorithm). +// * Validates all assertions in the claim (Step 6 in the algorithm). +// * Finally, the method pushes all eligible ingredient manifests onto the +// `to_visit` stack (Step 7 in the algorithm). +absl::Status ManifestGraph::ValidateNode( + const jumbf::SuperBox& node, bool is_active_manifest, + std::stack<const jumbf::SuperBox*>& to_visit) { + absl::string_view manifest_label = ManifestLabel(node); + std::string manifest_path = ManifestPath(node); + ManifestState& manifest_state = GetOrCreateManifestState(manifest_path); + if (manifest_state.validation_state == InternalValidationState::kValidated) { + return absl::OkStatus(); + } + if (!is_active_manifest) ingredient_manifest_paths_.push_back(manifest_path); + InternalValidationStateLock validation_state_lock(&manifest_state); + + Manifest& manifest = manifest_state.manifest; + manifest.set_label(manifest_label); + ValidationTracker tracker(&manifest); + { + if (IsLegacyManifestLabel(manifest_label)) { + tracker.RecordFailure( + FailureStatusCode::kGoogleUnsupportedSpecVersion, + {.explanation = "deprecated manifest label format"}); + return absl::OkStatus(); + } + if (!CheckManifestType(manifest, node.description.type_uuid, + &manifest_with_content_bindings_label_, tracker)) { + return absl::OkStatus(); + } + + auto validated_claim = claim_validator_.Validate(node, tracker); + if (!validated_claim.has_value()) { + return absl::OkStatus(); + } + *manifest.mutable_claim() = *std::move(validated_claim); + // Update the set of redacted assertions. + for (const auto& assertion : manifest.claim().redacted_assertions()) { + ProcessRedaction(assertion, manifest_path, tracker); + } + // + // Validate assertions and populate `manifest.assertions`. + assertion_validator_.ValidateClaimAssertions( + uri_resolver_, manifest_state.redacted_assertions, manifest, tracker); + // Find ingredient manifests from the validated ingredient assertions and + // add them to the `ingredients` field of the result. + std::vector<std::string> ingredients; + for (const auto& assertion : manifest.assertions()) { + if (IsIngredientAssertion(assertion)) { + auto ingredient_manifest_path = ProcessIngredientAssertion( + assertion, manifest_path, + /*default_algorithm=*/ + manifest.claim().default_algorithm(), tracker); + if (ingredient_manifest_path.has_value()) { + auto ingredient_manifest_box = + uri_resolver_.ResolvePath(*ingredient_manifest_path); + if (!ingredient_manifest_box.ok() || + *ingredient_manifest_box == nullptr) { + tracker.RecordFailure( + FailureStatusCode::kIngredientManifestMissing, + {.url = + absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, + assertion.label()), + .explanation = absl::StrCat( + "Failed to resolve the ingredient manifest URI: ", + *ingredient_manifest_path)}); + } else { + ManifestState& ingredient_manifest_state = + GetOrCreateManifestState(*ingredient_manifest_path); + ingredient_manifest_state.referencing_ingredient_assertions + .push_back(IngredientAssertion{.assertion = &assertion, + .assertion_uri = GetAssertionUri( + manifest_label, assertion)}); + // Update the set of ingredients that will be recursed into. + if (ingredient_manifest_state.validation_state != + InternalValidationState::kValidated) { + ingredients.push_back(*ingredient_manifest_path); + } + } + } + } + } + // Push ingredient manifests onto the stack for depth-first traversal. + for (; !ingredients.empty(); ingredients.pop_back()) { + const std::string& ingredient_manifest = ingredients.back(); + auto ingredient_manifest_box = + uri_resolver_.ResolvePath(ingredient_manifest); + if (!ingredient_manifest_box.ok() || + *ingredient_manifest_box == nullptr) { + // This should never happen because we have already validated the + // ingredient manifest URI. + continue; + } + to_visit.push(*ingredient_manifest_box); + } + } + if (manifest.validation().failures_size() == 0 && + manifest.validation().successes_size() == 0) { + tracker.RecordFailure(FailureStatusCode::kGoogleInternalError, + {.explanation = "No failure or success " + "status codes were recorded."}); + } + return absl::OkStatus(); +} + +absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> +ManifestGraph::ValidateWithRedactions( + std::unique_ptr<PartialValidationResultProto> result) { + return result; +} + +void ManifestGraph::ProcessRedaction(absl::string_view redacted_assertion_uri, + absl::string_view manifest_path, + ValidationTracker& tracker) { + // Resolve the redacted assertion URI to an absolute path before adding it + // to the set. + absl::StatusOr<std::string> absolute_path = + uri_resolver_.GetAbsolutePathFromUri(redacted_assertion_uri, + manifest_path); + if (!absolute_path.ok()) { + // Malformed URI. + tracker.RecordFailure(FailureStatusCode::kAssertionMissing, {}); + return; + } + // Check if the redacted assertion URI points into the manifest whose claim + // redacted it. + if (absl::StartsWith(*absolute_path, manifest_path)) { + tracker.RecordFailure(FailureStatusCode::kAssertionSelfRedacted, {}); + return; + } + std::optional<absl::string_view> redacted_assertion_manifest_path = + ManifestPathFromAssertionPath(*absolute_path); + if (!redacted_assertion_manifest_path.has_value()) { + // Malformed path. + tracker.RecordFailure(FailureStatusCode::kAssertionMissing, {}); + return; + } + GetOrCreateManifestState(*redacted_assertion_manifest_path) + .redacted_assertions.insert(*absolute_path); +} + +ManifestGraph::InternalValidationStateLock::InternalValidationStateLock( + ManifestState* absl_nonnull manifest_state) + : manifest_state_(*manifest_state) { + DCHECK(manifest_state_.validation_state == + InternalValidationState::kUnvalidated); + manifest_state_.validation_state = InternalValidationState::kBeingValidated; +} + +ManifestGraph::InternalValidationStateLock::~InternalValidationStateLock() { + manifest_state_.validation_state = InternalValidationState::kValidated; +} +} // namespace credentio
diff --git a/validator/graph.h b/validator/graph.h new file mode 100644 index 0000000..a0189c3 --- /dev/null +++ b/validator/graph.h
@@ -0,0 +1,171 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_H_ + +#include <memory> +#include <optional> +#include <stack> +#include <string> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" +#include "absl/log/die_if_null.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "assertion/validator.h" +#include "claim/validator.h" +#include "crypto/hash.h" +#include "jumbf/box.h" +#include "jumbf/uri.h" +#include "proto/assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "validator/tracker.h" +#include "validator/validator_options.h" + +namespace credentio { + +class ManifestGraph { + public: + ManifestGraph(const jumbf::SuperBox* absl_nonnull active_manifest, + const jumbf::UriResolver* absl_nonnull uri_resolver, + const AssertionValidator* absl_nonnull assertion_validator, + const ClaimValidator* absl_nonnull claim_validator, + const HashCheckerFactory* absl_nonnull hash_checker_factory, + const ValidatorOptions* absl_nonnull options) + : active_manifest_(*ABSL_DIE_IF_NULL(active_manifest)), + uri_resolver_(*ABSL_DIE_IF_NULL(uri_resolver)), + assertion_validator_(*ABSL_DIE_IF_NULL(assertion_validator)), + claim_validator_(*ABSL_DIE_IF_NULL(claim_validator)), + hash_checker_factory_(*ABSL_DIE_IF_NULL(hash_checker_factory)), + options_(*ABSL_DIE_IF_NULL(options)) {} + + // Perform claim and assertion validation for the active manifest and all + // ingredient manifests reachable from the active manifest. Caller should + // follow with calling the `Validate` method from + // of bindings/validator.h to validate + // content bindings. + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> Validate(); + + private: + // Internal validation state of a manifest, tracking whether the manifest has + // been processed by `ValidateInternal` yet. + enum class InternalValidationState { + // `ValidateInternal` has not processed the manifest yet. + kUnvalidated, + // `ValidateInternal` is currently processing the manifest. + kBeingValidated, + // `ValidateInternal` has processed the manifest already. + kValidated, + }; + + struct IngredientAssertion { + const Assertion* assertion = nullptr; + std::string assertion_uri; + }; + + // State associated with a specific manifest during validation. + struct ManifestState { + InternalValidationState validation_state = + InternalValidationState::kUnvalidated; + // Absolute paths of all assertions in this manifest that have been reported + // (in another manifest) as redacted. + absl::flat_hash_set<std::string> redacted_assertions; + // Ingredient assertions that reference this manifest. + std::vector<IngredientAssertion> referencing_ingredient_assertions; + + // Validation result, complete if `validation_state` is kValidated. + Manifest manifest; + }; + + // Returns the manifest state for the given path, creating it if it does not + // exist. + ManifestState& GetOrCreateManifestState(absl::string_view path); + // Returns the manifest state for the given path, or an error if the manifest + // is not yet validated. + absl::StatusOr<ManifestState* absl_nonnull> GetValidatedManifestState( + absl::string_view path); + + // Performs the first pass of the ingredient validation algorithm in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation + // for the active manifest to populate the set of redacted assertions and the + // set of ingredient manifests and to perform claim and (non-redacted) + // assertion validation for all manifests in the graph. + absl::Status ValidateGraph(); + absl::Status ValidateNode(const jumbf::SuperBox& node, + bool is_active_manifest, + std::stack<const jumbf::SuperBox*>& to_visit); + + // This method performs the second pass of the ingredient validation algorithm + // in + // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation. + // In this pass, we perform the hash validation checks for all ingredient + // manifests reachable from the active manifest, using claim signature or + // manifest hash validation methods. We also process the `validationResults` + // sections of v3 ingredient assertions and update the + // `PartialValidationResultProto` accordingly. + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> + ValidateWithRedactions(std::unique_ptr<PartialValidationResultProto> result); + + // Records the given redaction in the `redacted_assertions_` map. + void ProcessRedaction(absl::string_view redacted_assertion_uri, + absl::string_view manifest_path, + ValidationTracker& tracker); + + // Processes the given ingredient assertion, returning the absolute path to + // the ingredient's manifest if it should be processed. + std::optional<std::string> ProcessIngredientAssertion( + const Assertion& assertion, absl::string_view manifest_path, + absl::string_view default_algorithm, ValidationTracker& tracker); + + // Performs post-processing to complete population of the validation result + // following graph traversal via ValidateNode. + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> + ValidatePostProcess(); + + // RAII-style lock to manage validation state transitions using local scope. + class InternalValidationStateLock { + public: + explicit InternalValidationStateLock( + ManifestState* absl_nonnull manifest_state); + + ~InternalValidationStateLock(); + + private: + ManifestState& manifest_state_; + }; + + const jumbf::SuperBox& active_manifest_; + const jumbf::UriResolver& uri_resolver_; + const AssertionValidator& assertion_validator_; + const ClaimValidator& claim_validator_; + const HashCheckerFactory& hash_checker_factory_; + const ValidatorOptions& options_; + // Manifest validation states, keyed by manifest path. + absl::flat_hash_map<std::string, std::unique_ptr<ManifestState>> manifests_; + // Ingredient manifest paths, in pre-order traversal ordering. + std::vector<std::string> ingredient_manifest_paths_; + std::string manifest_with_content_bindings_label_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_H_
diff --git a/validator/graph_internals.cc b/validator/graph_internals.cc new file mode 100644 index 0000000..11bf4e8 --- /dev/null +++ b/validator/graph_internals.cc
@@ -0,0 +1,62 @@ +// 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 "validator/graph_internals.h" + +#include <string> +#include <utility> + +#include "absl/container/flat_hash_set.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { +namespace { + +ValidationStatus FromOriginalValidator(ValidationStatus status) { + std::string explanation = "from original validator"; + if (!status.explanation().empty()) { + absl::StrAppend(&explanation, ": ", status.explanation()); + } + status.set_explanation(std::move(explanation)); + return status; +} + +} // namespace + +void PropagateFailuresFromIngredientAssertion( + const IngredientAssertionV3& ingredient_assertion, + ValidationStatusSet& validation, + absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>& + existing_failures) { + const auto& failures = + ingredient_assertion.validation_results().active_manifest().failures(); + if (failures.empty()) { + return; + } + for (const auto& failure : failures) { + if (validation.failures_size() >= 10000) { + break; + } + if (existing_failures.insert(std::make_pair(failure.code(), failure.url())) + .second) { + *validation.add_failures() = FromOriginalValidator(failure); + } + } +} + +} // namespace credentio
diff --git a/validator/graph_internals.h b/validator/graph_internals.h new file mode 100644 index 0000000..ea927ab --- /dev/null +++ b/validator/graph_internals.h
@@ -0,0 +1,43 @@ +// 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. +// + +// Implementation support for ManifestGraph. +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_INTERNALS_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_INTERNALS_H_ + +#include <utility> + +#include "absl/container/flat_hash_set.h" +#include "absl/strings/string_view.h" +#include "proto/assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { + +// Copies any failure statuses from the ingredient assertion to the given +// validation results. It is important to treat these failures (from the claim +// generator that produced the ingredient assertion) as validation failures +// for the ingredient, because (particularly for content binding failures) the +// current validator may not be able to perform the check itself. +void PropagateFailuresFromIngredientAssertion( + const IngredientAssertionV3& assertion, + ValidationStatusSet& ingredient_validation, + absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>& + existing_failures); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_INTERNALS_H_
diff --git a/validator/graph_internals_test.cc b/validator/graph_internals_test.cc new file mode 100644 index 0000000..f7f5f52 --- /dev/null +++ b/validator/graph_internals_test.cc
@@ -0,0 +1,217 @@ +// 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 "validator/graph_internals.h" + +#include <utility> + +#include "absl/container/flat_hash_set.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; + +TEST(PropagateFailuresFromIngredientAssertionTest, V3IngredientWithFailures) { + auto assertion = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb( + validation_results { + active_manifest { + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.hash.data" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.actions" + explanation: "can't find assertion" + } + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.signature" + } + } + } + )pb"); + ValidationStatusSet validation; + absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>> + existing_failures; + PropagateFailuresFromIngredientAssertion(assertion, validation, + existing_failures); + EXPECT_THAT( + validation, EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb( + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.hash.data" + explanation: "from original validator" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.actions" + explanation: "from original validator: can't find assertion" + } + )pb"))); +} + +TEST(PropagateFailuresFromIngredientAssertionTest, DedupFailures) { + auto assertion = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb( + validation_results { + active_manifest { + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data" + explanation: "explanation 1" + } + # duplicated in ValidationStatusSet below, will be dropped + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.actions.v2" + explanation: "explanation 2" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata" + explanation: "explanation 3" + } + successes { + code: "timeStamp.validated" + url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.signature" + } + } + } + )pb"); + auto validation = ParseTextProtoOrDie<ValidationStatusSet>(R"pb( + failures { + code: "assertion.dataHash.malformed" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data" + explanation: "explanation 4" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.actions.v2" + explanation: "explanation 5" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata__1" + explanation: "explanation 6" + } + )pb"); + absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>> + existing_failures; + for (const auto& failure : validation.failures()) { + existing_failures.insert(std::make_pair(failure.code(), failure.url())); + } + PropagateFailuresFromIngredientAssertion(assertion, validation, + existing_failures); + EXPECT_THAT( + validation, EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb( + failures { + code: "assertion.dataHash.malformed" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data" + explanation: "explanation 4" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.actions.v2" + explanation: "explanation 5" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata__1" + explanation: "explanation 6" + } + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data" + explanation: "from original validator: explanation 1" + } + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata" + explanation: "from original validator: explanation 3" + } + )pb"))); +} + +TEST(PropagateFailuresFromIngredientAssertionTest, RepeatedCallsDedup) { + auto assertion1 = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb( + validation_results { + active_manifest { + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data" + explanation: "explanation 1" + } + } + } + )pb"); + auto assertion2 = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb( + validation_results { + active_manifest { + failures { + code: "assertion.dataHash.mismatch" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data" + explanation: "explanation 2" + } + } + } + )pb"); + ValidationStatusSet validation; + absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>> + existing_failures; + PropagateFailuresFromIngredientAssertion(assertion1, validation, + existing_failures); + PropagateFailuresFromIngredientAssertion(assertion2, validation, + existing_failures); + EXPECT_EQ(validation.failures_size(), 1); + EXPECT_EQ(validation.failures(0).explanation(), + "from original validator: explanation 1"); +} + +TEST(PropagateFailuresFromIngredientAssertionTest, MaxFailuresLimit) { + ValidationStatusSet validation; + absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>> + existing_failures; + for (int i = 0; i < 10000; ++i) { + auto* failure = validation.add_failures(); + failure->set_code(absl::StrCat("code_", i)); + failure->set_url(absl::StrCat("url_", i)); + existing_failures.insert(std::make_pair(failure->code(), failure->url())); + } + auto assertion = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb( + validation_results { + active_manifest { + failures { + code: "new.failure" + url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data" + } + } + } + )pb"); + PropagateFailuresFromIngredientAssertion(assertion, validation, + existing_failures); + EXPECT_EQ(validation.failures_size(), 10000); +} + +} // namespace +} // namespace credentio
diff --git a/validator/graph_test.cc b/validator/graph_test.cc new file mode 100644 index 0000000..2316c92 --- /dev/null +++ b/validator/graph_test.cc
@@ -0,0 +1,1926 @@ +// 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 "validator/graph.h" + +#include <memory> +#include <string> +#include <vector> + +#include "absl/container/flat_hash_set.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" // IWYU pragma: keep +#include "absl/status/status_matchers.h" +#include "absl/status/statusor.h" +#include "absl/strings/escaping.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" +#include "absl/types/span.h" +#include "assertion/validator.h" +#include "constants/status_codes.h" +#include "crypto/algorithms.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "jumbf/box.h" +#include "jumbf/uri.h" +#include "proto/assertion.pb.h" +#include "proto/data_hash_assertion.pb.h" +#include "proto/hashed_uri.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "testing/fake_assertion_validator.h" +#include "testing/fake_claim_validator.h" +#include "testing/jumbf_utils.h" +#include "testing/mock_assertion_validator.h" +#include "testing/proto_test_utils.h" +#include "validator/tracker.h" +#include "validator/validator_options.h" + +namespace credentio { +namespace { + +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::_; +using ::testing::Contains; +using ::testing::IsEmpty; +using ::testing::Property; +using ::testing::SizeIs; +using ::testing::UnorderedElementsAre; + +class NoopHashChecker : public HashChecker { + public: + void Update(absl::string_view data) override {} + bool Check(absl::string_view hash) override { return true; } + + private: + absl::string_view hash_value_; +}; + +class NoopHashCheckerFactory : public HashCheckerFactory { + public: + absl::StatusOr<std::unique_ptr<HashChecker>> Create( + HashAlgorithm algorithm) const override { + return std::make_unique<NoopHashChecker>(); + } +}; + +Claim DefaultClaim() { + Claim claim; + claim.set_default_algorithm("sha256"); + return claim; +} + +class ManifestGraphTest : public ::testing::Test { + protected: + ManifestGraphTest() + : claim_signature_box_bytes_( + EncodeClaimSignatureBox("claim-signature-cbor")), + claim_signature_box_(ParseSuperBoxOrDie(&claim_signature_box_bytes_)), + empty_data_hash_assertion_(ParseTextProtoOrDie<Assertion>( + "label: 'c2pa.hash.data' data_hash {}")) {} + + std::string claim_signature_box_bytes_; + jumbf::SuperBox claim_signature_box_; + NoopHashCheckerFactory noop_hash_checker_factory_; + ValidatorOptions options_; + Assertion empty_data_hash_assertion_; +}; + +TEST_F(ManifestGraphTest, ClaimValidationFails) { + FakeClaimValidator claim_validator({FailureStatusCode::kGeneralError}, + /*informationals=*/{}, /*successes=*/{}); + auto assertion_validator = std::make_unique<AssertionValidator>(); + jumbf::SuperBox root_box; + jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {}); + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + ManifestGraph graph(&active_manifest, &uri_resolver, + assertion_validator.get(), &claim_validator, + &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, + EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>( + R"pb(active_manifest { + label: "urn:c2pa:1" + validation { + failures { + code: "general.error" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + } + )pb"))); +} + +TEST_F(ManifestGraphTest, StandardManifestAccepted) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {})}; + assertion_validator.SetOutputAssertions("/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, PopulatesMultiAssetHashUri) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {})}; + + Assertion multi_asset_hash_assertion = ParseTextProtoOrDie<Assertion>( + "label: 'c2pa.hash.multi-asset' multi_asset_hash {}"); + Assertion part0_hash_assertion = ParseTextProtoOrDie<Assertion>( + "label: 'c2pa.hash.data.part' data_hash {}"); + Assertion part1_hash_assertion = ParseTextProtoOrDie<Assertion>( + "label: 'c2pa.hash.data.part__1' data_hash {}"); + + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, multi_asset_hash_assertion, + part0_hash_assertion, part1_hash_assertion}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "c2pa.hash.multi-asset" + multi_asset_hash {} + } + assertions { + label: "c2pa.hash.data.part" + data_hash {} + } + assertions { + label: "c2pa.hash.data.part__1" + data_hash {} + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + multi_asset_hash_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.multi-asset" + )pb"))); +} + +TEST_F(ManifestGraphTest, PopulatesHardBindingAndMultiAssetHashUris) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {})}; + + Assertion part1_data_hash_assertion = ParseTextProtoOrDie<Assertion>( + "label: 'c2pa.hash.data.part' data_hash {}"); + Assertion part2_boxes_hash_assertion = ParseTextProtoOrDie<Assertion>( + "label: 'c2pa.hash.boxes.part' boxes_hash {}"); + + Assertion multi_asset_hash_assertion = ParseTextProtoOrDie<Assertion>( + "label: 'c2pa.hash.multi-asset' multi_asset_hash {}"); + + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {part1_data_hash_assertion, part2_boxes_hash_assertion, + multi_asset_hash_assertion, empty_data_hash_assertion_}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data.part" + data_hash {} + } + assertions { + label: "c2pa.hash.boxes.part" + boxes_hash {} + } + assertions { + label: "c2pa.hash.multi-asset" + multi_asset_hash {} + } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + multi_asset_hash_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.multi-asset" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientStandardManifestAccepted) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:2", {}), + }; + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" + } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + // claimSignature.validated contains the "wrong" URL as it's added by the + // FakeClaimValidator. + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:2" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" } + claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" } + } + } + } + ingredient_manifests { + label: "urn:c2pa:1" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + claim { default_algorithm: "sha256" } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, UpdateManifestAccepted) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + auto standard_manifest = + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}); + auto update_manifest = CreateUpdateManifest("urn:c2pa:2", {}); + assertion_validator.SetOutputAssertions("/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf" + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" + } + })pb")}); + std::vector<jumbf::SuperBox> manifests = {standard_manifest, update_manifest}; + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:2" + is_update_manifest: true + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf" + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" } + claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" } + } + } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3" + } + } + } + ingredient_manifests { + label: "urn:c2pa:1" + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, + StandardManifestWithIngredientUpdateManifestAccepted) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:IngredientStandard", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:IngredientUpdate", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:ActiveStandard", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + auto standard_manifest = CreateStandardManifest("urn:c2pa:IngredientStandard", + {claim_signature_box_}); + auto update_manifest = + CreateUpdateManifest("urn:c2pa:IngredientUpdate", {claim_signature_box_}); + auto active_manifest = CreateStandardManifest("urn:c2pa:ActiveStandard", {}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:IngredientUpdate", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf" + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard/c2pa.signature" + } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:ActiveStandard", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf" + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate/c2pa.signature" + } + })pb")}); + std::vector<jumbf::SuperBox> manifests = {standard_manifest, update_manifest, + active_manifest}; + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[2], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:ActiveStandard" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:ActiveStandard" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:ActiveStandard/c2pa.assertions/c2pa.ingredient.v3" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf" + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate" } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate/c2pa.signature" + } + } + } + } + ingredient_manifests { + label: "urn:c2pa:IngredientUpdate" + is_update_manifest: true + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.ingredient.v3" + ingredient_v3 { + relationship: "parentOf" + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard/c2pa.signature" + } + } + } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate/c2pa.assertions/c2pa.ingredient.v3" + } + } + } + ingredient_manifests { + label: "urn:c2pa:IngredientStandard" + claim { default_algorithm: "sha256" } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:ActiveStandard/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientManifestV3UrlIsInvalid) { + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(label: "ingredient_v3" + ingredient_v3 { + active_manifest { url: "invalid_url" } + })pb")}); + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "ingredient_v3" + ingredient_v3 { active_manifest { url: "invalid_url" } } + } + validation { + failures { + code: "ingredient.manifest.missing" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/ingredient_v3" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientV3ClaimSignatureHashMatch) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:2", {}), + }; + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>(absl::Substitute( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" + hash: "$0" + algorithm: "sha256" + } + })pb", + absl::CEscape(GetHash("sha256", claim_signature_box_))))}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator, + &claim_validator, &DefaultHashCheckerFactory(), + &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty()); + EXPECT_THAT( + (*result)->active_manifest().validation().successes(), + Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3" + )pb")))); + ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(1)); + EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(), + IsEmpty()); +} + +TEST_F(ManifestGraphTest, IngredientV3ClaimSignatureHashMismatch) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:2", {}), + }; + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(label: "ingredient_v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" + hash: "wrong hash" + algorithm: "sha256" + } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator, + &claim_validator, &DefaultHashCheckerFactory(), + &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT((*result)->active_manifest().validation().failures(), + Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "ingredient.claimSignature.mismatch" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/ingredient_v3" + )pb")))); + EXPECT_THAT((*result)->ingredient_manifests(), IsEmpty()); +} + +TEST_F(ManifestGraphTest, IngredientManifestHashMatch) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:2", {}), + }; + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>(absl::Substitute( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:1" + hash: "$0" + algorithm: "sha256" + } + })pb", + absl::CEscape(GetHash("sha256", manifests[0]))))}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator, + &claim_validator, &DefaultHashCheckerFactory(), + &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty()); + EXPECT_THAT( + (*result)->active_manifest().validation().successes(), + Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3" + )pb")))); + ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(1)); + EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(), + IsEmpty()); +} + +TEST_F(ManifestGraphTest, IngredientManifestHashMismatch) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:2", {}), + }; + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:1" + hash: "wrong hash" + algorithm: "sha256" + } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator, + &claim_validator, &DefaultHashCheckerFactory(), + &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT( + (*result)->active_manifest().validation().failures(), + Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "ingredient.manifest.mismatch" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3" + )pb")))); + EXPECT_THAT((*result)->ingredient_manifests(), IsEmpty()); +} + +TEST_F(ManifestGraphTest, ManifestLegacyLabel) { + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + claim_validator.SetClaimResult("urn:uuid:some_label", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box, active_manifest; + active_manifest.description.label = "urn:uuid:some_label"; + assertion_validator.SetOutputAssertions( + "/c2pa/urn:uuid:some_label", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:uuid:some_label" + } + })pb")}); + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT( + **result, + EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:uuid:some_label" + validation { + failures { + code: "com.google.unsupportedSpecVersion" + url: "self#jumbf=/c2pa/urn:uuid:some_label" + explanation: "deprecated manifest label format" + } + } + } + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientManifestUrlValidButManifestMissing) { + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + claim_validator.SetClaimResult("urn:c2pa:some_label", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + jumbf::SuperBox active_manifest = + CreateStandardManifest("urn:c2pa:some_label", {}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:some_label", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(label: "ingredient_v3" + ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:some_label" + } + })pb")}); + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:some_label" + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "ingredient_v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:some_label" } + } + } + validation { + failures { + code: "ingredient.manifest.missing" + url: "self#jumbf=/c2pa/urn:c2pa:some_label/c2pa.assertions/ingredient_v3" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:some_label/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientManifestV3SelfRedacted) { + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + Claim claim = DefaultClaim(); + claim.add_redacted_assertions("self#jumbf=self_redacted"); + claim_validator.SetClaimResult("urn:c2pa:1", claim); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {}); + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT( + **result, + EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { + redacted_assertions: "self#jumbf=self_redacted" + default_algorithm: "sha256" + } + validation { + failures { + code: "assertion.selfRedacted" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + } + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientManifestV3InvalidRedactedAssertionUri) { + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + Claim claim = DefaultClaim(); + claim.add_redacted_assertions("invalid_uri"); + claim_validator.SetClaimResult("urn:c2pa:1", claim); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {}); + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT( + **result, + EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { + redacted_assertions: "invalid_uri" + default_algorithm: "sha256" + } + validation { + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + } + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientManifestV3InvalidRedactedAssertionPath) { + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + Claim claim = DefaultClaim(); + claim.add_redacted_assertions("self#jumbf=/invalid_path"); + claim_validator.SetClaimResult("urn:c2pa:1", claim); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {}); + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box); + ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT( + **result, + EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { + redacted_assertions: "self#jumbf=/invalid_path" + default_algorithm: "sha256" + } + validation { + failures { + code: "assertion.missing" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + } + )pb"))); +} + +TEST_F(ManifestGraphTest, RedactedAssertionsProvidedToAssertionValidator) { + // Manifest graph: + // urn:c2pa:4 (active) has ingredients urn:c2pa:2, urn:c2pa:3 + // unn:c2pa:2 has ingredient urn:c2pa:1 + // urn:c2pa:3 has ingredient urn:c2pa:1 + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + Claim claim1 = DefaultClaim(); + Claim claim2 = ParseTextProtoOrDie<Claim>(R"pb( + default_algorithm: "sha256" + redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim2" + )pb"); + Claim claim3 = ParseTextProtoOrDie<Claim>(R"pb( + default_algorithm: "sha256" + redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim3" + )pb"); + Claim claim4 = ParseTextProtoOrDie<Claim>(R"pb( + default_algorithm: "sha256" + redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim4" + redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:2/claim2_assertion_redacted_by_claim4" + redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:3/claim3_assertion_redacted_by_claim4" + )pb"); + claim_validator.SetClaimResult("urn:c2pa:1", claim1); + claim_validator.SetClaimResult("urn:c2pa:2", claim2); + claim_validator.SetClaimResult("urn:c2pa:3", claim3); + claim_validator.SetClaimResult("urn:c2pa:4", claim4); + MockAssertionValidator assertion_validator; + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:2", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:3", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:4", {})); + EXPECT_CALL(assertion_validator, + ValidateClaimAssertions( + _, _, Property(&Manifest::label, "urn:c2pa:1"), _)) + .WillOnce( + [&](const jumbf::UriResolver&, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, ValidationTracker&) { + EXPECT_THAT( + redacted_assertion_paths, + // Note that the assertion redacted by claim3 does not appear + // here, because we haven't reached claim3 yet in the DFS + // when we process claim1. See discussion in + // https://github.com/c2pa-org/specs-core/issues/1540. + UnorderedElementsAre( + "/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim4", + "/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim2")); + }); + EXPECT_CALL(assertion_validator, + ValidateClaimAssertions( + _, _, Property(&Manifest::label, "urn:c2pa:2"), _)) + .WillOnce( + [&](const jumbf::UriResolver&, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, ValidationTracker&) { + EXPECT_THAT( + redacted_assertion_paths, + UnorderedElementsAre( + "/c2pa/urn:c2pa:2/claim2_assertion_redacted_by_claim4")); + auto* active_manifest = manifest.add_assertions() + ->mutable_ingredient_v3() + ->mutable_active_manifest(); + active_manifest->set_url("self#jumbf=/c2pa/urn:c2pa:1"); + }); + EXPECT_CALL(assertion_validator, + ValidateClaimAssertions( + _, _, Property(&Manifest::label, "urn:c2pa:3"), _)) + .WillOnce( + [&](const jumbf::UriResolver&, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, ValidationTracker&) { + EXPECT_THAT( + redacted_assertion_paths, + UnorderedElementsAre( + "/c2pa/urn:c2pa:3/claim3_assertion_redacted_by_claim4")); + auto* active_manifest = manifest.add_assertions() + ->mutable_ingredient_v3() + ->mutable_active_manifest(); + active_manifest->set_url("self#jumbf=/c2pa/urn:c2pa:1"); + }); + EXPECT_CALL(assertion_validator, + ValidateClaimAssertions( + _, _, Property(&Manifest::label, "urn:c2pa:4"), _)) + .WillOnce( + [&](const jumbf::UriResolver&, + const absl::flat_hash_set<std::string>& redacted_assertion_paths, + Manifest& manifest, ValidationTracker&) { + EXPECT_THAT(redacted_assertion_paths, IsEmpty()); + auto* active_manifest1 = manifest.add_assertions() + ->mutable_ingredient_v3() + ->mutable_active_manifest(); + active_manifest1->set_url("self#jumbf=/c2pa/urn:c2pa:2"); + auto* active_manifest2 = manifest.add_assertions() + ->mutable_ingredient_v3() + ->mutable_active_manifest(); + active_manifest2->set_url("self#jumbf=/c2pa/urn:c2pa:3"); + }); + + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests.back(), &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); +} + +TEST_F(ManifestGraphTest, IngredientNesting) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:5", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:6", DefaultClaim()); + + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests = { + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:2", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:3", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:4", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:5", {claim_signature_box_}), + CreateStandardManifest("urn:c2pa:6", {claim_signature_box_}), + }; + for (int i = 2; i <= 6; ++i) { + assertion_validator.SetOutputAssertions( + absl::StrCat("/c2pa/urn:c2pa:", i), + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>(absl::Substitute( + R"pb( + label: "c2pa.ingredient.v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:$0" } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:$0/c2pa.signature" + } + })pb", + i - 1))}); + } + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + + ManifestGraph graph(&manifests[5], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty()); + ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(5)); + EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(), + IsEmpty()); + EXPECT_THAT((*result)->ingredient_manifests(1).validation().failures(), + IsEmpty()); + EXPECT_THAT((*result)->ingredient_manifests(2).validation().failures(), + IsEmpty()); + EXPECT_THAT((*result)->ingredient_manifests(3).validation().failures(), + IsEmpty()); + EXPECT_THAT((*result)->ingredient_manifests(4).validation().failures(), + IsEmpty()); +} + +TEST_F(ManifestGraphTest, GraphContainsLoop) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:active_manifest", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:ingredient_manifest", + DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + jumbf::SuperBox active_manifest = CreateStandardManifest( + "urn:c2pa:active_manifest", {claim_signature_box_}); + jumbf::SuperBox ingredient_manifest = CreateStandardManifest( + "urn:c2pa:ingredient_manifest", {claim_signature_box_}); + jumbf::SuperBox manifest_store = + CreateManifestStore({ingredient_manifest, active_manifest}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:active_manifest", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:ingredient_manifest" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:ingredient_manifest/c2pa.signature" + } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:ingredient_manifest", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(ingredient_v3 { + active_manifest { + url: "self#jumbf=/c2pa/urn:c2pa:active_manifest" + } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:active_manifest/c2pa.signature" + } + })pb")}); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty()); + ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(1)); + EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(), + IsEmpty()); +} + +TEST_F(ManifestGraphTest, IngredientV3WithInvalidClaimSignatureUri) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:2", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(label: "ingredient_v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + claim_signature { url: "invalid_uri" } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + failures { + code: "ingredient.claimSignature.missing" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/ingredient_v3" + explanation: "self#jumbf= prefix required" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "ingredient_v3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + claim_signature { url: "invalid_uri" } + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientV3WithoutManifest) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(label: "ingredient_v3" + ingredient_v3 { + relationship: "componentOf" + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" + } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "ingredient_v3" + ingredient_v3 { + relationship: "componentOf" + claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" } + } + } + validation { + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/ingredient_v3" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientV3InputToWithoutManifest) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb(ingredient_v3 { + relationship: "inputTo" + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" + } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + ingredient_v3 { + relationship: "inputTo" + claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" } + } + } + validation { + # No ingredient.unknownProvenance informational for inputTo ingredient. + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientV3WithValidClaimSignatureUri) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back( + CreateStandardManifest("urn:c2pa:1", {claim_signature_box_})); + manifests.push_back(CreateStandardManifest("urn:c2pa:2", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + claim_signature { + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" + } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "assert" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" } + } + } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + successes { + code: "ingredient.claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert" + } + } + } + ingredient_manifests { + label: "urn:c2pa:2" + claim { default_algorithm: "sha256" } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientV3WithMultipleParents) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:2", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:3", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:4", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_2" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + })pb"), + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_4" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:3", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_4" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_2" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_3" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "assert_for_2" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } } + } + assertions { + label: "assert_for_3" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } } + } + } + ingredient_manifests { + label: "urn:c2pa:2" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/assert_for_4" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "assert_for_4" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } } + } + } + ingredient_manifests { + label: "urn:c2pa:4" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:4" + } + } + claim { default_algorithm: "sha256" } + } + ingredient_manifests { + label: "urn:c2pa:3" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:3" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:3/c2pa.assertions/assert_for_4" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "assert_for_4" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientV3AllButOneIngredientManifestIsValid) { + FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{}, + /*successes=*/{}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimSuccesses( + "urn:c2pa:1", {SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + claim_validator.SetClaimSuccesses( + "urn:c2pa:2", {SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim()); + claim_validator.SetClaimSuccesses( + "urn:c2pa:3", {SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimInformationals( + "urn:c2pa:3", {InformationalStatusCode::kIngredientUnknownProvenance}); + claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim()); + claim_validator.SetClaimFailures( + "urn:c2pa:4", {FailureStatusCode::kClaimSignatureMismatch}); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:2", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:3", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:4", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_2" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + })pb"), + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } + })pb"), + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_4" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + claim { default_algorithm: "sha256" } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "assert_for_2" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } } + } + assertions { + label: "assert_for_3" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } } + } + assertions { + label: "assert_for_4" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } } + } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_2" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_3" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_4" + } + } + } + ingredient_manifests { + label: "urn:c2pa:2" + claim { default_algorithm: "sha256" } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2" + } + } + } + ingredient_manifests { + label: "urn:c2pa:3" + claim { default_algorithm: "sha256" } + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:3" + } + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:3" + } + } + } + ingredient_manifests { + label: "urn:c2pa:4" + claim { default_algorithm: "sha256" } + validation { + failures { + code: "claimSignature.mismatch" + url: "self#jumbf=/c2pa/urn:c2pa:4" + } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientV3WithRedactions) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + Claim claim1 = DefaultClaim(); + Claim claim2 = DefaultClaim(); + claim1.add_redacted_assertions("self#jumbf=/c2pa/urn:c2pa:2/some_assertion"); + claim2.add_created_assertions()->set_url("self#jumbf=some_assertion"); + claim_validator.SetClaimResult("urn:c2pa:1", claim1); + claim_validator.SetClaimResult("urn:c2pa:2", claim2); + claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:2", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:3", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:4", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_2" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + })pb"), + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_4" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:3", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_4" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie< + PartialValidationResultProto>(R"pb( + active_manifest { + label: "urn:c2pa:1" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_2" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_3" + } + } + claim { + redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:2/some_assertion" + default_algorithm: "sha256" + } + assertions { + label: "c2pa.hash.data" + data_hash {} + } + assertions { + label: "assert_for_2" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } } + } + assertions { + label: "assert_for_3" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } } + } + } + ingredient_manifests { + label: "urn:c2pa:2" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/assert_for_4" + } + } + claim { + created_assertions { url: "self#jumbf=some_assertion" } + default_algorithm: "sha256" + } + assertions { + label: "assert_for_4" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } } + } + } + ingredient_manifests { + label: "urn:c2pa:4" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:4" + } + } + claim { default_algorithm: "sha256" } + } + ingredient_manifests { + label: "urn:c2pa:3" + validation { + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:3" + } + successes { + code: "ingredient.manifest.validated" + url: "self#jumbf=/c2pa/urn:c2pa:3/c2pa.assertions/assert_for_4" + } + } + claim { default_algorithm: "sha256" } + assertions { + label: "assert_for_4" + ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } } + } + } + hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data" + )pb"))); +} + +TEST_F(ManifestGraphTest, IngredientAssertionsWithFailures) { + FakeClaimValidator claim_validator( + /*failures=*/{}, /*informationals=*/{}, + /*successes=*/{SuccessStatusCode::kClaimSignatureValidated}); + claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim()); + claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim()); + FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{}); + jumbf::SuperBox root_box; + std::vector<jumbf::SuperBox> manifests; + manifests.push_back(CreateStandardManifest("urn:c2pa:1", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:2", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:3", {})); + manifests.push_back(CreateStandardManifest("urn:c2pa:4", {})); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:1", + {empty_data_hash_assertion_, + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_2" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } + validation_results { + active_manifest { + failures { code: "assertion.dataHash.mismatch" url: "blah2" } + } + } + })pb"), + ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_3" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:2", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_4" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } + validation_results { + active_manifest { + failures { code: "assertion.dataHash.mismatch" url: "blah4" } + } + } + })pb")}); + assertion_validator.SetOutputAssertions( + "/c2pa/urn:c2pa:3", + {ParseTextProtoOrDie<Assertion>( + R"pb( + label: "assert_for_4" + ingredient_v3 { + active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } + validation_results { + active_manifest { + failures { code: "assertion.dataHash.malformed" url: "blah4" } + } + } + })pb")}); + jumbf::SuperBox manifest_store = CreateManifestStore(manifests); + jumbf::UriResolver uri_resolver = + jumbf::UriResolver::WithSingleRootChild(&manifest_store); + ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator, + &claim_validator, &noop_hash_checker_factory_, &options_); + auto result = graph.Validate(); + ABSL_ASSERT_OK(result); + ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(3)); + EXPECT_THAT((*result)->ingredient_manifests(0).label(), "urn:c2pa:2"); + EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(), + Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.dataHash.mismatch" + url: "blah2" + explanation: "from original validator" + )pb")))); + EXPECT_THAT((*result)->ingredient_manifests(1).label(), "urn:c2pa:4"); + EXPECT_THAT((*result)->ingredient_manifests(1).validation().failures(), + Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb( + code: "assertion.dataHash.mismatch" + url: "blah4" + explanation: "from original validator" + )pb")))); +} + +} // namespace +} // namespace credentio
diff --git a/validator/manifest_store_validator.h b/validator/manifest_store_validator.h new file mode 100644 index 0000000..9608614 --- /dev/null +++ b/validator/manifest_store_validator.h
@@ -0,0 +1,45 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/validation_result.pb.h" + +namespace credentio { + +class ManifestStoreValidator { + public: + virtual ~ManifestStoreValidator() = default; + + // Validates just the manifest store and returns the result and + // information about the hard binding. Returns an error if the validation + // could not be performed (eg. due to an internal error). + // + // NOTE: This method does NOT validate the hard binding. That must be done + // separately. Without that being processed, the result cannot be + // considered complete. + virtual absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> + Validate(absl::string_view manifest_store) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_H_
diff --git a/validator/manifest_store_validator_impl.cc b/validator/manifest_store_validator_impl.cc new file mode 100644 index 0000000..5fd6d9f --- /dev/null +++ b/validator/manifest_store_validator_impl.cc
@@ -0,0 +1,174 @@ +// 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 "validator/manifest_store_validator_impl.h" + +#include <memory> +#include <optional> +#include <utility> +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/status/status.h" +#include "absl/status/status_macros.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "assertion/validator.h" +#include "claim/validator.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "cose/verifier.h" +#include "crypto/default/hasher.h" +#include "crypto/hash.h" +#include "jumbf/box.h" +#include "jumbf/parse.h" +#include "jumbf/uri.h" +#include "proto/assertion.pb.h" +#include "proto/ingredient_assertion.pb.h" +#include "proto/validation_result.pb.h" +#include "utils/dual_status_tracker.h" +#include "uuid/uuid.h" +#include "validator/graph.h" +#include "validator/result.h" +#include "validator/validator_metrics.h" +#include "validator/validator_options.h" + +namespace credentio { + +namespace { + +using ::jumbf::SuperBox; + +bool IsManifestStore(const SuperBox& superbox) { + return superbox.description.label == kManifestStoreLabel && + superbox.description.type_uuid == kManifestStoreUuid; +} + +bool IsManifest(const SuperBox& superbox) { + const auto& uuid = superbox.description.type_uuid; + return uuid == kStandardManifestUuid || uuid == kUpdateManifestUuid || + uuid == kCompressedManifestUuid || uuid == kTimestampManifestUuid; +} + +absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> +ValidateManifestStore(absl::string_view manifest_store, + const ValidatorOptions& options, + const AssertionValidator& assertion_validator, + const ClaimValidator& claim_validator, + const HashCheckerFactory& hash_checker_factory, + ValidatorMetrics* absl_nullable metrics) { + // Obtain the superbox proto from the serialized manifest. + // Any error from parsing the JUMBF boxes is considered "No Manifest Found" + // A recursion limit of 3 should suffice, but we'll allow for a bit more in + // case of future changes. + absl::StatusOr<SuperBox> ms_superbox_or = + jumbf::ConsumeSuperBox(&manifest_store, /*recursion_limit=*/9); + if (!ms_superbox_or.ok()) { + return absl::NotFoundError(ms_superbox_or.status().message()); + } + SuperBox ms_superbox = *std::move(ms_superbox_or); + + auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&ms_superbox); + if (!IsManifestStore(ms_superbox)) { + return absl::NotFoundError( + "JUMBF SuperBox does not contain a manifest store"); + } + + // Fetch the active manifest from the manifest store. + // The last C2PA Manifest superbox in the C2PA Manifest Store superbox shall + // be considered the active manifest. + std::optional<SuperBox> active_manifest; + for (auto it = ms_superbox.contents.rbegin(); + it != ms_superbox.contents.rend(); ++it) { + if (it->Holds<SuperBox>() && IsManifest(it->Get<SuperBox>())) { + active_manifest = it->Get<SuperBox>(); + break; + } + } + if (!active_manifest.has_value()) { + return absl::NotFoundError("No active manifest found in manifest store"); + } + + ManifestGraph graph(&*active_manifest, &uri_resolver, &assertion_validator, + &claim_validator, &hash_checker_factory, &options); + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> result = + graph.Validate(); + + if (!result.ok()) { + // Don't suppress the internal error. + if (result.status().code() == absl::StatusCode::kInternal) { + return result; + } + return absl::NotFoundError(result.status().message()); + } + + return result; +} + +} // namespace + +ManifestStoreValidatorImpl::ManifestStoreValidatorImpl(ValidatorOptions options) + : cose_verifier_(CreateCoseVerifier({ + .crypto_read_handler = std::move(options.crypto_read_handler), + .clock = options.clock, + })), + options_(std::move(options)), + claim_validator_(cose_verifier_.get()), + assertion_validator_({.skip_actions_assertion_validation_for_test = + options_.accept_legacy_manifest_for_test}), + hash_checker_factory_(DefaultHashCheckerFactory()) {} + +absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> +ManifestStoreValidatorImpl::Validate(absl::string_view manifest_store) const { + ABSL_ASSIGN_OR_RETURN( + auto partial_validation_result, + ValidateManifestStore(manifest_store, options_, assertion_validator_, + claim_validator_, hash_checker_factory_, + options_.metrics)); + + if (options_.spec_version >= SpecVersion::kC2pa_2_4) { + partial_validation_result->set_spec_version( + SpecVersionToString(options_.spec_version)); + if (!options_.trust_list_uri.empty()) { + partial_validation_result->set_trust_list_uri(options_.trust_list_uri); + } + } + + if (options_.metrics != nullptr) { + options_.metrics->RecordPartialValidationResult(*partial_validation_result); + } + + if (partial_validation_result->has_hard_binding_uri()) { + // The manifest store indicates that a hard binding assertion is present. + const Assertion* absl_nullable assertion = + GetAssertion(partial_validation_result.get(), + partial_validation_result->hard_binding_uri()); + if (assertion == nullptr) { + // The assertion cannot be found, this means it was redacted for being a + // deprecated assertion. + ABSL_ASSIGN_OR_RETURN(auto tracker, + DualStatusTracker::FromPartialValidationResult( + partial_validation_result.get())); + tracker->RecordFailure( + FailureStatusCode::kClaimHardBindingsMissing, + {.explanation = "The active hard binding assertion was deprecated."}); + partial_validation_result->clear_hard_binding_uri(); + } + } + + return partial_validation_result; +} + +} // namespace credentio
diff --git a/validator/manifest_store_validator_impl.h b/validator/manifest_store_validator_impl.h new file mode 100644 index 0000000..a090447 --- /dev/null +++ b/validator/manifest_store_validator_impl.h
@@ -0,0 +1,51 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_IMPL_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_IMPL_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "assertion/validator.h" +#include "claim/validator.h" +#include "cose/verifier.h" +#include "crypto/hash.h" +#include "proto/ingredient_validation_result.pb.h" +#include "proto/validation_result.pb.h" +#include "validator/manifest_store_validator.h" +#include "validator/validator_options.h" + +namespace credentio { + +class ManifestStoreValidatorImpl : public ManifestStoreValidator { + public: + explicit ManifestStoreValidatorImpl(ValidatorOptions options); + + absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> Validate( + absl::string_view manifest_store) const override; + + private: + std::unique_ptr<CoseVerifier> cose_verifier_; + const ValidatorOptions options_; + ClaimValidatorImpl claim_validator_; + AssertionValidator assertion_validator_; + const HashCheckerFactory& hash_checker_factory_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_IMPL_H_
diff --git a/validator/result.cc b/validator/result.cc new file mode 100644 index 0000000..cd486f0 --- /dev/null +++ b/validator/result.cc
@@ -0,0 +1,101 @@ +// 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 "validator/result.h" + +#include <vector> + +#include "absl/base/nullability.h" +#include "absl/strings/str_split.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" + +namespace credentio { +namespace { + +const Manifest* GetManifest(const PartialValidationResultProto* result, + absl::string_view label) { + if (result->active_manifest().label() == label) { + return &result->active_manifest(); + } + for (const auto& ingredient_manifest : result->ingredient_manifests()) { + if (ingredient_manifest.label() == label) { + return &ingredient_manifest; + } + } + return nullptr; +} + +Manifest* GetMutableManifest(PartialValidationResultProto* result, + absl::string_view label) { + if (result->active_manifest().label() == label) { + return result->mutable_active_manifest(); + } + for (auto& ingredient_manifest : *result->mutable_ingredient_manifests()) { + if (ingredient_manifest.label() == label) { + return &ingredient_manifest; + } + } + return nullptr; +} + +} // namespace + +const Assertion* absl_nullable GetAssertion( + const PartialValidationResultProto* result, + absl::string_view assertion_uri) { + if (!absl::ConsumePrefix(&assertion_uri, "self#jumbf=/c2pa/")) { + return nullptr; + } + std::vector<absl::string_view> labels = absl::StrSplit(assertion_uri, '/'); + if (labels.size() != 3) { + return nullptr; + } + if (labels[1] != "c2pa.assertions") { + return nullptr; + } + absl::string_view manifest_label = labels[0]; + absl::string_view assertion_label = labels[2]; + const Manifest* manifest = GetManifest(result, manifest_label); + if (manifest == nullptr) { + return nullptr; + } + for (const auto& assertion : manifest->assertions()) { + if (assertion.label() == assertion_label) { + return &assertion; + } + } + return nullptr; +} + +Manifest* absl_nullable GetMutableManifestForAssertion( + PartialValidationResultProto* result, absl::string_view assertion_uri) { + if (!absl::ConsumePrefix(&assertion_uri, "self#jumbf=/c2pa/")) { + return nullptr; + } + std::vector<absl::string_view> labels = absl::StrSplit(assertion_uri, '/'); + if (labels.size() != 3) { + return nullptr; + } + if (labels[1] != "c2pa.assertions") { + return nullptr; + } + absl::string_view manifest_label = labels[0]; + return GetMutableManifest(result, manifest_label); +} + +} // namespace credentio
diff --git a/validator/result.h b/validator/result.h new file mode 100644 index 0000000..6af0a57 --- /dev/null +++ b/validator/result.h
@@ -0,0 +1,69 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_RESULT_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_RESULT_H_ + +#include <memory> +#include <string> +#include <utility> + +#include "absl/base/nullability.h" +#include "absl/strings/string_view.h" +#include "proto/assertion.pb.h" +#include "proto/validation_result.pb.h" + +namespace credentio { + +// Result of validating a C2PA asset. +// +// For now, this is just a wrapper for `ValidationResultProto`. +class ValidationResult { + public: + explicit ValidationResult(std::unique_ptr<ValidationResultProto> proto, + std::string manifest_store_bytes = "") + : proto_(std::move(proto)), + manifest_store_bytes_(std::move(manifest_store_bytes)) {} + + const ValidationResultProto& proto() const { return *proto_; } + ValidationResultProto& mutable_proto() { return *proto_; } + + std::unique_ptr<ValidationResultProto> release_proto() { + return std::move(proto_); + } + + absl::string_view manifest_store_bytes() const { + return manifest_store_bytes_; + } + + private: + std::unique_ptr<ValidationResultProto> proto_; + std::string manifest_store_bytes_; +}; + +// Returns a pointer to the assertion with the given URI, or nullptr if not +// found. +const Assertion* absl_nullable GetAssertion( + const PartialValidationResultProto* result, + absl::string_view assertion_uri); + +// Returns a pointer to the Manifest containing the assertion with the +// given URI, or nullptr if not found. +Manifest* absl_nullable GetMutableManifestForAssertion( + PartialValidationResultProto* result, absl::string_view assertion_uri); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_RESULT_H_
diff --git a/validator/result_test.cc b/validator/result_test.cc new file mode 100644 index 0000000..37b49b1 --- /dev/null +++ b/validator/result_test.cc
@@ -0,0 +1,240 @@ +// 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 "validator/result.h" + +#include <memory> +#include <utility> + +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/assertion.pb.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::IsNull; +using ::testing::Pointee; + +TEST(ValidationResultTest, ToProto) { + auto proto = std::make_unique<ValidationResultProto>(); + proto->mutable_active_manifest()->set_label("test"); + ValidationResult result(std::move(proto)); + ValidationResultProto expected; + expected.mutable_active_manifest()->set_label("test"); + EXPECT_THAT(result.proto(), EqualsProto(expected)); +} + +TEST(GetAssertionTest, ReturnsAssertionFromActiveManifest) { + PartialValidationResultProto result; + result.mutable_active_manifest()->set_label("active_manifest"); + result.mutable_active_manifest()->add_assertions()->set_label("assertion1"); + result.mutable_active_manifest()->add_assertions()->set_label("assertion2"); + result.mutable_active_manifest()->add_assertions()->set_label("assertion3"); + EXPECT_THAT( + GetAssertion( + &result, + "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion2"), + Pointee( + EqualsProto(ParseTextProtoOrDie<Assertion>("label: 'assertion2'")))); +} + +TEST(GetAssertionTest, ReturnsAssertionFromIngredientManifest) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "active_manifest" } + ingredient_manifests { + label: "ingredient_manifest1" + assertions { + label: "assertion1" + actions {} + } + } + ingredient_manifests { + label: "ingredient_manifest2" + assertions { + label: "assertion1" + data_hash {} + } + } + )pb"); + EXPECT_THAT( + GetAssertion( + &result, + "self#jumbf=/c2pa/ingredient_manifest2/c2pa.assertions/assertion1"), + Pointee(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb( + label: "assertion1" + data_hash {} + )pb")))); +} + +TEST(GetAssertionTest, InvalidUriPrefix) { + PartialValidationResultProto result; + EXPECT_THAT(GetAssertion(&result, "invalid_prefix"), IsNull()); +} + +TEST(GetAssertionTest, InvalidUriPathLength) { + PartialValidationResultProto result; + EXPECT_THAT( + GetAssertion(&result, "self#jumbf=/c2pa/active_manifest/assertion1"), + IsNull()); +} + +TEST(GetAssertionTest, InvalidUriAssertionsPart) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "active_manifest" + assertions { label: "assertion1" } + } + )pb"); + EXPECT_THAT( + GetAssertion(&result, + "self#jumbf=/c2pa/active_manifest/invalid_part/assertion1"), + IsNull()); +} + +TEST(GetAssertionTest, ManifestLabelNotFound) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "active_manifest" + assertions { label: "assertion1" } + } + ingredient_manifests { + label: "ingredient_manifest1" + assertions { + label: "assertion1" + actions {} + } + } + )pb"); + EXPECT_THAT(GetAssertion( + &result, + "self#jumbf=/c2pa/other_manifest/c2pa.assertions/assertion1"), + IsNull()); +} + +TEST(GetAssertionTest, AssertionLabelNotFound) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "active_manifest" + assertions { label: "assertion1" } + } + ingredient_manifests { + label: "ingredient_manifest1" + assertions { + label: "assertion1" + actions {} + } + } + )pb"); + EXPECT_THAT( + GetAssertion( + &result, + "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion2"), + IsNull()); +} + +TEST(GetMutableManifestForAssertionTest, ReturnsManifestFromActiveManifest) { + PartialValidationResultProto result; + result.mutable_active_manifest()->set_label("active_manifest"); + result.mutable_active_manifest()->add_assertions()->set_label("assertion1"); + result.mutable_active_manifest()->add_assertions()->set_label("assertion2"); + result.mutable_active_manifest()->add_assertions()->set_label("assertion3"); + EXPECT_THAT( + GetMutableManifestForAssertion( + &result, + "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion2"), + Pointee(EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb( + label: "active_manifest" + assertions { label: "assertion1" } + assertions { label: "assertion2" } + assertions { label: "assertion3" } + )pb")))); +} + +TEST(GetMutableManifestForAssertionTest, + ReturnsManifestFromIngredientManifest) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { label: "active_manifest" } + ingredient_manifests { + label: "ingredient_manifest1" + assertions { + label: "assertion1" + actions {} + } + } + ingredient_manifests { + label: "ingredient_manifest2" + assertions { + label: "assertion1" + data_hash {} + } + } + )pb"); + EXPECT_THAT( + GetMutableManifestForAssertion( + &result, + "self#jumbf=/c2pa/ingredient_manifest2/c2pa.assertions/assertion1"), + Pointee(EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb( + label: "ingredient_manifest2" + assertions { + label: "assertion1" + data_hash {} + } + )pb")))); +} + +TEST(GetMutableManifestForAssertionTest, ManifestLabelNotFound) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "active_manifest" + assertions { label: "assertion1" } + } + ingredient_manifests { + label: "ingredient_manifest1" + assertions { + label: "assertion1" + actions {} + } + } + )pb"); + EXPECT_THAT(GetMutableManifestForAssertion( + &result, + "self#jumbf=/c2pa/other_manifest/c2pa.assertions/assertion1"), + IsNull()); +} + +TEST(GetMutableManifestForAssertionTest, InvalidUriPrefix) { + auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb( + active_manifest { + label: "active_manifest" + assertions { label: "assertion1" } + } + )pb"); + EXPECT_THAT(GetMutableManifestForAssertion(&result, "invalid_prefix"), + IsNull()); + EXPECT_THAT( + GetMutableManifestForAssertion( + &result, + "self#jumbf=/c2pb/active_manifest/c2pa.assertions/assertion1"), + IsNull()); +} + +} // namespace +} // namespace credentio
diff --git a/validator/tracker.cc b/validator/tracker.cc new file mode 100644 index 0000000..ba65cfd --- /dev/null +++ b/validator/tracker.cc
@@ -0,0 +1,156 @@ +// 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 "validator/tracker.h" + +#include <string> + +#include "absl/container/flat_hash_set.h" +#include "absl/log/check.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "constants/labels.h" +#include "constants/status_codes.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { +namespace { + +std::string CreateUrl(absl::string_view manifest_label, + absl::string_view assertion_label, + absl::string_view url) { + if (url.empty()) { + if (assertion_label.empty()) { + return absl::StrCat("self#jumbf=/c2pa/", manifest_label); + } + return absl::StrCat("self#jumbf=/c2pa/", manifest_label, + "/c2pa.assertions/", assertion_label); + } + if (absl::StartsWith(url, "self#jumbf=")) { + return std::string(url); + } + return absl::StrCat("self#jumbf=/c2pa/", manifest_label, + kManifestLabelDelimiter, url); +} + +// Returns true if the label is well-formed. Used in DCHECKs to help catch bugs +// where a URI or path is provided instead of a label. +bool ValidLabel(absl::string_view label) { + return !label.empty() && !absl::StrContains(label, "/"); +} + +} // namespace + +ValidationTracker::ValidationTracker(Manifest* manifest) + : manifest_label_(manifest->label()), + status_set_(manifest->mutable_validation()), + id_("root") { + DCHECK(ValidLabel(manifest_label_)) << "label: " << manifest_label_; +} + +ValidationTracker::ValidationTracker(absl::string_view manifest_label, + ValidationStatusSet* status_set) + : manifest_label_(manifest_label), status_set_(status_set), id_("root") { + DCHECK(ValidLabel(manifest_label_)) << "label: " << manifest_label_; +} + +ValidationTracker ValidationTracker::AssertionTracker( + absl::string_view assertion_label) { + ValidationTracker new_tracker = *this; + new_tracker.assertion_label_ = assertion_label; + return new_tracker; +} + +void ValidationTracker::RecordFailure(FailureStatusCode status, + RecordOptions options) { + ValidationStatus* failure = status_set_->mutable_failures()->Add(); + failure->set_code(absl::StrCat(status)); + failure->set_url(CreateUrl(manifest_label_, assertion_label_, options.url)); + if (!options.explanation.empty()) { + failure->set_explanation(options.explanation); + } +} + +void ValidationTracker::RecordSuccess(SuccessStatusCode status, + RecordOptions options) { + ValidationStatus* success = status_set_->mutable_successes()->Add(); + success->set_code(absl::StrCat(status)); + success->set_url(CreateUrl(manifest_label_, assertion_label_, options.url)); + if (!options.explanation.empty()) { + success->set_explanation(options.explanation); + } +} + +void ValidationTracker::RecordInformational(InformationalStatusCode status, + RecordOptions options) { + ValidationStatus* informational = + status_set_->mutable_informationals()->Add(); + informational->set_code(absl::StrCat(status)); + informational->set_url( + CreateUrl(manifest_label_, assertion_label_, options.url)); + if (!options.explanation.empty()) { + informational->set_explanation(options.explanation); + } +} + +bool ValidationTracker::HasFailures() const { + return status_set_->failures_size() > 0; +} + +absl::flat_hash_set<std::string> ValidationTracker::GetFailures() const { + absl::flat_hash_set<std::string> failures; + for (const auto& failure_status : status_set_->failures()) { + failures.insert(std::string(failure_status.code())); + } + return failures; +} + +absl::flat_hash_set<std::string> ValidationTracker::GetSuccesses() const { + absl::flat_hash_set<std::string> successes; + for (const auto& success_status : status_set_->successes()) { + successes.insert(std::string(success_status.code())); + } + return successes; +} + +absl::flat_hash_set<std::string> ValidationTracker::GetInformationals() const { + absl::flat_hash_set<std::string> informational; + for (const auto& informational_status : status_set_->informationals()) { + informational.insert(std::string(informational_status.code())); + } + return informational; +} + +void ValidationTracker::MergeStatuses(const ValidationStatusSet& statuses) { + for (const auto& failure : statuses.failures()) { + ValidationStatus* f = status_set_->mutable_failures()->Add(); + *f = failure; + f->set_url(CreateUrl(manifest_label_, assertion_label_, failure.url())); + } + for (const auto& success : statuses.successes()) { + ValidationStatus* s = status_set_->mutable_successes()->Add(); + *s = success; + s->set_url(CreateUrl(manifest_label_, assertion_label_, success.url())); + } + for (const auto& info : statuses.informationals()) { + ValidationStatus* i = status_set_->mutable_informationals()->Add(); + *i = info; + i->set_url(CreateUrl(manifest_label_, assertion_label_, info.url())); + } +} + +} // namespace credentio
diff --git a/validator/tracker.h b/validator/tracker.h new file mode 100644 index 0000000..e6ae751 --- /dev/null +++ b/validator/tracker.h
@@ -0,0 +1,106 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_TRACKER_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_TRACKER_H_ + +#include <stdbool.h> + +#include <string> + +#include "absl/container/flat_hash_set.h" +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "google/protobuf/repeated_ptr_field.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { + +// Tracks the validation process by accumulating the C2PA status codes returned +// in various validation steps. +class ValidationTracker { + public: + // Constructs a new validation tracker for the given manifest. + // Status values will be recorded in the given manifest, and the URL of the + // manifest itself will be used by default. + explicit ValidationTracker(Manifest* manifest); + + // Constructs a new validation tracker for the given manifest label and + // status set. + explicit ValidationTracker(absl::string_view manifest_label, + ValidationStatusSet* status_set); + + // Returns a tracker that uses the URL of the given assertion by default. + // Status values are recorded to the same location as the parent tracker. + ValidationTracker AssertionTracker(absl::string_view assertion_label); + + struct RecordOptions { + // The url to record with the status. If the url is empty, the manifest url + // is used. If the url starts with "self#jumbf=", the supplied url is used. + // Otherwise the supplied value is interpreted as a path relative to the + // manifest url. + absl::string_view url = ""; + // An optional explanation for the status. + absl::string_view explanation = ""; + }; + + // Records a C2PA success status code. + void RecordSuccess(SuccessStatusCode status, RecordOptions options); + + // Records a C2PA informational status code. + void RecordInformational(InformationalStatusCode status, + RecordOptions options); + + // Records a C2PA failure status code. + void RecordFailure(FailureStatusCode status, RecordOptions options); + + // Returns if the validation process has any failures so far. + bool HasFailures() const; + // Returns the C2PA failure status codes observed so far. + absl::flat_hash_set<std::string> GetFailures() const; + // Returns the C2PA failure statuses observed so far. + google::protobuf::RepeatedPtrField<ValidationStatus> GetFailureStatuses() + const { + return status_set_->failures(); + } + // Returns the C2PA success status codes observed so far. + absl::flat_hash_set<std::string> GetSuccesses() const; + // Returns the C2PA success statuses observed so far. + google::protobuf::RepeatedPtrField<ValidationStatus> GetSuccessStatuses() + const { + return status_set_->successes(); + } + // Returns the C2PA informational status codes observed so far. + absl::flat_hash_set<std::string> GetInformationals() const; + // Returns the C2PA informational statuses observed so far. + google::protobuf::RepeatedPtrField<ValidationStatus> + GetInformationalStatuses() const { + return status_set_->informationals(); + } + + // Merges the statuses from a ValidationStatusSet into this tracker. + void MergeStatuses(const ValidationStatusSet& statuses); + + private: + absl::string_view manifest_label_; + absl::string_view assertion_label_; + ValidationStatusSet* const status_set_; + std::string id_; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_TRACKER_H_
diff --git a/validator/tracker_test.cc b/validator/tracker_test.cc new file mode 100644 index 0000000..e0dd484 --- /dev/null +++ b/validator/tracker_test.cc
@@ -0,0 +1,269 @@ +// 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 "validator/tracker.h" + +#include "absl/strings/string_view.h" +#include "constants/status_codes.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/manifest.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; +using ::testing::IsEmpty; +using ::testing::Property; + +constexpr absl::string_view kManifestLabel = "urn:c2pa:9999"; + +TEST(ValidationTrackerTest, RecordSuccess) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated, + {}); + EXPECT_FALSE(validation_tracker.HasFailures()); + EXPECT_THAT(validation_tracker.GetSuccesses(), + ElementsAre("claimSignature.validated")); + EXPECT_THAT(validation_tracker.GetSuccessStatuses(), + ElementsAre(Property(&ValidationStatus::code, + "claimSignature.validated"))); + EXPECT_THAT(validation_tracker.GetFailures(), IsEmpty()); + EXPECT_THAT(validation_tracker.GetInformationals(), IsEmpty()); + EXPECT_THAT(manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb(successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:9999" + })pb"))); +} + +TEST(ValidationTrackerTest, RecordInformational) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, {}); + EXPECT_FALSE(validation_tracker.HasFailures()); + EXPECT_THAT(validation_tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT(validation_tracker.GetSuccessStatuses(), IsEmpty()); + EXPECT_THAT(validation_tracker.GetInformationals(), + ElementsAre("ingredient.unknownProvenance")); + EXPECT_THAT(validation_tracker.GetInformationalStatuses(), + ElementsAre(Property(&ValidationStatus::code, + "ingredient.unknownProvenance"))); + EXPECT_THAT(validation_tracker.GetFailures(), IsEmpty()); + EXPECT_THAT(manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb(informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:9999" + })pb"))); +} + +TEST(ValidationTrackerTest, RecordInformationalWithMessage) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordInformational( + InformationalStatusCode::kIngredientUnknownProvenance, + {.explanation = "some message"}); + EXPECT_FALSE(validation_tracker.HasFailures()); + EXPECT_THAT(validation_tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT(validation_tracker.GetSuccessStatuses(), IsEmpty()); + EXPECT_THAT(validation_tracker.GetInformationals(), + ElementsAre("ingredient.unknownProvenance")); + EXPECT_THAT(validation_tracker.GetFailures(), IsEmpty()); + EXPECT_THAT(manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb(informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:9999" + explanation: "some message" + })pb"))); +} + +TEST(ValidationTrackerTest, RecordFailure) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing, + {}); + EXPECT_TRUE(validation_tracker.HasFailures()); + EXPECT_THAT(validation_tracker.GetSuccesses(), IsEmpty()); + EXPECT_THAT(validation_tracker.GetSuccessStatuses(), IsEmpty()); + EXPECT_THAT(validation_tracker.GetFailures(), + ElementsAre("claimSignature.missing")); + EXPECT_THAT(validation_tracker.GetInformationals(), IsEmpty()); + EXPECT_THAT(manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb(failures { + code: "claimSignature.missing" + url: "self#jumbf=/c2pa/urn:c2pa:9999" + })pb"))); +} + +TEST(ValidationTrackerTest, RecordFailureWithMessage) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordFailure(FailureStatusCode::kClaimMissing, {}); + validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing, + {.explanation = "some message"}); + EXPECT_TRUE(validation_tracker.HasFailures()); + EXPECT_THAT( + validation_tracker.GetFailureStatuses(), + ElementsAre(Property(&ValidationStatus::code, "claim.missing"), + Property(&ValidationStatus::code, "claimSignature.missing"))); + EXPECT_THAT( + manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb( + failures { code: "claim.missing" url: "self#jumbf=/c2pa/urn:c2pa:9999" } + failures { + code: "claimSignature.missing" + url: "self#jumbf=/c2pa/urn:c2pa:9999" + explanation: "some message" + } + )pb"))); +} + +TEST(ValidationTrackerTest, AssertionTracker) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + ValidationTracker assertion_tracker = + validation_tracker.AssertionTracker("testlabel.v2"); + assertion_tracker.RecordFailure( + FailureStatusCode::kAssertionActionMalformed, + {.url = "self#jumbf=/url/override", .explanation = "message1"}); + assertion_tracker.RecordFailure(FailureStatusCode::kAssertionActionMalformed, + {.explanation = "message2"}); + EXPECT_TRUE(assertion_tracker.HasFailures()); + EXPECT_TRUE(validation_tracker.HasFailures()); + EXPECT_THAT( + assertion_tracker.GetFailureStatuses(), + ElementsAre( + Property(&ValidationStatus::code, "assertion.action.malformed"), + Property(&ValidationStatus::code, "assertion.action.malformed"))); + EXPECT_THAT( + manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb( + failures { + code: "assertion.action.malformed" + url: "self#jumbf=/url/override" + explanation: "message1" + } + failures { + code: "assertion.action.malformed" + url: "self#jumbf=/c2pa/urn:c2pa:9999/c2pa.assertions/testlabel.v2" + explanation: "message2" + } + )pb"))); +} + +TEST(ValidationTrackerTest, HasFailures) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing, + {}); + validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated, + {}); + EXPECT_TRUE(validation_tracker.HasFailures()); +} + +TEST(ValidationTrackerTest, NothingRecorded) { + Manifest manifest; + manifest.set_label(kManifestLabel); + { + ValidationTracker validation_tracker(&manifest); + } + EXPECT_TRUE(manifest.has_validation()); +} + +TEST(ValidationTrackerTest, RecordSuccessFullUrl) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated, + {.url = "self#jumbf=/c2pa/urn:c2pa:1234"}); + EXPECT_THAT(manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb(successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:1234" + })pb"))); +} + +TEST(ValidationTrackerTest, RecordSuccessPartialUrl) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated, + {.url = "suffix"}); + EXPECT_THAT(manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>( + R"pb(successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/urn:c2pa:9999/suffix" + })pb"))); +} + +TEST(ValidationTrackerTest, MergeStatuses) { + Manifest manifest; + manifest.set_label(kManifestLabel); + ValidationTracker validation_tracker(&manifest); + + ValidationStatusSet statuses; + ValidationStatus* failure = statuses.add_failures(); + failure->set_code("claim.missing"); + failure->set_url("some/url"); + failure->set_explanation("some explanation"); + + ValidationStatus* success = statuses.add_successes(); + success->set_code("claimSignature.validated"); + success->set_url("self#jumbf=/c2pa/other/url"); + + ValidationStatus* informational = statuses.add_informationals(); + informational->set_code("ingredient.unknownProvenance"); + + validation_tracker.MergeStatuses(statuses); + + EXPECT_THAT(manifest.validation(), + EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb( + failures { + code: "claim.missing" + url: "self#jumbf=/c2pa/urn:c2pa:9999/some/url" + explanation: "some explanation" + } + successes { + code: "claimSignature.validated" + url: "self#jumbf=/c2pa/other/url" + } + informationals { + code: "ingredient.unknownProvenance" + url: "self#jumbf=/c2pa/urn:c2pa:9999" + } + )pb"))); +} + +} // namespace +} // namespace credentio
diff --git a/validator/validation_result_internal.cc b/validator/validation_result_internal.cc new file mode 100644 index 0000000..4a88fa2 --- /dev/null +++ b/validator/validation_result_internal.cc
@@ -0,0 +1,68 @@ +// 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 "validator/validation_result_internal.h" + +#include <memory> + +#include "absl/algorithm/container.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" + +namespace credentio { + +absl::StatusOr<std::unique_ptr<ValidationResultProto>> MakeFullValidationResult( + std::unique_ptr<PartialValidationResultProto> partial_result) { + if (partial_result->active_manifest().validation().failures().empty()) { + if (!absl::c_any_of( + partial_result->active_manifest().validation().successes(), + [&](ValidationStatus status) { + if (status.code() == "com.google.assertion.dataHash.match" || + status.code() == "com.google.assertion.bmffHash.match" || + status.code() == "com.google.assertion.boxesHash.match" || + status.code() == + "com.google.assertion.collectionHash.match" || + status.code() == "assertion.dataHash.match" || + status.code() == "assertion.bmffHash.match" || + status.code() == "assertion.boxesHash.match" || + status.code() == "assertion.collectionHash.match") { + return true; + } + return false; + })) { + return absl::InternalError( + "No failures encountered during validation but no hard binding " + "assertion match was recorded either."); + } + } + + auto result = std::make_unique<ValidationResultProto>(); + result->mutable_active_manifest()->Swap( + partial_result->mutable_active_manifest()); + result->mutable_ingredient_manifests()->Swap( + partial_result->mutable_ingredient_manifests()); + if (partial_result->has_spec_version()) { + result->set_spec_version(partial_result->spec_version()); + } + if (partial_result->has_trust_list_uri()) { + result->set_trust_list_uri(partial_result->trust_list_uri()); + } + return result; +} + +} // namespace credentio
diff --git a/validator/validation_result_internal.h b/validator/validation_result_internal.h new file mode 100644 index 0000000..188efd4 --- /dev/null +++ b/validator/validation_result_internal.h
@@ -0,0 +1,33 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATION_RESULT_INTERNAL_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATION_RESULT_INTERNAL_H_ + +#include <memory> + +#include "absl/status/statusor.h" +#include "proto/validation_result.pb.h" + +namespace credentio { + +// Creates a full validation result from a partial validation result, or returns +// an error if the partial validation result is malformed/incomplete. +absl::StatusOr<std::unique_ptr<ValidationResultProto>> MakeFullValidationResult( + std::unique_ptr<PartialValidationResultProto> partial_result); + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATION_RESULT_INTERNAL_H_
diff --git a/validator/validation_result_internal_test.cc b/validator/validation_result_internal_test.cc new file mode 100644 index 0000000..07ca304 --- /dev/null +++ b/validator/validation_result_internal_test.cc
@@ -0,0 +1,75 @@ +// 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 "validator/validation_result_internal.h" + +#include <memory> +#include <utility> + +#include "absl/status/status.h" +#include "absl/status/status_matchers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" +#include "proto/validation_status.pb.h" +#include "testing/proto_test_utils.h" + +namespace credentio { +namespace { + +using ::absl_testing::StatusIs; +using ::credentio_testing::EqualsProto; +using ::credentio_testing::ParseTextProtoOrDie; +using ::testing::ElementsAre; +using ::testing::Eq; + +TEST(MakeFullValidationResult_Failure, MakeFullValidationResult) { + auto partial_result = std::make_unique<PartialValidationResultProto>(); + partial_result->mutable_active_manifest()->set_label("active"); + partial_result->add_ingredient_manifests()->set_label("ingredient1"); + partial_result->add_ingredient_manifests()->set_label("ingredient2"); + EXPECT_THAT(MakeFullValidationResult(std::move(partial_result)), + StatusIs(absl::StatusCode::kInternal)); +} + +TEST(MakeFullValidationResult_Success, MakeFullValidationResult) { + auto partial_result = std::make_unique<PartialValidationResultProto>(); + partial_result->mutable_active_manifest()->set_label("active"); + partial_result->mutable_active_manifest() + ->mutable_validation() + ->add_successes() + ->set_code("com.google.assertion.dataHash.match"); + partial_result->add_ingredient_manifests()->set_label("ingredient1"); + partial_result->add_ingredient_manifests()->set_label("ingredient2"); + partial_result->set_spec_version("2.4.0"); + partial_result->set_trust_list_uri("urn:google:c2pa:trust-list:test"); + auto res = MakeFullValidationResult(std::move(partial_result)); + ASSERT_TRUE(res.ok()); + auto result = std::move(*res); + EXPECT_THAT(result->active_manifest().label(), Eq("active")); + EXPECT_THAT(result->ingredient_manifests(), + ElementsAre(EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb( + label: "ingredient1" + )pb")), + EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb( + label: "ingredient2" + )pb")))); + EXPECT_THAT(result->spec_version(), Eq("2.4.0")); + EXPECT_THAT(result->trust_list_uri(), Eq("urn:google:c2pa:trust-list:test")); +} + +} // namespace +} // namespace credentio
diff --git a/validator/validator_metrics.h b/validator/validator_metrics.h new file mode 100644 index 0000000..a4b5761 --- /dev/null +++ b/validator/validator_metrics.h
@@ -0,0 +1,40 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_METRICS_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_METRICS_H_ + +#include "absl/strings/string_view.h" +#include "proto/manifest.pb.h" +#include "proto/validation_result.pb.h" + +namespace credentio { +// Interface for recording metrics for C2PA validation. +class ValidatorMetrics { + public: + ValidatorMetrics() = default; + virtual ~ValidatorMetrics() = default; + // Records metrics for a partial validation result (without binding checks). + virtual void RecordPartialValidationResult( + const PartialValidationResultProto& result) const = 0; + // Records metrics for a full validation result. + virtual void RecordValidationResult( + absl::string_view media_type, + const ValidationResultProto& result) const = 0; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_METRICS_H_
diff --git a/validator/validator_options.h b/validator/validator_options.h new file mode 100644 index 0000000..8e5d5cc --- /dev/null +++ b/validator/validator_options.h
@@ -0,0 +1,67 @@ +// 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. +// + +#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_OPTIONS_H_ +#define THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_OPTIONS_H_ + +#include <cstdint> +#include <memory> +#include <string> + +#include "absl/base/nullability.h" +#include "absl/time/clock_interface.h" +#include "constants/spec_version.h" +#include "crypto/crypto_read_handler.h" +#include "validator/validator_metrics.h" + +namespace credentio { + +// Options controlling validator behavior. +struct ValidatorOptions { + // REQUIRED. Crypto read handler to use. + std::unique_ptr<CryptoReadHandler> absl_nonnull crypto_read_handler; + + // Clock to use (e.g., for certificate validity checks). + absl::Clock* clock = &absl::Clock::GetRealClock(); + + // Maximum size of chunks read from input files. + // + // If the C2PA metadata (manifest store) is larger than this limit, validation + // will fail. + int64_t max_chunk_bytes = 10 * 1024 * 1024; // 10 MiB + + // Enables the validator to process legacy manifests based on C2PA standards + // prior to version 2.1 (i.e. those with labels that do not begin with the + // prefix introduced in v2.1, `urn:c2pa`). This option bypasses the label + // check and the redaction of custom, deprecated, and gathered assertions; + // various pre-2.1 features are not supported. This option should only be used + // for testing. + // NOTE: This option is for testing only and cannot be used with + // CreateValidator. + bool accept_legacy_manifest_for_test = false; + + // Whether to collect metrics. + ValidatorMetrics* absl_nullable metrics = nullptr; + + // The C2PA specification version the product conforms to. + SpecVersion spec_version = SpecVersion::kUnspecified; + + // URI reference (including version/timestamp) to the trust list used. + std::string trust_list_uri; +}; + +} // namespace credentio + +#endif // THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_OPTIONS_H_