Security Architecture
This page explains how zopp achieves zero-knowledge encryption.
Key Hierarchy
User
└── Principal (device)
├── Ed25519 keypair (signing/authentication)
└── X25519 keypair (encryption)
└── Workspace
└── KEK (Key Encryption Key, wrapped per-principal)
└── Environment
└── DEK (Data Encryption Key, wrapped with KEK)
└── Secrets (encrypted with DEK)
Principal Keys
Each principal (device/credential) has two keypairs:
| Key Type | Purpose | Usage |
|---|---|---|
| Ed25519 | Signing | Authenticate requests to server |
| X25519 | Encryption | ECDH for key wrapping |
Keys are generated client-side and the private keys never leave the device.
Workspace KEK
The Key Encryption Key (KEK) protects all data within a workspace:
- Generated when workspace is created (32 random bytes)
- Wrapped separately for each principal with access
- Wrapping uses X25519 ECDH + XChaCha20-Poly1305
KEK Wrapping
1. Generate ephemeral X25519 keypair
2. Perform ECDH: shared_secret = ECDH(ephemeral_private, principal_public)
3. Derive key: wrap_key = HKDF(shared_secret, "zopp-kek-wrap")
4. Generate nonce: nonce = random(24 bytes)
5. Encrypt: wrapped_kek = XChaCha20-Poly1305(wrap_key, nonce, kek)
6. Store: (ephemeral_public, wrapped_kek, nonce)
The server stores only the wrapped form. Unwrapping requires the principal's private key.
Environment DEK
The Data Encryption Key (DEK) encrypts secrets within an environment:
- Generated when environment is created (32 random bytes)
- Wrapped with the workspace KEK
- Stored on server in wrapped form
Secret Encryption
Secrets are encrypted with XChaCha20-Poly1305 AEAD:
plaintext = secret_value
key = environment_dek
nonce = random_24_bytes
aad = workspace_id || project_id || environment_id || secret_key
ciphertext = XChaCha20-Poly1305.encrypt(key, nonce, plaintext, aad)
The AAD (Additional Authenticated Data) cryptographically binds the secret to its location.
Invite Flow
Workspace invites securely transfer the KEK to new members:
- Creator: Generates random 32-byte invite secret
- Creator: Wraps KEK with invite secret
- Creator: Sends
hash(invite_secret)to server - Invitee: Receives full invite code (includes encrypted KEK)
- Invitee: Unwraps KEK with invite secret
- Invitee: Re-wraps KEK for their own principal
- Server: Verifies hash, stores new wrapped KEK
The server only sees the hash—never the invite secret or plaintext KEK.
Request Authentication
All API requests are authenticated with Ed25519 signatures:
timestamp = current_unix_timestamp
message = timestamp || request_body
signature = Ed25519.sign(principal_private_key, message)
The signature and timestamp are sent as gRPC metadata. The server verifies:
- Signature is valid
- Timestamp is within acceptable window (prevents replay)
Data at Rest
What the server stores:
| Data | Format | Can Server Decrypt? |
|---|---|---|
| Secrets | Ciphertext + nonce | No |
| DEKs | Wrapped with KEK | No |
| KEKs | Wrapped per-principal | No |
| Public keys | Plaintext | N/A (public) |
| Invite hashes | SHA256 | No (hash only) |
Next Steps
- Cryptography - Primitive details
- Core Concepts - Key hierarchy overview