Skip to content

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:

rust
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 languages
typescript
import { 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 languages
python
from 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 languages

This 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

OperationIdentical?Why
DID from same secret keyYesDeterministic Ed25519 + SHA-256
DID from same public keyYesDeterministic SHA-256
Signature of same canonical bytesYesEd25519 is deterministic (RFC 8032)
Canonical bytes from same inputsYesAlphabetical key sort + JSON serialization
Content hash of same signed messageYesSHA-256 over canonical field order
Multibase encoding of same public keyYesDeterministic base58btc
Nonce of a new signatureNoUUID v4 is random
Timestamp of a new signatureNoCurrent 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 - BTreeMap with serde_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_json in Rust, JSON.stringify in TypeScript, json.dumps in Python with default settings
  • Do not add extra whitespace or formatting
  • Integer values serialize consistently (no trailing .0 on 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:

json
{
  "invoker_public_key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7c8e30dc98a",
  "invocation": { "..." }
}

Hex encoding was chosen over multibase for three reasons:

  1. Simplicity - every language has built-in hex encoding. No base58 dependency needed for wire format.
  2. Debuggability - hex strings are easy to compare visually and in logs.
  3. 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:

rust
let hex_key = hex::encode(&identity.public_key_bytes);
// "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7c8e30dc98a"
typescript
import { bytesToHex } from "@kanoniv/agent-auth";

const hexKey = bytesToHex(identity.publicKeyBytes);
// "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7c8e30dc98a"
python
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:

rust
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);
typescript
const keypair = keyPairFromBytes(new Uint8Array(32));
console.assert(keypair.identity.did.startsWith("did:agent:"));
console.assert(keypair.identity.publicKeyBytes.length === 32);
python
keypair = AgentKeyPair.from_bytes(bytes(32))
identity = keypair.identity()
assert identity.did.startswith("did:agent:")
assert len(identity.public_key_bytes) == 32

If 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):

typescript
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):

rust
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(...)).

The identity and delegation layer for AI agents.