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
- Signatures and passkeys: the
eddsa-jcs-2022company signature and the WebAuthn citizen assertion. - On-chain registry: where the root is anchored.