Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

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`