One JSON file keeps every SDK honest
Pin a cross-language wire protocol with a shared test-vector file — fixed keys, canonical strings, and expected signatures — that every implementation's test suite consumes verbatim.
A signed-request protocol implemented in two languages will drift, and the drift is invisible until an integration fails in the field. My gateway’s proof-of-possession scheme signs a canonical string — method, path-with-query, app ID, timestamp, nonce, body hash — with Ed25519. The TypeScript SDK builds that string; the Python SDK builds that string; the gateway builds it a third time to verify. Any disagreement, and the symptom is a bare “Invalid signature” with nothing to debug: was it the newline convention? Uppercased method? Whether the query string is included? Base64 versus base64url on the body hash? Unicode normalization of the body? Each implementation’s unit tests can pass perfectly while agreeing with nobody.
The standard fix for one codebase — share the canonical-string builder — doesn’t cross a language boundary. And Cookey deliberately vendors the ~100-line canonical builder into each SDK rather than importing server code (the SDK must stay zero-dependency), so even the two TypeScript copies can drift.
The artifact
The contract is pinned by a single artifact: sdks/test-vectors.json, checked into the repo at a language-neutral path, consumed verbatim by every implementation’s test suite. It contains a fixed keypair and a handful of fully-worked examples — inputs, every intermediate value, and the final signature:
{
"description": "PoP v1 cross-language test vectors. Seed is a fixed RFC 8032 test seed — NEVER use in production.",
"seedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"publicKeyBase64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
"vectors": [
{
"name": "post-chat-completion",
"method": "POST",
"pathWithQuery": "/r/llm/groq/v1/chat/completions",
"appId": "app_testvector_1",
"ts": "1730000000",
"nonce": "vectornonce000001",
"body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[…]}",
"bodyHashBase64Url": "2H09d4yg-XIuJP6HJB49KhIZCyoYi0RzKD4JrYJ32OY",
"canonicalRequest": "v1\nPOST\n/r/llm/groq/v1/chat/completions\napp_testvector_1\n1730000000\nvectornonce000001\n2H09d4yg-XIuJP6HJB49KhIZCyoYi0RzKD4JrYJ32OY\n",
"signatureBase64Url": "2kTUCwmcU5ENHQSD1EG0M-XxKwsH6KXYlAkQRy3fTGGWjIweRplVpWKZQleeWFYz-1rpcNE6JjLEpEY9iKiaAg"
}
]
}
Three deliberate choices are doing the work here. The seed is a published RFC 8032 test vector seed — unmistakably not a production key, and labeled as such in the file itself. Each vector includes the intermediate values (bodyHashBase64Url, canonicalRequest), not just the final signature, so a failing test tells you which stage diverged instead of just “signature mismatch.” And the vector set targets the known drift traps: a GET with a query string (is ?verbose=1 part of the signed path?), an empty body (the well-known SHA-256 of zero bytes), and a Unicode body ("héllo wörld 🎉" — UTF-8 encoding before hashing, where languages love to disagree).
The boundary is also what this file is not: it isn’t a conformance suite for the server’s auth decisions (timestamps, replays, revocation — those are server integration tests). It pins exactly one thing, the pure function from request → bytes → signature, because that’s the part that exists in N places.
Every stage, in every language
Each SDK’s test suite loads the same file by relative path and asserts every stage. The Python side (sdks/python/tests/test_vectors.py):
VECTORS = json.loads(
(Path(__file__).resolve().parents[2] / "test-vectors.json").read_text()
)
def test_vectors():
seed = bytes.fromhex(VECTORS["seedHex"])
public_key = base64.b64decode(VECTORS["publicKeyBase64"])
for vector in VECTORS["vectors"]:
body_hash = _b64url(hashlib.sha256(vector["body"].encode()).digest())
assert body_hash == vector["bodyHashBase64Url"], vector["name"]
canonical = build_canonical_request(
method=vector["method"],
path_with_query=vector["pathWithQuery"],
app_id=vector["appId"],
ts=vector["ts"],
nonce=vector["nonce"],
body_hash=body_hash,
)
assert canonical == vector["canonicalRequest"], vector["name"]
# Ed25519 is deterministic, so our signature must be byte-identical
# to the fixture signature — and both must verify.
signature = sign(seed, canonical.encode())
assert _b64url(signature) == vector["signatureBase64Url"], vector["name"]
assert verify(public_key, canonical.encode(), signature)
The TypeScript SDK and the gateway itself run the same file through their vendored builders (packages/sdk/src/canonical.ts, apps/proxy/src/server/auth/__tests__/pop-vectors.test.ts). Note the determinism assertion: because Ed25519 signatures are deterministic (no random nonce, unlike ECDSA), the test can demand byte-identical signatures, not merely “verifies.” If your protocol uses a randomized signature scheme, you lose that and must settle for verify-both-ways — one more reason deterministic signatures are pleasant in wire protocols.
To adapt this: put the vector file at a repo path that isn’t inside any one language’s package, generate it once with whichever implementation you trust most (then hand-check the canonical strings against your written spec), and wire every implementation’s CI to consume it by path. When the protocol evolves, vectors for v2 get added — existing v1 vectors are immutable, because deployed clients still speak v1 and the file is your only proof the server still understands them.
How drift gets caught
The mechanism’s own failure modes are mundane, which is the point. If someone edits the canonical format in one SDK, that SDK’s vector test fails in CI before the change ships — the file converts silent cross-repo drift into a loud single-repo test failure. If someone edits the vector file to make a broken implementation pass, the other implementations’ suites immediately fail, because they all consume the same bytes; gaming the fixture requires breaking every language at once, in the same pull request, visibly. The residual risk is a behavior no vector covers (the file can’t prove the absence of edge cases — percent-encoding in paths, say), so the vector set should grow a new entry with every wire-format bug found in the field: the vector is the regression test.
Porting gets a finish line
New SDKs stop being trust exercises. A contributor porting the signer to Go or Rust has a finish line you defined: load the file, pass every vector, done — no coordinated debugging session against a live gateway, no “works with my test key.” Protocol changes acquire a natural review artifact (the new vectors’ diff shows exactly what changed on the wire), and the question “did we break old clients?” has a mechanical answer for as long as the old vectors stay green.