docs/encryption.md
This document details how client data is encrypted, how encrypted blobs are structured, and how those blobs map onto protocol fields. It is based on packages/happy-cli/src/api/encryption.ts and the server routes that accept/emit these values.
For transport and event shapes, see protocol.md. For HTTP endpoints, see api.md.
graph TB
subgraph "Client (CLI/Mobile)"
Plain[Plaintext Data]
ClientEnc[Client Encryption]
B64[Base64 Encoded]
end
subgraph "Transport"
Wire[HTTP / WebSocket]
end
subgraph "Server"
Store[(Postgres)]
ServerEnc[Server Encryption]
Tokens[Service Tokens]
end
Plain --> ClientEnc --> B64 --> Wire --> Store
Tokens --> ServerEnc --> Store
style Plain fill:#e8f5e9
style B64 fill:#fff3e0
style Store fill:#e3f2fd
graph LR
subgraph "Variant Selection"
Check{Has dataKey?}
Check --> |No| Legacy[Legacy NaCl]
Check --> |Yes| DataKey[DataKey AES-GCM]
end
subgraph "Legacy"
L1[XSalsa20-Poly1305]
L2[32-byte shared secret]
end
subgraph "DataKey"
D1[AES-256-GCM]
D2[Per-session/machine key]
end
Legacy --> L1 & L2
DataKey --> D1 & D2
Clients currently use one of two encryption variants:
Used when the client only has a shared secret key.
Algorithm: tweetnacl.secretbox (XSalsa20-Poly1305)
Binary layout (plaintext JSON -> bytes):
[ nonce (24) | ciphertext+auth (secretbox output) ]
packet-beta
0-23: "nonce (24 bytes)"
24-55: "ciphertext + auth tag"
Used when the client supports per-session/per-machine data keys.
Algorithm: AES-256-GCM
Binary layout:
[ version (1) | nonce (12) | ciphertext (...) | authTag (16) ]
packet-beta
0-0: "ver"
1-12: "nonce (12 bytes)"
13-44: "ciphertext (...)"
45-60: "authTag (16 bytes)"
version is currently 0.flowchart LR
subgraph "Key Wrapping"
DEK[Data Encryption Key]
Eph[Ephemeral Keypair]
Box[tweetnacl.box]
Bundle[Key Bundle]
end
DEK --> Box
Eph --> Box
Box --> Bundle
subgraph "Content Encryption"
Plain[Plaintext]
AES[AES-256-GCM]
Cipher[Ciphertext]
end
DEK --> AES
Plain --> AES --> Cipher
When dataKey is used, the actual content key is encrypted for storage/transport.
Algorithm: tweetnacl.box with an ephemeral keypair.
Binary layout:
[ ephPublicKey (32) | nonce (24) | ciphertext (...) ]
packet-beta
0-31: "ephPublicKey (32 bytes)"
32-55: "nonce (24 bytes)"
56-87: "ciphertext (...)"
This blob is then wrapped with a version byte before being sent/stored:
[ version (1 = 0) | boxBundle (...) ]
The resulting bytes are base64-encoded and placed in fields such as dataEncryptionKey for sessions/machines/artifacts.
graph TB
subgraph "Client-Encrypted Fields"
direction TB
S1[Session metadata]
S2[Session agent state]
S3[Session messages]
M1[Machine metadata]
M2[Daemon state]
A1[Artifact header]
A2[Artifact body]
K1[KV store values]
AK[Access keys]
end
subgraph "Server Storage"
DB[(Postgres)]
end
S1 & S2 & S3 --> |opaque strings| DB
M1 & M2 --> |opaque strings| DB
A1 & A2 --> |opaque bytes| DB
K1 --> |opaque bytes| DB
AK --> |opaque string| DB
style S1 fill:#e1f5fe
style S2 fill:#e1f5fe
style S3 fill:#e1f5fe
style M1 fill:#e1f5fe
style M2 fill:#e1f5fe
style A1 fill:#e1f5fe
style A2 fill:#e1f5fe
style K1 fill:#e1f5fe
style AK fill:#e1f5fe
The server treats these fields as opaque strings/blobs. The client encrypts them before sending.
POST /v1/sessions (create/load)update-metadata / update-stateupdate-session eventssequenceDiagram
participant Client
participant Server
participant DB as Postgres
Client->>Client: Encrypt message
Client->>Server: emit "message" { sid, message: "<base64>" }
Server->>DB: Store { t: "encrypted", c: "<base64>" }
Note over Server: Later, sync to other clients
Server->>Client: update "new-message"
content: { t: "encrypted", c: "<base64>" }
Client->>Client: Decrypt message
message with a base64 encrypted blob.SessionMessage.content:
{ t: "encrypted", c: "<base64>" }new-message updates with the same structure.POST /v1/machinesmachine-update-metadata / machine-update-stateupdate-machine eventsheader and body are encrypted bytes encoded as base64 on the wire.Bytes in the DB.new-artifact / update-artifact events as base64 strings.AccessKey.data is treated as an opaque encrypted string.UserKVStore.value is encrypted bytes encoded as base64 on the wire.kvMutate expects base64 strings; kvGet/list/bulk return base64 strings.graph LR
subgraph "Wire Format"
JSON[JSON payload]
B64["base64 strings
(encrypted bytes)"]
Plain["plain values
(ids, versions, timestamps)"]
end
JSON --> B64
JSON --> Plain
Below are the typical JSON shapes that carry encrypted data. All ... values are base64 strings representing encrypted bytes.
POST /v1/sessions
{
"tag": "<string>",
"metadata": "<base64 encrypted>",
"agentState": "<base64 encrypted or null>",
"dataEncryptionKey": "<base64 data key bundle or null>"
}
Socket emit: "message"
{
"sid": "<session id>",
"message": "<base64 encrypted>"
}
update.body.t = "new-message"
{
"t": "encrypted",
"c": "<base64 encrypted>"
}
Socket emit: "update-metadata"
{
"sid": "<session id>",
"metadata": "<base64 encrypted>",
"expectedVersion": 3
}
Socket emit: "machine-update-state"
{
"machineId": "<machine id>",
"daemonState": "<base64 encrypted>",
"expectedVersion": 2
}
POST /v1/artifacts
{
"id": "<uuid>",
"header": "<base64 encrypted>",
"body": "<base64 encrypted>",
"dataEncryptionKey": "<base64 data key bundle>"
}
POST /v1/kv
{
"mutations": [
{ "key": "prefs.theme", "value": "<base64 encrypted>", "version": 2 },
{ "key": "prefs.legacy", "value": null, "version": 5 }
]
}
These are the client-side structures that get encrypted and sent over the wire. They are defined in packages/happy-cli/src/api/types.ts.
The payload stored in SessionMessage.content is always encrypted and wrapped as:
{ "t": "encrypted", "c": "<base64 encrypted>" }
Messages are encrypted as MessageContent and then base64 encoded:
User message
{
"role": "user",
"content": { "type": "text", "text": "..." },
"localKey": "...",
"meta": { }
}
Agent message
{
"role": "agent",
"content": { "type": "output | codex | acp | event", "data": "..." },
"meta": { }
}
{
"path": "...",
"host": "...",
"homeDir": "...",
"happyHomeDir": "...",
"happyLibDir": "...",
"happyToolsDir": "...",
"version": "...",
"name": "...",
"os": "...",
"summary": { "text": "...", "updatedAt": 123 },
"machineId": "...",
"claudeSessionId": "...",
"tools": ["..."],
"slashCommands": ["..."],
"startedFromDaemon": true,
"hostPid": 12345,
"startedBy": "daemon | terminal",
"lifecycleState": "running | archiveRequested | archived",
"lifecycleStateSince": 123,
"archivedBy": "...",
"archiveReason": "...",
"flavor": "..."
}
{
"controlledByUser": true,
"requests": {
"<id>": { "tool": "...", "arguments": {}, "createdAt": 123 }
},
"completedRequests": {
"<id>": {
"tool": "...",
"arguments": {},
"createdAt": 123,
"completedAt": 123,
"status": "canceled | denied | approved",
"reason": "...",
"mode": "default | acceptEdits | bypassPermissions | plan | read-only | safe-yolo | yolo",
"decision": "approved | approved_for_session | denied | abort",
"allowTools": ["..."]
}
}
}
{
"host": "...",
"platform": "...",
"happyCliVersion": "...",
"homeDir": "...",
"happyHomeDir": "...",
"happyLibDir": "..."
}
{
"status": "running | shutting-down",
"pid": 123,
"httpPort": 123,
"startedAt": 123,
"shutdownRequestedAt": 123,
"shutdownSource": "mobile-app | cli | os-signal | unknown"
}
flowchart TD
Start([Receive encrypted field]) --> B64[Decode base64 to bytes]
B64 --> Check{Has dataKey?}
Check --> |No| Legacy[Use legacy variant]
Check --> |Yes| DataKey[Use dataKey variant]
subgraph "Legacy Path"
Legacy --> ExtractL[Extract nonce + ciphertext]
ExtractL --> DecryptL[secretbox.open with shared key]
end
subgraph "DataKey Path"
DataKey --> GetDEK[Decrypt dataEncryptionKey bundle]
GetDEK --> ExtractD[Extract version + nonce + ciphertext + tag]
ExtractD --> DecryptD[AES-GCM decrypt with DEK]
end
DecryptL --> Plain([Plaintext JSON])
DecryptD --> Plain
legacy or dataKey) based on local credentials.For dataKey, clients must first decrypt or derive the per-session/per-machine data key from the stored dataEncryptionKey bundle.
graph LR
subgraph "Third-Party Tokens"
GH[GitHub OAuth]
OAI[OpenAI]
ANT[Anthropic]
GEM[Gemini]
end
subgraph "Server"
Secret[HANDY_MASTER_SECRET]
KeyTree[KeyTree]
Encrypt[Encrypt]
end
DB[(Postgres)]
Secret --> KeyTree --> Encrypt
GH & OAI & ANT & GEM --> Encrypt --> DB
style GH fill:#fff3e0
style OAI fill:#fff3e0
style ANT fill:#fff3e0
style GEM fill:#fff3e0
The server encrypts certain third-party tokens at rest:
GithubUser.token).ServiceAccountToken.token).These are encrypted with a server-only KeyTree derived from HANDY_MASTER_SECRET and are not end-to-end encrypted.
graph TB
subgraph "Encoding Rules"
E1["Encrypted bytes → base64 string"]
E2["Timestamps → plain number (epoch ms)"]
E3["IDs, tags, versions → plain string/number"]
end
subgraph "Examples"
Ex1["metadata: 'SGVsbG8gV29ybGQ='"]
Ex2["createdAt: 1704067200000"]
Ex3["id: 'abc-123', version: 5"]
end
E1 --> Ex1
E2 --> Ex2
E3 --> Ex3
packages/happy-cli/src/api/encryption.tspackages/happy-cli/src/api/types.tspackages/happy-server/sources/app/api/socket/sessionUpdateHandler.tspackages/happy-server/sources/app/api/routes/artifactsRoutes.ts, packages/happy-server/sources/app/kv/kvMutate.ts