Skip to content

DOM canonicalization spec

This spec defines how Fidemark turns a DOM subtree (a “region”) into a deterministic UTF-8 string for hashing. Every SDK and the browser extension implement the same rules; otherwise publisher hashes and verifier hashes drift and trust collapses.

The spec is frozen at version 1. Any change requires bumping FIDEMARK_DOM_VERSION and a parallel migration so old attestations can still be reproduced. Silent rule changes are a protocol break.

Off-the-shelf canonicalizers (XML c14n, JSON canonicalization) are wrong tools here:

  • XML c14n preserves attributes and tags, which we want to ignore (class renames, style refactors, ad slots).
  • JSON canonicalization assumes structured data, not human-readable content.
  • innerText and textContent differ between browsers and break on whitespace runs.

The spec below is intentionally narrow: it captures what a reader sees, not what the markup says.

  1. Walk the subtree depth-first.
  2. Skip entirely: <script>, <style>, <noscript>, <template>, HTML comments, and any element bearing [data-fidemark-ignore] (along with all its descendants).
  3. Each surviving text node contributes its raw nodeValue as a “text” segment.
  4. After exiting any block-level element (see table below), emit one “break” segment.
  5. Build the canonical string:
    1. For each text segment, replace runs of ASCII whitespace (space, tab, CR, LF, FF) with a single space.
    2. For each break segment, emit a literal \n.
    3. Concatenate the segments.
    4. Collapse runs of \n to a single \n.
    5. Trim leading and trailing spaces from each line.
    6. Drop leading and trailing blank lines.
    7. NFC-normalize the result.

The hash is SHA-256 of the resulting UTF-8 bytes, encoded as a 0x-prefixed lowercase 32-byte hex digest.

Each emits a single break after walking its children:

BR, P, DIV, LI, H1, H2, H3, H4, H5, H6, ARTICLE, SECTION, HEADER, FOOTER, BLOCKQUOTE, PRE, TABLE, TR.

Tag matching is case-insensitive. Element names not in this list contribute nothing of their own; their text descendants pass through unchanged.

Subtrees of these are dropped entirely:

SCRIPT, STYLE, NOSCRIPT, TEMPLATE. Plus any element matching [data-fidemark-ignore] (the attribute applies to the element AND its descendants).

Every SDK ships the same fixture file (sources/sdk/_fixtures/dom-canonicalization.json). A few rows:

Input HTML (with selector #root)Canonical textSHA-256
<article id="root"><p>Hello world.</p></article>Hello world.0xaa3ec16e...d115d11
<article id="root"><p>First.</p><p>Second.</p></article>First.\nSecond.0x31cf545a...d9c30
<p id="root"> a \t b \n c </p>a b c0xb771ebfb...2466ed
<article id="root"><script>alert('x')</script><p>visible</p></article>visible (script dropped)0x06c5928b...c043b
<article id="root"><p>keep</p><div data-fidemark-ignore><p>drop</p></div><p>keep too</p></article>keep\nkeep too0xaa8248f7...de863

When you implement a new SDK in another language, the fixture file is your acceptance test: every case must produce the listed canonical text and hash.

import { canonicalizeDom, hashRegion } from "@fidemark/sdk/dom";
const region = document.querySelector("article#post-42")!;
console.log(canonicalizeDom(region));
console.log(await hashRegion(region));
  • The rules above are version 1. They will not change without a FIDEMARK_DOM_VERSION bump.
  • The shared fixture file is the source of truth for cross-language parity; CI for each SDK runs against it.
  • Hashes computed today will continue to match new SDK builds as long as FIDEMARK_DOM_VERSION is 1, regardless of patch-level updates to the SDK.