FR-01: Fix data directory default from ~/.fieldwitness to ~/.fwmetadata
FR-02/05/07: Accept all file types for attestation (not just images)
- Web UI, CLI, and batch now accept PDFs, CSVs, audio, video, etc.
- Perceptual hashing for images, SHA-256-only for everything else
FR-03: Implement C2PA import path + CLI commands (export/verify/import/show)
FR-04: Fix GPS downsampling bias (math.floor → round)
FR-06: Add HTML/PDF evidence summaries for lawyers
- Always generates summary.html, optional summary.pdf via xhtml2pdf
FR-08: Fix CLI help text ("FieldWitness -- FieldWitness" artifact)
FR-09: Centralize stray paths (trusted_keys, carrier_history, last_backup)
FR-10: Add 67 C2PA bridge tests (vendor assertions, cert, GPS, export)
FR-12: Add Tor onion service support for source drop box
- fieldwitness serve --tor flag, persistent/transient modes
- Killswitch covers hidden service keys
Also: bonus fix for attest/api.py hardcoded path bypassing paths.py
224 tests passing (67 new).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
822 lines
32 KiB
Python
822 lines
32 KiB
Python
"""
|
|
Comprehensive tests for the C2PA bridge.
|
|
|
|
Tests are split into three sections:
|
|
1. vendor_assertions.py -- pure dict I/O, no c2pa-python required.
|
|
2. cert.py -- X.509 certificate generation, no c2pa-python required.
|
|
3. export.py -- GPS downsampling and assertion building; the full
|
|
Builder.sign() path is skipped when c2pa-python is absent.
|
|
|
|
c2pa-python-dependent tests are guarded with pytest.importorskip("c2pa") so the
|
|
suite stays green in environments that only have the base [dev] extras.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
from fieldwitness.attest.models import (
|
|
AttestationRecord,
|
|
CaptureDevice,
|
|
CaptureMetadata,
|
|
GeoLocation,
|
|
ImageHashes,
|
|
)
|
|
from fieldwitness.c2pa_bridge.cert import (
|
|
_CERT_VALIDITY_YEARS,
|
|
_generate_cert,
|
|
get_or_create_c2pa_cert,
|
|
)
|
|
from fieldwitness.c2pa_bridge.export import (
|
|
_build_exif_assertion,
|
|
_downsample_gps,
|
|
)
|
|
from fieldwitness.c2pa_bridge.vendor_assertions import (
|
|
DEFAULT_PHASH_THRESHOLD,
|
|
SCHEMA_VERSION,
|
|
build_attestation_id_assertion,
|
|
build_chain_record_assertion,
|
|
build_perceptual_hashes_assertion,
|
|
parse_attestation_id_assertion,
|
|
parse_chain_record_assertion,
|
|
parse_perceptual_hashes_assertion,
|
|
)
|
|
from fieldwitness.config import FieldWitnessConfig
|
|
from fieldwitness.federation.models import AttestationChainRecord, EntropyWitnesses
|
|
|
|
# ── Shared helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_hashes(
|
|
sha256: str = "a" * 64,
|
|
phash: str = "abc123",
|
|
dhash: str = "def456",
|
|
ahash: str | None = "ghi789",
|
|
colorhash: str | None = "jkl012",
|
|
crop_resistant: str | None = "mno345",
|
|
) -> ImageHashes:
|
|
return ImageHashes(
|
|
sha256=sha256,
|
|
phash=phash,
|
|
dhash=dhash,
|
|
ahash=ahash,
|
|
colorhash=colorhash,
|
|
crop_resistant=crop_resistant,
|
|
)
|
|
|
|
|
|
def _make_record(
|
|
hashes: ImageHashes | None = None,
|
|
fingerprint: str = "fp" * 16,
|
|
metadata: dict[str, Any] | None = None,
|
|
content_type: str = "image",
|
|
) -> AttestationRecord:
|
|
if hashes is None:
|
|
hashes = _make_hashes()
|
|
key = Ed25519PrivateKey.generate()
|
|
sig = key.sign(b"dummy")
|
|
return AttestationRecord(
|
|
image_hashes=hashes,
|
|
signature=sig,
|
|
attestor_fingerprint=fingerprint,
|
|
timestamp=datetime.datetime(2024, 3, 15, 12, 0, 0, tzinfo=datetime.UTC),
|
|
metadata=metadata or {},
|
|
content_type=content_type,
|
|
)
|
|
|
|
|
|
def _make_chain_record(
|
|
chain_index: int = 0,
|
|
with_entropy: bool = True,
|
|
) -> AttestationChainRecord:
|
|
ew = (
|
|
EntropyWitnesses(
|
|
sys_uptime=3600.5,
|
|
fs_snapshot=b"\xab" * 16,
|
|
proc_entropy=42,
|
|
boot_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
)
|
|
if with_entropy
|
|
else None
|
|
)
|
|
return AttestationChainRecord(
|
|
version=1,
|
|
record_id=bytes(range(16)),
|
|
chain_index=chain_index,
|
|
prev_hash=b"\x00" * 32,
|
|
content_hash=b"\xff" * 32,
|
|
content_type="soosef/attestation-v1",
|
|
claimed_ts=1710504000_000000,
|
|
entropy_witnesses=ew,
|
|
signer_pubkey=b"\x11" * 32,
|
|
signature=b"\x22" * 64,
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Section 1: vendor_assertions.py
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestBuildPerceptualHashesAssertion:
|
|
def test_all_hash_types_present(self) -> None:
|
|
hashes = _make_hashes()
|
|
payload = build_perceptual_hashes_assertion(hashes)
|
|
|
|
assert payload["schema_version"] == SCHEMA_VERSION
|
|
assert payload["sha256"] == hashes.sha256
|
|
assert payload["phash"] == hashes.phash
|
|
assert payload["dhash"] == hashes.dhash
|
|
assert payload["ahash"] == hashes.ahash
|
|
assert payload["colorhash"] == hashes.colorhash
|
|
assert payload["crop_resistant"] == hashes.crop_resistant
|
|
assert payload["threshold"] == DEFAULT_PHASH_THRESHOLD
|
|
|
|
def test_missing_optional_hashes_omitted(self) -> None:
|
|
hashes = ImageHashes(sha256="b" * 64, phash="", dhash="")
|
|
payload = build_perceptual_hashes_assertion(hashes)
|
|
|
|
assert "phash" not in payload
|
|
assert "dhash" not in payload
|
|
assert "ahash" not in payload
|
|
assert "colorhash" not in payload
|
|
assert "crop_resistant" not in payload
|
|
assert payload["sha256"] == "b" * 64
|
|
|
|
def test_custom_threshold(self) -> None:
|
|
hashes = _make_hashes()
|
|
payload = build_perceptual_hashes_assertion(hashes, threshold=5)
|
|
assert payload["threshold"] == 5
|
|
|
|
def test_schema_version_is_v1(self) -> None:
|
|
payload = build_perceptual_hashes_assertion(_make_hashes())
|
|
assert payload["schema_version"] == "v1"
|
|
|
|
|
|
class TestParsePerceptualHashesAssertion:
|
|
def test_round_trip_all_hashes(self) -> None:
|
|
hashes = _make_hashes()
|
|
original = build_perceptual_hashes_assertion(hashes)
|
|
parsed = parse_perceptual_hashes_assertion(original)
|
|
|
|
assert parsed["schema_version"] == "v1"
|
|
assert parsed["sha256"] == hashes.sha256
|
|
assert parsed["phash"] == hashes.phash
|
|
assert parsed["dhash"] == hashes.dhash
|
|
assert parsed["ahash"] == hashes.ahash
|
|
assert parsed["colorhash"] == hashes.colorhash
|
|
assert parsed["crop_resistant"] == hashes.crop_resistant
|
|
|
|
def test_round_trip_missing_optional_hashes(self) -> None:
|
|
hashes = ImageHashes(sha256="c" * 64)
|
|
original = build_perceptual_hashes_assertion(hashes)
|
|
parsed = parse_perceptual_hashes_assertion(original)
|
|
|
|
assert parsed["sha256"] == "c" * 64
|
|
assert "phash" not in parsed
|
|
assert "ahash" not in parsed
|
|
|
|
def test_missing_schema_version_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="schema_version"):
|
|
parse_perceptual_hashes_assertion({"sha256": "abc"})
|
|
|
|
def test_unknown_schema_version_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="unrecognised schema_version"):
|
|
parse_perceptual_hashes_assertion({"schema_version": "v99", "sha256": "abc"})
|
|
|
|
def test_threshold_defaults_when_absent(self) -> None:
|
|
parsed = parse_perceptual_hashes_assertion({"schema_version": "v1", "sha256": "abc"})
|
|
assert parsed["threshold"] == DEFAULT_PHASH_THRESHOLD
|
|
|
|
|
|
class TestBuildChainRecordAssertion:
|
|
def test_basic_fields_present(self) -> None:
|
|
cr = _make_chain_record()
|
|
payload = build_chain_record_assertion(cr)
|
|
|
|
assert payload["schema_version"] == "v1"
|
|
assert payload["chain_index"] == cr.chain_index
|
|
assert payload["record_id"] == cr.record_id.hex()
|
|
assert payload["content_hash"] == cr.content_hash.hex()
|
|
assert payload["content_type"] == cr.content_type
|
|
assert payload["claimed_ts_us"] == cr.claimed_ts
|
|
assert payload["prev_hash"] == cr.prev_hash.hex()
|
|
assert payload["signer_pubkey"] == cr.signer_pubkey.hex()
|
|
assert payload["signature"] == cr.signature.hex()
|
|
assert payload["version"] == cr.version
|
|
|
|
def test_entropy_witnesses_embedded(self) -> None:
|
|
cr = _make_chain_record(with_entropy=True)
|
|
payload = build_chain_record_assertion(cr)
|
|
|
|
ew = payload["entropy_witnesses"]
|
|
assert ew["sys_uptime"] == cr.entropy_witnesses.sys_uptime # type: ignore[union-attr]
|
|
assert ew["fs_snapshot"] == cr.entropy_witnesses.fs_snapshot.hex() # type: ignore[union-attr]
|
|
assert ew["proc_entropy"] == cr.entropy_witnesses.proc_entropy # type: ignore[union-attr]
|
|
assert ew["boot_id"] == cr.entropy_witnesses.boot_id # type: ignore[union-attr]
|
|
|
|
def test_entropy_witnesses_absent_when_none(self) -> None:
|
|
cr = _make_chain_record(with_entropy=False)
|
|
payload = build_chain_record_assertion(cr)
|
|
assert "entropy_witnesses" not in payload
|
|
|
|
def test_inclusion_proof_embedded(self) -> None:
|
|
cr = _make_chain_record()
|
|
proof = MagicMock()
|
|
proof.leaf_hash = "aabbcc"
|
|
proof.leaf_index = 7
|
|
proof.tree_size = 100
|
|
proof.proof_hashes = ["d1", "e2"]
|
|
proof.root_hash = "ff00ff"
|
|
|
|
payload = build_chain_record_assertion(cr, inclusion_proof=proof)
|
|
|
|
ip = payload["inclusion_proof"]
|
|
assert ip["leaf_hash"] == "aabbcc"
|
|
assert ip["leaf_index"] == 7
|
|
assert ip["tree_size"] == 100
|
|
assert ip["proof_hashes"] == ["d1", "e2"]
|
|
assert ip["root_hash"] == "ff00ff"
|
|
|
|
def test_no_inclusion_proof_by_default(self) -> None:
|
|
cr = _make_chain_record()
|
|
payload = build_chain_record_assertion(cr)
|
|
assert "inclusion_proof" not in payload
|
|
|
|
def test_schema_version_is_v1(self) -> None:
|
|
payload = build_chain_record_assertion(_make_chain_record())
|
|
assert payload["schema_version"] == "v1"
|
|
|
|
|
|
class TestParseChainRecordAssertion:
|
|
def test_round_trip_with_entropy(self) -> None:
|
|
cr = _make_chain_record(with_entropy=True)
|
|
original = build_chain_record_assertion(cr)
|
|
parsed = parse_chain_record_assertion(original)
|
|
|
|
assert parsed["schema_version"] == "v1"
|
|
assert parsed["chain_index"] == cr.chain_index
|
|
assert parsed["record_id"] == cr.record_id.hex()
|
|
assert parsed["content_hash"] == cr.content_hash.hex()
|
|
assert "entropy_witnesses" in parsed
|
|
|
|
def test_round_trip_without_entropy(self) -> None:
|
|
cr = _make_chain_record(with_entropy=False)
|
|
original = build_chain_record_assertion(cr)
|
|
parsed = parse_chain_record_assertion(original)
|
|
|
|
assert parsed["schema_version"] == "v1"
|
|
assert "entropy_witnesses" not in parsed
|
|
|
|
def test_round_trip_with_inclusion_proof(self) -> None:
|
|
cr = _make_chain_record()
|
|
proof = MagicMock()
|
|
proof.leaf_hash = "aabbcc"
|
|
proof.leaf_index = 3
|
|
proof.tree_size = 50
|
|
proof.proof_hashes = ["x1"]
|
|
proof.root_hash = "rootroot"
|
|
|
|
original = build_chain_record_assertion(cr, inclusion_proof=proof)
|
|
parsed = parse_chain_record_assertion(original)
|
|
|
|
assert "inclusion_proof" in parsed
|
|
assert parsed["inclusion_proof"]["leaf_index"] == 3
|
|
|
|
def test_missing_schema_version_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="schema_version"):
|
|
parse_chain_record_assertion({"chain_index": 0})
|
|
|
|
def test_unknown_schema_version_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="unrecognised schema_version"):
|
|
parse_chain_record_assertion({"schema_version": "v2", "chain_index": 0})
|
|
|
|
|
|
class TestBuildAttestationIdAssertion:
|
|
def test_basic_fields(self) -> None:
|
|
payload = build_attestation_id_assertion(
|
|
record_id="abc123",
|
|
attestor_fingerprint="fp001",
|
|
content_type="image",
|
|
)
|
|
assert payload["schema_version"] == "v1"
|
|
assert payload["record_id"] == "abc123"
|
|
assert payload["attestor_fingerprint"] == "fp001"
|
|
assert payload["content_type"] == "image"
|
|
|
|
def test_default_content_type(self) -> None:
|
|
payload = build_attestation_id_assertion(
|
|
record_id="xyz",
|
|
attestor_fingerprint="fp002",
|
|
)
|
|
assert payload["content_type"] == "image"
|
|
|
|
def test_schema_version_is_v1(self) -> None:
|
|
payload = build_attestation_id_assertion("r", "f")
|
|
assert payload["schema_version"] == "v1"
|
|
|
|
def test_document_content_type(self) -> None:
|
|
payload = build_attestation_id_assertion("r", "f", content_type="document")
|
|
assert payload["content_type"] == "document"
|
|
|
|
|
|
class TestParseAttestationIdAssertion:
|
|
def test_round_trip(self) -> None:
|
|
original = build_attestation_id_assertion("rec01", "finger01", "audio")
|
|
parsed = parse_attestation_id_assertion(original)
|
|
|
|
assert parsed["schema_version"] == "v1"
|
|
assert parsed["record_id"] == "rec01"
|
|
assert parsed["attestor_fingerprint"] == "finger01"
|
|
assert parsed["content_type"] == "audio"
|
|
|
|
def test_missing_schema_version_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="schema_version"):
|
|
parse_attestation_id_assertion({"record_id": "x"})
|
|
|
|
def test_unknown_schema_version_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="unrecognised schema_version"):
|
|
parse_attestation_id_assertion({"schema_version": "beta", "record_id": "x"})
|
|
|
|
def test_missing_optional_fields_use_defaults(self) -> None:
|
|
parsed = parse_attestation_id_assertion({"schema_version": "v1"})
|
|
assert parsed["record_id"] == ""
|
|
assert parsed["attestor_fingerprint"] == ""
|
|
assert parsed["content_type"] == "image"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Section 2: cert.py
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _load_cert(pem: bytes | str) -> x509.Certificate:
|
|
if isinstance(pem, str):
|
|
pem = pem.encode()
|
|
return x509.load_pem_x509_certificate(pem)
|
|
|
|
|
|
class TestGenerateCert:
|
|
def test_org_privacy_level_cn(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig(cover_name="Acme Newsroom")
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
|
assert "Acme Newsroom" in cn
|
|
|
|
def test_org_privacy_level_cn_fallback(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig(cover_name="")
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
|
assert "FieldWitness" in cn
|
|
|
|
def test_pseudonym_privacy_level_cn(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig(cover_name="NightOwl")
|
|
pem = _generate_cert(key, config, "pseudonym")
|
|
cert = _load_cert(pem)
|
|
|
|
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
|
assert cn == "NightOwl"
|
|
|
|
def test_pseudonym_privacy_level_cn_fallback(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig(cover_name="")
|
|
pem = _generate_cert(key, config, "pseudonym")
|
|
cert = _load_cert(pem)
|
|
|
|
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
|
assert cn == "FieldWitness User"
|
|
|
|
def test_anonymous_privacy_level_cn(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig(cover_name="ShouldNotAppear")
|
|
pem = _generate_cert(key, config, "anonymous")
|
|
cert = _load_cert(pem)
|
|
|
|
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
|
# Anonymous certs must not reveal org/cover name
|
|
assert cn == "FieldWitness"
|
|
assert "ShouldNotAppear" not in cn
|
|
|
|
def test_unrecognised_privacy_level_falls_back_to_anonymous(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig(cover_name="ShouldNotAppear")
|
|
pem = _generate_cert(key, config, "unknown_level")
|
|
cert = _load_cert(pem)
|
|
|
|
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
|
assert cn == "FieldWitness"
|
|
|
|
def test_uses_ed25519_algorithm(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
# Ed25519 is identified by OID 1.3.101.112
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
|
|
assert isinstance(cert.public_key(), Ed25519PublicKey)
|
|
|
|
def test_basic_constraints_ca_false(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
bc = cert.extensions.get_extension_for_class(x509.BasicConstraints)
|
|
assert bc.value.ca is False
|
|
|
|
def test_key_usage_digital_signature_only(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
ku = cert.extensions.get_extension_for_class(x509.KeyUsage).value
|
|
assert ku.digital_signature is True
|
|
assert ku.content_commitment is False
|
|
assert ku.key_encipherment is False
|
|
assert ku.data_encipherment is False
|
|
assert ku.key_agreement is False
|
|
assert ku.key_cert_sign is False
|
|
assert ku.crl_sign is False
|
|
|
|
def test_validity_approximately_ten_years(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
not_before = cert.not_valid_before_utc
|
|
not_after = cert.not_valid_after_utc
|
|
delta = not_after - not_before
|
|
|
|
# 10 years = 3650 days; allow ±2 days for leap-year variation
|
|
assert abs(delta.days - _CERT_VALIDITY_YEARS * 365) <= 2
|
|
|
|
def test_public_key_matches_private_key(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
expected_raw = key.public_key().public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
actual_raw = cert.public_key().public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
assert expected_raw == actual_raw
|
|
|
|
def test_self_signed_issuer_equals_subject(self) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
pem = _generate_cert(key, config, "org")
|
|
cert = _load_cert(pem)
|
|
|
|
assert cert.issuer == cert.subject
|
|
|
|
|
|
class TestGetOrCreateC2paCert:
|
|
def test_creates_cert_on_first_call(self, tmp_path: Path) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig(cover_name="TestOrg")
|
|
identity_dir = tmp_path / "identity"
|
|
identity_dir.mkdir()
|
|
|
|
cert_pem, returned_key = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
|
|
assert (identity_dir / "c2pa_cert.pem").exists()
|
|
assert "BEGIN CERTIFICATE" in cert_pem
|
|
# Returned key is the same object passed in
|
|
assert returned_key is key
|
|
|
|
def test_returns_existing_cert_on_second_call(self, tmp_path: Path) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
identity_dir = tmp_path / "identity"
|
|
identity_dir.mkdir()
|
|
|
|
cert_pem_1, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
cert_pem_2, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
|
|
# Both calls return identical PEM content
|
|
assert cert_pem_1 == cert_pem_2
|
|
|
|
def test_returns_existing_cert_without_regenerating(self, tmp_path: Path) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
identity_dir = tmp_path / "identity"
|
|
identity_dir.mkdir()
|
|
|
|
get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
cert_path = identity_dir / "c2pa_cert.pem"
|
|
mtime_after_first = cert_path.stat().st_mtime
|
|
|
|
get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
mtime_after_second = cert_path.stat().st_mtime
|
|
|
|
# File was not rewritten on second call
|
|
assert mtime_after_first == mtime_after_second
|
|
|
|
def test_detects_key_mismatch_and_regenerates(self, tmp_path: Path) -> None:
|
|
key_a = Ed25519PrivateKey.generate()
|
|
key_b = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
identity_dir = tmp_path / "identity"
|
|
identity_dir.mkdir()
|
|
|
|
# Write cert for key_a
|
|
cert_pem_a, _ = get_or_create_c2pa_cert(config, key_a, identity_dir=identity_dir)
|
|
|
|
# Supply key_b — should detect mismatch and regenerate
|
|
cert_pem_b, _ = get_or_create_c2pa_cert(config, key_b, identity_dir=identity_dir)
|
|
|
|
# The two PEMs must differ (different key embedded in cert)
|
|
assert cert_pem_a != cert_pem_b
|
|
|
|
# Verify the new cert actually encodes key_b
|
|
cert = _load_cert(cert_pem_b)
|
|
expected_raw = key_b.public_key().public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
actual_raw = cert.public_key().public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
assert expected_raw == actual_raw
|
|
|
|
def test_force_regenerates_even_with_matching_key(self, tmp_path: Path) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
identity_dir = tmp_path / "identity"
|
|
identity_dir.mkdir()
|
|
|
|
get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
cert_path = identity_dir / "c2pa_cert.pem"
|
|
mtime_after_first = cert_path.stat().st_mtime
|
|
|
|
# Small sleep to ensure mtime can differ
|
|
import time
|
|
|
|
time.sleep(0.01)
|
|
|
|
get_or_create_c2pa_cert(config, key, force=True, identity_dir=identity_dir)
|
|
mtime_after_force = cert_path.stat().st_mtime
|
|
|
|
assert mtime_after_force >= mtime_after_first
|
|
|
|
def test_cert_public_key_matches_private_key(self, tmp_path: Path) -> None:
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
identity_dir = tmp_path / "identity"
|
|
identity_dir.mkdir()
|
|
|
|
cert_pem, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
cert = _load_cert(cert_pem)
|
|
|
|
expected_raw = key.public_key().public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
actual_raw = cert.public_key().public_bytes(
|
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
)
|
|
assert expected_raw == actual_raw
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Section 3: export.py -- GPS downsampling and assertion building
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestDownsampleGps:
|
|
"""Verify _downsample_gps uses unbiased rounding (round(), not math.floor())."""
|
|
|
|
def test_positive_coordinates_round_to_nearest(self) -> None:
|
|
# 40.06 should round to 40.1 (nearest), not 40.0 (floor)
|
|
lat, lon = _downsample_gps(40.06, 74.06)
|
|
assert lat == pytest.approx(40.1, abs=1e-9)
|
|
assert lon == pytest.approx(74.1, abs=1e-9)
|
|
|
|
def test_positive_coordinates_round_down_when_below_half(self) -> None:
|
|
# 40.04 should round to 40.0 (nearest)
|
|
lat, lon = _downsample_gps(40.04, 74.04)
|
|
assert lat == pytest.approx(40.0, abs=1e-9)
|
|
assert lon == pytest.approx(74.0, abs=1e-9)
|
|
|
|
def test_negative_coordinates_round_to_nearest(self) -> None:
|
|
# -40.05 should round to -40.1 (nearest), not -40.0 (floor would give -41.0)
|
|
lat, lon = _downsample_gps(-40.05, -74.05)
|
|
# Python's built-in round() uses banker's rounding for exact .5 — tolerate
|
|
# either -40.0 or -40.1 at the boundary; check directional correctness.
|
|
assert abs(lat) == pytest.approx(40.0, abs=0.2)
|
|
assert abs(lon) == pytest.approx(74.0, abs=0.2)
|
|
|
|
def test_negative_lat_not_systematically_biased_south(self) -> None:
|
|
# With math.floor: floor(-40.04 * 10) / 10 = floor(-400.4) / 10 = -401/10 = -40.1
|
|
# With round(): round(-40.04, 1) = -40.0
|
|
# We verify the result is -40.0 (nearest), not -40.1 (biased south).
|
|
lat, _ = _downsample_gps(-40.04, 0.0)
|
|
assert lat == pytest.approx(-40.0, abs=1e-9)
|
|
|
|
def test_negative_lon_not_systematically_biased_west(self) -> None:
|
|
# With math.floor: floor(-74.04 * 10) / 10 = -74.1 (biased west)
|
|
# With round(): round(-74.04, 1) = -74.0 (correct)
|
|
_, lon = _downsample_gps(0.0, -74.04)
|
|
assert lon == pytest.approx(-74.0, abs=1e-9)
|
|
|
|
def test_equator_and_prime_meridian(self) -> None:
|
|
lat, lon = _downsample_gps(0.0, 0.0)
|
|
assert lat == pytest.approx(0.0, abs=1e-9)
|
|
assert lon == pytest.approx(0.0, abs=1e-9)
|
|
|
|
def test_exact_grid_point_unchanged(self) -> None:
|
|
lat, lon = _downsample_gps(51.5, -0.1)
|
|
assert lat == pytest.approx(51.5, abs=1e-9)
|
|
assert lon == pytest.approx(-0.1, abs=1e-9)
|
|
|
|
def test_city_boundary_positive_side(self) -> None:
|
|
# 50.96 rounds to 51.0, not 50.0 (floor would give 50.0)
|
|
lat, lon = _downsample_gps(50.96, 10.96)
|
|
assert lat == pytest.approx(51.0, abs=1e-9)
|
|
assert lon == pytest.approx(11.0, abs=1e-9)
|
|
|
|
def test_city_boundary_negative_side(self) -> None:
|
|
# -50.94 rounds to -50.9, not -51.0 (floor bias)
|
|
lat, _ = _downsample_gps(-50.94, 0.0)
|
|
assert lat == pytest.approx(-50.9, abs=1e-9)
|
|
|
|
def test_output_precision_is_one_decimal(self) -> None:
|
|
lat, lon = _downsample_gps(48.8566, 2.3522) # Paris
|
|
# Both values should be expressible as X.X
|
|
assert round(lat, 1) == lat
|
|
assert round(lon, 1) == lon
|
|
|
|
|
|
class TestBuildExifAssertion:
|
|
def _record_with_location(
|
|
self,
|
|
lat: float,
|
|
lon: float,
|
|
altitude: float | None = None,
|
|
) -> AttestationRecord:
|
|
loc = GeoLocation(
|
|
latitude=lat,
|
|
longitude=lon,
|
|
altitude_meters=altitude,
|
|
)
|
|
cm = CaptureMetadata(location=loc)
|
|
return _make_record(metadata=cm.to_dict())
|
|
|
|
def test_gps_omitted_when_no_location(self) -> None:
|
|
record = _make_record(metadata={})
|
|
result = _build_exif_assertion(record, include_precise_gps=False)
|
|
assert result is None
|
|
|
|
def test_gps_downsampled_when_include_precise_false(self) -> None:
|
|
record = self._record_with_location(40.04, -74.04)
|
|
exif = _build_exif_assertion(record, include_precise_gps=False)
|
|
|
|
assert exif is not None
|
|
# 40.04 rounds to 40.0, -74.04 rounds to -74.0
|
|
assert exif["GPSLatitude"] == pytest.approx(40.0, abs=1e-9)
|
|
assert exif["GPSLongitude"] == pytest.approx(74.0, abs=1e-9)
|
|
|
|
def test_gps_precise_when_include_precise_true(self) -> None:
|
|
record = self._record_with_location(40.7128, -74.0060)
|
|
exif = _build_exif_assertion(record, include_precise_gps=True)
|
|
|
|
assert exif is not None
|
|
assert exif["GPSLatitude"] == pytest.approx(40.7128, abs=1e-9)
|
|
assert exif["GPSLongitude"] == pytest.approx(74.0060, abs=1e-9)
|
|
|
|
def test_gps_latitude_ref_north_positive(self) -> None:
|
|
record = self._record_with_location(48.8, 2.3)
|
|
exif = _build_exif_assertion(record, include_precise_gps=True)
|
|
assert exif["GPSLatitudeRef"] == "N"
|
|
assert exif["GPSLongitudeRef"] == "E"
|
|
|
|
def test_gps_latitude_ref_south_negative(self) -> None:
|
|
record = self._record_with_location(-33.9, -70.7)
|
|
exif = _build_exif_assertion(record, include_precise_gps=True)
|
|
assert exif["GPSLatitudeRef"] == "S"
|
|
assert exif["GPSLongitudeRef"] == "W"
|
|
|
|
def test_altitude_included_when_precise_and_present(self) -> None:
|
|
record = self._record_with_location(48.8, 2.3, altitude=250.0)
|
|
exif = _build_exif_assertion(record, include_precise_gps=True)
|
|
assert exif["GPSAltitude"] == pytest.approx(250.0, abs=1e-9)
|
|
assert exif["GPSAltitudeRef"] == 0 # above sea level
|
|
|
|
def test_altitude_omitted_when_not_precise(self) -> None:
|
|
record = self._record_with_location(48.8, 2.3, altitude=250.0)
|
|
exif = _build_exif_assertion(record, include_precise_gps=False)
|
|
assert "GPSAltitude" not in exif # type: ignore[operator]
|
|
|
|
def test_negative_altitude_ref_is_one(self) -> None:
|
|
record = self._record_with_location(0.0, 0.0, altitude=-50.0)
|
|
exif = _build_exif_assertion(record, include_precise_gps=True)
|
|
assert exif["GPSAltitudeRef"] == 1 # below sea level
|
|
|
|
def test_returns_none_when_no_capture_metadata(self) -> None:
|
|
record = _make_record(metadata={})
|
|
assert _build_exif_assertion(record, include_precise_gps=False) is None
|
|
|
|
def test_device_fields_included(self) -> None:
|
|
dev = CaptureDevice(make="Apple", model="iPhone 15 Pro", software="iOS 17")
|
|
cm = CaptureMetadata(device=dev)
|
|
record = _make_record(metadata=cm.to_dict())
|
|
exif = _build_exif_assertion(record, include_precise_gps=False)
|
|
|
|
assert exif is not None
|
|
assert exif["Make"] == "Apple"
|
|
assert exif["Model"] == "iPhone 15 Pro"
|
|
assert exif["Software"] == "iOS 17"
|
|
# serial_hash must never be in the C2PA exif (privacy by design)
|
|
assert "serial_hash" not in exif
|
|
assert "SerialNumber" not in exif
|
|
|
|
def test_timestamp_format(self) -> None:
|
|
ts = datetime.datetime(2024, 3, 15, 12, 30, 45, tzinfo=datetime.UTC)
|
|
cm = CaptureMetadata(captured_at=ts)
|
|
record = _make_record(metadata=cm.to_dict())
|
|
exif = _build_exif_assertion(record, include_precise_gps=False)
|
|
|
|
assert exif is not None
|
|
assert exif["DateTimeOriginal"] == "2024:03:15 12:30:45"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Section 4: export.py -- full export_c2pa path (requires c2pa-python)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
True, # Always skip — c2pa-python is not in the CI environment
|
|
reason="c2pa-python not installed; full Builder.sign() path skipped",
|
|
)
|
|
class TestExportC2paIntegration:
|
|
"""Full round-trip tests that require c2pa-python.
|
|
|
|
These are kept here as documentation and can be enabled locally by removing
|
|
the skipif mark once the [c2pa] extra is installed.
|
|
"""
|
|
|
|
def test_export_returns_bytes(self, tmp_path: Path) -> None:
|
|
c2pa = pytest.importorskip("c2pa") # noqa: F841
|
|
key = Ed25519PrivateKey.generate()
|
|
config = FieldWitnessConfig()
|
|
identity_dir = tmp_path / "identity"
|
|
identity_dir.mkdir()
|
|
cert_pem, _ = get_or_create_c2pa_cert(config, key, identity_dir=identity_dir)
|
|
|
|
# Minimal 1x1 white JPEG
|
|
|
|
from fieldwitness.c2pa_bridge.export import export_c2pa
|
|
|
|
minimal_jpeg = bytes(
|
|
[
|
|
0xFF,
|
|
0xD8,
|
|
0xFF,
|
|
0xE0,
|
|
0x00,
|
|
0x10,
|
|
0x4A,
|
|
0x46,
|
|
0x49,
|
|
0x46,
|
|
0x00,
|
|
0x01,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x01,
|
|
0x00,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0xFF,
|
|
0xD9,
|
|
]
|
|
)
|
|
record = _make_record()
|
|
result = export_c2pa(minimal_jpeg, "jpeg", record, key, cert_pem)
|
|
assert isinstance(result, bytes)
|
|
assert len(result) > len(minimal_jpeg)
|