obs-agent Message Link Encryption¶
This document is for users who deploy and use Beak. It explains how Beak and self-hosted obs-agent encrypt communication, and what configuration is required.
Overview¶
On top of existing HTTPS/WSS, Beak adds another layer of application-layer encryption for chat payloads between Beak and obs-agent. Even if a man-in-the-middle captures WebSocket messages, they can only see base64-encoded ciphertext and a small set of routing fields. They cannot read user messages, agent replies, thinking, plans, task insights, or natural-language content in tool calls.
This is not full end-to-end encryption. The Beak server remains the trusted boundary: Beak decrypts messages, stores history, audits, searches, and displays plaintext to Web UI, obscli, and IM channels.
Protection Boundary¶
flowchart LR
U[User<br/>Web UI / obscli / IM] -->|HTTPS<br/>Plain business API| B[Beak Server<br/>Trusted boundary]
B -->|WSS + HPKE application ciphertext| A[Self-hosted obs-agent]
A -->|WSS + HPKE application ciphertext| B
B -->|HTTPS<br/>Plain display/history/audit| U
M((Man-in-the-middle))
M -.Can only see ciphertext.-> B
M -.Can only see ciphertext.-> A
The encryption goal is to protect the obs-agent link, not to hide plaintext from Beak itself. Therefore:
- Web UI, obscli, and IM channels do not need encryption/decryption changes.
- Beak database message history is still stored with the existing plaintext semantics.
- A self-hosted agent decrypts incoming messages before passing them to the LLM and local tools.
- Natural-language payloads returned by the agent are encrypted before being sent to Beak. Beak decrypts them and then broadcasts them to user-side clients.
Encryption Scheme¶
The current implementation uses HPKE Auth mode. The algorithm identifier is:
HPKE is public-key message encryption defined by IETF RFC 9180. In simple terms:
- The receiver owns a private key and publishes a public key.
- The sender encrypts the message with the receiver public key.
- The receiver decrypts the message with its private key.
- Auth mode also binds the sender identity, so the receiver can confirm that the message came from the expected sender key.
Encrypted Content¶
After encryption is enabled, the following obs-agent link payloads are encrypted:
chat:usersent from Beak to agentchat:agentsent from agent to Beakchat:agent_thinkingsent by agentchat:agent_tool_callsent by agent- Natural-language or business payloads inside
chat:agent_event, such as plan and task insight
Before encryption, Beak does not encrypt only the content field. It encodes both business content and business metadata as JSON, then encrypts that JSON:
{
"content": "Plaintext body",
"metadata": {
"event_type": "task_insight",
"tool_call_content": "..."
}
}
The WebSocket message on the link still keeps minimal routing fields, such as message ID, session ID, sender ID, message type, and metadata.message_encryption. These fields are used for routing and validation, and do not carry user text.
View Message Structure On The Wire¶
Encrypted content is base64-encoded ciphertext bytes, not readable JSON. metadata.message_encryption is a public envelope that tells the receiver which key, algorithm, and direction to use for decryption.
Example structure:
{
"type": "chat:user",
"id": "msg_123",
"session_id": "session_abc",
"sender_id": "user:u1",
"receiver_id": "agent_xxx",
"content": "BASE64_CIPHERTEXT",
"metadata": {
"message_encryption": {
"version": "v1",
"alg": "hpke-auth-x25519-hkdf-sha256-aes-256-gcm",
"key_id": "hpke-agent-key",
"sender_key_id": "beak-2026-06",
"direction": "beak_to_agent",
"enc": "BASE64_HPKE_ENCAPSULATED_KEY",
"expires_at": "2026-06-30T10:05:00Z"
}
}
}
Both content and enc are base64 strings. They can be safely transmitted through JSON/WebSocket, but they cannot be interpreted as plaintext.
Key Exchange¶
Beak and agent each maintain their own receiving key. The rule is: the side that decrypts owns the private key.
sequenceDiagram
autonumber
participant Agent as obs-agent
participant Beak as Beak
Agent->>Agent: Read or generate agent private key on startup
Agent->>Beak: control:connect<br/>metadata.message_encryption={agent key_id, agent public_key}
Beak->>Beak: Record agent public key on current connection
Beak-->>Agent: ack<br/>metadata.message_encryption={beak key_id, beak public_key}
Agent->>Agent: Store Beak public key in memory only
Key points:
- The agent private key is stored locally at
AGENT_MESSAGE_HPKE_KEY_PATH. The default path is/var/lib/obs-agent/message-hpke-key.json. - If the key file does not exist, the agent automatically generates a key pair and writes it with
0600permissions. - Beak does not generate or store the agent private key.
- Beak public key is sent to the agent through the connection ack. The agent keeps it in memory only and does not write it into the local key file.
- If the agent refreshes its own key during a long-lived connection, it sends
control:message_key_update. Beak uses the new public key from that message onward.
Trace Message Encryption And Decryption¶
sequenceDiagram
autonumber
participant User as User-side client
participant Beak as Beak
participant Agent as obs-agent
participant LLM as LLM/Tools
User->>Beak: Send user message
Beak->>Beak: Store plaintext history and choose target agent
Beak->>Beak: Encrypt JSON payload with agent public key
Beak->>Agent: chat:user<br/>content=BASE64_CIPHERTEXT
Agent->>Agent: Decrypt with agent private key
Agent->>LLM: Plaintext enters agent runtime
LLM-->>Agent: Generate reply/event/tool call
Agent->>Agent: Encrypt JSON payload with Beak public key
Agent->>Beak: chat:agent / thinking / tool_call / event<br/>content=BASE64_CIPHERTEXT
Beak->>Beak: Decrypt with Beak private key and validate context
Beak-->>User: Plain display, history, audit, stream
A man-in-the-middle on the obs-agent link can only see ciphertext, routing fields, and the envelope. Beak and agent restore plaintext inside their own trusted boundaries.
AAD Protection¶
AAD means Additional Authenticated Data: data that is not encrypted but is authenticated. It binds ciphertext to a specific context and prevents an attacker from copying a ciphertext into another deployment, workspace, agent, session, or direction.
The current unified AAD fields are:
workspace_uuiddeployment_idagent_uuidsession_uuidmessage_uuidmessage_typedirectionexpires_atversionkey_idalgorithm
If any of these fields is tampered with, decryption fails. Different message types do not use different AAD schemas. Business details such as tool calls, plans, and thinking are placed inside the encrypted payload so the protocol does not become unstable as business fields change.
flowchart TD
C[Ciphertext content] --> D{Validate before decryption}
E[message_encryption envelope] --> D
A[AAD context<br/>deployment / workspace / agent / session / message / direction / expires_at] --> D
D -->|All match| P[Decode JSON payload]
D -->|Any mismatch| R[Reject message<br/>structured error]
Expiration And Replay Protection¶
Each encryption envelope has expires_at. The current default validity period is 5 minutes. The receiver rejects decryption after expiration.
The receiver also records ciphertext fingerprints seen within a short time window to avoid processing the same ciphertext twice in one process. This is in-process replay protection. It does not promise global replay protection across Beak pods, agent restarts, or shared storage.
Deploy Beak¶
Beak enables message encryption by default. When enabled, Beak must have a receiving private key configured. Otherwise Beak fails to start and reports that BEAK_MESSAGE_HPKE_KEYS_JSON or BEAK_MESSAGE_HPKE_PRIVATE_KEY is missing.
Generate a Beak HPKE key:
Example output:
{
"key_id": "beak-2026-06",
"private_key": "BASE64_PRIVATE_KEY",
"public_key": "BASE64_PUBLIC_KEY",
"algorithm": "hpke-auth-x25519-hkdf-sha256-aes-256-gcm",
"status": "active"
}
For Kubernetes deployments, put the Beak key ring in a Secret:
kubectl create secret generic beak-message-hpke \
--from-literal=BEAK_MESSAGE_HPKE_ACTIVE_KEY_ID=beak-2026-06 \
--from-literal=BEAK_MESSAGE_HPKE_KEYS_JSON='[{"key_id":"beak-2026-06","private_key":"BASE64_PRIVATE_KEY","status":"active"}]'
Reference it in the Deployment:
Only explicitly disable encryption for temporary troubleshooting or compatibility with old deployments:
Deploy Agent¶
Agent message encryption is enabled by default:
Usually you do not need to configure the agent private key. The agent uses:
If the file does not exist, the agent automatically generates:
{
"version": "v1",
"algorithm": "hpke-auth-x25519-hkdf-sha256-aes-256-gcm",
"key_id": "hpke-...",
"private_key": "BASE64_PRIVATE_KEY",
"public_key": "BASE64_PUBLIC_KEY"
}
Directory and file permissions:
- Key directory:
0700 - Key file:
0600 - The agent runtime user must be able to read and write this path.
To disable link encryption for a specific agent:
This is an agent-level switch. Beak can still serve that agent as a plaintext-compatible connection and records observability logs and metrics. A connection that has declared encryption enabled does not silently downgrade to plaintext if encryption or decryption fails.
Key Rotation¶
The Beak key ring supports three statuses:
active: used for new messages and can decrypt.decrypt_only: only decrypts old messages and is no longer used for new messages.retired: no longer used to decrypt ordinary messages.
Regular Beak key rotation flow:
sequenceDiagram
autonumber
participant Ops as Ops
participant Secret as K8s Secret / Vault
participant Beak as Beak pods
participant Agent as agents
Ops->>Ops: Generate new Beak key
Ops->>Secret: New key=active<br/>old key=decrypt_only
Ops->>Beak: Rolling restart
Beak-->>Agent: New connection ack sends new Beak public key
Agent->>Beak: Later messages use new key_id
Ops->>Secret: Remove or retire old key after grace period
Ops->>Beak: Rolling restart again
Agent keys can be rotated manually by deleting or replacing the key file and restarting the agent, or automatically with:
The default 0 means automatic rotation is disabled. If the agent rotates its key during a long-lived connection, it sends control:message_key_update so Beak can use the new agent public key.
Logs And Troubleshooting¶
Common issues:
- Beak fails to start and reports missing
BEAK_MESSAGE_HPKE_KEYS_JSON or BEAK_MESSAGE_HPKE_PRIVATE_KEY: configure the Beak key ring, or temporarily setBEAK_MESSAGE_ENCRYPTION_ENABLED=false. - Agent cannot receive encrypted user messages: check whether the agent reported
control:connect.metadata.message_encryption, which must includekey_idandpublic_key. - Agent cannot encrypt responses: check whether the Beak ack includes
metadata.message_encryptionandworkspace_uuid. - Beak decryption fails: check whether the envelope
key_idexists in the Beak key ring and whether its status isactiveordecrypt_only. - Replay or expired errors appear: check whether the message was sent repeatedly, whether system time drift exists, or whether the message exceeded the default 5-minute validity period.
Debug logs can record full ciphertext and full metadata.message_encryption for local testing and controlled troubleshooting. Info/warn/error logs should not record chat plaintext or private keys. Do not keep debug logs enabled for long periods in production.
Difference From Full E2EE¶
| Capability | obs-agent link encryption | Full end-to-end encryption |
|---|---|---|
| Prevent MITM from reading obs-agent messages | Yes | Yes |
| Beak server can see plaintext | Yes | No |
| Web UI / obscli / IM need changes | No | Yes |
| Beak can keep history, audit, search, and routing | Yes | Requires redesign |
| First implementation scope | Core link implemented | Not a goal |
The current scheme prioritizes protecting self-hosted agent link exposure while keeping existing clients and Beak server capabilities unchanged.