Skip to content

Canonical JSON Signing Specification

Version: 0.1.0 Status: Draft

This document specifies how Kanoniv Agent Auth signs and verifies JSON payloads using Ed25519 signatures over a canonical byte representation.

1. SignedMessage Structure

A signed message envelope has five fields:

json
{
  "payload": <any JSON value>,
  "signer_did": "did:agent:<32-hex-chars>",
  "nonce": "<uuid-v4>",
  "timestamp": "<rfc3339-millis-utc>",
  "signature": "<hex-encoded-ed25519-signature>"
}
FieldTypeDescription
payloadAny JSON valueThe message content. Object, array, string, number, boolean, or null.
signer_didStringThe did:agent: identifier of the signing agent.
nonceStringA UUID v4 string for replay protection. MUST be unique per message.
timestampStringRFC 3339 timestamp with millisecond precision and UTC Z suffix. Example: 2026-03-15T14:30:00.123Z.
signatureStringLowercase hex-encoded 64-byte Ed25519 signature (128 hex characters).

2. Canonical Serialization

The signature covers a canonical byte representation of the envelope. The canonical form ensures deterministic serialization across languages and implementations.

2.1 Construction

  1. Construct an ordered map with exactly four entries in alphabetical key order:

    • "nonce" -> JSON string of the nonce value
    • "payload" -> the payload value as-is
    • "signer_did" -> JSON string of the signer DID
    • "timestamp" -> JSON string of the timestamp
  2. Serialize the ordered map to compact JSON (UTF-8, no trailing newline, no whitespace between tokens).

2.2 Payload Handling

The payload value is included in its original serialization form. Implementations MUST NOT recursively sort keys within the payload. Only the four top-level envelope keys are ordered.

Example: If the payload is {"z": 1, "a": 2}, the canonical form preserves this key order within the payload:

json
{"nonce":"550e8400-e29b-41d4-a716-446655440000","payload":{"z":1,"a":2},"signer_did":"did:agent:21fe31dfa154a261626bf854046fd227","timestamp":"2026-03-15T14:30:00.123Z"}

2.3 Implementation Notes

  • Rust: Use BTreeMap<&str, serde_json::Value> with keys inserted as "nonce", "payload", "signer_did", "timestamp". BTreeMap maintains alphabetical order. Serialize with serde_json::to_vec.
  • TypeScript: Manually construct the JSON string by concatenating keys in order. Do NOT use JSON.stringify on an object - JavaScript object key ordering is not guaranteed for integer-like keys.
  • Python: Use collections.OrderedDict with keys in alphabetical order, or manually construct the JSON string.

3. Signing Algorithm

Given an AgentKeyPair and a JSON payload:

  1. Derive the agent's identity: identity = keypair.identity().
  2. Generate a fresh nonce: nonce = UUID_v4().
  3. Generate the timestamp: timestamp = now_utc().to_rfc3339_millis() + "Z".
  4. Compute the canonical bytes: bytes = canonical_serialize(nonce, payload, identity.did, timestamp).
  5. Sign with Ed25519: sig = ed25519_sign(keypair.secret_key, bytes).
  6. Hex-encode the 64-byte signature: signature = hex_lower(sig).
  7. Return SignedMessage { payload, signer_did: identity.did, nonce, timestamp, signature }.

4. Verification Algorithm

Given a SignedMessage and an AgentIdentity (public key + DID):

  1. Check signer DID. If message.signer_did != identity.did, return error SignatureInvalid.
  2. Recompute canonical bytes. bytes = canonical_serialize(message.nonce, message.payload, message.signer_did, message.timestamp).
  3. Decode signature. sig_bytes = hex_decode(message.signature). If not valid hex, return error InvalidSignatureEncoding. If length is not 64 bytes, return error InvalidSignatureEncoding.
  4. Verify Ed25519 signature. ed25519_verify(identity.public_key, bytes, sig_bytes). If verification fails, return error SignatureInvalid.
  5. Return success.

Important: Verification does not check the timestamp or nonce for freshness. Timestamp and nonce validation (replay protection) is application-specific and left to the verifier.

5. Content Hash

The content hash uniquely identifies a signed message. It is used as:

  • parent_id in provenance DAG chaining
  • delegation_hash in invocation payloads
  • Revocation identifiers

5.1 Algorithm

  1. Construct an ordered map with exactly five entries in alphabetical key order:

    • "nonce" -> JSON string of the nonce
    • "payload" -> the payload value as-is
    • "signature" -> JSON string of the hex signature
    • "signer_did" -> JSON string of the signer DID
    • "timestamp" -> JSON string of the timestamp
  2. Serialize to compact JSON (UTF-8).

  3. Compute SHA-256 of the JSON bytes.

  4. Return lowercase hex encoding of the 32-byte hash (64 hex characters).

5.2 Distinction from Signing Canonical Form

The signing canonical form has 4 keys: {nonce, payload, signer_did, timestamp} (excludes signature because the signature does not exist yet at signing time).

The content hash canonical form has 5 keys: {nonce, payload, signature, signer_did, timestamp} (includes signature because it identifies the complete signed message).

Both use alphabetical key ordering. Both use BTreeMap (Rust) or equivalent ordered map.

5.3 Properties

  • Deterministic. The same signed message always produces the same content hash.
  • Collision resistant. SHA-256 provides 128-bit collision resistance.
  • Includes signature. Two messages with identical payloads but different nonces (and therefore different signatures) produce different content hashes.

6. Replay Protection

Each signed message includes a UUID v4 nonce. Implementations MUST generate a fresh nonce for every message. Reusing a nonce enables replay attacks.

Verifiers MAY implement replay protection by:

  • Maintaining a set of seen nonces and rejecting duplicates
  • Rejecting messages with timestamps outside a configurable time window
  • Combining both approaches

The protocol provides the nonce and timestamp; enforcement policy is application-specific.

7. Cross-Language Interoperability

7.1 Requirements

All implementations MUST:

  1. Produce byte-identical canonical forms for the same inputs.
  2. Verify signatures produced by any other implementation.
  3. Compute identical content hashes for the same signed message.

7.2 Test Vector

Using the test keypair:

Secret: 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
Public: d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
DID:    did:agent:21fe31dfa154a261626bf854046fd227

Given:

  • payload: {"action": "test"}
  • nonce: "550e8400-e29b-41d4-a716-446655440000"
  • timestamp: "2026-01-01T00:00:00.000Z"

The canonical bytes (for signing) MUST be:

json
{"nonce":"550e8400-e29b-41d4-a716-446655440000","payload":{"action":"test"},"signer_did":"did:agent:21fe31dfa154a261626bf854046fd227","timestamp":"2026-01-01T00:00:00.000Z"}

Any implementation signing these canonical bytes with the test secret key MUST produce the same 64-byte Ed25519 signature.

The identity and delegation layer for AI agents.