SonusLab StorageSonusLab Storage

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

If you lose the key (or passphrase), the data is unrecoverable. SonusLab cannot help you. Back up your key somewhere you trust before encrypting anything you care about.

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 CryptoKey in 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.