End-to-End Encryption
PickLayer supports E2EE for stored responses and files. Encrypted data is opaque to the server — only the client holds the keys required to decrypt.
Key Hierarchy
Password
└── Argon2id ──► KEK (Key Encryption Key)
└── wraps ──► UMK (User Master Key)
├── wraps ──► X25519 private key (response decryption)
└── derives ──► per-file key via HKDF-SHA256 (file decryption)- KEK is derived from the user’s password using Argon2id and is never stored.
- UMK is generated randomly and stored encrypted (wrapped by KEK) in the keyring.
- X25519 private key is generated by the client, wrapped (encrypted) by UMK, and stored on the server alongside the public key. The server cannot decrypt it — only the client with the UMK can unwrap it. This allows private key recovery from any device using just a password.
- Per-file keys are derived on demand:
file_key = HKDF-SHA256(UMK, "picklayer:file:" + file_id).
Response Encryption (V2 Public-Key Model)
Responses use a two-layer sealed-envelope scheme. The server generates two keys: a random content DEK that encrypts the response fields, and an inner DEK (derived from a shared secret) that wraps the content DEK. This two-layer design isolates the content key from the key-agreement material.
Encryption (server-side)
- Client generates an X25519 keypair and uploads the public key to the keyring.
- When a response is stored with
encrypt_content: true, the server:- Generates a random content DEK (32 bytes).
- Encrypts each content field (
content,output) withXChaCha20-Poly1305(field, content_dek, field_nonce). - Generates a fresh ephemeral X25519 keypair and performs Diffie-Hellman with the client’s public key → shared secret.
- Derives an inner DEK via
BLAKE2b(shared_secret). - Wraps the content DEK:
sealed_dek = XChaCha20-Poly1305(content_dek, inner_dek, sealed_dek_nonce). - Wraps the inner DEK:
wrapped_dek = XChaCha20-Poly1305(inner_dek, shared_secret, wrapped_dek_nonce). - Stores the ephemeral public key,
wrapped_dek,sealed_dek, and their nonces in theencryptionenvelope alongside the ciphertext.
- The server discards the ephemeral private key and all DEKs. It cannot decrypt the response.
Decryption (client-side)
The client reverses the two layers:
1. shared_secret = X25519(private_key, envelope.ephemeral_public_key)
2. inner_dek = decrypt(envelope.wrapped_dek, shared_secret, envelope.wrapped_dek_nonce)
3. content_dek = decrypt(sealed_dek, inner_dek, sealed_dek_nonce)
4. plaintext = decrypt(field_ciphertext, content_dek, field_nonce)Each response has a unique ephemeral keypair, providing per-response forward secrecy.
Envelope Fields
| Field | Description |
|---|---|
version | 2 for public-key encryption |
envelope.ephemeral_public_key | Server’s ephemeral X25519 public key (base64url) |
envelope.wrapped_dek | Inner DEK encrypted with shared secret (base64url) |
envelope.wrapped_dek_nonce | Nonce for wrapped_dek |
envelope.alg | x25519-xchacha20poly1305 |
sealed_dek | Content DEK encrypted with inner DEK (base64url) |
sealed_dek_nonce | Nonce for sealed_dek |
encrypted_fields | List of encrypted field names (e.g., ["content", "output"]) |
encrypted_at | ISO timestamp |
encrypt_content: true applies to stored response content (content/output). It does not automatically encrypt arbitrary request metadata or other operational metadata the server needs to keep readable for routing, retrieval, billing, and status handling. Clients should treat metadata as non-secret and avoid putting sensitive user text, document excerpts, API keys, or PII in it.
See Responses API for the full endpoint reference and SDK example.
File Encryption
Files use per-file symmetric keys derived from the UMK:
file_key = HKDF-SHA256(UMK, "picklayer:file:" + file_id)The file content is encrypted with XChaCha20-Poly1305 before upload. The server stores only ciphertext. The UMK (held by the client) is required to derive the file key for decryption.
Attaching encrypted files to chat requests
Canonical-additive change.
file_keysis a new PickLayer extension, but it is strictly additive — clients sending pure canonical OpenAI / Anthropic requests with plaintext files continue to work unchanged. The field is needed only because end-to-end encryption is not part of any canonical chat API. Ifis_encrypted: trueis on the file row, you must supply the per-file key; otherwise the field has no effect.
When a chat request references a file with is_encrypted: true, the gateway must decrypt the stored ciphertext on-the-fly before forwarding bytes to the upstream provider. Because the UMK never leaves the client, the client supplies a per-file key for each referenced UUID via the new top-level file_keys field on the request body.
This works on:
- Responses API —
POST /v1/responses - Messages API —
POST /v1/messages - Chat Completions —
POST /v1/chat/completions
Field shape
{
// ... rest of the request
"file_keys": {
"<picklayer-file-uuid>": "<base64url-derived-key>"
// one entry per encrypted file referenced anywhere in input/messages
}
}- Key — the PickLayer file UUID that appears in
image_url.url,input_audio.url, orinput_file.file_id. - Value — the per-file key derived as
HKDF-SHA256(UMK, "picklayer:file:" + file_id), base64url-encoded (no padding). - Plaintext files do not appear in this map. A stray entry for a file whose row has
is_encrypted: falsereturns400 unexpected_file_key. - Lifetime — request-scoped, transient. Never persisted to logs (logger redaction enforced).
Decryption path selection
The server picks one of two methods per file based on size and provider capability — the caller does not choose:
| Method | When chosen | What goes on the wire to the provider |
|---|---|---|
| Inline data URL | Bytes ≤ provider maxInlineBytes and the adapter accepts inline base64. | The server decrypts in-process and emits data:image/jpeg;base64,… (or audio/document analog). Plaintext never leaves the gateway as a URL. |
| Signed CDN URL | Bytes exceed maxInlineBytes, or the adapter prefers signed URLs. | The server mints a CloudFront URL with an opaque efk query parameter — an AES-256-GCM-wrapped form of the per-file key. The CloudFront origin handler unwraps efk at fetch time and serves plaintext. The plaintext file_key is never present in the URL. |
The wrap key for efk is derived via HKDF from FILE_DOWNLOAD_SIGNING_SECRET (no new secret required; rotation follows the existing cadence). Tampered efk values are rejected at origin with 400 e2ee_invalid_file_key.
Worked example (cURL)
# 1) Upload an encrypted JPEG. UMK never sent — client derives file_key locally.
# file_key = HKDF-SHA256(UMK, "picklayer:file:" + file_id)
curl -X POST "https://api.picklayer.dev/api/org/{org}/app/{app}/v1/files" \
-H "Authorization: Bearer $TOKEN" \
-F "purpose=vision" \
-F "file_id=6fefb90d-71fe-49ab-8f8f-2c5b4f2e0a01" \
-F "file_key=Ei5dZbVaKQlnhtdtKuecl78o95fryNJPUvcQ9mSmk3c" \
-F "encrypt=true" \
-F "file=@/tmp/cat.encrypted.bin;type=application/octet-stream"
# Response: { "id": "6fefb90d-...", "is_encrypted": true, ... }
# 2) Chat-reference the encrypted file with file_keys at the top level.
curl -X POST "https://api.picklayer.dev/api/org/{org}/app/{app}/v1/responses" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-4o",
"input": [
{ "type": "input_text", "text": "What animal is in this image?" },
{ "type": "input_image",
"image_url": { "url": "6fefb90d-71fe-49ab-8f8f-2c5b4f2e0a01" } }
],
"file_keys": {
"6fefb90d-71fe-49ab-8f8f-2c5b4f2e0a01": "Ei5dZbVaKQlnhtdtKuecl78o95fryNJPUvcQ9mSmk3c"
}
}'Python SDK example
from picklayer import PickLayerClient
from picklayer.crypto import derive_umk_from_password, derive_file_key
client = PickLayerClient(org_handler="acme", app_handler="myapp", token=token)
# 1) Unlock UMK from password.
keyring = client.keyring_status(include_keyring=True).data["keyring_data"]
umk = derive_umk_from_password("user-password", keyring)
# 2) Upload encrypted image.
reserve = client.reserve_file().data
file_id = reserve["file_id"]
file_key = derive_file_key(umk, file_id)
client.upload_file(
purpose="vision",
file_path="/path/to/cat.jpg",
file_id=file_id,
file_key=file_key,
encrypt=True,
)
# 3) Chat-reference. SDK helper auto-populates file_keys when umk= is supplied;
# here we show the explicit form so the wire contract is visible.
resp = client.responses(
model="openai/gpt-4o",
input=[
{"type": "input_text", "text": "What animal is in this image?"},
{"type": "input_image", "image_url": {"url": file_id}},
],
file_keys={file_id: file_key},
)The Flutter SDK (packages/picklayer/lib/src/crypto.dart — deriveFileKey) and the React/TypeScript SDK (ui/src/lib/crypto.ts — deriveFileKey) expose the same primitive; both client wrappers can auto-populate file_keys when given a UMK.
Cross-session re-derivation
The file_keys map is recomputable on demand from any device that holds the UMK — there is no server-side key registry to sync. To resume an old conversation:
- Client fetches the conversation/message history; each message carries the original
file_ids. - Client recovers the UMK from the local keyring (unlocked by the user’s password).
- For each referenced UUID, client calls
deriveFileKey(umk, file_id). - Client builds the
file_keysmap and sends the request.
The server-stored umk_id field on each file row tells the client which UMK to use under rotation; the keyring retains old UMKs accessible during rotation.
Cross-device limitation. If the user has no UMK on the current device (different browser, cleared storage, no keyring sync), encrypted files are unrecoverable. This is fundamental to E2EE, not a chat-specific limitation.
Failure modes
| Condition | HTTP | code | param |
|---|---|---|---|
Encrypted UUID referenced without a file_keys entry | 400 | missing_file_key | file_keys.<uuid> |
file_keys value has fingerprint mismatch (wrong key) | 400 | e2ee_invalid_file_key | file_keys.<uuid> |
Plaintext UUID referenced with a stray file_keys entry | 400 | unexpected_file_key | file_keys.<uuid> |
File row carries encryption_meta.version === 1 (legacy V1) | 400 | e2ee_legacy_encryption | file_keys.<uuid> |
Tampered efk query param on signed URL (origin-side) | 400 | e2ee_invalid_file_key | — |
In every case the gateway fails closed — the upstream provider is not called, no billing event is generated, and no plaintext or ciphertext leaves the gateway.
Privacy invariants
- UMK never leaves the client. The server only ever sees per-file derived keys, only on requests that need them, only for the duration of that request.
file_keysmap is request-scoped. A logger filter redacts any object key matching/^(file_keys?|fileKeys?|encrypted_file_key|efk)$/to<redacted>before serialization.- Orthogonal to
encrypt_content. That flag controls stored conversation encryption (responsecontent/outputat rest, sealed envelope V2 — see above). It does not encrypt the wire request and does not affect file decryption. They can be used independently. - All three reference types share the same map. Images (
input_image/image_url), audio (input_audio), and documents (input_file) all read from the samefile_keysobject.
Keyring Management
The keyring holds the encrypted UMK and the user’s X25519 public key. All keyring operations require the current session token.
| Endpoint | Description |
|---|---|
POST /keyring/init | Initialize a new keyring with a password. Returns encrypted keyring data. |
POST /keyring/validate | Validate keyring data and derive UMK (password required). |
POST /keyring/public-key | Upload X25519 public key: { "public_key": "<base64url>" } |
POST /keyring/change-password | Re-wrap UMK under a new KEK derived from a new password. |
POST /keyring/recover | Recover UMK using a backup recovery code. |
Initialization Example
from picklayer import PickLayerClient
from picklayer.crypto import CryptoHelper
client = PickLayerClient(org_handler="myorg", app_handler="myapp")
client.set_token(token)
# Initialize keyring
resp = client.keyring_init(password="strong-passphrase")
keyring_data = client.keyring_get_data().data["keyring_data"]
# Generate X25519 keypair and upload to server
crypto = CryptoHelper()
crypto.derive_umk(password="strong-passphrase", keyring_data=keyring_data)
crypto.generate_keypair()
# Upload both keys: public key in plaintext, private key wrapped with UMK
wrapped_sk = crypto.wrap_and_store_private_key()
client.keyring_upload_keypair(crypto.public_key, wrapped_sk)
# The server stores the wrapped private key but cannot decrypt itAlgorithms
| Algorithm | Usage |
|---|---|
| Argon2id | Password-based KEK derivation |
| XChaCha20-Poly1305 | Symmetric AEAD encryption (responses and files) |
| X25519 | Asymmetric key exchange for response sealed envelopes |
| HKDF-SHA256 | Per-file key derivation from UMK |
| BLAKE2b | Content integrity hashing |
What the Server Can and Cannot See
| Server can see | Server cannot see | |
|---|---|---|
| Responses | Model, usage tokens, timestamps, status, request metadata, and other operational metadata | content, output (encrypted) |
| Files | File ID, size, MIME type, purpose | File content (encrypted) |
| Keyring | Encrypted UMK, X25519 public key | Password, KEK, UMK, X25519 private key |