use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use encypher_c2pa::{
detached_manifest_evidence, mime_from_path, set_telemetry_enabled, supported_mime_types,
telemetry_preference, verify_file, verify_fragmented_with_options, verify_with_options, Error,
TelemetryOptions, VerifyOptions,
};
mod encypher_api;
const MAX_PATH_ASSET_BYTES: u64 = 128 * 1024 * 1024;
#[derive(Debug, Parser)]
#[command(name = "encypher-c2pa", version, about = "Local C2PA verification")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum TelemetrySetting {
On,
Off,
Status,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Subcommand)]
enum Command {
Verify {
asset: PathBuf,
#[arg(long)]
mime: Option<String>,
#[arg(long, value_name = "FILE", conflicts_with = "encypher_api")]
fragment: Vec<PathBuf>,
#[arg(long, value_name = "PEM")]
trust: Vec<PathBuf>,
#[arg(long, value_name = "PEM")]
tsa_trust: Vec<PathBuf>,
#[arg(long, visible_alias = "allowed-certs", value_name = "PEM")]
allowed: Vec<PathBuf>,
#[arg(long, value_name = "PEM")]
cawg_trust: Vec<PathBuf>,
#[arg(long, value_name = "PEM")]
cawg_allowed: Vec<PathBuf>,
#[arg(long)]
no_default_trust: bool,
#[arg(long, value_name = "JSON")]
cawg_did_documents: Vec<PathBuf>,
#[arg(long)]
cawg_strict_encoding: bool,
#[arg(long, visible_alias = "validation-time", value_name = "RFC3339")]
time: Option<String>,
#[arg(long)]
telemetry: bool,
#[arg(long, conflicts_with = "telemetry")]
no_telemetry: bool,
#[arg(long, value_name = "URL")]
telemetry_endpoint: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
encypher_api: bool,
#[arg(long, value_name = "URL", hide = true)]
encypher_api_endpoint: Option<String>,
},
Telemetry {
#[arg(value_enum)]
setting: TelemetrySetting,
},
Formats {
#[arg(long)]
json: bool,
},
Explain { code: String },
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(code) => code,
Err(error) => {
eprintln!("{}: {error}", error.code());
if matches!(error, Error::UnsupportedMime(_)) {
ExitCode::from(3)
} else {
ExitCode::FAILURE
}
}
}
}
fn run(cli: Cli) -> Result<ExitCode, Error> {
match cli.command {
Command::Verify {
asset,
mime,
fragment,
trust,
tsa_trust,
allowed,
cawg_trust,
cawg_allowed,
no_default_trust,
cawg_did_documents,
cawg_strict_encoding,
time,
telemetry,
no_telemetry,
telemetry_endpoint,
json,
encypher_api,
encypher_api_endpoint,
} => {
let explicit_telemetry = if telemetry {
Some(true)
} else if no_telemetry {
Some(false)
} else {
None
};
if let Some(enabled) = explicit_telemetry {
if let Err(error) = set_telemetry_enabled(enabled) {
eprintln!(
"warning: could not save telemetry preference ({error}); \
telemetry stays {} for this run",
if enabled { "enabled" } else { "disabled" }
);
}
}
let options = VerifyOptions {
trust_pem: read_merged_pem(&trust)?,
tsa_trust_pem: read_merged_pem(&tsa_trust)?,
allowed_list_pem: read_merged_pem(&allowed)?,
cawg_trust_pem: read_merged_pem(&cawg_trust)?,
cawg_allowed_certs_pem: read_merged_pem(&cawg_allowed)?,
no_default_trust,
cawg_did_documents: read_did_documents(&cawg_did_documents)?,
cawg_strict_encoding,
strict_conformance: false,
validation_time: time,
telemetry: TelemetryOptions {
enabled: explicit_telemetry,
endpoint: telemetry_endpoint,
sdk_name: Some("cli".to_string()),
},
};
let (report, encypher_api_result) = if encypher_api {
let mime = match mime {
Some(value) => value,
None => mime_from_path(&asset)
.ok_or_else(|| Error::UnsupportedMime(asset.display().to_string()))?
.to_string(),
};
let bytes = read_path_asset(&asset)?;
let report = verify_with_options(&bytes, &mime, &options)?;
let evidence = detached_manifest_evidence(&bytes, &mime)?;
let endpoint = encypher_api_endpoint
.unwrap_or_else(|| encypher_api::DEFAULT_ENDPOINT.to_string());
let api_key = std::env::var("ENCYPHER_API_KEY")
.or_else(|_| std::env::var("ENCYPHER_API_TOKEN"))
.ok();
let result = encypher_api::verify(
&endpoint,
api_key.as_deref(),
&bytes,
&mime,
&report,
evidence.as_ref(),
);
(report, Some(result))
} else if fragment.is_empty() {
(verify_file(&asset, mime.as_deref(), &options)?, None)
} else {
let mime = match mime {
Some(value) => value,
None => mime_from_path(&asset)
.ok_or_else(|| Error::UnsupportedMime(asset.display().to_string()))?
.to_string(),
};
let init_segment = read_path_asset(&asset)?;
let fragment_bytes: Vec<Vec<u8>> = fragment
.iter()
.map(|path| read_path_asset(path))
.collect::<Result<_, _>>()?;
let fragment_refs: Vec<&[u8]> = fragment_bytes.iter().map(Vec::as_slice).collect();
(
verify_fragmented_with_options(&init_segment, &fragment_refs, &mime, &options)?,
None,
)
};
if json {
if let Some(lookup) = &encypher_api_result {
let mut value: serde_json::Value =
serde_json::from_str(&report.to_pretty_json()?)
.map_err(Error::Serialize)?;
if let Some(object) = value.as_object_mut() {
object.insert("encypher_api".to_string(), lookup.clone());
}
println!(
"{}",
serde_json::to_string_pretty(&value).map_err(Error::Serialize)?
);
} else {
println!("{}", report.to_pretty_json()?);
}
} else {
println!("asset: {}", asset.display());
println!("profile: {}", report.profile);
println!(
"provenance: {}",
if report.present { "present" } else { "absent" }
);
println!("integrity: {}", report.integrity);
println!("signature: {}", report.signature);
println!("hard binding: {}", report.hard_binding);
println!("trust: {} ({})", report.trust.status, report.trust.basis);
if !report.validation_results.failure.is_empty() {
println!("failures:");
for status in &report.validation_results.failure {
println!(" {}: {}", status.code, status.explanation);
}
}
println!("docs: https://encypher.com/c2pa/codes");
if let Some(lookup) = &encypher_api_result {
encypher_api::render_human(lookup);
}
}
Ok(if report.integrity == "valid" {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
})
}
Command::Telemetry { setting } => {
match setting {
TelemetrySetting::On => {
set_telemetry_enabled(true)?;
println!("Failure telemetry enabled.");
}
TelemetrySetting::Off => {
set_telemetry_enabled(false)?;
println!("Failure telemetry disabled.");
}
TelemetrySetting::Status => match telemetry_preference()? {
Some(true) => println!("Failure telemetry is enabled."),
Some(false) => println!("Failure telemetry is disabled."),
None => println!("Failure telemetry preference is not set."),
},
}
Ok(ExitCode::SUCCESS)
}
Command::Formats { json } => {
let formats = supported_mime_types();
if json {
println!(
"{}",
serde_json::to_string_pretty(&formats).map_err(Error::Serialize)?
);
} else {
for mime in formats {
println!("{mime}");
}
}
Ok(ExitCode::SUCCESS)
}
Command::Explain { code } => {
let explanation = explain(&code).ok_or_else(|| {
Error::Verification(format!("unknown validation status code: {code}"))
})?;
println!("{code}: {explanation}");
println!("details: https://encypher.com/c2pa/codes/{code}");
Ok(ExitCode::SUCCESS)
}
}
}
fn read_merged_pem(paths: &[PathBuf]) -> Result<Option<String>, Error> {
if paths.is_empty() {
return Ok(None);
}
let mut pem = String::new();
for path in paths {
pem.push_str(&fs::read_to_string(path)?);
if !pem.ends_with('\n') {
pem.push('\n');
}
}
Ok(Some(pem))
}
fn read_path_asset(path: &Path) -> io::Result<Vec<u8>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC);
let mut file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("asset path is not a regular file: {}", path.display()),
));
}
if metadata.len() > MAX_PATH_ASSET_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("asset exceeds the 128 MiB path limit: {}", path.display()),
));
}
let expected_len = usize::try_from(metadata.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "asset size is not addressable"))?;
let mut data = vec![0_u8; expected_len + 1];
let mut used = 0;
while used < expected_len {
let count = file.read(&mut data[used..expected_len])?;
if count == 0 {
break;
}
used += count;
}
if used == expected_len && file.read(&mut data[expected_len..expected_len + 1])? != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"asset grew while being read",
));
}
data.truncate(used);
Ok(data)
}
fn read_did_documents(
paths: &[PathBuf],
) -> Result<Option<HashMap<String, serde_json::Value>>, Error> {
if paths.is_empty() {
return Ok(None);
}
let mut store = HashMap::new();
fn insert_doc(
doc: serde_json::Value,
path: &std::path::Path,
store: &mut HashMap<String, serde_json::Value>,
) -> Result<(), Error> {
let id = doc
.get("id")
.and_then(|value| value.as_str())
.filter(|id| id.starts_with("did:"))
.ok_or_else(|| {
Error::Verification(format!(
"cawg did documents: {}: document lacks a DID `id`",
path.display()
))
})?
.to_string();
store.insert(id.split('#').next().unwrap_or(&id).to_string(), doc);
Ok(())
}
for path in paths {
let contents = fs::read_to_string(path)?;
let parsed: serde_json::Value = serde_json::from_str(&contents).map_err(|error| {
Error::Verification(format!("cawg did documents: {}: {error}", path.display()))
})?;
match parsed {
serde_json::Value::Array(docs) => {
for doc in docs {
insert_doc(doc, path, &mut store)?;
}
}
doc @ serde_json::Value::Object(_) if doc.get("id").is_some() => {
insert_doc(doc, path, &mut store)?;
}
serde_json::Value::Object(map) => {
for (did, doc) in map {
if !did.starts_with("did:") {
return Err(Error::Verification(format!(
"cawg did documents: {}: key {did:?} is not a DID",
path.display()
)));
}
store.insert(did.split('#').next().unwrap_or(&did).to_string(), doc);
}
}
_ => {
return Err(Error::Verification(format!(
"cawg did documents: {}: expected a DID document, array, or DID->document map",
path.display()
)))
}
}
}
Ok(Some(store))
}
fn explain(code: &str) -> Option<&'static str> {
Some(match code {
"claimSignature.validated" => "The active claim signature is cryptographically valid.",
"claimSignature.mismatch" => "The active claim signature does not verify.",
"assertion.hashedURI.match" => "A claim reference matches the exact assertion bytes.",
"assertion.hashedURI.mismatch" => {
"A referenced assertion changed or is not the referenced bytes."
}
"assertion.dataHash.match" => "The asset bytes match the signed data-hash assertion.",
"assertion.dataHash.mismatch" => {
"The asset bytes do not match the signed data-hash assertion."
}
"assertion.bmffHash.match" => "The BMFF boxes match the signed box-hash assertion.",
"assertion.bmffHash.mismatch" => {
"The BMFF boxes do not match the signed box-hash assertion."
}
"signingCredential.trusted" => "The signer chains to configured trust material.",
"signingCredential.untrusted" => "The signer does not chain to configured trust material.",
"signingCredential.ocsp.revoked" => {
"Supplied revocation evidence marks the signer as revoked."
}
"claim.missing" => "No readable active C2PA claim is present.",
"ingredient.manifest.missing" => {
"An ingredient points to a manifest absent from the store."
}
_ => return None,
})
}