Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`). Headers from both are sent. `Content-Type` is set by the tool and can't be overridden.

## Example

Expand Down
12 changes: 12 additions & 0 deletions docs/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tsa_url vs ta_url split is pre-existing. c2pa-rs settings and --signer-info use tsa_url, the manifest JSON and env vars use ta_. I just matched each one. Probably not worth renaming here.


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 <token>"
```

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.
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Signer> = if let Some(ref identity_cmd) = args.identity_signer_path {
Expand Down
119 changes: 117 additions & 2 deletions src/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -27,6 +28,102 @@ pub fn get_ta_url() -> Option<String> {
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<Vec<(String, String)>> {
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<Vec<u8>> {
self.signer.sign(data)
}

fn alg(&self) -> SigningAlg {
self.signer.alg()
}

fn certs(&self) -> c2pa::Result<Vec<Vec<u8>>> {
self.signer.certs()
}

fn reserve_size(&self) -> usize {
self.signer.reserve_size()
}

fn time_authority_url(&self) -> Option<String> {
self.signer.time_authority_url()
}

fn timestamp_request_headers(&self) -> Option<Vec<(String, String)>> {
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<Vec<u8>> {
self.signer.timestamp_request_body(message)
}

fn ocsp_val(&self) -> Option<Vec<u8>> {
self.signer.ocsp_val()
}

fn ocsp_vals(&self) -> Vec<Vec<u8>> {
self.signer.ocsp_vals()
}

fn direct_cose_handling(&self) -> bool {
self.signer.direct_cose_handling()
}

fn dynamic_assertions(&self) -> Vec<Box<dyn DynamicAssertion>> {
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
Expand Down Expand Up @@ -105,6 +202,8 @@ pub struct SignConfig {
pub sign_cert: Option<PathBuf>,
/// A Url to a Time Authority to use when signing the manifest
pub ta_url: Option<String>,
/// HTTP headers to send with Time Authority requests
pub ta_headers: Option<BTreeMap<String, String>>,
}

impl SignConfig {
Expand All @@ -127,6 +226,22 @@ impl SignConfig {
self
}

/// Time Authority request headers from `ta_headers`, then `C2PA_TA_HEADERS`.
pub fn ta_headers(&self) -> Result<Vec<(String, String)>> {
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<BoxedSigner> {
let alg = self.alg.as_deref().unwrap_or("es256").to_lowercase();
let alg: SigningAlg = alg.parse().map_err(|_| c2pa::Error::UnsupportedType)?;
Expand Down
101 changes: 101 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}