FR EN

Canonicalization and hashing

Everything Agreely hashes or signs is first reduced to one deterministic byte string. If two implementations can disagree on those bytes, every signature and commitment downstream is worthless. So the rules here are deliberately narrow and fail loudly.

JCS / RFC 8785, made number-free

Before anything is hashed, the value is canonicalized with JCS (RFC 8785): object keys sorted by byte order, no insignificant whitespace, minimal string escaping, UTF-8 output. JCS exists precisely so that the same logical JSON produces the same bytes in any language.

Agreely adds one hard constraint on top of RFC 8785: no numbers. The canonicalizer accepts only strings, objects, arrays, booleans, and null, and throws on any integer or float.

The other pinned rules: object keys must be ASCII-only and are sorted by strcmp byte order (identical to RFC 8785's UTF-16 sort for ASCII); array order is preserved; strings use RFC 8259 minimal escaping (escape only ", \, and the U+0000-U+001F control range, with \b \t \n \f \r short forms), and / is not escaped; all other characters, including non-ASCII, are emitted as raw UTF-8 bytes. An empty object is {}, an empty array is [].

encode({ "b": "2", "a": "1" })  ->  {"a":"1","b":"2"}
encode({ "n": 1 })              ->  throws (numbers forbidden)

keccak256: the Ethereum variant, not NIST SHA3

Agreely hashes with keccak256 using the original 0x01 padding, which is what Ethereum, Solidity's keccak256, and viem/ethers use. This is not NIST SHA3-256, which uses 0x06 padding and produces different output for the same input.

The per-cell commitment

Each consent cell is bound by a commitment over its claim and a secret salt:

commitment = keccak256( JCS(claim) || salt32 )

The frozen claim schema is number-free by construction:

{
  "issuer": "did:web:agreely.ca:c:acme",
  "subject": "did:agreely:citizen:9F8K2M4P7Q1R3T5V8W0X2Y4Z6B",
  "item": {
    "itemId": "0x4b08...",
    "category": "phone number",
    "purpose": "billing"
  },
  "grantedAt": "2026-06-26T15:04:05Z"
}

item.category and item.purpose carry the normalized keys (lowercased canonical values), the same strings the catalog stores and the check keys off, so the commitment, the receipt, and enforcement all agree byte for byte. The salt is what makes the commitment unguessable, and destroying the salt is exactly what erasure destroys.

The Merkle tree: OpenZeppelin-compatible

A consent can cover several cells. Their commitments are combined into one Merkle root so a single signature (and a single on-chain anchor) covers every cell, and any one cell can be shredded while the rest still verify. The construction is a hand-port of OpenZeppelin's SimpleMerkleTree, so the same proof verifies on-chain against the anchored root. The exact formulas (commitment, leaf, pairing, and the N = 1 case) live in the verify recipe.

Next