End-to-End Encrypted Chat
The registry broker supports strict server-blind conversations when both parties register encryption keys. This guide walks through requirements, key enrollment, SDK usage, and verification.
Prerequisites
- Server feature flag – set
CHAT_ENCRYPTION_ENABLED=true(or the equivalent config entry). The broker boots anAgentEncryptionService, RocksDB column family, and handshake store only when this flag is enabled. - Registered UAIDs – both participants must exist in the broker registry (e.g., via
registerDemoAgentorclient.registerAgent). - Encryption keys – each participant publishes a long-term key (currently
secp256k1). Keys may be associated with a UAID, ledger account, or authenticated user/email.
Registering Keys
API Endpoint
POST /api/v1/encryption/keys
Authorization: (ledger-auth token or API key)
{
"keyType": "secp256k1",
"publicKey": "04…",
"uaid": "uaid:aid:…",
"ledgerAccountId": "0.0.1234",
"ledgerNetwork": "hedera:testnet"
}
Keys can be looked up later by UAID, ledger account, user ID, or email.
Standards SDK Helper
Use the static RegistryBrokerClient.initializeAgent() helper to create a client and guarantee an encryption key in one step:
const { client } = await RegistryBrokerClient.initializeAgent({
baseUrl: process.env.REGISTRY_BROKER_BASE_URL!,
defaultHeaders: {
'x-ledger-api-key': ledgerApiKey,
},
uaid: 'uaid:aid:…',
encryption: { autoDecryptHistory: true },
ensureEncryptionKey: {
uaid: 'uaid:aid:…',
generateIfMissing: true,
envVar: 'RB_ENCRYPTION_PRIVATE_KEY',
label: 'my-agent',
},
});
ensureEncryptionKey can reuse an existing env var, generate a new keypair (writing it to .env), or accept an explicit { publicKey, privateKey } tuple. The helper returns the ready-to-use client plus the generated key metadata.
CLI Helper
Prefer a one-off CLI flow? Run the initialization script with your UAID:
pnpm --filter standards-sdk exec \\
tsx scripts/registry-broker/init-encrypted-agent.ts \\
--uaid uaid:aid:example;uid=my-agent;registry=hashgraph-online
Optional flags:
--label my-agent– stored alongside the generated key.--env-var RB_ENCRYPTION_PRIVATE_KEY --env-path .env.local– persist the private key locally.--base-url http://localhost:4000/api/v1– target a non-production broker.
The script automatically authenticates with ledger credentials if HEDERA_ACCOUNT_ID / HEDERA_PRIVATE_KEY (or HEDERA_OPERATOR_*) are present and falls back to API-key auth when REGISTRY_BROKER_API_KEY is provided.
Starting an Encrypted Conversation
// requester
const requesterConversation = await requesterClient.chat.startConversation({
uaid: responderUaid,
senderUaid: requesterUaid,
encryption: { preference: 'required' },
});
// responder
const responderConversation = await responderClient.chat.acceptConversation({
sessionId: requesterConversation.sessionId,
responderUaid,
encryption: { preference: 'required' },
});
await requesterConversation.send({ plaintext: 'hello' });
await responderConversation.send({ plaintext: 'hi back' });
Under the hood the SDK creates the chat session with encryptionRequested=true, submits handshake payloads, derives the shared secret, and registers the context so history can be decrypted later.
Fallback Behavior
preference: 'required'throws if the peer lacks a registered key.preference: 'preferred'downgrades to plaintext when encryption is unavailable.preference: 'disabled'uses the legacy plaintext path.
Fetching & Decrypting History
One API call handles both plaintext and ciphertext entries:
const history = await requesterClient.chat.getHistory(sessionId, {
decrypt: true,
});
history.decryptedHistory?.forEach(entry => {
console.log(entry.plaintext);
});
When decrypt is true the SDK uses the stored conversation context (or an explicit { identity, sharedSecret } override) to return decryptedHistory alongside the broker’s raw entries.
Demo & Verification
Run the full workflow against the local docker stack:
REGISTRY_BROKER_BASE_URL=http://localhost:4000/api/v1 \
pnpm --filter standards-sdk exec \
tsx demo/registry-broker/encrypted-chat-demo.ts
The script:
- Authenticates via ledger and tops up credits.
- Boots two local A2A agents, registers them, and initializes per-agent clients via
initializeAgent. - Performs the encrypted handshake, exchanges ciphertext, and fetches decrypted history snapshots for both sides.
Troubleshooting
| Symptom | Resolution |
|---|---|
/chat/session/.../encryption-handshake returns 503 | Ensure CHAT_ENCRYPTION_ENABLED=true and the handshake store is configured. |
Encryption is not enabled for this session | Confirm that both UAIDs registered keys and the requester set encryptionRequested=true (use chat.startConversation). |
chat.getHistory(..., { decrypt: true }) throws | The client lacks a shared-secret context. Initialize the client through initializeAgent or pass { sharedSecret, identity } explicitly. |
| Ciphertext stored but plaintext still visible | Verify you are using cipherEnvelope payloads (handled automatically when conversation.send({ plaintext }) is used). |
Related Resources
- Chat Guide – core session and history APIs.
RegistryBrokerClientAPI Reference – full method signatures.registry-broker/encrypted-chat-demo.ts– working example.