diff --git a/docs/manifest.md b/docs/manifest.md index 7f8ebcc1..fdcd819c 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -53,6 +53,7 @@ The following manifest properties are specific to C2PA Tool and used for signing - `private_key`: Private key to use. Default: `es256_private.key` - `sign_cert`: Signing certificate to use. Default: `es256_certs.pem` - `ta_url`: Time Authority URL for getting a time-stamp (for example, `http://timestamp.digicert.com`). A time-stamp provides a way to confirm that the manifest was signed when the certificate was valid, even if the certificate has since expired. However, the Time Authority URL requires a live online connection for confirmation, which may not always be available. +- `ta_headers`: HTTP headers to send with Time Authority requests, as an object of header names to values (for example, `{"X-Tenant": "acme"}`). Applies to the claim and ingredient time-stamps, whichever signer is used. Don't put secrets here. Use the `C2PA_TA_HEADERS` environment variable instead, one `Name: value` per line (for example, `Authorization: Bearer `). Headers from both are sent. `Content-Type` is set by the tool and can't be overridden. ## Example diff --git a/docs/signing.md b/docs/signing.md index a021df12..180f95f8 100644 --- a/docs/signing.md +++ b/docs/signing.md @@ -156,6 +156,18 @@ If `[cawg_x509_signer]` is absent, no CAWG identity assertion is generated. An example settings file is in the [`cli/tests/fixtures` folder](https://github.com/contentauth/c2patool/tree/main/tests/fixtures/trust/cawg_sign_settings.toml). +## Time-stamping + +Set the timestamp authority URL with `ta_url` in the manifest JSON, `tsa_url` in the settings file or `--signer-info` response, or the `C2PA_TA_URL` environment variable. `C2PA_TA_URL` is used only when no URL is set elsewhere. + +If the timestamp authority requires authentication, set the `C2PA_TA_HEADERS` environment variable to one `Name: value` header per line: + +```sh +export C2PA_TA_HEADERS="Authorization: Bearer " +``` + +C2PA Tool sends these headers with the claim and ingredient timestamp requests, for every signer type. Non-secret headers can also go in `ta_headers` in the [manifest definition file](manifest.md), and headers from both are sent. Keep secrets in the environment variable, not the manifest. `Content-Type` is set by C2PA Tool and can't be overridden. + ## Writing your own signer A signer is any executable that implements the two-operation protocol described above. It does not need to be written in Rust or have any knowledge of C2PA internals. diff --git a/src/main.rs b/src/main.rs index 15bd7c18..f6ef2ddb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1257,6 +1257,7 @@ fn main() -> Result<()> { } else { sign_config.signer()? }; + let c2pa_signer = signer::with_ta_headers(c2pa_signer, sign_config.ta_headers()?); // Step 2: optionally wrap with a CAWG identity callback signer. let signer: Box = if let Some(ref identity_cmd) = args.identity_signer_path { diff --git a/src/signer.rs b/src/signer.rs index 3b250ad2..8029ff7e 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -12,12 +12,13 @@ // each license. use std::{ + collections::BTreeMap, env, path::{Path, PathBuf}, }; -use anyhow::{Context, Result}; -use c2pa::{create_signer, BoxedSigner, SigningAlg}; +use anyhow::{bail, Context, Result}; +use c2pa::{create_signer, dynamic_assertion::DynamicAssertion, BoxedSigner, Signer, SigningAlg}; use serde::Deserialize; const DEFAULT_CERTS: &[u8] = include_bytes!("../sample/es256_certs.pem"); @@ -27,6 +28,102 @@ pub fn get_ta_url() -> Option { std::env::var("C2PA_TA_URL").ok() } +/// Time Authority request headers from `C2PA_TA_HEADERS`, one `Name: value` per line. +pub fn get_ta_headers() -> Result> { + let Ok(lines) = env::var("C2PA_TA_HEADERS") else { + return Ok(Vec::new()); + }; + + lines + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + // Don't echo the line in the error, it's likely a secret. + let (name, value) = line + .split_once(':') + .context("C2PA_TA_HEADERS: each line must be `Name: value`")?; + Ok((name.trim().to_owned(), value.trim().to_owned())) + }) + .collect() +} + +fn validate_ta_header_name(name: &str) -> Result<()> { + if name.is_empty() || name.contains(char::is_whitespace) { + bail!("Invalid Time Authority header name: {name:?}"); + } + if name.eq_ignore_ascii_case("content-type") { + bail!("The Time Authority `Content-Type` header is set by c2pa and can't be overridden"); + } + Ok(()) +} + +/// Wraps `signer` so its Time Authority requests also send `headers`. +pub fn with_ta_headers(signer: BoxedSigner, headers: Vec<(String, String)>) -> BoxedSigner { + if headers.is_empty() { + return signer; + } + Box::new(TaHeadersSigner { signer, headers }) +} + +/// Adds headers to a signer's Time Authority requests. +/// +/// Forwards everything to the wrapped signer except `send_timestamp_request`. +/// The trait default builds the request from this wrapper's +/// `timestamp_request_headers`, and forwarding would drop the added headers. +struct TaHeadersSigner { + signer: BoxedSigner, + headers: Vec<(String, String)>, +} + +impl Signer for TaHeadersSigner { + fn sign(&self, data: &[u8]) -> c2pa::Result> { + self.signer.sign(data) + } + + fn alg(&self) -> SigningAlg { + self.signer.alg() + } + + fn certs(&self) -> c2pa::Result>> { + self.signer.certs() + } + + fn reserve_size(&self) -> usize { + self.signer.reserve_size() + } + + fn time_authority_url(&self) -> Option { + self.signer.time_authority_url() + } + + fn timestamp_request_headers(&self) -> Option> { + let mut headers = self.signer.timestamp_request_headers().unwrap_or_default(); + headers.extend(self.headers.iter().cloned()); + Some(headers) + } + + fn timestamp_request_body(&self, message: &[u8]) -> c2pa::Result> { + self.signer.timestamp_request_body(message) + } + + fn ocsp_val(&self) -> Option> { + self.signer.ocsp_val() + } + + fn ocsp_vals(&self) -> Vec> { + self.signer.ocsp_vals() + } + + fn direct_cose_handling(&self) -> bool { + self.signer.direct_cose_handling() + } + + fn dynamic_assertions(&self) -> Vec> { + self.signer.dynamic_assertions() + } +} + /// Output signer info JSON for the `sign-mode --signer-info` subcommand. /// /// Uses cert/alg from `[signer.local]` settings if configured, otherwise the @@ -105,6 +202,8 @@ pub struct SignConfig { pub sign_cert: Option, /// A Url to a Time Authority to use when signing the manifest pub ta_url: Option, + /// HTTP headers to send with Time Authority requests + pub ta_headers: Option>, } impl SignConfig { @@ -127,6 +226,22 @@ impl SignConfig { self } + /// Time Authority request headers from `ta_headers`, then `C2PA_TA_HEADERS`. + pub fn ta_headers(&self) -> Result> { + let mut headers: Vec<(String, String)> = self + .ta_headers + .iter() + .flatten() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + headers.extend(get_ta_headers()?); + + for (name, _) in &headers { + validate_ta_header_name(name)?; + } + Ok(headers) + } + pub fn signer(&self) -> Result { let alg = self.alg.as_deref().unwrap_or("es256").to_lowercase(); let alg: SigningAlg = alg.parse().map_err(|_| c2pa::Error::UnsupportedType)?; diff --git a/tests/integration.rs b/tests/integration.rs index 8f350bdf..28f5cfe5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1030,3 +1030,104 @@ fn external_manifest_rejected_with_info() { "--external-manifest is not supported with the info command", )); } + +/// Writes a minimal manifest definition pointing at `ta_url` and returns its path. +fn ta_manifest(name: &str, ta_url: &str, ta_headers: Value) -> PathBuf { + let path = temp_path(name); + let manifest = serde_json::json!({ + "ta_url": ta_url, + "ta_headers": ta_headers, + "claim_generator_info": [{ "name": "c2patool test", "version": "1.0.0" }], + "title": "TSA headers", + }); + fs::write(&path, manifest.to_string()).unwrap(); + path +} + +#[test] +fn tool_sign_sends_ta_headers() -> Result<(), Box> { + let server = MockServer::start(); + // Only answers when both the manifest and env headers are present. The + // response isn't a valid time stamp, so signing still fails afterwards. + let tsa = server.mock(|when, then| { + when.method(POST) + .path("/tsa") + .header("content-type", "application/timestamp-query") + .header("x-tenant", "acme") + .header("authorization", "Bearer abc123"); + then.status(500); + }); + let manifest = ta_manifest( + "ta_headers.json", + &server.url("/tsa"), + serde_json::json!({ "X-Tenant": "acme" }), + ); + let settings = temp_path("ta_headers_no_settings.toml"); + + Command::new(cargo::cargo_bin!("c2patool")) + .arg(fixture_path(TEST_IMAGE)) + .arg("-m") + .arg(&manifest) + .arg("-o") + .arg(temp_path("ta_headers_out.jpg")) + .arg("-f") + .arg("--settings") + .arg(&settings) + .env("C2PA_TA_HEADERS", "Authorization: Bearer abc123") + .assert() + .failure(); + + assert!( + tsa.calls() > 0, + "TSA request didn't carry the configured headers" + ); + Ok(()) +} + +#[test] +fn tool_sign_rejects_ta_content_type_header() -> Result<(), Box> { + let manifest = ta_manifest( + "ta_headers_content_type.json", + "http://localhost:1/tsa", + serde_json::json!({ "Content-Type": "text/plain" }), + ); + + Command::new(cargo::cargo_bin!("c2patool")) + .arg(fixture_path(TEST_IMAGE)) + .arg("-m") + .arg(&manifest) + .arg("-o") + .arg(temp_path("ta_headers_content_type_out.jpg")) + .arg("-f") + .arg("--settings") + .arg(temp_path("ta_headers_no_settings.toml")) + .assert() + .failure() + .stderr(str::contains("`Content-Type` header")); + Ok(()) +} + +#[test] +fn tool_sign_rejects_malformed_ta_headers_env_without_echoing() -> Result<(), Box> { + let manifest = ta_manifest( + "ta_headers_bad_env.json", + "http://localhost:1/tsa", + Value::Null, + ); + + Command::new(cargo::cargo_bin!("c2patool")) + .arg(fixture_path(TEST_IMAGE)) + .arg("-m") + .arg(&manifest) + .arg("-o") + .arg(temp_path("ta_headers_bad_env_out.jpg")) + .arg("-f") + .arg("--settings") + .arg(temp_path("ta_headers_no_settings.toml")) + .env("C2PA_TA_HEADERS", "Bearer super-secret-token") + .assert() + .failure() + .stderr(str::contains("C2PA_TA_HEADERS")) + .stderr(str::contains("super-secret-token").not()); + Ok(()) +}