Image and Media Signatures

Why this matters

You have an image — a photo, an AI-generated render, a chart — and you want a downstream consumer to verify who produced it and when (claimed time) before trusting the asset. With jacs sign-image the signed-document JSON sits inside the image itself in a metadata chunk (PNG iTXt / JPEG APP11 / WebP XMP); no sidecar file, no AGPL dependency, no external service. The signature proves who signed which canonical bytes at the agent's claimed time — it does not prove first creation or legal ownership.

At a glance

ActionCLI verbPythonNodeGoRust
Signjacs sign-image <in> --out <out>jacs.sign_image(path, out=...)jacs.signImage(in, out)jacs.SignImage(in, out, nil)jacs::media::sign_image(&agent, in, out, opts)
Verifyjacs verify-image <file>jacs.verify_image(path)jacs.verifyImage(path)jacs.VerifyImage(path, nil)jacs::media::verify_image(&agent, path, opts)
Extractjacs extract-media-signature <file>jacs.extract_media_signature(path)jacs.extractMediaSignature(path)jacs.ExtractMediaSignature(path, nil)jacs::media::extract_signature(path, opts)

Per-format embedding

FormatWhere the signature livesNotes
PNGiTXt chunk with keyword jacs-signatureInserted before the IDAT chunks. Lossless; existing chunks (incl. tEXt, pHYs, iCCP) are preserved.
JPEGAPP11 segment with marker JACS\0Inserted after SOI and any APP0/APP1 (Exif/JFIF). Existing markers preserved.
WebPXMP chunk inside RIFF containerThe RIFF header size field is updated. Lossless and lossy variants are both supported.

The embedded payload is base64url-encoded JACS signed-document JSON (deliberately JSON, not YAML — images are binary containers and YAML's whitespace sensitivity adds no value). The size cap is 64 KiB; oversized payloads surface as PayloadTooLarge.

Signed claim shape

{
  "mediaSignatureVersion": 1,
  "format": "png",
  "canonicalization": "jacs-media-v1",
  "hashAlgorithm": "sha256",
  "contentHash": "sha256:e3b0c44...",
  "embeddingChannels": ["chunk:iTXt:jacs-signature"],
  "robust": false,
  "pixelHash": "sha256:9f86d08..."
}

Wrapped in a JACS signed document, the same shape is what verify-image reads to confirm signer identity, claimed timestamp, and pixel-content integrity.

Inline text vs media — why the asymmetry

Inline text uses YAML because humans skim it next to the markdown. Image payloads are read by tooling, not humans — JSON is what every JACS binding already canonicalises and signs. The two formats share the cryptographic core (domain-separated pre-image, hash algorithm, canonicalization tag) but differ in serialisation. Don't try to unify them.

Robust mode (--robust) — opt-in LSB fallback

The default mode embeds in metadata chunks, which survives most pipelines but does not survive a deliberate "strip all metadata" pass. Robust mode adds an LSB (least-significant-bit) embedding in the pixel data so a stripped image still verifies.

FormatRobust support
PNGYes (--robust)
JPEGYes (--robust)
WebPDeferred to a future release

Capacity math: roughly width × height bits. A 512×512 image holds about 32 KiB of robust-mode payload; small thumbnails may not fit.

Robust mode is OFF by default because LSB embedding mutates pixel values (imperceptible to humans, but real). Opt in only when the metadata-strip threat model justifies the change.

extract-media-signature — getting the payload back out

# Default — decoded JSON (ready to read or pipe to jq)
jacs extract-media-signature signed.png
# {"mediaSignatureVersion": 1, "format": "png", ...}

# Wire form — raw base64url payload (useful for re-embedding or transport)
jacs extract-media-signature signed.png --raw-payload
# eyJtZWRpYVNpZ25hdHVyZVZlcnNpb24iOjEsImZvcm1hdC...

# Pipeline-friendly — extract the signer's claimed timestamp
jacs extract-media-signature signed.png | jq -r .signedAt
# 2026-04-24T18:00:00Z

extract-media-signature does NOT verify the signature; it only decodes the embedded payload. Use verify-image for verification.

Single-signer and overwrite policy

Images are single-signer: re-running sign-image overwrites the embedded signature with the new one. Use --refuse-overwrite to opt into first-signer-wins:

# Default: overwrite if already signed
jacs sign-image photo.png --out signed.png

# Refuse to overwrite — exit non-zero if already signed
jacs sign-image photo.png --out signed.png --refuse-overwrite

This is intentionally narrower than inline text: text is reviewed and counter-signed by humans / agents; images are typically signed once at the point of capture or generation. If you need a multi-signer flow over images, stage the signatures off-image (e.g. as a JACS agreement that references contentHash).

Permissive vs strict verification

Same model as inline text — see Inline Text Signatures.

.bak policy

When sign-image overwrites an existing file, it writes a .bak next to the output (mode 0o600 on Unix — owner-read/write only). .bak files contain the unsigned original, which may include sensitive metadata. Treat them like any other plaintext source file: they are not encrypted, do not commit them, and remove them when no longer needed.

--out <path> to a different filename avoids .bak files entirely.

--key-dir override

Same semantics as inline text — directory of <signer_id>.public.pem files, used when the trust store does not have the signer's key:

jacs verify-image signed.png --key-dir ./trusted-keys/

Caps and rejected inputs

LimitCause failure
Embedded payload > 64 KiBErr(ErrorKind::PayloadTooLarge)
Unknown format (extension or magic)Err(ErrorKind::UnsupportedFormat)
--refuse-overwrite + already-signed inputErr(ErrorKind::AlreadySigned)
Robust mode requested for WebPErr(ErrorKind::RobustNotSupported) (v0.10.0)

Clean-room provenance

The jacs-media crate is 100% Rust, dual-licensed Apache-2.0 / MIT. Zero AGPL dependencies — we cite the prior art (PNG iTXt RFC, Adobe XMP spec) without copying any source. A cargo deny license gate prevents future regressions.