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
{
"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"
}| Field | Type | Purpose |
|---|---|---|
payload | JSON value | The message content (arbitrary JSON) |
signer_did | string | The did:agent: identifier of the signer |
nonce | string | UUID v4 for replay protection |
timestamp | string | RFC 3339 with millisecond precision, UTC (Z suffix) |
signature | string | Hex-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:
{"nonce":"...","payload":{...},"signer_did":"...","timestamp":"..."}Key properties:
- Top-level keys are sorted alphabetically:
nonce,payload,signer_did,timestamp - The
payloadvalue 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
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 charsimport { 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 charsimport 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 charsEach 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:
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),
}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);
}# 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_diddoes 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:
let hash = signed.content_hash();
println!("Content hash: {}", hash); // 64 hex charsimport { contentHash } from "@kanoniv/agent-auth";
const hash = contentHash(signed);
console.log("Content hash:", hash); // 64 hex charshash_value = signed.content_hash()
print(f"Content hash: {hash_value}") # 64 hex charsThe 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:
// 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();// Serialize
const json = JSON.stringify(signed);
// Deserialize
const restored: SignedMessage = JSON.parse(json);
// Verify the deserialized message
verifyMessage(restored, identity);# 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:
// 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()); // SignatureInvalidThis 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.
