Skip to content

Commit 380aca4

Browse files
ralyodioclaude
andauthored
feat(provenance): badge what a media file declares about how it was made (#83)
Adds @tronbrowser/provenance. Reads C2PA manifests, the IPTC DigitalSourceType field and generator hints out of images and video, and badges the findings on the page. Local by default, because the alternative is telemetry. The only network traffic a scan causes is a re-request for an image the page already loaded, to the origin that already served it, normally answered from cache — Range-limited, force-cache, same-origin credentials. No third party learns anything. Asking one is a separate function that is off by default and throws RemoteLookupDisabledError rather than silently proceeding; there is no configuration that makes it quietly on, and the opt-in check runs before the URL is touched so a misconfigured caller cannot leak on the way to discovering it was disabled. What the badge refuses to say matters more than what it says: - There is no "authentic" or "verified real" state, because nothing here can establish that. The strongest positive claim available is "the file says it was captured by a camera", and that is what it says. - Absence is never rendered as reassurance. Most sites strip metadata, so a bare file is equally common for real photographs and AI output. - A generator name is a hint, never a verdict, and never shows unprompted. Editors write their own name into the same fields, so surfacing it would train people to read "opened in Firefly" as "fake". - signatureVerified is typed as the literal false, so presence can never drift into implying validity. - SynthID is never checked and every report says so. Only findings that say something are drawn without being asked. Badging every image on a page produces noise people learn to ignore, which is worse than no badge, so the quiet ones are available via showAll. Scans are bounded (40 elements, 96px floor) so a thumbnail gallery does not become hundreds of range requests. Badges are inserted as siblings — img and video cannot hold children, and appending to them silently does nothing — and carry the caveat in aria-label rather than a hover-only title. 59 tests, including assertions on what is fetched, not just what renders. Full workspace suite green. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a9b9bf6 commit 380aca4

13 files changed

Lines changed: 1606 additions & 1 deletion

File tree

‎packages/provenance/README.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# @tronbrowser/provenance
2+
3+
Reads what a media file declares about how it was made, and badges it on the page.
4+
5+
## What it does
6+
7+
Reads three things out of the bytes of an image or video:
8+
9+
- **C2PA manifests** — JPEG APP11/JUMBF, PNG `caBX`, WebP `C2PA`, ISO-BMFF
10+
- **IPTC `DigitalSourceType`** — the standard vocabulary (`trainedAlgorithmicMedia`, `digitalCapture`, …)
11+
- **Generator names** left in XMP by Midjourney, DALL·E, Firefly, Imagen, Veo and others
12+
13+
## Local by default
14+
15+
The only network traffic a scan causes is a re-request for an image the page **already loaded**, to the origin that **already served it**, normally answered from cache. No third party learns anything.
16+
17+
That is not incidental — it is why this is a browser feature rather than a call to somebody's API. Badging every image on every page by asking a remote service would be telemetry on the user's entire browsing session.
18+
19+
Asking a third party is available, but it is a separate function, off by default, and it throws rather than silently proceeding if the user has not opted in:
20+
21+
```ts
22+
import { lookupRemote, RemoteLookupDisabledError } from '@tronbrowser/provenance';
23+
24+
// Throws RemoteLookupDisabledError — there is no config that makes this silently on
25+
await lookupRemote(url, { enabled: false, apiKey: '' });
26+
```
27+
28+
## What it will not claim
29+
30+
There is deliberately **no "authentic" or "verified real" badge.** Nothing here can establish that. The strongest positive statement available is *"the file says it was captured by a camera"*, and that is exactly what the badge says.
31+
32+
Three rules the tests enforce:
33+
34+
1. **Absence proves nothing.** Most sites strip metadata on upload, so a bare file is equally common for real photographs and AI output. "No provenance" is never rendered as "probably real".
35+
2. **Self-declaration is not proof.** Only C2PA is signed, and even then this reports its *presence* — validating the certificate chain needs a trust list and is a separate job. `signatureVerified` is typed as the literal `false` so it cannot drift.
36+
3. **A generator name is a hint, not a verdict.** Image editors write their own name into the same fields, so "opened in Firefly" and "made by Firefly" are indistinguishable there. It never triggers an AI badge, and never shows unprompted.
37+
38+
**SynthID is never checked**, and every report says so rather than staying quiet about it — Google's watermarks are verified by its own service, not from a file's bytes.
39+
40+
## Usage
41+
42+
```ts
43+
import { scanMedia } from '@tronbrowser/provenance';
44+
45+
const result = await scanMedia();
46+
// { examined: 12, badged: 2, skipped: 1 }
47+
```
48+
49+
Only findings that actually say something are drawn unprompted. Most images on most pages have no provenance at all, and badging all of them is noise people learn to ignore — which is worse than no badge. Pass `showAll: true` for the on-demand view.
50+
51+
Scans are bounded (`maxElements`, default 40) and skip anything under `minSize` (default 96px), so a gallery of thumbnails does not turn into hundreds of range requests.
52+
53+
## Reading one file directly
54+
55+
```ts
56+
import { readProvenance, toBadge } from '@tronbrowser/provenance';
57+
58+
const report = readProvenance(bytes);
59+
const badge = toBadge(report);
60+
```
61+
62+
`readProvenance` is pure and has no DOM or Node dependencies — it runs in a content script, a worker, or a test.

‎packages/provenance/package.json‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@tronbrowser/provenance",
3+
"version": "3.8.8",
4+
"private": true,
5+
"description": "Reads what a media file declares about how it was made, locally, and badges it on the page",
6+
"type": "module",
7+
"main": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsc -p tsconfig.json",
17+
"typecheck": "tsc -p tsconfig.json --noEmit",
18+
"test": "vitest run --passWithNoTests",
19+
"lint": "eslint src"
20+
},
21+
"devDependencies": {
22+
"happy-dom": "^20.10.6",
23+
"typescript": "^5.6.3",
24+
"vitest": "^2.1.4"
25+
}
26+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { shouldDisplay, toBadge } from './badge.js';
4+
import { readProvenance } from './read.js';
5+
6+
/**
7+
* The badge is the only part of this package a user ever sees, so the wording
8+
* is the part that can mislead. These tests are mostly about what it refuses
9+
* to say: there is no "authentic" state, absence never reads as real, and a
10+
* tool name never becomes an accusation.
11+
*/
12+
13+
/** Builds bytes from literal bytes and ASCII strings. */
14+
function bytes(...parts: (number[] | string)[]): Uint8Array {
15+
const out: number[] = [];
16+
for (const part of parts) {
17+
if (typeof part === 'string') {
18+
for (let i = 0; i < part.length; i += 1) out.push(part.charCodeAt(i));
19+
} else {
20+
out.push(...part);
21+
}
22+
}
23+
return new Uint8Array(out);
24+
}
25+
26+
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
27+
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
28+
29+
function xmp(inner: string): string {
30+
return `<x:xmpmeta xmlns:x="adobe:ns:meta/">${inner}</x:xmpmeta>`;
31+
}
32+
33+
function sourceType(term: string): string {
34+
return xmp(
35+
`<rdf:Description Iptc4xmpExt:DigitalSourceType="http://cv.iptc.org/newscodes/digitalsourcetype/${term}"/>`,
36+
);
37+
}
38+
39+
describe('what the badge says', () => {
40+
it('calls a declared generative file AI-generated, and shows it unprompted', () => {
41+
const badge = toBadge(readProvenance(bytes(JPEG, sourceType('trainedAlgorithmicMedia'))));
42+
43+
expect(badge.kind).toBe('ai');
44+
expect(badge.label).toBe('AI-generated');
45+
expect(badge.prominent).toBe(true);
46+
});
47+
48+
it('reports a camera claim as a claim, not as authenticity', () => {
49+
const badge = toBadge(readProvenance(bytes(JPEG, sourceType('digitalCapture'))));
50+
51+
expect(badge.kind).toBe('camera');
52+
expect(badge.label).toBe('Camera capture');
53+
// Nothing here can establish a photo is genuine, so nothing says so.
54+
expect(badge.label).not.toMatch(/authentic|verified|genuine|real/i);
55+
expect(badge.detail).toMatch(/not a signature/i);
56+
});
57+
58+
it('does not interrupt the page for an unsigned camera claim', () => {
59+
const unsigned = toBadge(readProvenance(bytes(JPEG, sourceType('digitalCapture'))));
60+
const signed = toBadge(readProvenance(bytes(PNG, 'caBX', sourceType('digitalCapture'))));
61+
62+
expect(unsigned.prominent).toBe(false);
63+
expect(signed.prominent).toBe(true);
64+
});
65+
66+
it('reports a manifest that declares nothing as exactly that', () => {
67+
const badge = toBadge(readProvenance(bytes(PNG, 'caBX', 'manifest')));
68+
69+
expect(badge.kind).toBe('signed');
70+
expect(badge.detail).toMatch(/does not declare how it was made/i);
71+
expect(badge.prominent).toBe(true);
72+
});
73+
74+
it('treats a generator name as a hint and keeps it off the page', () => {
75+
const badge = toBadge(readProvenance(bytes(JPEG, xmp('<xmp:CreatorTool>Midjourney</xmp:CreatorTool>'))));
76+
77+
expect(badge.kind).toBe('hint');
78+
expect(badge.label).toBe('Possible AI tool');
79+
// "Opened in Firefly" must not train people to read the badge as "fake".
80+
expect(badge.prominent).toBe(false);
81+
});
82+
83+
it('never turns a bare file into a claim about it', () => {
84+
const badge = toBadge(readProvenance(bytes(JPEG, 'ordinary pixels')));
85+
86+
expect(badge.kind).toBe('unknown');
87+
expect(badge.prominent).toBe(false);
88+
expect(badge.detail).toMatch(/not evidence either way/i);
89+
});
90+
91+
it('attaches the report caveats to every badge', () => {
92+
const samples = [
93+
bytes(JPEG, sourceType('trainedAlgorithmicMedia')),
94+
bytes(JPEG, sourceType('digitalCapture')),
95+
bytes(PNG, 'caBX'),
96+
bytes(JPEG, xmp('<xmp:CreatorTool>Midjourney</xmp:CreatorTool>')),
97+
bytes(JPEG, 'nothing'),
98+
];
99+
100+
for (const sample of samples) {
101+
const report = readProvenance(sample);
102+
const badge = toBadge(report);
103+
// The caveat is what stops the badge overclaiming, so it is never optional.
104+
for (const note of report.notes) expect(badge.detail).toContain(note);
105+
}
106+
});
107+
108+
it('never labels anything as verified', () => {
109+
const samples = [
110+
bytes(PNG, 'caBX', sourceType('digitalCapture')),
111+
bytes(PNG, 'caBX'),
112+
bytes(JPEG, sourceType('digitalCapture')),
113+
];
114+
115+
for (const sample of samples) {
116+
expect(toBadge(readProvenance(sample)).label).not.toMatch(/verified|authentic/i);
117+
}
118+
});
119+
});
120+
121+
describe('shouldDisplay', () => {
122+
it('draws findings that say something and holds back the rest', () => {
123+
const ai = toBadge(readProvenance(bytes(JPEG, sourceType('trainedAlgorithmicMedia'))));
124+
const nothing = toBadge(readProvenance(bytes(JPEG, 'plain')));
125+
const hint = toBadge(readProvenance(bytes(JPEG, xmp('<x>Midjourney</x>'))));
126+
127+
expect(shouldDisplay(ai)).toBe(true);
128+
expect(shouldDisplay(nothing)).toBe(false);
129+
expect(shouldDisplay(hint)).toBe(false);
130+
});
131+
});

‎packages/provenance/src/badge.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Turning a provenance report into what the user actually sees.
3+
*
4+
* Pure: report in, presentation out. Kept separate from the DOM work so the
5+
* wording — the part that can mislead someone — is testable on its own.
6+
*
7+
* The hard rule is that the badge never asserts more than the file does. There
8+
* is deliberately no "authentic" or "real photo" state, because nothing here
9+
* can establish that. The strongest positive claim available is "the file says
10+
* it was captured by a camera", and the badge says exactly that.
11+
*/
12+
13+
import type { ProvenanceReport } from './read.js';
14+
15+
/**
16+
* What the badge is telling the user.
17+
*
18+
* `ai` — the file declares generative origin.
19+
* `camera` — the file declares camera capture.
20+
* `signed` — a manifest is present but says neither.
21+
* `hint` — a generator name, too weak to call.
22+
* `unknown` — nothing found. Shown only on request, never volunteered.
23+
*/
24+
export type BadgeKind = 'ai' | 'camera' | 'signed' | 'hint' | 'unknown';
25+
26+
export interface Badge {
27+
readonly kind: BadgeKind;
28+
/** Short text on the badge itself. */
29+
readonly label: string;
30+
/** The longer explanation, shown on hover or focus. */
31+
readonly detail: string;
32+
/** True when the badge should appear without the user asking. */
33+
readonly prominent: boolean;
34+
}
35+
36+
/** Wording for each state, in one place so it can be reviewed as a set. */
37+
const LABELS: Readonly<Record<BadgeKind, string>> = {
38+
ai: 'AI-generated',
39+
camera: 'Camera capture',
40+
signed: 'Signed provenance',
41+
hint: 'Possible AI tool',
42+
unknown: 'No provenance',
43+
};
44+
45+
/**
46+
* Maps a report onto a badge.
47+
*
48+
* @param report - What the file declared
49+
* @returns The badge to show
50+
*/
51+
export function toBadge(report: ProvenanceReport): Badge {
52+
if (report.declaredAiGenerated === true) {
53+
return {
54+
kind: 'ai',
55+
label: LABELS.ai,
56+
detail: qualify(report, report.digitalSourceTypeLabel ?? 'The file declares generative origin.'),
57+
prominent: true,
58+
};
59+
}
60+
61+
if (report.declaredAiGenerated === false) {
62+
return {
63+
kind: 'camera',
64+
label: LABELS.camera,
65+
detail: qualify(report, report.digitalSourceTypeLabel ?? 'The file declares camera capture.'),
66+
// A capture claim is worth showing when signed, and not worth
67+
// interrupting the page for when it is a bare editable field.
68+
prominent: report.strength === 'signed',
69+
};
70+
}
71+
72+
if (report.c2pa.present) {
73+
return {
74+
kind: 'signed',
75+
label: LABELS.signed,
76+
detail: qualify(
77+
report,
78+
'This file carries a C2PA manifest but does not declare how it was made.',
79+
),
80+
prominent: true,
81+
};
82+
}
83+
84+
if (report.generators.length > 0) {
85+
return {
86+
kind: 'hint',
87+
label: LABELS.hint,
88+
detail: qualify(report, `Metadata mentions ${report.generators.join(', ')}.`),
89+
// A tool name is not a finding. Surfacing it unprompted would train
90+
// people to read "opened in Firefly" as "fake".
91+
prominent: false,
92+
};
93+
}
94+
95+
return {
96+
kind: 'unknown',
97+
label: LABELS.unknown,
98+
detail: qualify(report, 'This file carries no provenance metadata.'),
99+
prominent: false,
100+
};
101+
}
102+
103+
/**
104+
* Appends the report's own caveats to a summary.
105+
*
106+
* The caveat is not optional garnish — it is the part that stops a badge from
107+
* overclaiming — so it is attached here rather than left to each caller.
108+
*
109+
* @param report - The report the badge came from
110+
* @param summary - The one-line summary
111+
* @returns Summary plus caveats
112+
*/
113+
function qualify(report: ProvenanceReport, summary: string): string {
114+
return [summary, ...report.notes].join(' ');
115+
}
116+
117+
/**
118+
* Whether a badge should be drawn on the page without being asked for.
119+
*
120+
* Most images on most pages have no provenance at all. Badging every one of
121+
* them would be noise that people learn to ignore, which is worse than no
122+
* badge — so only findings that say something get drawn, and the rest are
123+
* available on demand.
124+
*
125+
* @param badge - The badge in question
126+
* @returns True when it should be shown unprompted
127+
*/
128+
export function shouldDisplay(badge: Badge): boolean {
129+
return badge.prominent;
130+
}

‎packages/provenance/src/index.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* `@tronbrowser/provenance` — what a file declares about how it was made.
3+
*
4+
* Local by default: everything the badge shows is read from bytes the browser
5+
* already has. Asking a third party is a separate, opt-in feature.
6+
*/
7+
8+
export {
9+
readProvenance,
10+
detectC2pa,
11+
detectContainer,
12+
extractXmp,
13+
readDigitalSourceType,
14+
readGenerators,
15+
indexOfAscii,
16+
DIGITAL_SOURCE_TYPES,
17+
SCAN_BYTES,
18+
type C2paFinding,
19+
type MediaContainer,
20+
type ProvenanceReport,
21+
type ProvenanceSignal,
22+
type ProvenanceStrength,
23+
} from './read.js';
24+
25+
export { toBadge, shouldDisplay, type Badge, type BadgeKind } from './badge.js';
26+
27+
export {
28+
scanMedia,
29+
inspect,
30+
fetchHead,
31+
mediaUrl,
32+
attachBadge,
33+
SCANNED_ATTRIBUTE,
34+
type ScanOptions,
35+
type ScanResult,
36+
} from './scan.js';
37+
38+
export {
39+
lookupRemote,
40+
RemoteLookupDisabledError,
41+
REMOTE_LOOKUP_DISCLOSURE,
42+
type RemoteLookupConfig,
43+
type RemoteVerdict,
44+
} from './remote.js';

0 commit comments

Comments
 (0)