Skip to content

Signing

Every signed message in Kanoniv uses the same envelope format: a JSON payload, the signer's DID, a nonce, a timestamp, and an Ed25519 signature over the canonical representation. This format is used for provenance entries, delegation proofs, invocation proofs, and MCP authentication.

SignedMessage structure

json
{
  "payload": { "action": "merge", "entity_id": "abc123" },
  "signer_did": "did:agent:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "nonce": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "timestamp": "2026-03-15T10:30:00.000Z",
  "signature": "3a4f8b...128 hex chars...9c2e"
}
FieldTypePurpose
payloadJSON valueThe message content (arbitrary JSON)
signer_didstringThe did:agent: identifier of the signer
noncestringUUID v4 for replay protection
timestampstringRFC 3339 with millisecond precision, UTC (Z suffix)
signaturestringHex-encoded Ed25519 signature (128 hex chars = 64 bytes)

Canonical form

The signature covers a canonical JSON representation of the envelope, not the raw payload. Canonical form ensures that every language produces the same bytes for the same message.

The canonical representation is a JSON object with four keys in alphabetical order:

json
{"nonce":"...","payload":{...},"signer_did":"...","timestamp":"..."}

Key properties:

  • Top-level keys are sorted alphabetically: nonce, payload, signer_did, timestamp
  • The payload value is serialized as-is (inner keys are NOT re-sorted)
  • No whitespace between keys or values
  • Standard JSON encoding (no trailing commas, no comments)

In Rust, this is achieved with BTreeMap (which maintains sorted key order). In TypeScript, canonical JSON is constructed by string concatenation in the correct order to avoid relying on JS object key ordering.

TIP

Only the top-level envelope keys are sorted. If your payload is {"z": 1, "a": 2}, the payload is serialized as {"z":1,"a":2} - the inner ordering is preserved from the original JSON. This matters for cross-language determinism: all three languages must serialize the payload identically.

Sign a message

rust
use kanoniv_agent_auth::{AgentKeyPair, SignedMessage};

let keypair = AgentKeyPair::generate();

let payload = serde_json::json!({
    "action": "merge",
    "entity_id": "abc123",
    "source_ids": ["hubspot:hs-42", "stripe:cus-99"]
});

let signed = SignedMessage::sign(&keypair, payload).unwrap();

// The signed message contains all fields
println!("Signer: {}", signed.signer_did);
println!("Nonce: {}", signed.nonce);
println!("Timestamp: {}", signed.timestamp);
println!("Signature: {}", signed.signature); // 128 hex chars
typescript
import { generateKeyPair, signMessage } from "@kanoniv/agent-auth";

const keypair = generateKeyPair();

const payload = {
  action: "merge",
  entity_id: "abc123",
  source_ids: ["hubspot:hs-42", "stripe:cus-99"],
};

const signed = signMessage(keypair, payload);

console.log("Signer:", signed.signer_did);
console.log("Nonce:", signed.nonce);
console.log("Timestamp:", signed.timestamp);
console.log("Signature:", signed.signature); // 128 hex chars
python
import json
from kanoniv_agent_auth import AgentKeyPair

keypair = AgentKeyPair.generate()

payload = json.dumps({
    "action": "merge",
    "entity_id": "abc123",
    "source_ids": ["hubspot:hs-42", "stripe:cus-99"]
})

signed = keypair.sign(payload)

print(f"Signer: {signed.signer_did}")
print(f"Nonce: {signed.nonce}")
print(f"Timestamp: {signed.timestamp}")
print(f"Signature: {signed.signature}")  # 128 hex chars

Each call to sign generates a fresh nonce and timestamp. Two signatures of the same payload will produce different nonces, timestamps, and signatures - this is expected and provides replay protection.

Verify a message

Verification checks that the signature is valid for the canonical form of the envelope, using the signer's public key:

rust
use kanoniv_agent_auth::AgentIdentity;

// Receive the signed message and the signer's public key
let signer_identity: AgentIdentity = get_signer_identity();

match signed.verify(&signer_identity) {
    Ok(()) => println!("Signature valid"),
    Err(e) => println!("Verification failed: {}", e),
}
typescript
import { verifyMessage } from "@kanoniv/agent-auth";

// Receive the signed message and the signer's public identity
const signerIdentity = getSignerIdentity();

try {
  verifyMessage(signed, signerIdentity);
  console.log("Signature valid");
} catch (e) {
  console.log("Verification failed:", e.message);
}
python
# Receive the signed message and the signer's public identity
signer_identity = get_signer_identity()

try:
    signed.verify(signer_identity)
    print("Signature valid")
except ValueError as e:
    print(f"Verification failed: {e}")

Verification fails if:

  • The signer_did does not match the identity's DID
  • The signature bytes are not valid hex or not 64 bytes
  • The Ed25519 signature does not match the canonical bytes
  • Any field (payload, nonce, timestamp, signer_did) was tampered with after signing

Content hashing

Every SignedMessage can produce a SHA-256 content hash - a fingerprint of the entire signed envelope:

rust
let hash = signed.content_hash();
println!("Content hash: {}", hash); // 64 hex chars
typescript
import { contentHash } from "@kanoniv/agent-auth";

const hash = contentHash(signed);
console.log("Content hash:", hash); // 64 hex chars
python
hash_value = signed.content_hash()
print(f"Content hash: {hash_value}")  # 64 hex chars

The content hash covers all five fields in alphabetical order: {nonce, payload, signature, signer_did, timestamp}. This is different from the signing canonical form (which excludes signature).

Content hashes are used for:

  • Provenance chains - each entry references its parent by content hash
  • Delegation revocation - revoke a delegation by its content hash
  • Deduplication - detect duplicate signed messages

WARNING

The content hash includes the signature, so it is unique per signing operation. Two signatures of the same payload will produce different content hashes (because of different nonces and signatures).

Serialization

Signed messages serialize to standard JSON and can be deserialized in any language:

rust
// Serialize
let json = serde_json::to_string(&signed).unwrap();

// Deserialize
let restored: SignedMessage = serde_json::from_str(&json).unwrap();

// Verify the deserialized message
restored.verify(&identity).unwrap();
typescript
// Serialize
const json = JSON.stringify(signed);

// Deserialize
const restored: SignedMessage = JSON.parse(json);

// Verify the deserialized message
verifyMessage(restored, identity);
python
# Serialize
json_str = signed.to_json()

# Deserialize
from kanoniv_agent_auth import SignedMessage
restored = SignedMessage.from_json(json_str)

# Verify the deserialized message
restored.verify(identity)

A message signed in Rust can be serialized to JSON, sent over the wire, deserialized in TypeScript or Python, and verified there. See Cross-Language Interop for details on how determinism is maintained.

Tamper detection

The signature covers every field in the envelope. Changing any field after signing causes verification to fail:

rust
// Tamper with the payload
signed.payload = serde_json::json!({"action": "split"});
assert!(signed.verify(&identity).is_err()); // SignatureInvalid

// Tamper with the nonce
signed.nonce = "tampered-nonce".to_string();
assert!(signed.verify(&identity).is_err()); // SignatureInvalid

// Tamper with the timestamp
signed.timestamp = "2020-01-01T00:00:00.000Z".to_string();
assert!(signed.verify(&identity).is_err()); // SignatureInvalid

// Tamper with the signer DID
signed.signer_did = "did:agent:0000000000000000000000000000fake".to_string();
assert!(signed.verify(&identity).is_err()); // SignatureInvalid

This is the foundation of the trust model: if verification succeeds, the message was created by the holder of the secret key and has not been modified since signing.

The identity and delegation layer for AI agents.