Part 2 of the Local-First Evidence series. Part 1 covered why routing sensitive exports through a third-party server is risky. This post is the mechanics: how the handoff and the hashing actually work.
Saying "it's local-first" is easy. Doing it without sacrificing reliability or integrity means solving two separate problems: how to pass structured data across browser tabs safely, and how to hash JSON so two different tools arrive at the exact same fingerprint for the exact same record.
Here's how it actually works.
Why not the obvious options
A few standard patterns get ruled out before landing on a window handshake:
A server API. Routing data through an intermediate backend defeats the entire point. It recreates the exact server-side attack surface and data-retention risk the previous post was about eliminating.
A browser extension. Tracepack's original design used
externally_connectableinside a browser extension. That's gone now, replaced with a plain hosted page. An extension adds install friction and one more background process users have to trust before they've gotten any value from it.URL state (query parameters or hash fragments). Fragments break once a payload grows past a few kilobytes, and worse, URL state leaks into browser history, referrer headers, and any proxy sitting in between. That's a non-starter for records containing customer PII.
A server-mediated iframe embed. Still routes customer bytes through infrastructure you have to run, secure, and pay for.
The actual mechanism: window.open plus postMessage
Tracepack uses a direct handshake between the source app (a help desk, an internal tool, whatever you're building) and the evidence viewer tab it opens. No server sees the payload at any point, not even Tracepack's own: the hosted app only ever serves static JavaScript.
There are two versions of this handshake live at once, and which one you get depends on whether you hand-roll the postMessage calls yourself or use the @tracepack/integration package. Both are real, both are wired into the live receiver, they just make different tradeoffs.
The plain handshake , the one you'd write by hand from the embed guide:
+-------------------+ +-------------------+
| Source App | | Tracepack Viewer |
| (e.g. Helpdesk) | | (Hosted app) |
+---------+---------+ +---------+---------+
| |
| 1. window.open(tracepackUrl) |
|--------------------------------------------->|
| |
| 2. { source: "tracepack", type: "ready" } |
|<---------------------------------------------|
| |
| 3. { source: "tracepack-producer", |
| type: "evidence", |
| payload: { ... } } |
|--------------------------------------------->|
| |
| 4. { source: "tracepack", type: "imported", |
| projectId, evidenceCount } |
| (best effort, may never arrive if the |
| tab is closed before a pack is picked) |
|<---------------------------------------------|
If the payload fails validation here, there's no reply telling the source tab why. Tracepack shows the error inline on its own page, and that's the only place it shows up. Fine for a producer that just wants to fire evidence at Tracepack and doesn't need to react to failure programmatically.
Protocol v1, via @tracepack/integration, is a real state machine, not a looser version of the same thing:
ready ──▶ send(handoff_id) ──┬──▶ accepted(handoff_id) ──┬──▶ imported(handoff_id, project_id, evidence_count)
│ └──▶ cancelled(handoff_id)
└──▶ rejected(handoff_id, issues[])
Every message after send carries the same handoff_id, so a producer can correlate a specific submission to its outcome, not just infer success from silence. rejected carries a real issues[] array with a machine-readable code per problem (UNSUPPORTED_PROTOCOL, INVALID_HANDOFF, PAYLOAD_TOO_LARGE, DUPLICATE_HANDOFF, and others), so a producer can show the user something more useful than "something went wrong." Re-sending the exact same accepted handoff is idempotent, it just gets acknowledged again rather than double-imported, and trying to send a second, different handoff while one is already awaiting review gets an explicit DUPLICATE_HANDOFF rejection instead of silently clobbering the first.
On origin checking: both versions already check that the reply actually came from the window that was opened (event.source === window.opener on the receiving side), so a message from some other tab is ignored regardless of which protocol you're using. What differs is origin pinning. @tracepack/integration's producer side records the exact origin it opened (targetOrigin) and refuses to trust a reply from anywhere else, from the very first message. The receiver's very first ready broadcast has to go out to "*", because it genuinely cannot know the opener's origin before the opener's own first message arrives, that's a hard limit of cross-origin window.opener, not a shortcut. Every reply after that targets the real origin exactly. None of this is an allowlist of approved producer sites, there isn't one, and there's no practical way to pre-register every app that might embed this button. That's a deliberate trust boundary, not a gap.
Why hashing raw JSON isn't enough
Set the problem up before the fix: two semantically identical JSON documents can serialize to completely different bytes. Key order, whitespace, and number formatting (1 versus 1.0) all vary by library. Hash the raw bytes and the same evidence can produce two different hashes depending on which tool wrote the JSON, which breaks the entire point of hashing in the first place. Proving nothing changed only works if "nothing changed" can actually be checked.
RFC 8785 in one paragraph
RFC 8785, the JSON Canonicalization Scheme, fixes this by defining one deterministic serialization: fixed key ordering, fixed number formatting, fixed string escaping. The same logical document always serializes to the same bytes, no matter which tool produced it. That's what makes integrity.payload_hash meaningful. It's a hash of the canonical form, not whatever bytes happened to come off the wire.
The two-hash model
A single record actually carries two separate hashes, each proving a different thing:
content_hashon each attachment: a SHA-256 of the raw file bytes. Proves a specific screenshot or document wasn't altered, independent of everything else in the record.integrity.payload_hash: a SHA-256 of the whole envelope after RFC 8785 canonicalization. Proves the record as a whole (metadata, observations, attachment references) wasn't altered.
Together you can check a single file, the entire record, or both, and know exactly which one failed if a check ever comes back wrong.
What's actually proven versus what's asserted
source.producer_id and source.producer_name are self-reported by whatever tool packaged the evidence. There's no signature tying them to anything. The hash proves the bytes weren't changed after export. It doesn't prove who exported them. Those are two different guarantees, and conflating them is the easiest mistake to make when writing about evidence handling.
If you've seen Sigstore mentioned around this project, here's the accurate scope of it today: Sigstore protects Tracepack's own published npm and CLI release artifacts. You can verify a downloaded package came from the real repository's release workflow with cosign verify-blob. It isn't used for evidence payloads or producer identity, and evidence data never goes anywhere near a public transparency log. A tracepack-authentication layer for real producer identity is a documented research direction, not a shipped feature.
What this design doesn't solve
Worth being honest about the edges:
It doesn't stop someone from editing a record before hashing it. Garbage in, verifiably-consistent garbage out.
It doesn't prove producer identity, as above.
It depends on both tabs being genuine. A compromised or spoofed viewer tab could still claim
importedwhen nothing actually happened.
Next
Part 3 walks through building your own "Send to Tracepack" button end to end, using @tracepack/integration and protocol v1, the version worth building against.
Repo: https://github.com/ace2016/tracepack Format spec: https://github.com/ace2016/tracepack/blob/main/packages/evidence-interchange/SPEC.md

Top comments (0)