Back to Happy

Encryption and Data Encoding

docs/encryption.md

312.9 KB
Original Source

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"
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
(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": "<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