diff --git a/Makefile b/Makefile index b9672ea69..78856e59c 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ clippy: cargo clippy --features="$(FEATURES)" --all-targets -- -D warnings test-local: - $(CARGO_TEST) --features="$(FEATURES)" --all-targets + $(CARGO_TEST) --features="$(FEATURES)" --lib --tests --bins --examples # Quick SDK-only test pass: unit tests + integration tests, no examples, benches, # WASM, or doc checks. Use this during active development for a fast feedback loop. diff --git a/c2pa_c_ffi/src/c_api.rs b/c2pa_c_ffi/src/c_api.rs index bd18bd80f..ad268263e 100644 --- a/c2pa_c_ffi/src/c_api.rs +++ b/c2pa_c_ffi/src/c_api.rs @@ -20,8 +20,8 @@ use std::{ #[cfg(feature = "file_io")] use c2pa::Ingredient; use c2pa::{ - assertions::DataHash, Builder as C2paBuilder, CallbackSigner, Context, ProgressPhase, - Reader as C2paReader, Settings as C2paSettings, SigningAlg, + assertions::DataHash, create_signer, Builder as C2paBuilder, CallbackSigner, Context, + ProgressPhase, Reader as C2paReader, Settings as C2paSettings, SigningAlg, }; //use tokio::runtime::Builder; @@ -2697,6 +2697,76 @@ pub unsafe extern "C" fn c2pa_signer_create( }) } +/// Creates a C2paSigner that handles both C2PA claim signing and X.509 identity assertion signing +/// by combining two existing [`C2paSigner`] instances. +/// +/// The resulting signer will embed one X.509 identity assertion (using `cawg.x509.cose`) +/// into every manifest it signs. +/// +/// Both input signers are **consumed** by this call — ownership transfers to the returned +/// signer and the caller MUST NOT free them afterward. +/// +/// # Parameters +/// * `c2pa_signer`: A `C2paSigner` used to sign the C2PA claim. Consumed by this call. +/// * `identity_signer`: A `C2paSigner` used to sign the X.509 identity assertion. Consumed by +/// this call. +/// * `referenced_assertions`: A NULL-terminated array of NULL-terminated UTF-8 strings naming +/// assertions to reference in the identity assertion, or NULL if none. +/// * `roles`: A NULL-terminated array of NULL-terminated UTF-8 strings specifying the named +/// actor's roles, or NULL if none. +/// +/// # Errors +/// Returns NULL if either signer pointer is NULL; call `c2pa_error` to retrieve the error string. +/// +/// # Safety +/// Both signer pointers must have been created by a `c2pa_signer_*` function and not yet freed. +/// After this call they are invalid — do NOT pass them to `c2pa_free`. +/// The returned value MUST be released by calling `c2pa_free`. +/// `referenced_assertions` and `roles`, if non-NULL, must each point to a NULL-terminated array +/// of NULL-terminated UTF-8 strings that remain valid for the duration of this call. +/// +/// # Example +/// ```c +/// C2paSigner* c2pa = c2pa_signer_create(c2pa_ctx, c2pa_sign_cb, C2PA_SIGNING_ALG_ES256, c2pa_certs, NULL); +/// C2paSigner* identity = c2pa_signer_create(id_ctx, id_sign_cb, C2PA_SIGNING_ALG_ES256, id_certs, NULL); +/// const char* refs[] = { "c2pa.actions", NULL }; +/// C2paSigner* signer = c2pa_identity_signer_create(c2pa, identity, refs, NULL); +/// if (signer == NULL) { +/// char* error = c2pa_error(); +/// printf("Error: %s\n", error); +/// c2pa_string_free(error); +/// } +/// ``` +#[no_mangle] +pub unsafe extern "C" fn c2pa_identity_signer_create( + c2pa_signer_ptr: *mut C2paSigner, + identity_signer_ptr: *mut C2paSigner, + referenced_assertions: *const *const c_char, + roles: *const *const c_char, +) -> *mut C2paSigner { + untrack_or_return_null!(c2pa_signer_ptr, C2paSigner); + untrack_or_return_null!(identity_signer_ptr, C2paSigner); + let c2pa_signer = Box::from_raw(c2pa_signer_ptr); + let identity_signer = Box::from_raw(identity_signer_ptr); + + let referenced_assertions = cstr_array_or_return_null!(referenced_assertions); + let roles = cstr_array_or_return_null!(roles); + + let refs: Vec<&str> = referenced_assertions.iter().map(|s| s.as_str()).collect(); + let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect(); + + let signer = create_signer::from_x509_identity( + Box::new(c2pa_signer.signer), + Box::new(identity_signer.signer), + &refs, + &role_refs, + ); + + box_tracked!(C2paSigner { + signer: Box::new(signer), + }) +} + /// Creates a C2paSigner from a SignerInfo. /// The signer is created from the sign_cert and private_key fields. /// an optional url to an RFC 3161 compliant time server will ensure the signature is timestamped. @@ -5231,4 +5301,122 @@ verify_after_sign = true unsafe { c2pa_free(context as *mut c_void) }; } + + /// Verify that `c2pa_identity_signer_create` produces a combined signer + /// that embeds a valid X.509 identity assertion in the signed manifest. + #[test] + fn test_c2pa_identity_signer_create() { + let source_image = include_bytes!(fixture_path!("IMG_0003.jpg")); + let mut source_stream = TestStream::new(source_image.to_vec()); + let dest_vec = Vec::new(); + let mut dest_stream = TestStream::new(dest_vec); + + // Build two independent signers from the test Ed25519 credentials: + // one for the C2PA claim signature, one for the identity assertion. + let make_signer = || { + let certs = include_str!(fixture_path!("certs/ed25519.pub")); + let private_key = include_bytes!(fixture_path!("certs/ed25519.pem")); + let alg = CString::new("Ed25519").unwrap(); + let sign_cert = CString::new(certs).unwrap(); + let private_key = CString::new(private_key).unwrap(); + let signer_info = C2paSignerInfo { + alg: alg.as_ptr(), + sign_cert: sign_cert.as_ptr(), + private_key: private_key.as_ptr(), + ta_url: std::ptr::null(), + }; + let signer = unsafe { c2pa_signer_from_info(&signer_info) }; + assert!(!signer.is_null()); + signer + }; + + let c2pa_signer = make_signer(); + let identity_signer = make_signer(); + + // NULL-terminated arrays of referenced assertions and roles. + let ref_c2pa_actions = CString::new("c2pa.actions").unwrap(); + let refs: [*const c_char; 2] = [ref_c2pa_actions.as_ptr(), std::ptr::null()]; + let roles: [*const c_char; 1] = [std::ptr::null()]; + + // Consume both signers and produce a combined identity signer. + let combined = unsafe { + c2pa_identity_signer_create(c2pa_signer, identity_signer, refs.as_ptr(), roles.as_ptr()) + }; + assert!( + !combined.is_null(), + "c2pa_identity_signer_create returned NULL: {:?}", + CimplError::last_message() + ); + + let manifest_def = CString::new("{}").unwrap(); + let builder = unsafe { c2pa_builder_from_json(manifest_def.as_ptr()) }; + assert!(!builder.is_null()); + + let format = CString::new("image/jpeg").unwrap(); + let mut manifest_bytes_ptr = std::ptr::null(); + let result = unsafe { + c2pa_builder_sign( + builder, + format.as_ptr(), + source_stream.as_ptr(), + dest_stream.as_ptr(), + combined, + &mut manifest_bytes_ptr, + ) + }; + assert!( + result > 0, + "signing failed (result={}): {:?}", + result, + CimplError::last_message() + ); + unsafe { c2pa_manifest_bytes_free(manifest_bytes_ptr) }; + + // Read the signed output back and verify a cawg.identity assertion is present. + dest_stream.stream_mut().rewind().unwrap(); + let reader = unsafe { c2pa_reader_from_stream(format.as_ptr(), dest_stream.as_ptr()) }; + assert!( + !reader.is_null(), + "reader creation failed: {:?}", + CimplError::last_message() + ); + + let json_ptr = unsafe { c2pa_reader_json(reader) }; + assert!(!json_ptr.is_null()); + let json_str = unsafe { CString::from_raw(json_ptr) }; + let json = json_str.to_str().unwrap(); + + assert!( + json.contains("cawg.identity"), + "expected 'cawg.identity' assertion in manifest JSON" + ); + + unsafe { + c2pa_builder_free(builder); + c2pa_signer_free(combined); + c2pa_reader_free(reader); + } + } + + /// Verify that `c2pa_identity_signer_create` fails gracefully when either + /// signer pointer is NULL and that the error string is set correctly. + #[test] + fn test_c2pa_identity_signer_create_null_signers() { + let refs: [*const c_char; 1] = [std::ptr::null()]; + let roles: [*const c_char; 1] = [std::ptr::null()]; + + let result = unsafe { + c2pa_identity_signer_create( + std::ptr::null_mut(), + std::ptr::null_mut(), + refs.as_ptr(), + roles.as_ptr(), + ) + }; + assert!(result.is_null(), "expected NULL for null c2pa_signer_ptr"); + + let error = unsafe { c2pa_error() }; + assert!(!error.is_null()); + let _ = unsafe { CString::from_raw(error) }; + } } diff --git a/c2pa_c_ffi/src/cimpl/macros.rs b/c2pa_c_ffi/src/cimpl/macros.rs index 6193832d7..a84177f66 100644 --- a/c2pa_c_ffi/src/cimpl/macros.rs +++ b/c2pa_c_ffi/src/cimpl/macros.rs @@ -112,6 +112,7 @@ //! ## Input Validation (from C) //! - **Pointer from C**: `deref_or_return_null!(ptr, Type)` → validates & dereferences to `&Type` //! - **String from C**: `cstr_or_return_null!(c_str)` → converts C string to Rust `String` +//! - **String array from C**: `cstr_array_or_return_null!(ptr)` → converts NULL-terminated `*const *const c_char` to `Vec` //! - **Byte array from C**: `bytes_or_return_null!(ptr, len, "name")` → validates & converts to `&[u8]` //! - **Check not null**: `ptr_or_return_null!(ptr)` → just null check, no deref (for output params) //! @@ -405,6 +406,10 @@ macro_rules! untrack_or_return_null { /// Maximum length for C strings when using bounded conversion (64KB) pub const MAX_CSTRING_LEN: usize = 1048576; +/// Maximum number of entries accepted from a NULL-terminated C string array. +/// Guards against runaway iteration when a caller omits the NULL terminator. +pub const MAX_STRING_ARRAY_LEN: usize = 256; + /// Convert C string with bounded length check or early-return with error value. /// Errors if the string exceeds MAX_CSTRING_LEN bytes. #[macro_export] @@ -766,6 +771,88 @@ macro_rules! bytes_or_return_int { }}; } +// ============================================================================ +// NULL-Terminated C String Array Macros +// ============================================================================ + +/// Convert a NULL-terminated C string array (`*const *const c_char`) to a +/// `Vec`, or early-return with a custom error value. +/// +/// * A NULL outer pointer is treated as an empty list (not an error). +/// * Returns early if the array exceeds [`MAX_STRING_ARRAY_LEN`] entries +/// (likely a missing NULL terminator). +/// * Returns early if any individual string is not valid UTF-8. +/// +/// # Examples +/// ```rust,ignore +/// let refs = cstr_array_or_return!(refs_ptr, std::ptr::null_mut()); +/// ``` +#[macro_export] +macro_rules! cstr_array_or_return { + ($ptr:expr, $err_val:expr) => {{ + let ptr = $ptr; + if ptr.is_null() { + Vec::::new() + } else { + let mut result = Vec::::new(); + let mut i = 0usize; + loop { + if i >= $crate::macros::MAX_STRING_ARRAY_LEN { + $crate::CimplError::new( + 2, + concat!( + stringify!($ptr), + ": array exceeds maximum length or missing NULL terminator" + ), + ) + .set_last(); + return $err_val; + } + // SAFETY: caller guarantees ptr points to a valid NULL-terminated array. + let entry = unsafe { *ptr.add(i) }; + if entry.is_null() { + break; + } + // SAFETY: caller guarantees each entry is a valid NULL-terminated C string. + let cstr = unsafe { std::ffi::CStr::from_ptr(entry) }; + match cstr.to_str() { + Ok(s) => result.push(s.to_owned()), + Err(_) => { + $crate::CimplError::new( + 2, + concat!(stringify!($ptr), ": non-UTF-8 string in array"), + ) + .set_last(); + return $err_val; + } + } + i += 1; + } + result + } + }}; +} + +/// Convert a NULL-terminated C string array to a `Vec`, returning NULL on error. +/// +/// See [`cstr_array_or_return`] for full documentation. +#[macro_export] +macro_rules! cstr_array_or_return_null { + ($ptr:expr) => { + $crate::cstr_array_or_return!($ptr, std::ptr::null_mut()) + }; +} + +/// Convert a NULL-terminated C string array to a `Vec`, returning -1 on error. +/// +/// See [`cstr_array_or_return`] for full documentation. +#[macro_export] +macro_rules! cstr_array_or_return_int { + ($ptr:expr) => { + $crate::cstr_array_or_return!($ptr, -1) + }; +} + /// Free a pointer that was allocated by cimpl. /// /// This is a convenience macro wrapper around `cimpl_free` (see [`crate::cimpl::utils::cimpl_free`]). diff --git a/cli/docs/cawg_x509_signing.md b/cli/docs/cawg_x509_signing.md index 1406e3e7d..9eddd5f61 100644 --- a/cli/docs/cawg_x509_signing.md +++ b/cli/docs/cawg_x509_signing.md @@ -1,32 +1,77 @@ # Using an X.509 certificate for CAWG signing -The `c2patool` uses some custom properties in the `cawg_x509_signer` section of the settings file for signing: +`c2patool` supports two ways to attach a CAWG X.509 identity assertion to a signed asset: -- `private_key`: Path to the private key file. -- `sign_cert`: Path to the signing certificate file. -- `alg`: Algorithm to use, if not the default ES256. +- [Settings-only signing](#settings-only-signing) — provide the private key and certificate directly in the settings file. The tool signs the CAWG assertion internally. +- [Subprocess signing with `--identity-signer-path`](#subprocess-signing-with---identity-signer-path) — delegate CAWG signing to an external executable. Use this when the private key is not accessible on the machine running the tool (for example, when it lives in an HSM or remote signing service). -Both the private key and signing certificate must be in PEM (privacy-enhanced mail) format. The signing certificate must contain a PEM certificate chain starting with the end-entity certificate used to sign the claim ending with the intermediate certificate before the root CA certificate. +Both methods require a C2PA signer to already be configured (via the `[signer]` section of the settings file, or `--signer-path`). The CAWG identity assertion is signed independently of the C2PA claim signature. -If the settings file doesn't include the `cawg_x509_signer.sign_cert` and `cawg_x509_signer.private_key` properties, c2patool will not generate a CAWG identity assertion. An example settings file demonstrating how this works is provided in the [c2patool repo sample folder](https://github.com/contentauth/c2pa-rs/tree/main/cli/tests/fixtures/trust/cawg_sign_settings.toml). +## Settings-only signing -If you are using a signing algorithm other than the default `es256`, specify it in the manifest definition field `alg` with one of the following values: +Add a `[cawg_x509_signer.local]` section to the settings file. The CAWG settings are entirely independent of the C2PA `[signer]` settings — each has its own certificate, private key, algorithm, and optional TSA URL. -- `ps256` -- `ps384` -- `ps512` -- `es256` -- `es384` -- `es512` -- `ed25519` +| Field | Required | Description | +|---|---|---| +| `sign_cert` | Yes | Signing certificate in PEM format (chain from end-entity to intermediate). | +| `private_key` | Yes | Private key in PEM format. | +| `alg` | No | Signing algorithm (default: `es256`). | +| `tsa_url` | No | URL of a timestamp authority. | +| `referenced_assertions` | No | Assertion labels to include in the identity assertion. | +| `roles` | No | Named actor roles to attach to the identity assertion. | -The specified algorithm must be compatible with the values of private key and signing certificate. For more information, see [Signing manifests](https://opensource.contentauthenticity.org/docs/signing-manifests). +If `sign_cert` and `private_key` are absent from `[cawg_x509_signer]`, no CAWG identity assertion is generated. -To sign an asset using this technique, adapt the following command-line invocation: +Supported algorithm values: `ps256`, `ps384`, `ps512`, `es256`, `es384`, `es512`, `ed25519`. The algorithm must be compatible with the private key and signing certificate. For more information, see [Signing manifests](https://opensource.contentauthenticity.org/docs/signing-manifests). + +An example settings file is provided in the [c2patool repo sample folder](https://github.com/contentauth/c2pa-rs/tree/main/cli/tests/fixtures/trust/cawg_sign_settings.toml). + +To sign an asset using this method: + +```sh +$ c2patool \ + --settings (path to settings.toml file) \ + (path to source file) \ + -m (path to manifest definition file) \ + -o (path to output file) +``` + +## Subprocess signing with `--identity-signer-path` + +Use `--identity-signer-path` to delegate signing of the CAWG identity assertion bytes to an external executable. The C2PA claim signature is still handled by the normal signer configuration. + +The subprocess protocol is identical to `--signer-path`: the tool writes the bytes to be signed to the executable's `stdin`, and the executable must write the raw signature bytes to `stdout`. + +The `[cawg_x509_signer.local]` settings section is **required** when using `--identity-signer-path`. It must supply `sign_cert` and `alg` so the tool can construct the identity assertion; `private_key` is not needed since the subprocess handles signing. `tsa_url` is optional. + +```toml +[cawg_x509_signer.local] +alg = "es256" +sign_cert = """-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE----- +""" +# tsa_url = "https://timestamp.example.com" +``` + +To sign an asset using a subprocess for CAWG identity signing: + +```sh +$ c2patool \ + --settings (path to settings.toml file) \ + --identity-signer-path (path to signing executable) \ + (path to source file) \ + -m (path to manifest definition file) \ + -o (path to output file) +``` + +If you need to reserve extra space for the signature, use `--reserve-size` (default: 20000): ```sh $ c2patool \ --settings (path to settings.toml file) \ + --identity-signer-path (path to signing executable) \ + --reserve-size 20248 \ (path to source file) \ -m (path to manifest definition file) \ -o (path to output file) diff --git a/cli/docs/signing.md b/cli/docs/signing.md new file mode 100644 index 000000000..c045e0e8e --- /dev/null +++ b/cli/docs/signing.md @@ -0,0 +1,204 @@ +# Signing assets with c2patool + +C2PA assets may carry two independent signatures: + +- **C2PA claim signature** (required to create a manifest): identifies the tool or service that created the manifest. +- **CAWG identity assertion** (optional): cryptographically binds a named identity (person or organization) to the asset. + +Each signature is produced by a separate signer. Both signers are configured independently and may use different keys and certificates. + +> **Private keys in settings files are for development and testing only.** In production, use a subprocess signer or a remote signing service so that private key material never passes through c2patool. + +--- + +## Signing options + +| Method | C2PA claim | CAWG identity | +|---|---|---| +| Subprocess signer | `--signer-path` | `--identity-signer-path` | +| Remote signing service | `[signer.remote]` in settings | _(not yet supported)_ | +| Settings with private key _(testing only)_ | `[signer.local]` in settings | `[cawg_x509_signer.local]` in settings | +| Manifest fields _(testing only)_ | `sign_cert` + `private_key` in manifest JSON | — | + +--- + +## Subprocess signer protocol + +A subprocess signer is any executable that implements two operations: **info** and **sign**. The same protocol applies to both `--signer-path` and `--identity-signer-path`. + +### Info query (`--signer-info`) + +Before signing, c2patool calls the subprocess with `--signer-info` to discover the signing certificate and algorithm. The subprocess must write a JSON object to stdout and exit 0: + +```json +{ + "alg": "es256", + "sign_cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n", + "tsa_url": "https://timestamp.example.com" +} +``` + +| Field | Required | Description | +|---|---|---| +| `alg` | Yes | Signing algorithm. One of `ps256`, `ps384`, `ps512`, `es256`, `es384`, `es512`, `ed25519`. | +| `sign_cert` | Yes | PEM certificate chain, from end-entity certificate to intermediate CA. | +| `tsa_url` | No | URL of a timestamp authority. | +| `reserve_size` | No | Bytes to reserve in the asset for the signature. The signer knows its own maximum signature size. If absent, a default based on the certificate size is used. | + +If cert and algorithm are already configured in settings (see [Settings-only signing](#settings-only-signing-testing-only) below), the info query is skipped and those values are used directly. + +### Signing + +When signing, c2patool writes the bytes to be signed to the subprocess's stdin. The subprocess must write the raw signature bytes to stdout and exit 0. + +### Error handling + +If the subprocess exits with a non-zero status, c2patool treats it as a signing failure and surfaces the subprocess's stderr output in the error message. c2patool does not retry. + +If the subprocess exits 0 but writes nothing to stdout, c2patool also returns an error. + +### Reserve size + +c2patool must reserve space in the asset file for the signature before calling the signer. The signer declares how much space it needs by returning `reserve_size` in the `--signer-info` response. If the field is absent, c2patool uses a default based on the certificate size. + +> **Deprecated:** When cert and algorithm are supplied via settings rather than `--signer-info`, c2pa tool passes `--alg` and `--reserve-size` to the subprocess for backwards compatibility. This behavior will be removed in a future release. + +--- + +## Signing with a subprocess signer + +### C2PA claim signing (`--signer-path`) + +```sh +c2patool image.jpg \ + --manifest manifest.json \ + --signer-path ./my-signer \ + -o signed.jpg +``` + +The value of `--signer-path` is a command string: a binary path optionally followed by arguments. For example: + +```sh +--signer-path "my-kms-wrapper --profile production" +``` + +### CAWG identity signing (`--identity-signer-path`) + +```sh +c2patool image.jpg \ + --manifest manifest.json \ + --signer-path ./my-c2pa-signer \ + --identity-signer-path ./my-identity-signer \ + -o signed.jpg +``` + +The C2PA and CAWG signers are independent. They may be the same executable or different ones. + +--- + +## Signing with a remote service + +Configure a remote signing service in the settings file under `[signer.remote]`: + +```toml +[signer.remote] +url = "https://signing.example.com/sign" +alg = "es256" +sign_cert = """-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE----- +""" +``` + +c2patool sends a POST request with the bytes to sign as the body and expects the raw signature bytes in the response. + +--- + +## Settings-only signing (testing only) + +For development and testing, you can provide the private key directly in the settings file. **Do not use this in production.** + +### C2PA claim + +```toml +[signer.local] +alg = "es256" +sign_cert = """-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE----- +""" +private_key = """-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY----- +""" +tsa_url = "https://timestamp.digicert.com" +``` + +Alternatively, put `sign_cert` and `private_key` as file paths in the manifest JSON, or set the `C2PA_SIGN_CERT` and `C2PA_PRIVATE_KEY` environment variables. + +If no signer is configured at all, c2patool uses a built-in test certificate and key. This is only suitable for development. + +### CAWG identity assertion + +```toml +[cawg_x509_signer.local] +alg = "es256" +sign_cert = """-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE----- +""" +private_key = """-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY----- +""" +tsa_url = "https://timestamp.digicert.com" +referenced_assertions = ["c2pa.actions"] +roles = ["creator"] +``` + +If `[cawg_x509_signer]` is absent, no CAWG identity assertion is generated. + +An example settings file is in the [c2patool repo sample folder](https://github.com/contentauth/c2pa-rs/tree/main/cli/tests/fixtures/trust/cawg_sign_settings.toml). + +--- + +## 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. + +A minimal signer in shell (for illustration only — not for production): + +```sh +#!/bin/sh +if [ "$1" = "--signer-info" ]; then + echo '{"alg":"es256","sign_cert":"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"}' + exit 0 +fi +# Read bytes from stdin, sign them, write raw signature to stdout +openssl dgst -sha256 -sign my-key.pem +``` + +In practice, a production signer would: + +1. Implement `--signer-info` by fetching the certificate from the KMS/HSM/keychain. +2. Implement signing by sending the stdin bytes to the KMS/HSM/keychain API and writing the returned signature to stdout. +3. Handle its own authentication (API tokens, IAM roles, PIN prompts, etc.) internally — c2patool has no involvement in that. +4. Exit non-zero and write a diagnostic to stderr on failure. + +The signer is responsible for all key management. c2patool only sees the public certificate (from `--signer-info`) and the resulting signature bytes. + +--- + +## Supported algorithms + +| Value | Algorithm | +|---|---| +| `ps256` | RSASSA-PSS with SHA-256 | +| `ps384` | RSASSA-PSS with SHA-384 | +| `ps512` | RSASSA-PSS with SHA-512 | +| `es256` | ECDSA with SHA-256 (default) | +| `es384` | ECDSA with SHA-384 | +| `es512` | ECDSA with SHA-512 | +| `ed25519` | EdDSA | + +The algorithm must be compatible with the private key and signing certificate. For more information, see [Signing manifests](https://opensource.contentauthenticity.org/docs/signing-manifests). diff --git a/cli/docs/usage.md b/cli/docs/usage.md index b9297c50d..2d561a321 100644 --- a/cli/docs/usage.md +++ b/cli/docs/usage.md @@ -39,10 +39,11 @@ The following options are available with any (or no) subcommand. Additional opt | `--output` | `-o` | `` | Path to output folder or file. This option can be used in two ways:
•With the `-m` option to [add a manifest to the specified asset file](#adding-a-manifest-to-an-asset-file). The argument then specifies the name of the resulting asset file with Content Credentials added.
•Without the `-m` option to [write the manifest data to a directory](#saving-manifest-data-to-a-directory) (including assertion and ingredient thumbnails). The argument then specifies the output directory to use. | | `--parent` | `-p` | `` | Path to parent file. See [Specifying a parent file](#specifying-a-parent-file). | | `--remote` | `-r` | `` | URL for remote manifest available over HTTP. See [Generating a remote manifest](#generating-a-remote-manifest). | -| `--reserve-size` | N/A | `` | Only valid with the `--signer-path` option. The amount of memory to reserve for signing. Default: 20000. See [Signing claim bytes with your own signer](#signing-claim-bytes-with-your-own-signer). | +| `--identity-signer-path` | N/A | `` | Command for signing the CAWG identity assertion. Same protocol as `--signer-path`. See [Signing assets](signing.md). | +| `--reserve-size` | N/A | `` | Space to reserve for signatures when using `--signer-path` or `--identity-signer-path`. Default: 20000. See [Signing assets](signing.md). | | `--settings` | N/A | `` | Path to the settings file. Default is the value of the `C2PATOOL_SETTINGS` environment variable. If not set, defaults to `~/.config/c2pa/c2pa.toml`. See [Configuring SDK settings](../../docs/context-settings.md). | | `--sidecar` | `-s` | N/A | Put manifest in external "sidecar" file with `.c2pa` extension. See [Generating an external manifest](#generating-an-external-manifest). | -| `--signer-path` | N/A | `` | Path to a command-line executable for signing. See [Signing claim bytes with your own signer](#signing-claim-bytes-with-your-own-signer). | +| `--signer-path` | N/A | `` | Command for signing the C2PA claim. See [Signing assets](signing.md). | | `--tree` | | N/A | Create a tree diagram of the manifest store. See [Displaying a tree diagram](#displaying-a-tree-diagram). | | `--version` | `-V` | N/A | Display version information. | @@ -145,10 +146,10 @@ The tool generates a new manifest using the values given in the file and display > [!WARNING] > If the output file is the same as the source file, the tool will overwrite the source file. -If the manifest definition file has `private_key` and `sign_cert` fields, then the tool signs the manifest using the private key and certificate they specify, respectively. Otherwise, the tool uses the built-in test certificate and key, which is suitable ONLY for development and testing. You can also specify the private key and certificate using environment variables; for more information, see [Creating and using an X.509 certificate](x_509.md). To sign with a CAWG identity assertion, see [Using an X.509 certificate for CAWG signing](cawg_x509_signing.md). +For full details on configuring signers — including how to write a subprocess signer, use a remote signing service, or add a CAWG identity assertion — see [Signing assets](signing.md). > [!WARNING] -> Accessing the private key and signing certificate directly like this is fine during development, but doing so in production may be insecure. Instead, use a key management service (KMS) or a hardware security module (HSM) to access the certificate and key; for example, as shown in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). +> Providing a private key directly in a manifest file or settings file is suitable only for development and testing. In production, use a subprocess signer or remote signing service so that private key material never passes through c2patool. ### Specifying a parent file @@ -187,23 +188,19 @@ In the example above, the tool will embed the URL `http://my_server/myasset.c2pa If you use both the `-s` and `-r` options, the tool embeds a manifest in the output file and also adds the remote reference. -### Signing claim bytes with your own signer +### Signing with your own signer -When generating a manifest, if the private key is not accessible on the system on which you are running the tool, use the `--signer-path` argument to specify the path to an executable that performs signing. -This executable receives the claim bytes (the bytes to be signed) from standard input (`stdin`) and outputs the signature bytes to standard output (`stdout`). - - For example, the following command signs the asset's claim bytes by using an executable named `custom-signer`: +Use `--signer-path` to delegate C2PA claim signing to an external executable, and `--identity-signer-path` to additionally embed a CAWG identity assertion signed by a separate executable. Both accept a command string (binary path and optional arguments): ```shell c2patool sample/image.jpg \ --manifest sample/test.json \ --output sample/signed-image.jpg \ - --signer-path ./custom-signer \ - --reserve-size 20248 \ + --signer-path ./my-signer \ -f ``` -For information on calculating the value of the `--reserve-size` argument, see `c2patool --help`. +The signer executable must implement the subprocess signing protocol: respond to `--signer-info` with a JSON object describing its certificate and algorithm, and sign bytes received on stdin by writing the raw signature to stdout. For the full protocol specification, error handling details, and guidance on writing your own signer, see [Signing assets](signing.md). ### Providing a manifest definition on the command line diff --git a/cli/src/callback_signer.rs b/cli/src/callback_signer.rs deleted file mode 100644 index 1b66d3608..000000000 --- a/cli/src/callback_signer.rs +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright 2024 Adobe. All rights reserved. -// This file is licensed to you under the Apache License, -// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -// or the MIT license (http://opensource.org/licenses/MIT), -// at your option. -// Unless required by applicable law or agreed to in writing, -// this software is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -// implied. See the LICENSE-MIT and LICENSE-APACHE files for the -// specific language governing permissions and limitations under -// each license. - -use std::{ - io::Write, - path::PathBuf, - process::{Command, Stdio}, -}; - -use anyhow::{bail, Context}; -use c2pa::{Error, Signer, SigningAlg}; - -use crate::signer::SignConfig; - -/// A struct that implements [SignCallback]. This struct will call out to the client provided -/// external signer to get the signed bytes for the asset. -pub(crate) struct ExternalProcessRunner { - config: CallbackSignerConfig, - signer_path: PathBuf, -} - -impl ExternalProcessRunner { - pub fn new(config: CallbackSignerConfig, signer_path: PathBuf) -> Self { - Self { - config, - signer_path, - } - } -} - -impl SignCallback for ExternalProcessRunner { - /// Runs the client-provided [Command], passing to it, via stdin, the bytes to be signed. We - /// also pass the `reserve-size`, `sign-cert`, and `alg` as CLI arguments to the [Command]. - fn sign(&self, bytes: &[u8]) -> anyhow::Result> { - let sign_cert = self - .config - .sign_cert_path - .as_os_str() - .to_str() - .context("Unable to read sign_certs. Is the sign_cert path valid?")?; - - // Spawn external process provided by the `c2patool` client. - let mut child = Command::new(&self.signer_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .args(["--reserve-size", &self.config.reserve_size.to_string()]) - .args(["--alg", &format!("{}", &self.config.alg)]) - .args(["--sign-cert", sign_cert]) - .spawn() - .context(format!("Failed to run command at {:?}", self.signer_path))?; - - // Write claim bytes to spawned processes' `stdin`. - child - .stdin - .take() - .context("Failed to access `stdin` of external process")? - .write_all(bytes) - .context("Failed to write data to the provided external process")?; - - let output = child - .wait_with_output() - .context(format!("Failed to read stdout from {:?}", self.signer_path))?; - - if !output.status.success() { - bail!(format!( - "User supplied signer process failed. It's stderr output was: \n{}", - String::from_utf8(output.stderr).unwrap_or_default() - )); - } - - let bytes = output.stdout; - if bytes.is_empty() { - bail!("User supplied process succeeded, but the external process did not write signature bytes to stdout"); - } - - Ok(bytes) - } -} - -/// A config containing the required values for signing an asset with an external command. -#[derive(Clone, Debug)] -pub(crate) struct CallbackSignerConfig { - /// Signing algorithm to use - must match the associated certs - /// - /// Must be one of [ ps256 | ps384 | ps51024 | es256 | es384 | es51024 | ed25519 ] - pub alg: SigningAlg, - /// A path to a file containing the signing cert required for signing - pub sign_cert_path: PathBuf, - /// Size of the claim bytes. - pub reserve_size: usize, - pub tsa_url: Option, -} - -impl CallbackSignerConfig { - /// Constructs a new [CallbackSignerConfig] using a manifest sign config, the name of an - /// external process, and the reserve_size. - pub fn new(sign_config: &SignConfig, reserve_size: usize) -> anyhow::Result { - let alg = sign_config - .alg - .as_deref() - .map_or_else(|| "es256".to_string(), |alg| alg.to_lowercase()) - .parse::() - .context("Invalid signing algorithm provided")?; - - let sign_cert_path = sign_config - .sign_cert - .clone() - .context("Unable to load the provided sign_cert_path")?; - - Ok(CallbackSignerConfig { - alg, - sign_cert_path, - reserve_size, - tsa_url: sign_config.ta_url.clone(), - }) - } -} - -#[cfg_attr(test, mockall::automock)] -pub(crate) trait SignCallback { - /// Method which will be called with the `data` to be signed. Implementors - /// should return the signed bytes as an [anyhow::Result]. - fn sign(&self, data: &[u8]) -> anyhow::Result>; -} - -/// A [Signer] implementation that allows clients to provide their own function -/// to sign the manifest bytes. -pub(crate) struct CallbackSigner<'a> { - callback: Box, - config: CallbackSignerConfig, -} - -impl<'a> CallbackSigner<'a> { - pub fn new(callback: Box, config: CallbackSignerConfig) -> Self { - Self { callback, config } - } -} - -impl Signer for CallbackSigner<'_> { - fn sign(&self, data: &[u8]) -> c2pa::Result> { - self.callback.sign(data).map_err(|e| { - eprintln!("Unable to embed signature into asset. {e}"); - Error::EmbeddingError - }) - } - - fn alg(&self) -> SigningAlg { - self.config.alg - } - - fn certs(&self) -> c2pa::Result>> { - let cert_contents = std::fs::read(&self.config.sign_cert_path) - .map_err(|_| Error::FileNotFound(format!("{:?}", self.config.sign_cert_path)))?; - - let mut pems = pem::parse_many(cert_contents).map_err(|_| Error::CoseInvalidCert)?; - // [pem::parse_many] returns an empty vector if you supply invalid contents, like json, for example. - // Check here if the pems vector is empty. - if pems.is_empty() { - return Err(Error::CoseInvalidCert); - } - - let sign_cert = pems - .drain(..) - .map(|p| p.into_contents()) - .collect::>>(); - - Ok(sign_cert) - } - - fn reserve_size(&self) -> usize { - self.config.reserve_size - } - - fn time_authority_url(&self) -> Option { - self.config.tsa_url.clone() - } -} - -#[cfg(test)] -mod test { - use anyhow::anyhow; - - use super::*; - - fn sign_cert_path() -> PathBuf { - #[cfg(not(target_os = "wasi"))] - return PathBuf::from(env!("CARGO_MANIFEST_DIR")); - #[cfg(target_os = "wasi")] - return PathBuf::from("/"); - } - - #[test] - fn test_signing_succeeds_returns_bytes() { - let mut sign_cert_path = sign_cert_path(); - sign_cert_path.push("sample/es256_certs.pem"); - - let sign_config = SignConfig { - alg: Some(SigningAlg::Es256.to_string()), - sign_cert: Some(sign_cert_path), - ..Default::default() - }; - - let result = vec![1, 2, 3]; - let expected = result.clone(); - - let mut mock_callback_signer = MockSignCallback::default(); - mock_callback_signer - .expect_sign() - .returning(move |_| Ok(result.clone())); - - let config = CallbackSignerConfig::new(&sign_config, 1024).unwrap(); - let callback = Box::new(mock_callback_signer); - let signer = CallbackSigner::new(callback, config); - - assert_eq!(Signer::sign(&signer, &[]).unwrap(), expected); - } - - #[test] - fn test_signing_succeeds_returns_error_embedding() { - let mut sign_cert_path = sign_cert_path(); - sign_cert_path.push("sample/es256_certs.pem"); - - let sign_config = SignConfig { - alg: Some(SigningAlg::Es256.to_string()), - sign_cert: Some(sign_cert_path), - ..Default::default() - }; - - let mut mock_callback_signer = MockSignCallback::default(); - mock_callback_signer - .expect_sign() - .returning(|_| Err(anyhow!(""))); - - let config = CallbackSignerConfig::new(&sign_config, 1024).unwrap(); - let callback = Box::new(mock_callback_signer); - let signer = CallbackSigner::new(callback, config); - - assert!(matches!( - Signer::sign(&signer, &[]), - Err(Error::EmbeddingError) - )); - } - - #[test] - fn test_sign_config_to_external_sign_config_fails() { - let sign_config = SignConfig::default(); - assert!(CallbackSignerConfig::new(&sign_config, 1024).is_err()); - } - - #[test] - fn test_sign_config_to_external_sign_config_fails_with_invalid_signing_alg() { - let sign_config = SignConfig { - alg: Some("invalid_signing_alg".to_owned()), - ..Default::default() - }; - - let result = CallbackSignerConfig::new(&sign_config, 1024); - let error = result.err().unwrap(); - assert_eq!(format!("{error}"), "Invalid signing algorithm provided") - } - - #[test] - fn test_sign_config_to_external_sign_config_fails_with_missing_sign_certs() { - let sign_config = SignConfig { - alg: Some(SigningAlg::Es256.to_string()), - sign_cert: None, - ..Default::default() - }; - - let result = CallbackSignerConfig::new(&sign_config, 1024); - let error = result.err().unwrap(); - assert_eq!( - format!("{error}"), - "Unable to load the provided sign_cert_path" - ) - } - - #[test] - fn test_try_from_succeeds_for_valid_sign_config() { - let mut sign_cert_path = sign_cert_path(); - sign_cert_path.push("sample/es256_certs.pem"); - - let expected_alg = SigningAlg::Es256; - let sign_config = SignConfig { - alg: Some(expected_alg.to_string()), - sign_cert: Some(sign_cert_path), - ..Default::default() - }; - - let expected_reserve_size = 10248; - let esc = CallbackSignerConfig::new(&sign_config, expected_reserve_size).unwrap(); - let callback = Box::::default(); - let signer = CallbackSigner::new(callback, esc); - - assert_eq!(Signer::alg(&signer), expected_alg); - assert_eq!(Signer::reserve_size(&signer), expected_reserve_size); - } - - #[test] - fn test_callback_signer_error_file_not_found() { - let mut sign_cert_path = sign_cert_path(); - sign_cert_path.push("sample/NOT-HERE"); - - let sign_config = SignConfig { - alg: Some(SigningAlg::Es256.to_string()), - sign_cert: Some(sign_cert_path), - ..Default::default() - }; - - let config = CallbackSignerConfig::new(&sign_config, 10248).unwrap(); - let callback = Box::::default(); - let signer = CallbackSigner::new(callback, config); - - assert!(matches!(signer.certs(), Err(Error::FileNotFound(_)))); - } - - #[test] - fn test_callback_signer_error_invalid_cert() { - let mut sign_cert_path = sign_cert_path(); - sign_cert_path.push("sample/test.json"); - - let sign_config = SignConfig { - alg: Some(SigningAlg::Es256.to_string()), - sign_cert: Some(sign_cert_path), - ..Default::default() - }; - - let config = CallbackSignerConfig::new(&sign_config, 1024).unwrap(); - let callback = Box::::default(); - let signer = CallbackSigner::new(callback, config); - - assert!(matches!(signer.certs(), Err(Error::CoseInvalidCert))); - } - - #[test] - fn test_callback_signer_valid_sign_certs() { - let mut sign_cert_path = sign_cert_path(); - sign_cert_path.push("sample/es256_certs.pem"); - - let sign_config = SignConfig { - alg: Some(SigningAlg::Es256.to_string()), - sign_cert: Some(sign_cert_path), - ..Default::default() - }; - - let config = CallbackSignerConfig::new(&sign_config, 1024).unwrap(); - let callback = Box::::default(); - let signer = CallbackSigner::new(callback, config); - - assert_eq!(signer.certs().unwrap().len(), 2); - } -} diff --git a/cli/src/main.rs b/cli/src/main.rs index c01f1e535..dba0674d6 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -29,8 +29,9 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use c2pa::{ - format_from_path, settings::Settings, Builder, ClaimGeneratorInfo, Context as C2paContext, - Error, Ingredient, ManifestDefinition, Reader, Signer, + create_signer, format_from_path, settings::Settings, BoxedSigner, Builder, CallbackSigner, + ClaimGeneratorInfo, Context as C2paContext, Error, Ingredient, ManifestDefinition, Reader, + Signer, SigningAlg, }; use clap::{Parser, Subcommand}; use env_logger::Env; @@ -41,15 +42,11 @@ use signer::SignConfig; use tempfile::NamedTempFile; use url::Url; -use crate::{ - callback_signer::{CallbackSigner, CallbackSignerConfig, ExternalProcessRunner}, - info::info, -}; +use crate::info::info; mod info; mod tree; -mod callback_signer; mod signer; /// Official C2PA conformance trust list (PEM bundle). @@ -145,12 +142,27 @@ struct CliArgs { #[clap(long)] info: bool, - /// Path to an executable that will sign the claim bytes. + /// Command (binary and optional args) that will sign the claim bytes. + /// + /// The process receives bytes via stdin and must write the signature to stdout. + /// Cert and algorithm come from the manifest's `sign_cert`/`alg` fields; if absent, + /// the subprocess is queried via `--signer-info`. + /// Example: --signer-path "c2patool sign-mode" + #[clap(long)] + signer_path: Option, + + /// Command (binary and optional args) that will sign the CAWG identity assertion bytes. + /// + /// The process receives bytes via stdin and must write the signature to stdout, + /// identical to `--signer-path`. Cert and algorithm come from `[cawg_x509_signer]` + /// settings; if absent, the subprocess is queried via `--signer-info`. + /// Example: --identity-signer-path "c2patool sign-mode" #[clap(long)] - signer_path: Option, + identity_signer_path: Option, - /// Reserved buffer size for `--signer-path` signing only. - #[clap(long, default_value("20000"))] + /// Reserved buffer size for the signature. Deprecated: the subprocess signer should + /// declare this via `--signer-info` instead. + #[clap(long, hide = true, default_value("20000"))] reserve_size: usize, // TODO: ideally this would be called config, not to be confused with the other config arg @@ -238,6 +250,20 @@ enum Commands { #[arg(long = "fragments_glob", verbatim_doc_comment)] fragments_glob: Option, }, + /// Hidden test-only subcommand implementing the subprocess signing protocol. + /// + /// Signs using the baked-in es256 test key — not for production use. + /// --signer-info outputs {"alg","sign_cert","tsa_url"} JSON and exits + /// (default) reads bytes from stdin, writes raw signature to stdout + #[command(hide = true, name = "test-signer")] + SignMode { + /// Output signer info (cert, alg, tsa_url) as JSON and exit. + #[arg(long)] + signer_info: bool, + /// Exit with an error (for testing failure paths). + #[arg(long)] + fail: bool, + }, } #[derive(Debug, Default, Deserialize)] @@ -264,7 +290,169 @@ fn special_errs(e: c2pa::Error) -> anyhow::Error { } } +/// Signer identity advertised by a subprocess via `--signer-info`. +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct SignerInfo { + alg: SigningAlg, + /// PEM certificate chain. + sign_cert: String, + #[serde(skip_serializing_if = "Option::is_none")] + tsa_url: Option, + /// Bytes to reserve in the asset for this signer's signature. + /// The signer knows its own maximum signature size; if absent, a default is used. + #[serde(skip_serializing_if = "Option::is_none")] + reserve_size: Option, +} + +/// Split a command string (e.g. `"c2patool sign-mode"`) into a binary path and base args. +/// Paths containing spaces are not supported. +fn parse_command(cmd: &str) -> (PathBuf, Vec) { + let mut parts = cmd.split_whitespace(); + let binary = PathBuf::from(parts.next().unwrap_or_default()); + let args: Vec = parts.map(str::to_string).collect(); + (binary, args) +} + +/// Call `binary base_args --signer-info`, parse the JSON response, and return it. +fn query_subprocess_info(binary: &Path, base_args: &[String]) -> Result { + use std::process::Command; + let output = Command::new(binary) + .args(base_args) + .arg("--signer-info") + .output() + .with_context(|| format!("Failed to run {binary:?} --signer-info"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Subprocess --signer-info failed for {binary:?}: {stderr}"); + } + serde_json::from_slice(&output.stdout) + .with_context(|| format!("Parsing --signer-info JSON from {binary:?}")) +} + // Normalize extensions so we can compare them. +/// Spawn an external signing process, pipe `data` to its stdin, and return the signature bytes +/// written to stdout. When `compat_mode` is true (cert/alg came from settings rather than +/// `--signer-info`), the process also receives `--reserve-size N --alg ALG` for back-compat. +fn make_subprocess_signer( + signer_binary: PathBuf, + signer_base_args: Vec, + alg: SigningAlg, + cert_bytes: Vec, + reserve_size: Option, + tsa_url: Option, + compat_mode: bool, +) -> Result { + use std::{ + io::Write, + process::{Command, Stdio}, + }; + + let effective_reserve = reserve_size.unwrap_or(10000 + cert_bytes.len()); + let alg_str = alg.to_string(); + let reserve_str = effective_reserve.to_string(); + + let mut signer = CallbackSigner::new( + move |_ctx: *const (), data: &[u8]| { + let mut cmd = Command::new(&signer_binary); + cmd.args(&signer_base_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if compat_mode { + cmd.args(["--reserve-size", &reserve_str]) + .args(["--alg", &alg_str]); + } + + let mut child = cmd.spawn().map_err(|e| { + Error::BadParam(format!("Failed to run command at {signer_binary:?}: {e}")) + })?; + + child + .stdin + .take() + .ok_or(Error::EmbeddingError)? + .write_all(data) + .map_err(|e| Error::OtherError(Box::new(e)))?; + + let output = child + .wait_with_output() + .map_err(|e| Error::OtherError(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8(output.stderr).unwrap_or_default(); + return Err(Error::BadParam(format!( + "User supplied signer process failed. Its stderr output was: \n{stderr}" + ))); + } + + if output.stdout.is_empty() { + return Err(Error::BadParam( + "User supplied process succeeded, but the external process did not write \ + signature bytes to stdout" + .to_string(), + )); + } + + Ok(output.stdout) + }, + alg, + cert_bytes, + ); + + signer.reserve_size = effective_reserve; + if let Some(url) = tsa_url { + signer = signer.set_tsa_url(url); + } + + Ok(Box::new(signer)) +} + +/// Cert/alg and assertion metadata extracted from `cawg_x509_signer` settings. +struct CawgIdentityInfo { + /// Inline PEM cert bytes and signing algorithm from settings, or `None` when absent. + cert_and_alg: Option<(Vec, SigningAlg)>, + tsa_url: Option, + referenced_assertions: Vec, + roles: Vec, +} + +/// Extract cert bytes, alg, tsa_url, referenced_assertions, and roles from a +/// `cawg_x509_signer` settings value. `cert_and_alg` is `None` when no CAWG settings +/// are present. +fn extract_cawg_identity_info( + cawg_settings: Option, +) -> CawgIdentityInfo { + match cawg_settings { + Some(c2pa::settings::signer::SignerSettings::Local { + alg, + sign_cert, + tsa_url, + referenced_assertions, + roles, + .. + }) + | Some(c2pa::settings::signer::SignerSettings::Remote { + alg, + sign_cert, + tsa_url, + referenced_assertions, + roles, + .. + }) => CawgIdentityInfo { + cert_and_alg: Some((sign_cert.into_bytes(), alg)), + tsa_url, + referenced_assertions: referenced_assertions.unwrap_or_default(), + roles: roles.unwrap_or_default(), + }, + _ => CawgIdentityInfo { + cert_and_alg: None, + tsa_url: None, + referenced_assertions: vec![], + roles: vec![], + }, + } +} + fn ext_normal(path: &Path) -> String { let ext = path .extension() @@ -781,6 +969,16 @@ fn main() -> Result<()> { }; } + if let Some(Commands::SignMode { signer_info, fail }) = args.command { + if fail { + anyhow::bail!("Subprocess signer deliberately failed (--fail)"); + } + if signer_info { + return signer::output_signer_info(&args.settings); + } + return signer::sign_from_stdin(); + } + let path = args .path .as_ref() @@ -920,27 +1118,99 @@ fn main() -> Result<()> { builder.set_no_embed(true); } - let signer = if let Some(signer_process_name) = args.signer_path { - let cb_config = CallbackSignerConfig::new(&sign_config, args.reserve_size)?; - - let process_runner = Box::new(ExternalProcessRunner::new( - cb_config.clone(), - signer_process_name, - )); - let signer = CallbackSigner::new(process_runner, cb_config); - - Box::new(signer) + // Step 1: build the base C2PA signer. + let c2pa_signer: BoxedSigner = if let Some(ref signer_cmd) = args.signer_path { + let (signer_binary, signer_base_args) = parse_command(signer_cmd); + let (cert_bytes, alg, tsa_url, reserve_size, compat_mode) = + match sign_config.sign_cert.clone() { + Some(p) => { + let bytes = + std::fs::read(&p).context(format!("Reading sign cert: {p:?}"))?; + let alg: SigningAlg = sign_config + .alg + .as_deref() + .unwrap_or("es256") + .to_lowercase() + .parse() + .context("Invalid signing algorithm")?; + let tsa_url = sign_config.ta_url.clone().or_else(signer::get_ta_url); + (bytes, alg, tsa_url, None, true) + } + None => { + let info = query_subprocess_info(&signer_binary, &signer_base_args)?; + let tsa_url = info.tsa_url.or_else(signer::get_ta_url); + ( + info.sign_cert.into_bytes(), + info.alg, + tsa_url, + info.reserve_size, + false, + ) + } + }; + make_subprocess_signer( + signer_binary, + signer_base_args, + alg, + cert_bytes, + reserve_size, + tsa_url, + compat_mode, + )? } else if let Some(signer_cfg) = settings.signer.take() { - let c2pa_signer = signer_cfg.c2pa_signer()?; - if let Some(cawg_cfg) = settings.cawg_x509_signer.take() { - cawg_cfg.cawg_signer(c2pa_signer)? - } else { - c2pa_signer - } + signer_cfg.c2pa_signer()? } else { sign_config.signer()? }; + // Step 2: optionally wrap with a CAWG identity callback signer. + let signer: Box = if let Some(ref identity_cmd) = args.identity_signer_path { + let (identity_binary, identity_base_args) = parse_command(identity_cmd); + let CawgIdentityInfo { + cert_and_alg, + tsa_url: cawg_tsa_url, + referenced_assertions, + roles, + } = extract_cawg_identity_info(settings.cawg_x509_signer.take()); + + let (cert_bytes, alg, tsa_url, reserve_size, compat_mode) = match cert_and_alg { + Some((bytes, alg)) => { + let tsa_url = cawg_tsa_url.or_else(signer::get_ta_url); + (bytes, alg, tsa_url, None, true) + } + None => { + let info = query_subprocess_info(&identity_binary, &identity_base_args)?; + let tsa_url = info.tsa_url.or_else(signer::get_ta_url); + ( + info.sign_cert.into_bytes(), + info.alg, + tsa_url, + info.reserve_size, + false, + ) + } + }; + + let identity_signer = make_subprocess_signer( + identity_binary, + identity_base_args, + alg, + cert_bytes, + reserve_size, + tsa_url, + compat_mode, + )?; + + let refs: Vec<&str> = referenced_assertions.iter().map(String::as_str).collect(); + let roles_refs: Vec<&str> = roles.iter().map(String::as_str).collect(); + + create_signer::from_x509_identity(c2pa_signer, identity_signer, &refs, &roles_refs) + } else if let Some(cawg_cfg) = settings.cawg_x509_signer.take() { + cawg_cfg.cawg_signer(c2pa_signer)? + } else { + c2pa_signer + }; + if let Some(output) = args.output { // fragmented embedding if let Some(Commands::Fragment { fragments_glob }) = &args.command { @@ -1172,6 +1442,56 @@ pub mod tests { assert_eq!(std::fs::read_to_string(&dest).unwrap(), "second"); } + #[test] + fn extract_cawg_identity_info_returns_cert_and_alg_from_local_settings() { + use c2pa::settings::signer::SignerSettings; + + let cert_pem = "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"; + let settings = SignerSettings::Local { + alg: SigningAlg::Ps256, + sign_cert: cert_pem.to_string(), + private_key: "key".to_string(), + tsa_url: None, + referenced_assertions: Some(vec!["c2pa.hash.data".to_string()]), + roles: Some(vec!["creator".to_string()]), + }; + + let info = extract_cawg_identity_info(Some(settings)); + let (bytes, alg) = info.cert_and_alg.expect("cert info should be present"); + assert_eq!(bytes, cert_pem.as_bytes()); + assert_eq!(alg, SigningAlg::Ps256); + assert!(info.tsa_url.is_none()); + assert_eq!(info.referenced_assertions, ["c2pa.hash.data"]); + assert_eq!(info.roles, ["creator"]); + } + + #[test] + fn extract_cawg_identity_info_returns_none_when_no_settings() { + let info = extract_cawg_identity_info(None); + assert!(info.cert_and_alg.is_none()); + assert!(info.tsa_url.is_none()); + assert!(info.referenced_assertions.is_empty()); + assert!(info.roles.is_empty()); + } + + #[cfg(not(target_os = "wasi"))] + #[test] + fn make_subprocess_signer_fails_when_signer_path_not_found() { + let signer = make_subprocess_signer( + PathBuf::from("./nonexistent-signer-binary"), + vec![], + SigningAlg::Es256, + b"cert-bytes".to_vec(), + None, + None, + false, + ) + .unwrap(); + + let result = Signer::sign(signer.as_ref(), &[1, 2, 3]); + assert!(result.is_err()); + } + #[test] fn apply_trust_sidecars_reads_official_pem() { const SAMPLE_ANCHOR_PEM: &str = include_str!("../../cli/tests/fixtures/trust/anchors.pem"); diff --git a/cli/src/signer.rs b/cli/src/signer.rs index 1022533ac..384e3823e 100644 --- a/cli/src/signer.rs +++ b/cli/src/signer.rs @@ -17,16 +17,81 @@ use std::{ }; use anyhow::{Context, Result}; -use c2pa::{create_signer, Signer, SigningAlg}; +use c2pa::{create_signer, BoxedSigner, SigningAlg}; use serde::Deserialize; -// Pull in default certs so the binary can self config const DEFAULT_CERTS: &[u8] = include_bytes!("../sample/es256_certs.pem"); const DEFAULT_KEY: &[u8] = include_bytes!("../sample/es256_private.key"); pub fn get_ta_url() -> Option { std::env::var("C2PA_TA_URL").ok() } + +/// Output signer info JSON for the `sign-mode --signer-info` subcommand. +/// +/// Uses cert/alg from `[signer.local]` settings if configured, otherwise the +/// baked-in es256 test cert. +pub fn output_signer_info(settings_path: &std::path::Path) -> anyhow::Result<()> { + use std::io::Write; + + // Try to load cert/alg from settings; fall back to DEFAULT_CERTS. + let (cert_pem, alg, tsa_url) = + if let Ok(settings) = c2pa::settings::Settings::new().with_file(settings_path) { + match settings.signer { + Some(c2pa::settings::signer::SignerSettings::Local { + alg, + sign_cert, + tsa_url, + .. + }) => (sign_cert, alg, tsa_url), + _ => ( + String::from_utf8_lossy(DEFAULT_CERTS).into_owned(), + SigningAlg::Es256, + None, + ), + } + } else { + ( + String::from_utf8_lossy(DEFAULT_CERTS).into_owned(), + SigningAlg::Es256, + None, + ) + }; + + let info = crate::SignerInfo { + alg, + sign_cert: cert_pem, + tsa_url, + reserve_size: None, + }; + let json = serde_json::to_string(&info).context("serializing signer info")?; + std::io::stdout() + .write_all(json.as_bytes()) + .context("writing signer info to stdout")?; + Ok(()) +} + +/// Read raw bytes from stdin, sign them with the baked-in es256 test key, and write +/// the raw signature bytes to stdout. Used by the `sign-mode` subcommand. +pub fn sign_from_stdin() -> anyhow::Result<()> { + use std::io::{Read, Write}; + + let mut data = Vec::new(); + std::io::stdin() + .read_to_end(&mut data) + .context("reading bytes from stdin")?; + + let signer = create_signer::from_keys(DEFAULT_CERTS, DEFAULT_KEY, SigningAlg::Es256, None) + .context("creating test signer")?; + + let sig = c2pa::Signer::sign(signer.as_ref(), &data).context("signing")?; + + std::io::stdout() + .write_all(&sig) + .context("writing signature to stdout")?; + + Ok(()) +} #[derive(Debug, Default, Deserialize)] pub struct SignConfig { /// Signing algorithm to use - must match the associated certs @@ -62,7 +127,7 @@ impl SignConfig { self } - pub fn signer(&self) -> Result> { + 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)?; let tsa_url = self.ta_url.clone().or_else(get_ta_url); diff --git a/cli/tests/integration.rs b/cli/tests/integration.rs index 7f846a080..80f99d5c7 100644 --- a/cli/tests/integration.rs +++ b/cli/tests/integration.rs @@ -341,92 +341,89 @@ fn tool_sign_to_same_file_no_force() -> Result<(), Box> { Ok(()) } -// #[test] -// fn test_succeed_using_example_signer() -> Result<(), Box> { -// let output = temp_path("./output_external.jpg"); -// // We are calling a cargo/bin here that successfully signs claim bytes. We are using -// // a cargo/bin because it works on all OSs, we like Rust, and our example external signing -// // code is compiled and verified during every test of this project. -// let mut successful_process = PathBuf::from(env!("CARGO_MANIFEST_DIR")); -// successful_process.push("target/debug/signer-path-success"); -// Command::cargo_bin("c2patool")? -// .arg(fixture_path("earth_apollo17.jpg")) -// .arg("--signer-path") -// .arg(&successful_process) -// .arg("--reserve-size") -// .arg("20248") -// .arg("--manifest") -// .arg("sample/test.json") -// .arg("-o") -// .arg(&output) -// .arg("-f") -// .assert() -// .success(); -// Ok(()) -// } -// #[test] -// fn test_fails_for_not_found_external_signer() -> Result<(), Box> { -// let output = temp_path("./output_external.jpg"); -// Command::cargo_bin("c2patool")? -// .arg(fixture_path("earth_apollo17.jpg")) -// .arg("--signer-path") -// .arg("./executable-not-found-test") -// .arg("--reserve-size") -// .arg("10248") -// .arg("--manifest") -// .arg("sample/test.json") -// .arg("-o") -// .arg(&output) -// .arg("-f") -// .assert() -// .stderr(str::contains("Failed to run command at")) -// .failure(); -// Ok(()) -// } -// #[test] -// fn test_fails_for_external_signer_failure() -> Result<(), Box> { -// let output = temp_path("./output_external.jpg"); -// let mut failing_process = PathBuf::from(env!("CARGO_MANIFEST_DIR")); -// failing_process.push("target/debug/signer-path-fail"); -// Command::cargo_bin("c2patool")? -// .arg(fixture_path("earth_apollo17.jpg")) -// .arg("--signer-path") -// .arg(&failing_process) -// .arg("--reserve-size") -// .arg("20248") -// .arg("--manifest") -// .arg("sample/test.json") -// .arg("-o") -// .arg(&output) -// .arg("-f") -// .assert() -// .stderr(str::contains("User supplied signer process failed")) -// // Ensures stderr from user executable is revealed to client. -// .stderr(str::contains("signer-path-fail-stderr")) -// .failure(); -// Ok(()) -// } -// #[test] -// fn test_fails_for_external_signer_success_without_stdout() -> Result<(), Box> { -// let output = temp_path("./output_external.jpg"); -// let mut failing_process = PathBuf::from(env!("CARGO_MANIFEST_DIR")); -// failing_process.push("target/debug/signer-path-no-stdout"); -// Command::cargo_bin("c2patool")? -// .arg(fixture_path("earth_apollo17.jpg")) -// .arg("--signer-path") -// .arg(&failing_process) -// .arg("--reserve-size") -// .arg("10248") -// .arg("--manifest") -// .arg("sample/test.json") -// .arg("-o") -// .arg(&output) -// .arg("-f") -// .assert() -// .stderr(str::contains("User supplied process succeeded, but the external process did not write signature bytes to stdout")) -// .failure(); -// Ok(()) -// } +#[test] +fn test_sign_using_c2patool_as_subprocess_signer() -> Result<(), Box> { + let output = temp_path("output_subprocess_signer.jpg"); + // "c2patool test-signer" implements the subprocess signing protocol: + // --signer-info returns the baked-in es256 cert; default mode signs stdin bytes. + let signer_cmd = format!("{} test-signer", cargo::cargo_bin!("c2patool").display()); + Command::new(cargo::cargo_bin!("c2patool")) + .arg(fixture_path(TEST_IMAGE)) + .arg("--signer-path") + .arg(&signer_cmd) + .arg("--manifest") + .arg("sample/test.json") + .arg("-o") + .arg(&output) + .arg("-f") + .assert() + .success(); + Ok(()) +} + +#[test] +fn test_sign_cawg_using_c2patool_as_identity_signer() -> Result<(), Box> { + let output = temp_path("output_cawg_identity_signer.jpg"); + // Both --signer-path and --identity-signer-path use "c2patool test-signer". + // --signer-info is called for each to discover the baked-in es256 cert and alg. + let signer_cmd = format!("{} test-signer", cargo::cargo_bin!("c2patool").display()); + Command::new(cargo::cargo_bin!("c2patool")) + .arg(fixture_path(TEST_IMAGE)) + .arg("--signer-path") + .arg(&signer_cmd) + .arg("--identity-signer-path") + .arg(&signer_cmd) + .arg("--manifest") + .arg("sample/test.json") + .arg("-o") + .arg(&output) + .arg("-f") + .assert() + .success() + .stdout(str::contains("cawg.identity")); + Ok(()) +} + +#[test] +fn test_fails_for_not_found_external_signer() -> Result<(), Box> { + let output = temp_path("output_not_found_signer.jpg"); + Command::new(cargo::cargo_bin!("c2patool")) + .arg(fixture_path(TEST_IMAGE)) + .arg("--signer-path") + .arg("./nonexistent-signer-binary-xyz") + .arg("--manifest") + .arg("sample/test.json") + .arg("-o") + .arg(&output) + .arg("-f") + .assert() + .failure() + .stderr(str::contains("Failed to run")); + Ok(()) +} + +#[test] +fn test_fails_for_external_signer_failure() -> Result<(), Box> { + let output = temp_path("output_failing_signer.jpg"); + // "c2patool test-signer --fail" exits with an error, exercising the failure path. + let signer_cmd = format!( + "{} test-signer --fail", + cargo::cargo_bin!("c2patool").display() + ); + Command::new(cargo::cargo_bin!("c2patool")) + .arg(fixture_path(TEST_IMAGE)) + .arg("--signer-path") + .arg(&signer_cmd) + .arg("--manifest") + .arg("sample/test.json") + .arg("-o") + .arg(&output) + .arg("-f") + .assert() + .failure() + .stderr(str::contains("deliberately failed")); + Ok(()) +} #[test] // With RUST_LOG unset, default level is `error` (see main) — debug! lines in configure_sdk must not appear. diff --git a/sdk/src/builder.rs b/sdk/src/builder.rs index e72e03d7f..fe32047d3 100644 --- a/sdk/src/builder.rs +++ b/sdk/src/builder.rs @@ -3361,7 +3361,7 @@ mod tests { #![allow(clippy::unwrap_used)] #![allow(deprecated)] use std::{ - io::{self, Cursor, Write}, + io::{self, Cursor}, vec, }; diff --git a/sdk/src/callback_signer.rs b/sdk/src/callback_signer.rs index 55743e42f..954d8dd0d 100644 --- a/sdk/src/callback_signer.rs +++ b/sdk/src/callback_signer.rs @@ -184,3 +184,121 @@ impl AsyncSigner for CallbackSigner { self.tsa_url.clone() } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use c2pa_macros::c2pa_test_async; + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + use crate::{AsyncSigner, Signer, SigningAlg}; + + const ED25519_CERTS: &[u8] = include_bytes!("../tests/fixtures/certs/ed25519.pub"); + const ED25519_PRIVATE_KEY: &[u8] = include_bytes!("../tests/fixtures/certs/ed25519.pem"); + + fn make_ed25519_signer() -> CallbackSigner { + let callback = + |_ctx: *const (), data: &[u8]| CallbackSigner::ed25519_sign(data, ED25519_PRIVATE_KEY); + CallbackSigner::new(callback, SigningAlg::Ed25519, ED25519_CERTS) + } + + #[test] + fn new_sets_expected_defaults() { + let signer = make_ed25519_signer(); + assert_eq!(signer.alg, SigningAlg::Ed25519); + assert_eq!(signer.tsa_url, None); + assert!(signer.context.is_null()); + assert!(!signer.certs.is_empty()); + assert!(signer.reserve_size >= 10000); + } + + #[test] + fn set_tsa_url_stores_url() { + let signer = make_ed25519_signer().set_tsa_url("http://timestamp.example.com"); + assert_eq!( + signer.tsa_url, + Some("http://timestamp.example.com".to_string()) + ); + assert_eq!( + Signer::time_authority_url(&signer), + Some("http://timestamp.example.com".to_string()) + ); + } + + #[test] + fn set_context_stores_pointer() { + let value: u32 = 42; + let ptr = &value as *const u32 as *const (); + let signer = make_ed25519_signer().set_context(ptr); + assert_eq!(signer.context, ptr); + } + + #[test] + fn ed25519_sign_produces_valid_signature() { + let data = b"test data to sign"; + let sig = CallbackSigner::ed25519_sign(data, ED25519_PRIVATE_KEY).unwrap(); + assert_eq!(sig.len(), 64); // Ed25519 signatures are always 64 bytes + } + + #[test] + fn signer_trait_sign_and_alg() { + let signer = make_ed25519_signer(); + assert_eq!(Signer::alg(&signer), SigningAlg::Ed25519); + let sig = Signer::sign(&signer, b"hello").unwrap(); + assert_eq!(sig.len(), 64); + } + + #[test] + fn signer_trait_certs_parses_pem() { + let signer = make_ed25519_signer(); + let certs = Signer::certs(&signer).unwrap(); + assert!(!certs.is_empty()); + } + + #[test] + fn signer_trait_reserve_size_is_reasonable() { + let signer = make_ed25519_signer(); + assert!(Signer::reserve_size(&signer) >= 10000); + } + + #[test] + fn default_callback_returns_unsupported_type() { + let signer = CallbackSigner::default(); + assert!(matches!( + Signer::sign(&signer, b"data"), + Err(crate::Error::UnsupportedType) + )); + } + + #[c2pa_test_async] + async fn async_signer_sign_produces_same_result() { + let signer = make_ed25519_signer(); + let data = b"async test data".to_vec(); + let sig = AsyncSigner::sign(&signer, data.clone()).await.unwrap(); + assert_eq!(sig.len(), 64); + // Verify async and sync produce identical signatures (Ed25519 is deterministic) + let sync_sig = Signer::sign(&signer, &data).unwrap(); + assert_eq!(sig, sync_sig); + } + + #[c2pa_test_async] + async fn async_signer_alg_and_certs_match_sync() { + let signer = make_ed25519_signer(); + assert_eq!(AsyncSigner::alg(&signer), Signer::alg(&signer)); + assert_eq!( + AsyncSigner::certs(&signer).unwrap(), + Signer::certs(&signer).unwrap() + ); + assert_eq!( + AsyncSigner::reserve_size(&signer), + Signer::reserve_size(&signer) + ); + assert_eq!( + AsyncSigner::time_authority_url(&signer), + Signer::time_authority_url(&signer) + ); + } +} diff --git a/sdk/src/create_signer.rs b/sdk/src/create_signer.rs index a46b5561f..f78703980 100644 --- a/sdk/src/create_signer.rs +++ b/sdk/src/create_signer.rs @@ -69,3 +69,91 @@ pub fn from_files>( from_keys(&cert_chain, &private_key, alg, tsa_url) } + +/// Creates a combined [`Signer`](crate::Signer) that signs the C2PA claim with +/// `c2pa_signer` and embeds an X.509 identity assertion signed by `identity_signer`. +/// +/// # Arguments +/// +/// * `c2pa_signer` - Signs the C2PA claim +/// * `identity_signer` - Signs the X.509 identity assertion (`cawg.x509.cose`) +/// * `referenced_assertions` - Assertion labels to include in the identity assertion +/// * `roles` - Named actor roles to attach to the identity assertion +pub fn from_x509_identity( + c2pa_signer: BoxedSigner, + identity_signer: BoxedSigner, + referenced_assertions: &[&str], + roles: &[&str], +) -> BoxedSigner { + Box::new( + crate::settings::signer::CawgX509IdentitySigner::from_signer( + c2pa_signer, + identity_signer, + referenced_assertions, + roles, + ), + ) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use std::io::{Cursor, Seek}; + + use c2pa_macros::c2pa_test_async; + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + use wasm_bindgen_test::wasm_bindgen_test; + + use crate::{ + crypto::raw_signature::SigningAlg, + identity::tests::fixtures::{manifest_json, parent_json}, + utils::test_signer::test_signer, + Builder, Reader, + }; + + const TEST_IMAGE: &[u8] = include_bytes!("../tests/fixtures/CA.jpg"); + const TEST_THUMBNAIL: &[u8] = include_bytes!("../tests/fixtures/thumbnail.jpg"); + + /// Verify that `from_x509_identity` produces a valid manifest containing + /// one X.509 identity assertion signed by the identity signer and + /// one valid C2PA claim signed by the C2PA signer. + #[c2pa_test_async] + async fn from_x509_identity_signs_and_validates() { + let format = "image/jpeg"; + let mut source = Cursor::new(TEST_IMAGE); + let mut dest = Cursor::new(Vec::new()); + + let mut builder = Builder::default().with_definition(manifest_json()).unwrap(); + builder + .add_ingredient_from_stream(parent_json(), format, &mut source) + .unwrap(); + builder + .add_resource("thumbnail.jpg", Cursor::new(TEST_THUMBNAIL)) + .unwrap(); + + let c2pa_signer = test_signer(SigningAlg::Ps256); + let identity_signer = test_signer(SigningAlg::Ed25519); + + let signer = + super::from_x509_identity(c2pa_signer, identity_signer, &["c2pa.actions"], &[]); + + builder + .sign(signer.as_ref(), format, &mut source, &mut dest) + .unwrap(); + + dest.rewind().unwrap(); + + let manifest_store = Reader::default().with_stream(format, &mut dest).unwrap(); + assert_eq!( + manifest_store.validation_state(), + crate::ValidationState::Trusted + ); + + let manifest = manifest_store.active_manifest().unwrap(); + assert!(manifest + .assertions() + .iter() + .any(|a| a.label().contains("cawg.identity"))); + } +} diff --git a/sdk/src/settings/signer.rs b/sdk/src/settings/signer.rs index b29b1416b..ee7f7f1ae 100644 --- a/sdk/src/settings/signer.rs +++ b/sdk/src/settings/signer.rs @@ -11,17 +11,25 @@ // specific language governing permissions and limitations under // each license. +use std::sync::Arc; + use http::Request; use serde::{Deserialize, Serialize}; use crate::{ create_signer, - crypto::raw_signature::RawSigner, + crypto::{ + raw_signature::{ + signer_from_cert_chain_and_private_key, RawSigner, RawSignerError, SigningAlg, + }, + time_stamp::{TimeStampError, TimeStampProvider}, + }, dynamic_assertion::DynamicAssertion, http::{SyncGenericResolver, SyncHttpResolver}, identity::{builder::IdentityAssertionBuilder, x509::X509CredentialHolder}, settings::{Settings, SettingsValidate}, - BoxedSigner, Error, Result, Signer, SigningAlg, + signer::OwnedSignerWrapper, + BoxedSigner, Error, Result, Signer, }; /// Settings for configuring a local or remote [`Signer`]. @@ -139,17 +147,16 @@ impl SignerSettings { referenced_assertions: cawg_referenced_assertions, roles: cawg_roles, } => { - let cawg_dual_signer = CawgX509IdentitySigner { + let signer = CawgX509IdentitySigner::from_settings( c2pa_signer, cawg_alg, - cawg_sign_cert, - cawg_private_key, + cawg_sign_cert.as_bytes(), + cawg_private_key.as_bytes(), cawg_tsa_url, - cawg_referenced_assertions: cawg_referenced_assertions.unwrap_or_default(), - cawg_roles: cawg_roles.unwrap_or_default(), - }; - - Ok(Box::new(cawg_dual_signer)) + cawg_referenced_assertions.unwrap_or_default(), + cawg_roles.unwrap_or_default(), + )?; + Ok(Box::new(signer)) } SignerSettings::Remote { @@ -170,17 +177,100 @@ impl SettingsValidate for SignerSettings { } } -struct CawgX509IdentitySigner { +/// Wraps an `Arc` so it can be passed as an owned `Box`. +struct ArcRawSigner(Arc); + +impl TimeStampProvider for ArcRawSigner { + fn time_stamp_service_url(&self) -> Option { + self.0.time_stamp_service_url() + } + + fn time_stamp_request_headers(&self) -> Option> { + self.0.time_stamp_request_headers() + } + + fn time_stamp_request_body( + &self, + message: &[u8], + ) -> std::result::Result, TimeStampError> { + self.0.time_stamp_request_body(message) + } + + fn send_time_stamp_request( + &self, + message: &[u8], + ) -> Option, TimeStampError>> { + self.0.send_time_stamp_request(message) + } +} + +impl RawSigner for ArcRawSigner { + fn sign(&self, data: &[u8]) -> std::result::Result, RawSignerError> { + self.0.sign(data) + } + + fn alg(&self) -> SigningAlg { + self.0.alg() + } + + fn cert_chain(&self) -> std::result::Result>, RawSignerError> { + self.0.cert_chain() + } + + fn reserve_size(&self) -> usize { + self.0.reserve_size() + } + + fn ocsp_response(&self) -> Option> { + self.0.ocsp_response() + } +} + +pub(crate) struct CawgX509IdentitySigner { c2pa_signer: BoxedSigner, - cawg_alg: SigningAlg, - cawg_sign_cert: String, - cawg_private_key: String, - cawg_tsa_url: Option, - cawg_referenced_assertions: Vec, - cawg_roles: Vec, - // NOTE: The CAWG signing settings are stored here because - // we can't clone or transfer ownership of an `X509CredentialHolder` - // inside the dynamic_assertions callback. + identity_signer: Arc, + referenced_assertions: Vec, + roles: Vec, +} + +impl CawgX509IdentitySigner { + /// Creates a combined signer from cert/key bytes for the identity signer. + pub(crate) fn from_settings( + c2pa_signer: BoxedSigner, + alg: SigningAlg, + sign_cert: &[u8], + private_key: &[u8], + tsa_url: Option, + referenced_assertions: Vec, + roles: Vec, + ) -> Result { + let raw_signer = + signer_from_cert_chain_and_private_key(sign_cert, private_key, alg, tsa_url)?; + Ok(Self { + c2pa_signer, + identity_signer: Arc::from(raw_signer), + referenced_assertions, + roles, + }) + } + + /// Creates a combined signer from an already-constructed identity [`Signer`]. + pub(crate) fn from_signer( + c2pa_signer: BoxedSigner, + identity_signer: BoxedSigner, + referenced_assertions: &[&str], + roles: &[&str], + ) -> Self { + Self { + c2pa_signer, + identity_signer: Arc::new(OwnedSignerWrapper(identity_signer)), + referenced_assertions: referenced_assertions + .iter() + .map(|s| s.to_string()) + .collect(), + roles: roles.iter().map(|s| s.to_string()).collect(), + } + } } impl Signer for CawgX509IdentitySigner { @@ -225,35 +315,23 @@ impl Signer for CawgX509IdentitySigner { } fn dynamic_assertions(&self) -> Vec> { - let Ok(raw_signer) = crate::crypto::raw_signature::signer_from_cert_chain_and_private_key( - self.cawg_sign_cert.as_bytes(), - self.cawg_private_key.as_bytes(), - self.cawg_alg, - self.cawg_tsa_url.clone(), - ) else { - // dynamic_assertions() API doesn't let us fail. - // signer_from_cert_chain_and_private_key rarely fails, - // so when it does, we do so silently. - return vec![]; - }; - - let x509_credential_holder = X509CredentialHolder::from_raw_signer(raw_signer); + let identity_signer: Box = + Box::new(ArcRawSigner(Arc::clone(&self.identity_signer))); + let x509_credential_holder = X509CredentialHolder::from_raw_signer(identity_signer); let mut iab = IdentityAssertionBuilder::for_credential_holder(x509_credential_holder); - // Add referenced assertions if configured - if !self.cawg_referenced_assertions.is_empty() { - let referenced_assertions: Vec<&str> = self - .cawg_referenced_assertions + if !self.referenced_assertions.is_empty() { + let refs: Vec<&str> = self + .referenced_assertions .iter() .map(|s| s.as_str()) .collect(); - iab.add_referenced_assertions(&referenced_assertions); + iab.add_referenced_assertions(&refs); } - // Add roles if configured - if !self.cawg_roles.is_empty() { - let roles: Vec<&str> = self.cawg_roles.iter().map(|s| s.as_str()).collect(); + if !self.roles.is_empty() { + let roles: Vec<&str> = self.roles.iter().map(|s| s.as_str()).collect(); iab.add_roles(&roles); } @@ -313,7 +391,7 @@ pub mod tests { #![allow(clippy::unwrap_used)] #![allow(clippy::expect_used)] - use crate::{settings::Settings, utils::test_signer, SigningAlg}; + use crate::{settings::Settings, utils::test_signer, Signer, SigningAlg}; #[cfg(not(target_arch = "wasm32"))] fn remote_signer_mock_server<'a>( @@ -359,6 +437,85 @@ pub mod tests { assert!(signer.sign(&[1, 2, 3]).is_ok()); } + #[test] + fn test_make_cawg_local_signer_from_settings() { + let alg = SigningAlg::Ed25519; + let (sign_cert, private_key) = test_signer::cert_chain_and_private_key_for_alg(alg); + + let settings = Settings::new() + .with_toml( + &toml::toml! { + [signer.local] + alg = (alg.to_string()) + sign_cert = (String::from_utf8(sign_cert.to_vec()).unwrap()) + private_key = (String::from_utf8(private_key.to_vec()).unwrap()) + + [cawg_x509_signer.local] + alg = (alg.to_string()) + sign_cert = (String::from_utf8(sign_cert.to_vec()).unwrap()) + private_key = (String::from_utf8(private_key.to_vec()).unwrap()) + referenced_assertions = ["c2pa.actions"] + roles = ["creator"] + } + .to_string(), + ) + .unwrap(); + + let c2pa_settings = settings.signer.expect("signer settings should be present"); + let c2pa_signer = c2pa_settings.c2pa_signer().unwrap(); + + let cawg_settings = settings + .cawg_x509_signer + .expect("cawg signer settings should be present"); + let combined = cawg_settings.cawg_signer(c2pa_signer).unwrap(); + + // Verify the combined signer delegates alg/certs to the underlying c2pa signer. + assert_eq!(combined.alg(), alg); + assert!(!combined.certs().unwrap().is_empty()); + // The combined signer produces dynamic assertions (the identity assertion builder). + assert_eq!(combined.dynamic_assertions().len(), 1); + } + + #[test] + fn test_cawg_identity_signer_from_signer_path() { + use crate::{create_signer, settings::signer::CawgX509IdentitySigner, Signer}; + + let alg = SigningAlg::Ps256; + let (sign_cert, private_key) = test_signer::cert_chain_and_private_key_for_alg(alg); + + let c2pa_signer = create_signer::from_keys(sign_cert, private_key, alg, None).unwrap(); + let identity_signer = create_signer::from_keys(sign_cert, private_key, alg, None).unwrap(); + + let combined = CawgX509IdentitySigner::from_signer( + c2pa_signer, + identity_signer, + &["c2pa.actions"], + &["creator"], + ); + + assert_eq!(combined.alg(), alg); + assert!(!combined.certs().unwrap().is_empty()); + assert_eq!(combined.dynamic_assertions().len(), 1); + // Sign delegates to c2pa_signer, so it should succeed with valid data. + assert!(combined.sign(b"test data").is_ok()); + } + + #[test] + fn test_cawg_signer_no_referenced_assertions_or_roles() { + use crate::{create_signer, settings::signer::CawgX509IdentitySigner}; + + let alg = SigningAlg::Ps256; + let (sign_cert, private_key) = test_signer::cert_chain_and_private_key_for_alg(alg); + + let c2pa_signer = create_signer::from_keys(sign_cert, private_key, alg, None).unwrap(); + let identity_signer = create_signer::from_keys(sign_cert, private_key, alg, None).unwrap(); + + let combined = CawgX509IdentitySigner::from_signer(c2pa_signer, identity_signer, &[], &[]); + + // dynamic_assertions still returns one builder even with empty refs/roles. + assert_eq!(combined.dynamic_assertions().len(), 1); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn test_make_remote_signer() { diff --git a/sdk/src/signer.rs b/sdk/src/signer.rs index ffa8ee121..cda722386 100644 --- a/sdk/src/signer.rs +++ b/sdk/src/signer.rs @@ -346,7 +346,6 @@ impl RawSigner for Box { } fn ocsp_response(&self) -> Option> { - eprintln!("HUH, A DIFFERENT I WANTED @ 397"); self.as_ref().ocsp_val() } } @@ -479,3 +478,64 @@ impl Signer for RawSignerWrapper { Some(Box::new(&*self.0)) } } + +/// Adapts an owned [`BoxedSigner`] to implement [`RawSigner`]. +/// +/// This is the reverse of [`RawSignerWrapper`]: it allows a `BoxedSigner` to be +/// used wherever a `Box` is expected. +#[allow(dead_code)] +pub(crate) struct OwnedSignerWrapper(pub(crate) BoxedSigner); + +// SAFETY: WASM is single-threaded; no concurrent access is possible. +#[cfg(target_arch = "wasm32")] +unsafe impl Send for OwnedSignerWrapper {} +#[cfg(target_arch = "wasm32")] +unsafe impl Sync for OwnedSignerWrapper {} + +impl RawSigner for OwnedSignerWrapper { + fn sign(&self, data: &[u8]) -> std::result::Result, RawSignerError> { + Ok(Signer::sign(self.0.as_ref(), data)?) + } + + fn alg(&self) -> SigningAlg { + Signer::alg(self.0.as_ref()) + } + + fn cert_chain(&self) -> std::result::Result>, RawSignerError> { + Ok(self.0.certs()?) + } + + fn reserve_size(&self) -> usize { + Signer::reserve_size(self.0.as_ref()) + } + + fn ocsp_response(&self) -> Option> { + self.0.ocsp_val() + } +} + +impl TimeStampProvider for OwnedSignerWrapper { + fn time_stamp_service_url(&self) -> Option { + self.0.time_authority_url() + } + + fn time_stamp_request_headers(&self) -> Option> { + self.0.timestamp_request_headers() + } + + fn time_stamp_request_body( + &self, + message: &[u8], + ) -> std::result::Result, TimeStampError> { + Ok(self.0.timestamp_request_body(message)?) + } + + fn send_time_stamp_request( + &self, + message: &[u8], + ) -> Option, TimeStampError>> { + self.0 + .send_timestamp_request(message) + .map(|r| r.map_err(|e| e.into())) + } +}