Skip to content

Encryption Keys

Content-level end-to-end encryption is on by default in Teleportal. Every Provider requires an encryptionKey – a KeyResolver produced by createEncryptionKey(), passwordKey(), or registryKey(). A resolver describes how the provider obtains its CryptoKey; Provider.create resolves it after the connection is ready but before the provider is built. The key never reaches the server; all encryption and decryption happens client-side using AES-256-GCM via the Web Crypto API.

This guide covers creating, sharing, storing, and distributing encryption keys.

  • Creating and exporting encryption keys
  • Sharing keys via URL fragments
  • Importing keys from strings
  • Key wrapping for secure storage
  • Three tiers of key distribution (direct, password, registry)

createEncryptionKey() returns a KeyResolver. Called with no argument it delegates to simpleEncryption(), which derives the document’s AES-256-GCM key from the document ID via PBKDF2. Passing a password delegates to passwordKey(password) instead (see Password-Derived Keys):

import { createEncryptionKey } from "teleportal/encryption-key";
import { Provider } from "teleportal/providers";
const encryptionKey = createEncryptionKey();
const provider = await Provider.create({
url: "wss://example.com/sync",
document: "my-doc",
encryptionKey,
});

exportEncryptionKey / importEncryptionKey operate on a raw CryptoKey, not a resolver. Generate a random extractable key with generateEncryptionKey(), export it to a JWK string for storage or transfer, then import it back later. An imported CryptoKey can be passed directly as the encryptionKey:

import {
generateEncryptionKey,
exportEncryptionKey,
importEncryptionKey,
} from "teleportal/encryption-key";
// Generate a random key and export it to a storable string
const key = await generateEncryptionKey();
const keyString = await exportEncryptionKey(key);
// Store the keyString in localStorage, a database, etc.
localStorage.setItem("doc-key", keyString);
// Import it back later (returns a CryptoKey, usable as an encryptionKey)
const stored = localStorage.getItem("doc-key");
const importedKey = await importEncryptionKey(stored);

The safest way to share a key in a browser is via the URL fragment (the part after #). Browsers never send the fragment to the server, so the key stays client-side:

import {
generateEncryptionKey,
exportEncryptionKey,
importEncryptionKey,
keyToUrlFragment,
keyFromUrlFragment,
} from "teleportal/encryption-key";
// Sharer: create a shareable link
const key = await generateEncryptionKey();
const shareUrl = `https://app.example.com/doc/abc123#${keyToUrlFragment(await exportEncryptionKey(key))}`;
// e.g. https://app.example.com/doc/abc123#token=<key>
// Recipient: extract the key from the URL
const keyString = keyFromUrlFragment(location.hash); // string | null
if (keyString) {
const sharedKey = await importEncryptionKey(keyString);
// Use sharedKey with Provider.create
}

keyFromUrlFragment accepts the raw location.hash (with or without a leading #) and returns null when no token parameter is present.

For simple multi-user scenarios, derive a per-document key from a shared passphrase. No server involvement is needed – anyone with the passphrase can decrypt:

import { passwordKey } from "teleportal/encryption-key";
const provider = await Provider.create({
url: "wss://example.com/sync",
document: "my-doc",
encryptionKey: passwordKey("shared-passphrase"),
});

The passwordKey resolver uses PBKDF2 internally to derive a unique AES-256-GCM key for each document from the passphrase.

Key Registry (Server-Managed Distribution)

Section titled “Key Registry (Server-Managed Distribution)”

For multi-user applications that need per-user access control and key revocation, use the key registry protocol. The server stores wrapped (encrypted) copies of the document key for each user. Clients unwrap their copy locally – the server never sees the plaintext key.

import { Server } from "teleportal/server";
import {
getKeyRegistryRpcHandlers,
getKeyRegistryHandlers,
} from "teleportal/protocols/key-registry";
const keyRegistryStorage = new InMemoryKeyRegistryStorage();
const MASTER_SECRET = new TextEncoder().encode(process.env.MASTER_SECRET!);
const server = new Server({
storage: async (ctx) => new MemoryDocumentStorage(ctx.encrypted),
rpcHandlers: {
...getKeyRegistryRpcHandlers(keyRegistryStorage),
},
});
// HTTP handlers for key management (called by your app server, not clients)
const keyHandlers = getKeyRegistryHandlers({
storage: keyRegistryStorage,
masterSecret: MASTER_SECRET,
});

The registryKey resolver fetches the wrapped key via RPC and unwraps it locally using the wrapping key embedded in the user’s JWT. The unwrapped key is cached per document (a failed fetch or unwrap does not poison the cache). On key rotation, the key-registry extension invalidates only that document’s cached key, so the next access re-fetches the new wrapped key:

import { Provider } from "teleportal/providers";
import { registryKey, importWrappingKey } from "teleportal/encryption-key";
import { createKeyRegistryRpc } from "teleportal/protocols/key-registry";
// wrappingKey comes from the JWT issued by your app server
const wrappingKey = await importWrappingKey(tokenPayload.wrappingKey);
const provider = await Provider.create({
url: "wss://example.com/sync",
document: "my-doc",
encryptionKey: registryKey({ wrappingKey }),
rpc: { keys: createKeyRegistryRpc },
});
Tier Mechanism Per-User Revocation Server Involvement Best For
Direct key encryptionKey: key or URL fragment No None Quick sharing, demos
Password encryptionKey: passwordKey("...") No None Shared workspaces, simple apps
Registry encryptionKey: registryKey({ wrappingKey }) Yes Full Multi-user apps with access control

All three tiers use the same encryptionKey option on Provider.create. You can start with direct keys and migrate to the registry later without changing the rest of your code.

  • Never send keys to your server. Use URL fragments, out-of-band channels, or the key registry for distribution.
  • Store exported keys securely. If you persist a key string in localStorage or a database, treat it as a secret.
  • Use the key registry for production multi-user apps. It provides per-user revocation, key rotation, and audit via generation counters.
  • Rotate keys when revoking access. Removing a user’s wrapped key prevents future access, but rotating the document key ensures they cannot decrypt new content even if they cached the old key.