Getting started
Encryption
Encrypt and decrypt locally with AES-256-GCM. The API receives an envelope, never plaintext or a key.
Envelope format#
{
"version": 1,
"iv": "<canonical unpadded base64url>",
"ciphertext": "<encrypted bytes plus authentication tag>"
}| version | Exactly 1. |
| iv | Fresh random 12-byte IV. |
| ciphertext | Encrypted UTF-8 text with a 16-byte tag appended. |
Key handling#
Generate a fresh random 32-byte key per secret. Encode binary fields as canonical, unpadded base64url. Do not use additional authenticated data in version 1.
The backend validates structure and size; it cannot prove the content was encrypted correctly.
Encryption helper#
Copy this complete helper into encryption.mjs. It comes from the working backend example ↗.
// Envelope interoperability example. No storage, logging, or network calls.
const encode = bytes => btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const decode = value => {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
throw new Error('Invalid base64url encoding.')
}
const bytes = Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
if (encode(bytes) !== value) throw new Error('Invalid base64url encoding.')
return bytes
}
export async function encryptText(text) {
if (typeof text !== 'string') throw new Error('Expected text.')
const plaintext = new TextEncoder().encode(text)
if (plaintext.length < 1 || plaintext.length > 32768) throw new Error('Text must be 1–32768 UTF-8 bytes.')
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, plaintext)
return {
envelope: { version: 1, iv: encode(iv), ciphertext: encode(new Uint8Array(ciphertext)) },
keyFragment: encode(new Uint8Array(await crypto.subtle.exportKey('raw', key))),
}
}
export async function decryptText(envelope, keyFragment) {
if (envelope?.version !== 1) throw new Error('Unsupported envelope version.')
const rawKey = decode(keyFragment)
const iv = decode(envelope.iv)
const ciphertext = decode(envelope.ciphertext)
if (rawKey.length !== 32 || iv.length !== 12 || ciphertext.length < 17 || ciphertext.length > 32784) {
throw new Error('Invalid envelope or key size.')
}
const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['decrypt'])
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, ciphertext)
return new TextDecoder('utf-8', { fatal: true }).decode(plaintext)
}Text limits#
Accept 1 to 32,768 bytes of UTF-8 text. Count bytes, not characters. The ciphertext including its tag is 17 to 32,784 bytes. The full JSON request may not exceed 49,152 bytes.