13 KiB
Encryption and Data Encoding
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.
Overview
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
Design goals
- Keep the server blind to user content (end-to-end encryption on clients).
- Use explicit, stable binary layouts so clients can interoperate across versions.
- Prefer simple, consistent base64 encoding on the wire.
Encryption variants
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:
1) legacy (NaCl secretbox)
Used when the client only has a shared secret key.
Algorithm: tweetnacl.secretbox (XSalsa20-Poly1305)
- Nonce length: 24 bytes
- Key length: 32 bytes
Binary layout (plaintext JSON -> bytes):
[ nonce (24) | ciphertext+auth (secretbox output) ]
packet-beta
0-23: "nonce (24 bytes)"
24-55: "ciphertext + auth tag"
2) dataKey (AES-256-GCM)
Used when the client supports per-session/per-machine data keys.
Algorithm: AES-256-GCM
- Nonce length: 12 bytes
- Auth tag: 16 bytes
- Key length: 32 bytes
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)"
versionis currently0.
Data encryption key (dataKey variant)
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.
- Ephemeral public key: 32 bytes
- Nonce: 24 bytes
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.
Where encryption is applied
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.
Session metadata + agent state
- Encrypted by client and stored as strings in the DB.
- Used in:
POST /v1/sessions(create/load)- WebSocket
update-metadata/update-state update-sessionevents
Session messages
sequenceDiagram
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"<br/>content: { t: "encrypted", c: "<base64>" }
Client->>Client: Decrypt message
- Client emits
messagewith a base64 encrypted blob. - Server stores it as
SessionMessage.content:{ t: "encrypted", c: "<base64>" }
- Server emits it back in
new-messageupdates with the same structure.
Machine metadata + daemon state
- Encrypted by client and stored as strings in the DB.
- Used in:
POST /v1/machines- WebSocket
machine-update-metadata/machine-update-state update-machineevents
Artifacts
headerandbodyare encrypted bytes encoded as base64 on the wire.- Stored as
Bytesin the DB. - Emitted in
new-artifact/update-artifactevents as base64 strings.
Access keys
AccessKey.datais treated as an opaque encrypted string.- The server does not decode it or inspect its contents.
Key-value store
UserKVStore.valueis encrypted bytes encoded as base64 on the wire.kvMutateexpects base64 strings;kvGet/list/bulkreturn base64 strings.
On-wire formats (encrypted fields)
graph LR
subgraph "Wire Format"
JSON[JSON payload]
B64["base64 strings<br/>(encrypted bytes)"]
Plain["plain values<br/>(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.
Session creation
POST /v1/sessions
{
"tag": "<string>",
"metadata": "<base64 encrypted>",
"agentState": "<base64 encrypted or null>",
"dataEncryptionKey": "<base64 data key bundle or null>"
}
Encrypted message (client -> server)
Socket emit: "message"
{
"sid": "<session id>",
"message": "<base64 encrypted>"
}
Encrypted message (server -> client)
update.body.t = "new-message"
{
"t": "encrypted",
"c": "<base64 encrypted>"
}
Session metadata update (WebSocket)
Socket emit: "update-metadata"
{
"sid": "<session id>",
"metadata": "<base64 encrypted>",
"expectedVersion": 3
}
Machine update (WebSocket)
Socket emit: "machine-update-state"
{
"machineId": "<machine id>",
"daemonState": "<base64 encrypted>",
"expectedVersion": 2
}
Artifact create/update (HTTP)
POST /v1/artifacts
{
"id": "<uuid>",
"header": "<base64 encrypted>",
"body": "<base64 encrypted>",
"dataEncryptionKey": "<base64 data key bundle>"
}
KV mutate (HTTP)
POST /v1/kv
{
"mutations": [
{ "key": "prefs.theme", "value": "<base64 encrypted>", "version": 2 },
{ "key": "prefs.legacy", "value": null, "version": 5 }
]
}
Client-side types (shapes used before encryption)
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.
Session message content (encrypted)
The payload stored in SessionMessage.content is always encrypted and wrapped as:
{ "t": "encrypted", "c": "<base64 encrypted>" }
Encrypted message payload (plaintext before encryption)
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": { }
}
Metadata (encrypted)
{
"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": "..."
}
Agent state (encrypted)
{
"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": ["..."]
}
}
}
Machine metadata (encrypted)
{
"host": "...",
"platform": "...",
"happyCliVersion": "...",
"homeDir": "...",
"happyHomeDir": "...",
"happyLibDir": "..."
}
Daemon state (encrypted)
{
"status": "running | shutting-down",
"pid": 123,
"httpPort": 123,
"startedAt": 123,
"shutdownRequestedAt": 123,
"shutdownSource": "mobile-app | cli | os-signal | unknown"
}
Decryption flow (client side)
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
- Read base64 field from API/Socket.
- Decode base64 to bytes.
- Choose encryption variant (
legacyordataKey) based on local credentials. - Decrypt bytes using the appropriate key and algorithm.
For dataKey, clients must first decrypt or derive the per-session/per-machine data key from the stored dataEncryptionKey bundle.
Server-side encryption (service tokens)
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:
- GitHub OAuth tokens (
GithubUser.token). - Vendor service tokens (
ServiceAccountToken.token).
These are encrypted with a server-only KeyTree derived from HANDY_MASTER_SECRET and are not end-to-end encrypted.
Encoding conventions
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
- All encrypted bytes are base64 strings on the wire unless explicitly noted.
- Timestamps remain plain numbers (epoch ms) and are not encrypted by the server.
- Non-encrypted identifiers (ids, tags, versions) are always plain strings/numbers.
Implementation references
- Client crypto:
packages/happy-cli/src/api/encryption.ts - Session message format:
packages/happy-cli/src/api/types.ts - Server message ingestion:
packages/happy-server/sources/app/api/socket/sessionUpdateHandler.ts - Artifact/KV routes:
packages/happy-server/sources/app/api/routes/artifactsRoutes.ts,packages/happy-server/sources/app/kv/kvMutate.ts