Cross-Language Interop
The agent-auth library ships in three languages - Rust, TypeScript (via @noble/ed25519), and Python (via PyO3 bindings to the Rust crate). All three produce byte-identical outputs for the same inputs. A message signed in TypeScript verifies in Rust. A DID generated in Python matches the one generated in Rust from the same key.
Same key, same DID
Given the same 32-byte secret key, all three languages produce the same DID:
use kanoniv_agent_auth::AgentKeyPair;
let secret: [u8; 32] = [
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60,
0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c, 0xc4,
0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19,
0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae, 0x7f, 0x60,
];
let keypair = AgentKeyPair::from_bytes(&secret);
println!("{}", keypair.identity().did);
// Output is identical across all three languagesimport { keyPairFromBytes } from "@kanoniv/agent-auth";
const secret = new Uint8Array([
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60,
0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c, 0xc4,
0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19,
0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae, 0x7f, 0x60,
]);
const keypair = keyPairFromBytes(secret);
console.log(keypair.identity.did);
// Output is identical across all three languagesfrom kanoniv_agent_auth import AgentKeyPair
secret = bytes([
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60,
0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c, 0xc4,
0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19,
0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae, 0x7f, 0x60,
])
keypair = AgentKeyPair.from_bytes(secret)
print(keypair.identity().did)
# Output is identical across all three languagesThis works because the DID derivation is pure math: Ed25519 public key derivation followed by SHA-256 truncation. No randomness, no platform-specific behavior.
What is guaranteed identical
| Operation | Identical? | Why |
|---|---|---|
| DID from same secret key | Yes | Deterministic Ed25519 + SHA-256 |
| DID from same public key | Yes | Deterministic SHA-256 |
| Signature of same canonical bytes | Yes | Ed25519 is deterministic (RFC 8032) |
| Canonical bytes from same inputs | Yes | Alphabetical key sort + JSON serialization |
| Content hash of same signed message | Yes | SHA-256 over canonical field order |
| Multibase encoding of same public key | Yes | Deterministic base58btc |
| Nonce of a new signature | No | UUID v4 is random |
| Timestamp of a new signature | No | Current wall clock time |
TIP
Ed25519 signatures are fully deterministic per RFC 8032. Given the same secret key and the same message bytes, the signature is always the same. This is different from ECDSA, which requires a random nonce and produces different signatures each time.
Canonical JSON determinism
The signing canonical form uses alphabetically sorted top-level keys:
{"nonce":"...","payload":{...},"signer_did":"...","timestamp":"..."}Each language achieves this differently:
- Rust -
BTreeMapwithserde_json::to_vec(BTreeMap maintains sorted key order) - TypeScript - Manual string concatenation in the correct alphabetical order (does not rely on JS object key ordering)
- Python - PyO3 calls directly into the Rust implementation
The payload value is serialized by each language's JSON serializer. For cross-language verification to work, the payload JSON must be byte-identical. In practice this means:
- Use
serde_jsonin Rust,JSON.stringifyin TypeScript,json.dumpsin Python with default settings - Do not add extra whitespace or formatting
- Integer values serialize consistently (no trailing
.0on whole numbers) - String escaping follows the JSON spec
Hex encoding for MCP proofs
In MCP proofs, the invoker's public key is transmitted as a hex-encoded string rather than multibase:
{
"invoker_public_key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7c8e30dc98a",
"invocation": { "..." }
}Hex encoding was chosen over multibase for three reasons:
- Simplicity - every language has built-in hex encoding. No base58 dependency needed for wire format.
- Debuggability - hex strings are easy to compare visually and in logs.
- Cross-language safety - hex encoding has zero ambiguity. The same bytes always produce the same 64-character lowercase string in every language.
All three implementations produce lowercase hex:
let hex_key = hex::encode(&identity.public_key_bytes);
// "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7c8e30dc98a"import { bytesToHex } from "@kanoniv/agent-auth";
const hexKey = bytesToHex(identity.publicKeyBytes);
// "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7c8e30dc98a"hex_key = identity.public_key_bytes.hex()
# "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7c8e30dc98a"Testing cross-language compatibility
To verify that your installation produces correct outputs, use a known test vector. Given the all-zero secret key (0x00 repeated 32 times):
Secret key (hex): 0000000000000000000000000000000000000000000000000000000000000000
Public key (hex): 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
DID: did:agent:<sha256(public_key)[0..16] as hex>You can verify this in any language:
let keypair = AgentKeyPair::from_bytes(&[0u8; 32]);
let identity = keypair.identity();
assert!(identity.did.starts_with("did:agent:"));
assert_eq!(identity.public_key_bytes.len(), 32);const keypair = keyPairFromBytes(new Uint8Array(32));
console.assert(keypair.identity.did.startsWith("did:agent:"));
console.assert(keypair.identity.publicKeyBytes.length === 32);keypair = AgentKeyPair.from_bytes(bytes(32))
identity = keypair.identity()
assert identity.did.startswith("did:agent:")
assert len(identity.public_key_bytes) == 32If all three produce the same DID string, your installation is correct and cross-language signing will work.
End-to-end example: sign in TypeScript, verify in Rust
A common pattern: an agent running in Node.js signs a message, sends it over HTTP, and a Rust server verifies it.
TypeScript (agent side):
import { generateKeyPair, signMessage, bytesToHex } from "@kanoniv/agent-auth";
const keypair = generateKeyPair();
const signed = signMessage(keypair, { action: "resolve", source: "crm" });
// Send to server
await fetch("https://api.example.com/v1/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: signed,
public_key: bytesToHex(keypair.identity.publicKeyBytes),
}),
});Rust (server side):
use kanoniv_agent_auth::{AgentIdentity, SignedMessage};
// Receive from client
let public_key_hex: &str = &request.public_key;
let pk_bytes = hex::decode(public_key_hex).unwrap();
let identity = AgentIdentity::from_bytes(&pk_bytes).unwrap();
// Deserialize and verify
let signed: SignedMessage = serde_json::from_value(request.message).unwrap();
signed.verify(&identity).unwrap(); // Throws if invalid
println!("Verified action from: {}", identity.did);The signature created by @noble/ed25519 in TypeScript is verified by ed25519-dalek in Rust. The canonical JSON bytes are identical because both languages sort the top-level keys alphabetically and serialize the payload the same way.
Common pitfalls
Payload JSON differences. If the TypeScript agent serializes {"count": 1} but the Rust server re-serializes the deserialized value as {"count":1} (without space), the canonical bytes will differ and verification will fail. Always verify the message as-received, without re-serializing the payload.
Floating point numbers. 1.0 in some JSON serializers becomes 1 in others. Avoid floating point in signed payloads when possible. Use strings for decimal values (e.g., "cost": "5.00" instead of "cost": 5.0).
Key encoding mismatch. Public keys in DID Documents use multibase (z + base58btc). Public keys in MCP proofs and wire transport use hex. Do not mix them - check whether you need from_multibase() or from_bytes(hex_decode(...)).
