End-to-end Encryption
Encrypt bytes with AES-256-GCM before they leave your process. The storage backend only ever sees ciphertext, stored under an opaque random name — the original filename and content-type are sealed inside the container. Keys never touch our infrastructure.
Threat model
- Defends against: backend compromise, bucket breach, hostile insider with bucket access.
- Does not defend against: a compromised client process, a leaked key, or a malicious browser extension reading memory.
- Metadata leakage: the backend still sees the ciphertext length (your plaintext size plus the 35-byte header, the 16-byte GCM tag, and the sealed metadata frame), the upload time, and any app-level metadata fields you attach yourself. Filenames and content-types are not visible — but don't put secrets in your own metadata.
Container format
Every encrypted payload is a single Uint8Array laid out as follows. Version byte gives us a forward-compatible upgrade path.
Byte 0 : Version byte (0x02)
Byte 1 : Flags byte (bit 0 = passphrase-derived, bit 1 = inner metadata frame)
Byte 2 : KDF id (0x00 = none/raw key, 0x01 = PBKDF2-SHA256)
Bytes 3-6 : KDF iterations (uint32 big-endian; 0 when KDF id is 0x00)
Bytes 7-22 : Salt (16 bytes; zeros if not passphrase-derived)
Bytes 23-34 : IV (12 random bytes)
Bytes 35+ : AES-256-GCM ciphertext (16-byte tag appended by WebCrypto)
The full 35-byte header is passed to GCM as additionalData, so tampering
with the version, flags, KDF params, salt, or IV fails the tag check.
When the inner-metadata flag is set, the plaintext inside the ciphertext
starts with a frame — uint16 big-endian JSON length, then UTF-8 JSON —
holding the original filename and content-type.
Legacy 0x01 containers (30-byte header, no authenticated header, PBKDF2 fixed at 100,000 iterations) are still decrypted transparently. They are never written.
Generating a key
32 random bytes from crypto.getRandomValues. You're responsible for storing it.
import { generateEncryptionKey } from 'sonuslab-storage/crypto'
// Uint8Array(32) — store this somewhere you trust (KMS, env var, hardware token).
const key = generateEncryptionKey()
Server-side upload
Pass encrypt: { key } to storage.upload. The SDK encrypts, swaps the content-type to application/octet-stream, replaces the name with an opaque random <32 hex>.enc, and merges marker metadata so you can identify encrypted objects later. Only encrypted and encryptionVersion are visible server-side.
import { StorageClient } from 'sonuslab-storage/server'
import { generateEncryptionKey } from 'sonuslab-storage/crypto'
const storage = new StorageClient({
apiKey: process.env.SONUSLAB_STORAGE_API_KEY!,
})
const key = generateEncryptionKey()
const file = await storage.upload({
name: 'contract.pdf',
contentType: 'application/pdf',
data: pdfBuffer,
encrypt: { key },
})
// file.name — opaque random '<32 hex>.enc'
// file.contentType === 'application/octet-stream'
// file.metadata.encrypted === true
// file.metadata.encryptionVersion === 2
//
// 'contract.pdf' and 'application/pdf' are NOT in the metadata — they are
// sealed inside the container. Read them back with decryptContainer().
Browser upload
Same shape on the Vue composable. WebCrypto runs in the browser — your server still mints the upload URL, but the name, size, and content-type it advertises describe the ciphertext, not the file the user picked.
<script setup lang="ts">
import { useUpload } from 'sonuslab-storage/vue'
import { generateEncryptionKey } from 'sonuslab-storage/crypto'
// In a real app: read the key from secure storage (IndexedDB-wrapped CryptoKey,
// passkey-derived secret, or have the user paste it). Do NOT hardcode.
const key = generateEncryptionKey()
const { upload, progress, status } = useUpload({
presignEndpoint: '/api/upload/presign',
completeEndpoint: '/api/upload/complete',
encrypt: { key },
})
async function onChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
await upload(file)
}
</script>
Passphrase mode
Pass a string instead of a key — PBKDF2-SHA256 (600,000 iterations, random 16-byte salt per file) derives the AES key. The salt and the iteration count are stored in the container header, so the same passphrase reproduces the key on decrypt and the count can be raised in a later release without invalidating existing data.
await storage.upload({
name: 'diary.txt',
contentType: 'text/plain',
data: bytes,
encrypt: { key: 'correct horse battery staple' },
})
// Container header records a random 16-byte salt, the passphrase flag,
// the KDF id, and the iteration count.
// PBKDF2-SHA256, 600_000 iterations, derives the AES-256 key.
Key storage tips
- Server: AWS KMS / GCP KMS / 1Password / env var sealed at deploy time.
- Browser: wrap as a non-extractable
CryptoKeyin IndexedDB, or derive on-demand from a passkey / user passphrase. - Never log keys. Never send them to your own analytics. Never embed them in JS bundles.
// CryptoKey wrapped via the browser's SubtleCrypto + IndexedDB
const cryptoKey = await crypto.subtle.importKey(
'raw',
rawKeyBytes,
{ name: 'AES-GCM' },
false, // non-extractable
['encrypt', 'decrypt'],
)
// Hand cryptoKey directly to encrypt: { key: cryptoKey }
Decryption
Server-side, there's no helper that combines download + decrypt — resolve the url with getDownloadUrl, fetch the bytes, and pass them to decryptContainer, which returns the plaintext and the sealed filename and content-type. Since the stored object name is opaque, this is the only way to recover what the file was originally called.
import { decryptContainer } from 'sonuslab-storage/crypto'
const url = await storage.getDownloadUrl(fileId)
const res = await fetch(url)
const cipher = new Uint8Array(await res.arrayBuffer())
const { data, metadata } = await decryptContainer({ key, data: cipher })
// data : Uint8Array — the original bytes
// metadata : { name: 'contract.pdf', contentType: 'application/pdf' } | null
If you only need the bytes, decryptBytes is unchanged and returns a Uint8Array:
import { decryptBytes } from 'sonuslab-storage/crypto'
// Same decrypt, bytes only — any sealed metadata frame is stripped.
const plaintext = await decryptBytes({ key, data: cipher })
The browser export bundles the fetch and decrypt steps:
import { decryptDownload } from 'sonuslab-storage/client'
// Server-side, get the url with storage.getDownloadUrl(fileId). In the browser,
// proxy that call through your own endpoint — the API key stays on the server.
const plaintext = await decryptDownload({ url, key })
// plaintext: Uint8Array — bytes only, like decryptBytes.
// Need the original name/content-type to render or download? Fetch the
// container yourself and use decryptContainer instead.
Limitations
- Single-PUT only. Multipart uploads are not encrypted yet.
- No streaming. The whole container is buffered in memory to encrypt/decrypt.
- No key escrow. SonusLab has no copy of your key, ever.
- No re-encryption. Rotate keys by re-uploading objects under a new key.
