Lumera Cascade
Guides

Encrypted Storage

Store encrypted files on Cascade with wallet-derived keys and secure collaboration.

Overview

Cascade stores files publicly by default — any wallet can download a file if they know the action ID. For private data, you can encrypt files client-side before uploading to Cascade. This guide shows how to implement wallet-based encryption using libsodium.

This pattern is used in the Lumera Research Archive for encrypted draft papers with secure collaboration.

Architecture

┌───────────────┐      ┌──────────────┐      ┌─────────────┐
│  Plaintext    │─────▶│  Encrypt     │─────▶│  Cascade    │
│  File         │      │  (XChaCha20) │      │  (Encrypted)│
└───────────────┘      └──────┬───────┘      └─────────────┘

                    ┌─────────▼─────────┐
                    │  Document Key     │
                    │  (random 256-bit) │
                    └─────────┬─────────┘

                    ┌─────────▼─────────┐
                    │  Wallet-Derived   │
                    │  Encryption Key   │
                    │  (ADR-036 + BLAKE2b)│
                    └───────────────────┘

Dependencies

npm install libsodium-wrappers-sumo

If using Vite, add this to your config:

vite.config.ts
export default defineConfig({
  // ... other config
  optimizeDeps: {
    exclude: ["libsodium-sumo", "libsodium-wrappers-sumo"],
  },
  resolve: {
    alias: {
      "libsodium-sumo": "libsodium-sumo/dist/modules/libsodium-sumo.js",
    },
  },
});

Deriving an Encryption Key from a Wallet

Instead of asking users to manage separate encryption passwords, derive a deterministic key from their wallet signature:

src/crypto.ts
import sodium from "libsodium-wrappers-sumo";
 
await sodium.ready;
 
const DERIVE_MESSAGE = "Lumera App: Derive encryption key";
 
export async function deriveKeyFromWallet(
  chainId: string,
  address: string,
  signArbitrary: (chainId: string, address: string, data: string) => Promise<any>
): Promise<Uint8Array> {
  // Sign a fixed message with ADR-036
  const signResult = await signArbitrary(chainId, address, DERIVE_MESSAGE);
  const signatureBytes = Uint8Array.from(
    atob(signResult.signature),
    (c) => c.charCodeAt(0)
  );
 
  // Hash the signature with BLAKE2b to produce a 256-bit key
  const key = sodium.crypto_generichash(
    sodium.crypto_secretbox_KEYBYTES,
    signatureBytes
  );
 
  return key;
}

Why ADR-036? By signing a fixed message, the same wallet always produces the same signature, which derives the same encryption key. The key is never stored — it is re-derived on demand. Users only need their wallet to decrypt.

Encrypting and Decrypting Files

src/crypto.ts
export function encrypt(
  plaintext: Uint8Array,
  key: Uint8Array
): { ciphertext: Uint8Array; nonce: Uint8Array } {
  const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
  const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
  return { ciphertext, nonce };
}
 
export function decrypt(
  ciphertext: Uint8Array,
  nonce: Uint8Array,
  key: Uint8Array
): Uint8Array {
  return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
}

Uploading Encrypted Files

src/encrypted-cascade.ts
import { encrypt, deriveKeyFromWallet } from "./crypto";
 
export async function uploadEncrypted(
  client: any,
  file: Uint8Array,
  fileName: string,
  walletKey: Uint8Array
): Promise<{ actionId: string; documentKey: string }> {
  // Generate a random document key (not the wallet key)
  const documentKey = sodium.crypto_secretbox_keygen();
 
  // Encrypt the file with the document key
  const { ciphertext, nonce } = encrypt(file, documentKey);
 
  // Build a manifest with metadata + encrypted content
  const manifest = {
    version: 1,
    fileName,
    encrypted: true,
    nonce: sodium.to_base64(nonce),
    ciphertext: sodium.to_base64(ciphertext),
    // Encrypt the document key with the wallet key for recovery
    encryptedDocumentKey: sodium.to_base64(
      encrypt(documentKey, walletKey).ciphertext
    ),
    encryptedDocumentKeyNonce: sodium.to_base64(
      encrypt(documentKey, walletKey).nonce
    ),
  };
 
  const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest));
 
  const result = await client.Cascade.uploader.uploadFile(manifestBytes, {
    fileName: `${fileName}.encrypted.json`,
    isPublic: true, // The encryption handles confidentiality
    taskOptions: { pollInterval: 2000, timeout: 300000 },
  });
 
  return {
    actionId: (result as any).action_id || result.taskId,
    documentKey: sodium.to_base64(documentKey),
  };
}

Files are uploaded as isPublic: true even when encrypted. Cascade's isPublic flag controls access at the SN-API level, but client-side encryption is the real confidentiality mechanism. This ensures the encrypted blob is available for download by any collaborator you share the key with.

Downloading and Decrypting

src/encrypted-cascade.ts
export async function downloadDecrypted(
  client: any,
  actionId: string,
  walletKey: Uint8Array
): Promise<{ plaintext: Uint8Array; fileName: string }> {
  // Download the encrypted manifest
  const stream = await client.Cascade.downloader.download(actionId);
  const reader = stream.getReader();
  const chunks: Uint8Array[] = [];
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    if (value) chunks.push(value);
  }
 
  const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
  const bytes = new Uint8Array(totalLength);
  let offset = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, offset);
    offset += chunk.length;
  }
 
  const manifest = JSON.parse(new TextDecoder().decode(bytes));
 
  // Recover the document key using the wallet key
  const documentKey = decrypt(
    sodium.from_base64(manifest.encryptedDocumentKey),
    sodium.from_base64(manifest.encryptedDocumentKeyNonce),
    walletKey
  );
 
  // Decrypt the file with the document key
  const plaintext = decrypt(
    sodium.from_base64(manifest.ciphertext),
    sodium.from_base64(manifest.nonce),
    documentKey
  );
 
  return { plaintext, fileName: manifest.fileName };
}

Sharing with Collaborators

To share an encrypted file, re-encrypt the document key under the collaborator's wallet-derived key:

// Owner creates a share invitation
const collaboratorKey = /* collaborator's wallet-derived key */;
const { ciphertext, nonce } = encrypt(documentKey, collaboratorKey);
 
const invitation = {
  draftId: actionId,
  encryptedDocumentKey: sodium.to_base64(ciphertext),
  nonce: sodium.to_base64(nonce),
};
 
// Upload the invitation to Cascade
await client.Cascade.uploader.uploadFile(
  new TextEncoder().encode(JSON.stringify(invitation)),
  { fileName: `invitation_${collaboratorAddress}_${actionId}.json`, isPublic: true }
);

The collaborator downloads the invitation, decrypts the document key with their wallet, and uses it to decrypt the file.

For a complete implementation of this pattern, see the Research Archive example.

Next Steps

On this page