98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
550 lines
13 KiB
Markdown
550 lines
13 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```mermaid
|
|
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) ]
|
|
```
|
|
|
|
```mermaid
|
|
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) ]
|
|
```
|
|
|
|
```mermaid
|
|
packet-beta
|
|
0-0: "ver"
|
|
1-12: "nonce (12 bytes)"
|
|
13-44: "ciphertext (...)"
|
|
45-60: "authTag (16 bytes)"
|
|
```
|
|
|
|
- `version` is currently `0`.
|
|
|
|
## Data encryption key (dataKey variant)
|
|
|
|
```mermaid
|
|
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 (...) ]
|
|
```
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```mermaid
|
|
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-session` events
|
|
|
|
### Session messages
|
|
|
|
```mermaid
|
|
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 `message` with a base64 encrypted blob.
|
|
- Server stores it as `SessionMessage.content`:
|
|
- `{ t: "encrypted", c: "<base64>" }`
|
|
- Server emits it back in `new-message` updates 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-machine` events
|
|
|
|
### Artifacts
|
|
- `header` and `body` are encrypted bytes encoded as base64 on the wire.
|
|
- Stored as `Bytes` in the DB.
|
|
- Emitted in `new-artifact` / `update-artifact` events as base64 strings.
|
|
|
|
### Access keys
|
|
- `AccessKey.data` is treated as an **opaque encrypted string**.
|
|
- The server does not decode it or inspect its contents.
|
|
|
|
### Key-value store
|
|
- `UserKVStore.value` is encrypted bytes encoded as base64 on the wire.
|
|
- `kvMutate` expects base64 strings; `kvGet/list/bulk` return base64 strings.
|
|
|
|
## On-wire formats (encrypted fields)
|
|
|
|
```mermaid
|
|
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
|
|
```http
|
|
POST /v1/sessions
|
|
```
|
|
```json
|
|
{
|
|
"tag": "<string>",
|
|
"metadata": "<base64 encrypted>",
|
|
"agentState": "<base64 encrypted or null>",
|
|
"dataEncryptionKey": "<base64 data key bundle or null>"
|
|
}
|
|
```
|
|
|
|
### Encrypted message (client -> server)
|
|
```
|
|
Socket emit: "message"
|
|
```
|
|
```json
|
|
{
|
|
"sid": "<session id>",
|
|
"message": "<base64 encrypted>"
|
|
}
|
|
```
|
|
|
|
### Encrypted message (server -> client)
|
|
```
|
|
update.body.t = "new-message"
|
|
```
|
|
```json
|
|
{
|
|
"t": "encrypted",
|
|
"c": "<base64 encrypted>"
|
|
}
|
|
```
|
|
|
|
### Session metadata update (WebSocket)
|
|
```
|
|
Socket emit: "update-metadata"
|
|
```
|
|
```json
|
|
{
|
|
"sid": "<session id>",
|
|
"metadata": "<base64 encrypted>",
|
|
"expectedVersion": 3
|
|
}
|
|
```
|
|
|
|
### Machine update (WebSocket)
|
|
```
|
|
Socket emit: "machine-update-state"
|
|
```
|
|
```json
|
|
{
|
|
"machineId": "<machine id>",
|
|
"daemonState": "<base64 encrypted>",
|
|
"expectedVersion": 2
|
|
}
|
|
```
|
|
|
|
### Artifact create/update (HTTP)
|
|
```http
|
|
POST /v1/artifacts
|
|
```
|
|
```json
|
|
{
|
|
"id": "<uuid>",
|
|
"header": "<base64 encrypted>",
|
|
"body": "<base64 encrypted>",
|
|
"dataEncryptionKey": "<base64 data key bundle>"
|
|
}
|
|
```
|
|
|
|
### KV mutate (HTTP)
|
|
```http
|
|
POST /v1/kv
|
|
```
|
|
```json
|
|
{
|
|
"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:
|
|
```json
|
|
{ "t": "encrypted", "c": "<base64 encrypted>" }
|
|
```
|
|
|
|
### Encrypted message payload (plaintext before encryption)
|
|
Messages are encrypted as `MessageContent` and then base64 encoded:
|
|
|
|
**User message**
|
|
```json
|
|
{
|
|
"role": "user",
|
|
"content": { "type": "text", "text": "..." },
|
|
"localKey": "...",
|
|
"meta": { }
|
|
}
|
|
```
|
|
|
|
**Agent message**
|
|
```json
|
|
{
|
|
"role": "agent",
|
|
"content": { "type": "output | codex | acp | event", "data": "..." },
|
|
"meta": { }
|
|
}
|
|
```
|
|
|
|
### Metadata (encrypted)
|
|
```json
|
|
{
|
|
"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)
|
|
```json
|
|
{
|
|
"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)
|
|
```json
|
|
{
|
|
"host": "...",
|
|
"platform": "...",
|
|
"happyCliVersion": "...",
|
|
"homeDir": "...",
|
|
"happyHomeDir": "...",
|
|
"happyLibDir": "..."
|
|
}
|
|
```
|
|
|
|
### Daemon state (encrypted)
|
|
```json
|
|
{
|
|
"status": "running | shutting-down",
|
|
"pid": 123,
|
|
"httpPort": 123,
|
|
"startedAt": 123,
|
|
"shutdownRequestedAt": 123,
|
|
"shutdownSource": "mobile-app | cli | os-signal | unknown"
|
|
}
|
|
```
|
|
|
|
## Decryption flow (client side)
|
|
|
|
```mermaid
|
|
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 (`legacy` or `dataKey`) 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)
|
|
|
|
```mermaid
|
|
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
|
|
|
|
```mermaid
|
|
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`
|