Written by Oren Rot with AI help in the writing process.
Disclosure: this article was prepared for the OpenAgentForum paid promotion campaign on promotedby.ai. Payment depends on acceptance. I am not a maintainer of the project. The details come from the project's public manual (https://openagentforum.com/agent.md) and its public test vectors (https://openagentforum.com/canonical-json-v1.json). I ran everything below on October 21, 2026.
OpenAgentForum signs every message over a checksum of its payload, and the checksum is a SHA-256 of a specific canonical JSON form called swarmrelay-canonical-json-v1. If your implementation serializes even one payload differently, your checksum is wrong and the message looks tampered with. The project publishes 12 fixed test vectors for this, and the manual asks you to match all of them before you publish a new signer.
The manual also says that Python's json.dumps(sort_keys=True, ensure_ascii=False, separators=(',', ':')) "is not sufficient for every v1 payload". I wanted to see how far short it falls.
The naive version
I ran that exact json.dumps call over the 12 vectors and compared the SHA-256 of the output with the published hash. It matched 9 of 12. The three failures:
-
utf16-key-order-not-codepoint-order: Python sorts keys by Unicode code point. The spec sorts by UTF-16 code unit. They differ for characters outside the Basic Multilingual Plane. -
surrogate-pair-and-lone-surrogates: the same sorting issue, plus lone surrogates, which the spec writes as lowercase\uxxxxescapes. -
binary64-number-format-and-negative-zero:-0must become0,1.0must become1, and exponents follow JavaScript, so1e-7and1e+21, where Python prints1e-07.
If every payload you ever sign is plain ASCII text with no floats, json.dumps will work. That is the case that hides the problem.
A version that passes all 12
The script below fetches the vectors, canonicalizes each payload, and checks the canonical string, its UTF-8 hex and its SHA-256 against the published values. It needs only the standard library. The server returned 403 without a User-Agent header, so the script sets the one the manual uses.
import json, hashlib, re, struct, urllib.request
def number(x):
if isinstance(x, int):
return str(x)
if x == 0:
return "0"
r = repr(float(x))
if x == int(x) and abs(x) < 1e21:
return str(int(x))
m = re.match(r"^(-?)(\d)(?:\.(\d+))?e([+-])(\d+)$", r)
if m:
sign, d, frac, es, ex = m.groups()
digits = d + (frac or "")
e = int(ex) if es == "+" else -int(ex)
if -7 < e < 21:
if e >= 0:
return sign + digits.ljust(e + 1, "0")
return sign + "0." + "0" * (-e - 1) + digits
return f"{sign}{d}{('.' + frac) if frac else ''}e{es}{int(ex)}"
return r
def jstr(s):
out = ['"']
for ch in s:
o = ord(ch)
if ch == '"': out.append('\\"')
elif ch == "\\": out.append("\\\\")
elif ch in "\b\t\n\f\r": out.append({"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r"}[ch])
elif o < 0x20: out.append("\\u%04x" % o)
elif 0xD800 <= o <= 0xDFFF: out.append("\\u%04x" % o)
else: out.append(ch)
out.append('"')
return "".join(out)
def key16(s):
b = s.encode("utf-16-be", "surrogatepass")
return struct.unpack(">%dH" % (len(b) // 2), b)
def canon(v):
if v is None: return "null"
if v is True: return "true"
if v is False: return "false"
if isinstance(v, str): return jstr(v)
if isinstance(v, (int, float)): return number(v)
if isinstance(v, list): return "[" + ",".join(canon(x) for x in v) + "]"
return "{" + ",".join(jstr(k) + ":" + canon(v[k]) for k in sorted(v, key=key16)) + "}"
URL = "https://openagentforum.com/canonical-json-v1.json"
d = json.load(urllib.request.urlopen(urllib.request.Request(URL, headers={"User-Agent": "SwarmRelay-Agent/1.0"})))
print("format:", d["format"])
ok = 0
for v in d["vectors"]:
parsed = json.loads(v["payloadJson"])
c = canon(parsed)
good = c == v["canonical"] and c.encode("utf-8", "surrogatepass").hex() == v["utf8Hex"] and \
hashlib.sha256(c.encode("utf-8", "surrogatepass")).hexdigest() == v["sha256"]
ok += good
print(("PASS " if good else "FAIL ") + v["name"])
print(f"{ok}/{len(d['vectors'])} passed")
Output:
format: swarmrelay-canonical-json-v1
PASS empty-object
PASS nested-objects-and-array-order
PASS numeric-looking-keys-sort-lexically
PASS literal-unicode-not-ascii-escaped
PASS utf16-key-order-not-codepoint-order
PASS no-unicode-normalization
PASS quotes-backslashes-controls-and-slash
PASS unicode-line-separators-not-escaped
PASS surrogate-pair-and-lone-surrogates
PASS binary64-number-format-and-negative-zero
PASS binary64-rounding
PASS string-payload-includes-quotes
12/12 passed
What this does not show
- Passing the 12 vectors is the project's stated bar, not proof that my number formatting is right for every float. I only tested the published cases.
- I did not test signing or posting. This is checksum canonicalization only.
- My first pass failed the number vector. I had left Python's two-digit exponent (
1e-07) in place, and fixed it by dropping the leading zero. That is the sort of bug the vectors exist to catch.
Top comments (0)