End-to-end Encryption
Encrypt bytes with AES-256-GCM before they leave your process. The storage backend only ever sees ciphertext. Keys never touch our infrastructure.
No key escrow
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: file size (within ~30 bytes), upload timing, and your app-level metadata fields remain visible to the backend.
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 (0x01)
Byte 1 : Flags byte (bit 0 = passphrase-derived)
Bytes 2-17 : Salt (16 bytes; zeros if not passphrase-derived)
Bytes 18-29 : IV (12 random bytes)
Bytes 30+ : AES-256-GCM ciphertext (16-byte tag appended by WebCrypto)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, and merges marker metadata so you can identify encrypted objects later.
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.contentType === 'application/octet-stream'
// file.metadata.encrypted === true
// file.metadata.encryptionVersion === 1
// file.metadata.originalContentType === 'application/pdf'
// file.metadata.originalName === 'contract.pdf'Browser upload
Same shape on the Vue composable. WebCrypto runs in the browser — your server still mints the upload URL, but the size and content-type it advertises reflect the ciphertext.
<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 (100,000 iterations, random 16-byte salt per file) derives the AES key. The salt is stored in the container header so the same passphrase reproduces the key on decrypt.
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.
// PBKDF2-SHA256, 100_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 — fetch the bytes (signed URL, public download URL, whatever) and pass them to decryptBytes.
import { decryptBytes } from 'sonuslab-storage/crypto'
const res = await fetch(downloadUrl)
const cipher = new Uint8Array(await res.arrayBuffer())
const plaintext = await decryptBytes({ key, data: cipher })The browser export bundles the fetch and decrypt steps:
import { decryptDownload } from 'sonuslab-storage/client'
const plaintext = await decryptDownload({ url: signedUrl, key })
// plaintext: Uint8Array — wrap in Blob with the original content-type to render.Limitations
v1 scope
- • 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.