# 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: "" } Server->>DB: Store { t: "encrypted", c: "" } Note over Server: Later, sync to other clients Server->>Client: update "new-message"
content: { t: "encrypted", c: "" } Client->>Client: Decrypt message ``` - Client emits `message` with a base64 encrypted blob. - Server stores it as `SessionMessage.content`: - `{ t: "encrypted", c: "" }` - 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
(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. ### Session creation ```http POST /v1/sessions ``` ```json { "tag": "", "metadata": "", "agentState": "", "dataEncryptionKey": "" } ``` ### Encrypted message (client -> server) ``` Socket emit: "message" ``` ```json { "sid": "", "message": "" } ``` ### Encrypted message (server -> client) ``` update.body.t = "new-message" ``` ```json { "t": "encrypted", "c": "" } ``` ### Session metadata update (WebSocket) ``` Socket emit: "update-metadata" ``` ```json { "sid": "", "metadata": "", "expectedVersion": 3 } ``` ### Machine update (WebSocket) ``` Socket emit: "machine-update-state" ``` ```json { "machineId": "", "daemonState": "", "expectedVersion": 2 } ``` ### Artifact create/update (HTTP) ```http POST /v1/artifacts ``` ```json { "id": "", "header": "", "body": "", "dataEncryptionKey": "" } ``` ### KV mutate (HTTP) ```http POST /v1/kv ``` ```json { "mutations": [ { "key": "prefs.theme", "value": "", "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": "" } ``` ### 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": { "": { "tool": "...", "arguments": {}, "createdAt": 123 } }, "completedRequests": { "": { "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`