⏱ Estimated read time: 20 minutes · 🏷 Tags: cryptography, javascript, ipfs, security, web-dev, zero-knowledge Introduction Most “secure” file apps encrypt data⏱ Estimated read time: 20 minutes · 🏷 Tags: cryptography, javascript, ipfs, security, web-dev, zero-knowledge Introduction Most “secure” file apps encrypt data

Build a Zero-Knowledge Encrypted Document Vault: Complete Developer Guide

2026/06/09 15:51
22 min read
For feedback or concerns regarding this content, please contact us at [email protected]

Estimated read time: 20 minutes · 🏷 Tags: cryptography, javascript, ipfs, security, web-dev, zero-knowledge

Introduction

Most “secure” file apps encrypt data at rest on the server. That means the server holds the encryption key. Which means the server or anyone who compromises it can read your files. That’s not security. That’s trust.

A Zero-Knowledge vault is different. Files are encrypted in the browser before they are uploaded. The server stores only encrypted blobs. The encryption keys are wrapped using the user’s RSA public key and stored separately on IPFS. The private key never leaves the user’s device. Ever.

This guide walks through a complete, working implementation called SECUREDOC. We’ll cover every meaningful piece of code: key pair generation, Pinata/IPFS storage, the upload encryption pipeline, the share re-wrap flow, decryption, and access revocation. Each section includes both real JavaScript and language-agnostic pseudo-code so you can implement this in Python, Go, Rust, or any runtime with standard crypto libraries.

  • Every user has an RSA-2048 key pair generated in their browser
  • Files are encrypted with a random AES-256-GCM key per document
  • That AES key is RSA-encrypted (wrapped) with the user’s public key and stored on IPFS
  • Sharing = re-wrapping the AES key for the recipient’s public key, locally in the browser
  • The server only stores: encrypted blobs (IPFS CIDs), wrapped key CIDs, and metadata
  • The server cannot decrypt anything ever

The Architecture at a Glance

Before diving into code, here’s the full system map:

┌─────────────────────────────────────────────────────────────────┐
│ USER'S BROWSER │
│ │
│ RSA Key Pair ──────── stays here (private key never leaves) │
│ AES Encrypt ──────── file encrypted before any network call │
│ Key Wrapping ──────── AES key encrypted with RSA public key │
└──────────────┬──────────────────────────────────────┬──────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ PINATA / IPFS │ │ EXPRESS BACKEND │
│ │ │ │
│ Encrypted Blob CID │ │ Document metadata │
│ Wrapped Key CID │ │ Access Control List │
│ (can't read either) │ │ Public key registry │
└─────────────────────┘ └──────────────────────┘

Part 1 — The Cryptography, Plain English

You don’t need a math degree. You need two concepts.

RSA-OAEP: Asymmetric Encryption

RSA uses two linked keys:

  • Public key — share this freely. Anyone can use it to encrypt data for you.
  • Private key — keep this secret. Only you can use it to decrypt data encrypted with your public key.

The golden rule: data encrypted with a public key can only be decrypted with its matching private key. Even the person who encrypted it can’t decrypt it again without the private key.

We use RSA-OAEP with SHA-256 the modern, padding-safe variant. Never use raw RSA (textbook RSA) in production.

AES-GCM: Symmetric Encryption

AES is symmetric the same key encrypts and decrypts. It’s extremely fast for large files.

We use AES-256-GCM specifically because GCM mode provides both confidentiality and authentication. If anyone tampers with the encrypted bytes, decryption throws a hard error you can’t get garbled output, you get nothing.

The Hybrid Pattern

RSA is slow on large data. AES is fast but requires sharing the key securely. Solution: use both.

# Pseudo-code — the core pattern
symmetricKey = AES_GCM.generate_256bit_key() # fast key for the file
encryptedFile = AES_GCM.encrypt(symmetricKey, file) # encrypt the big file
wrappedKey = RSA_OAEP.encrypt(userPublicKey, symmetricKey) # protect the small key

# What gets stored / transmitted:
store(encryptedFile) # safe — useless without the AES key
store(wrappedKey) # safe — only the private key holder can open this

That’s the entire vault in four lines. Everything else is implementation detail.

Part 2 — Setting Up Pinata (IPFS Storage)

IPFS is a decentralized content-addressed storage network. Pinata is a managed pinning service that gives you a reliable HTTP API on top of it. Files are stored by their content hash (CID) immutable, permanent, and globally accessible.

Step 1: Create a Pinata Account and Generate a JWT

  1. Sign up at app.pinata.cloud (free tier works)
  2. Go to API KeysNew Key
  3. Under Scopes, enable pinFileToIPFS only — this is the only scope you need
  4. Click Generate copy the JWT token immediately (it won’t be shown again)

Step 2: The Gateway URL

Every file pinned to IPFS gets a CID. Fetch it back using:

GET https://gateway.pinata.cloud/ipfs/<CID>

Because every file in this system is AES-encrypted before upload, anyone who fetches the raw bytes from IPFS gets an unreadable blob. The CID being public is completely fine.

Step 3: The Core Upload Function

We use pinFileToIPFS for everything both the large encrypted blobs and the small wrapped key JSON files.

// JavaScript (browser)
async function pinataPinFile(fileBlob, filename, metadata = {}) {
const form = new FormData();
form.append("file", fileBlob, filename);
form.append("pinataMetadata", JSON.stringify({
name: filename,
keyvalues: metadata // optional searchable tags
}));
form.append("pinataOptions", JSON.stringify({ cidVersion: 1 })); // CIDv1 recommended

const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: { Authorization: `Bearer ${PINATA_JWT}` },
body: form
// Note: do NOT set Content-Type header manually —
// the browser sets it automatically with the correct boundary for multipart
});

if (!response.ok) {
const err = await response.text();
throw new Error(`Pinata upload failed (${response.status}): ${err}`);
}

const data = await response.json();
return data.IpfsHash; // e.g. "bafybeifgc2voidpz2rh773vkvqutkpu..."
}

# Pseudo-code (Python / Go / Rust / any language)
function pinataPinFile(fileBytes, filename, metadata):
form = new MultipartForm()
form.add_file_field("file", fileBytes, filename)
form.add_text_field("pinataMetadata", json_encode({ name: filename, keyvalues: metadata }))
form.add_text_field("pinataOptions", json_encode({ cidVersion: 1 }))

response = http_post(
url = "https://api.pinata.cloud/pinning/pinFileToIPFS",
headers = { "Authorization": "Bearer " + PINATA_JWT },
body = form
)
return response.json()["IpfsHash"]

Part 3 — Wallet Creation (Key Pair Generation)

Each user’s “wallet” is an RSA-2048 key pair. Generated in the browser. The private key never touches the network — not even encrypted at first generation.

Generate the RSA Key Pair

// JavaScript (browser — Web Crypto API)
async function generateKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 2048, // 2048-bit — solid for 2025+
publicExponent: new Uint8Array([1, 0, 1]), // 65537 — standard safe exponent
hash: "SHA-256"
},
true, // extractable: yes (needed to export to JWK)
["encrypt", "decrypt"]
);
return keyPair; // { publicKey: CryptoKey, privateKey: CryptoKey }
}

# Pseudo-code
function generateKeyPair():
return RSA.generate(
algorithm = "RSA-OAEP",
key_size = 2048,
exponent = 65537,
hash = "SHA-256",
usages = ["encrypt", "decrypt"]
)
# Returns: { publicKey, privateKey }
# privateKey NEVER leaves this function's caller context to the network

Export the Public Key as JWK

JWK (JSON Web Key) is the standard interoperable format for sharing cryptographic keys.

// JavaScript
const pubKeyJWK = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
// Result: { kty: "RSA", alg: "RSA-OAEP-256", n: "qW80Lgi...", e: "AQAB", ... }
// Safe to send to the server — this is the PUBLIC key only

Derive a User Identity from the Public Key

Instead of random UUIDs, the user’s identity is derived from their public key. This means the identity is cryptographically bound you can’t claim someone else’s identity without their key.

// JavaScript
async function deriveUserDID(publicKey) {
const pubJWKString = JSON.stringify(await crypto.subtle.exportKey("jwk", publicKey));
// SHA-256 hash of the public key JSON
const hashBuffer = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(pubJWKString)
);
const hex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
return `did:local:${hex.slice(0, 32)}`;
// e.g. "did:local:8912c294b807019086b09342da9f7cf0"
}

# Pseudo-code
function deriveUserDID(publicKey):
pubKeyStr = json_encode(export_jwk(publicKey))
hash = SHA256(utf8_encode(pubKeyStr))
return "did:local:" + hex_encode(hash)[0:32]

Register with the Backend

After creating the wallet locally, send the public key and the encrypted wallet to the server. The encrypted wallet is the private key locked with the user’s password the server stores it but cannot open it.

// JavaScript
async function registerUser(email, password, keyPair, did, encryptedWallet) {
const publicKeyJWK = await crypto.subtle.exportKey("jwk", keyPair.publicKey);

const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password, // hashed with bcrypt server-side
did,
publicKey: publicKeyJWK, // ✅ safe to send — public key only
encryptedWallet // ✅ private key AES-locked with user's password
// privateKey // ❌ NEVER include this
})
});
return response.json(); // returns JWT token
}

Part 4 — Upload & Encrypt a Document

This is the core of the vault. Here’s the full pipeline:

File

├─[1]─ Generate AES-256-GCM key (K) ←── unique per document

├─[2]─ Encrypt file with K ──────────→ Encrypted Blob

├─[3]─ SHA-256(original file) ───────→ Integrity Hash

├─[4]─ RSA_OAEP_encrypt(publicKey, K) → Wrapped Key (U1)

├─[5]─ Upload Encrypted Blob → Pinata → Blob CID

├─[6]─ Upload Wrapped Key → Pinata → Key CID

└─[7]─ POST metadata to server ───────→ Document registered

Step 1: Generate a Symmetric Key

// JavaScript
async function generateSymmetricKey() {
return crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable — needed for key wrapping
["encrypt", "decrypt"]
);
}

# Pseudo-code
function generateSymmetricKey():
return AES_GCM.generate_key(bits=256, extractable=true)

Every document gets its own unique key. This is critical: if one key is ever compromised, only that document is affected.

Step 2: Encrypt the File

// JavaScript
async function encryptFile(symmetricKey, fileArrayBuffer) {
// 12-byte random IV — MUST be unique for every encryption with the same key
const iv = crypto.getRandomValues(new Uint8Array(12));

const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
symmetricKey,
fileArrayBuffer
);

return { ciphertext, iv };
// ciphertext: ArrayBuffer (file bytes, fully encrypted)
// iv: Uint8Array(12) (must be stored alongside ciphertext to decrypt)
}

# Pseudo-code
function encryptFile(symmetricKey, fileBytes):
iv = random_bytes(12) # CRITICAL: never reuse IV with same key
ciphertext = AES_GCM.encrypt(
key = symmetricKey,
iv = iv,
data = fileBytes
)
return { ciphertext, iv }
# AES-GCM appends a 16-byte auth tag to ciphertext automatically
# If ciphertext is tampered with, decryption will throw — not return garbage

Step 3: Wrap the AES Key with RSA

This step protects the symmetric key. The wrapped key can be stored publicly on IPFS — only the private key holder can open it.

// JavaScript
async function wrapSymmetricKey(symmetricKey, rsaPublicKey) {
// Export the AES key to raw bytes
const rawKeyBytes = await crypto.subtle.exportKey("raw", symmetricKey);
// rawKeyBytes is 32 bytes (256 bits)

// Encrypt those bytes with RSA-OAEP
const wrappedKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
rsaPublicKey,
rawKeyBytes
);

return wrappedKey;
// wrappedKey: ArrayBuffer — 256 bytes for RSA-2048
// Safe to store publicly — only the RSA private key can reverse this
}

# Pseudo-code
function wrapSymmetricKey(symmetricKey, rsaPublicKey):
rawBytes = export_raw_bytes(symmetricKey) # 32 bytes
wrappedKey = RSA_OAEP.encrypt(rsaPublicKey, rawBytes)
return wrappedKey
# Result: ~256 bytes. Useless without the matching private key.

Step 4: Upload Encrypted Blob to Pinata/IPFS

// JavaScript
async function uploadEncryptedBlob(iv, ciphertext, originalFilename) {
// Pack into a single binary blob: [1 byte version][12 bytes IV][N bytes ciphertext]
const ivBytes = new Uint8Array(iv);
const ctBytes = new Uint8Array(ciphertext);
const packed = new Uint8Array(1 + ivBytes.length + ctBytes.length);
packed[0] = 1; // version byte — for future format changes
packed.set(ivBytes, 1);
packed.set(ctBytes, 1 + ivBytes.length);

const blob = new Blob([packed.buffer], { type: "application/octet-stream" });
return pinataPinFile(blob, `${originalFilename}.enc`, { type: "zk-enc-blob" });
// Returns CID — e.g. "bafybeifgc2voidpz2rh773..."
}

# Pseudo-code
function uploadEncryptedBlob(iv, ciphertext, filename):
packed = concat_bytes([byte(1), iv, ciphertext]) # version + IV + data
return ipfs_upload(packed, filename + ".enc")

Step 5: Upload Wrapped Key to Pinata/IPFS

// JavaScript
async function uploadWrappedKey(wrappedKeyBuffer, ownerDID) {
const payload = {
wrappedKey: btoa(String.fromCharCode(...new Uint8Array(wrappedKeyBuffer))),
// base64-encoded wrapped key — safe to store publicly
owner: ownerDID
};
const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
return pinataPinFile(blob, `wrapped_key.json`, { type: "zk-wrapped-key" });
// Returns CID — e.g. "bafkreidjmhvyxz5ckzi5ouuo2..."
}

# Pseudo-code
function uploadWrappedKey(wrappedKeyBytes, ownerDID):
payload = json_encode({
wrappedKey: base64_encode(wrappedKeyBytes),
owner: ownerDID
})
return ipfs_upload(payload, "wrapped_key.json")

Step 6: Anchor Metadata on the Backend

// JavaScript
async function anchorDocument(docId, hash, blobCID, keyCID, ownerDID, filename) {
const response = await fetch("/api/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
docId,
hash, // SHA-256 of the ORIGINAL plaintext file
encryptedBlobCid: blobCID, // IPFS CID of the encrypted blob
wrappedKeyCid: keyCID, // IPFS CID of the wrapped key JSON
ownerDid: ownerDID,
filename
})
});
if (!response.ok) throw new Error("Failed to anchor document on server");
}

The server stores only CIDs and metadata never file contents, never keys in plaintext.

The Full Upload Function

// JavaScript — full upload pipeline
async function uploadDocument(file, userPublicKey, userDID) {
const fileBuffer = await file.arrayBuffer();

// 1. Generate a unique AES key for this document
const symKey = await generateSymmetricKey();

// 2. Encrypt the file
const { ciphertext, iv } = await encryptFile(symKey, fileBuffer);

// 3. SHA-256 hash of original file (integrity anchor)
const hashBuf = await crypto.subtle.digest("SHA-256", fileBuffer);
const hash = Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, "0")).join("");

// 4. Wrap the AES key with the user's RSA public key
const wrappedKey = await wrapSymmetricKey(symKey, userPublicKey);

// 5. Upload encrypted blob to IPFS
const blobCID = await uploadEncryptedBlob(iv, ciphertext, file.name);

// 6. Upload wrapped key to IPFS
const keyCID = await uploadWrappedKey(wrappedKey, userDID);

// 7. Register document on backend
const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await anchorDocument(docId, hash, blobCID, keyCID, userDID, file.name);

console.log(" Upload complete.");
console.log(" The server stored: blobCID, keyCID, SHA-256 hash, filename");
console.log(" The server did NOT see: file contents, AES key, or private key");
}

# Pseudo-code (any language)
function uploadDocument(file, publicKey, userDID):
fileBytes = read(file)
symKey = AES_GCM.generate_key(256)
iv, cipher = AES_GCM.encrypt(symKey, fileBytes)
hash = SHA256(fileBytes)
wrappedKey = RSA_OAEP.encrypt(publicKey, export_raw(symKey))
blobCID = ipfs_upload(pack(iv, cipher))
keyCID = ipfs_upload(json({ wrappedKey: base64(wrappedKey) }))
server_post("/api/documents", { blobCID, keyCID, hash, userDID, filename })

Part 5 — Sharing a Document (The Re-Wrap Flow)

This is the most technically interesting part of the entire system. When User 1 shares a document with User 2, the server never sees the AES key in plaintext. The re-encryption is done entirely in User 1’s browser.

The 6-Step Share Flow

① Fetch User 2's public key from the server's key registry
(safe — public keys are meant to be shared)

② Fetch Wrapped Key (U1) from IPFS
(encrypted with User 1's public key — safe to fetch over network)

③ [LOCAL] Decrypt Wrapped Key (U1) with User 1's PRIVATE KEY
↳ Recovers raw AES key bytes in browser memory
↳ Private key never leaves the browser. Raw AES key never hits the network.

④ [LOCAL] Re-encrypt raw AES key bytes with User 2's PUBLIC KEY
↳ Creates Wrapped Key (U2)
↳ Only User 2's private key can open this

⑤ Upload Wrapped Key (U2) to Pinata/IPFS
↳ Returns new CID

⑥ Update Access Control List on server
↳ Server links User 2's DID to the new key CID for this document

The Code

// JavaScript — full share flow
async function shareDocument(
docId, senderPrivateKey, senderDID,
recipientDID, existingWrappedKeyCID
) {
// Step 1: Fetch recipient's public key from server registry
const pkRes = await fetch(`/api/did/${encodeURIComponent(recipientDID)}/publickey`);
if (!pkRes.ok) throw new Error("Recipient not found. Have they created their wallet?");

const { publicKey: recipientJWK } = await pkRes.json();
const recipientPublicKey = await crypto.subtle.importKey(
"jwk",
recipientJWK,
{ name: "RSA-OAEP", hash: "SHA-256" },
false, // non-extractable — we only use it for encrypt
["encrypt"]
);

// Step 2: Fetch Wrapped Key (U1) from IPFS
const keyData = await fetch(
`https://gateway.pinata.cloud/ipfs/${existingWrappedKeyCID}`
).then(r => r.json());
const wrappedKeyU1 = Uint8Array.from(
atob(keyData.wrappedKey), c => c.charCodeAt(0)
).buffer;

// Step 3: LOCAL ONLY — Unwrap the AES key with sender's private key
// The raw AES bytes live only in browser memory. Never sent anywhere.
const rawAESKeyBytes = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
senderPrivateKey, // stays on device
wrappedKeyU1
);
// rawAESKeyBytes: 32 bytes in memory — the original AES-256 key

// Step 4: LOCAL ONLY — Re-encrypt with recipient's public key
const wrappedKeyU2 = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
recipientPublicKey,
rawAESKeyBytes // same 32 bytes, now encrypted for recipient
);

// Step 5: Upload new wrapped key to IPFS
const newKeyCID = await uploadWrappedKey(wrappedKeyU2, recipientDID);

// Step 6: Update ACL on server
await fetch("/api/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
docId,
recipientDid: recipientDID,
wrappedKeyCid: newKeyCID,
requesterDid: senderDID // server verifies this is the document owner
})
});

console.log(" Access granted to", recipientDID);
console.log(" Server stored: new key CID for recipient");
console.log(" Server did NOT see: raw AES key or any private key");
}

# Pseudo-code (any language)
function shareDocument(docId, senderPrivKey, senderDID, recipientDID, keyCID):
recipPubKey = fetch_from_server("/api/did/" + recipientDID + "/publickey")
wrappedU1 = ipfs_fetch(keyCID).wrappedKey # still encrypted
rawAESBytes = RSA_OAEP.decrypt(senderPrivKey, wrappedU1) # LOCAL ONLY
wrappedU2 = RSA_OAEP.encrypt(recipPubKey, rawAESBytes) # LOCAL ONLY
newCID = ipfs_upload(json({ wrappedKey: base64(wrappedU2) }))
server_post("/api/share", { docId, recipientDID, wrappedKeyCid: newCID, senderDID })

Part 6 — Decrypting & Downloading a Document

When a user opens a document, their browser fetches the encrypted blob and their wrapped key, decrypts everything locally, and triggers a download.

// JavaScript — full decrypt flow
async function decryptAndDownload(blobCID, wrappedKeyCID, filename, userPrivateKey) {
// 1. Fetch encrypted blob from IPFS
const blobResponse = await fetch(`https://gateway.pinata.cloud/ipfs/${blobCID}`);
const packedBuffer = await blobResponse.arrayBuffer();

// Unpack: skip version byte (index 0), read 12-byte IV, read remaining ciphertext
const packed = new Uint8Array(packedBuffer);
const iv = packed.slice(1, 13); // bytes 1–12
const ciphertext = packed.slice(13).buffer; // bytes 13 to end

// 2. Fetch wrapped key from IPFS
const keyData = await fetch(`https://gateway.pinata.cloud/ipfs/${wrappedKeyCID}`)
.then(r => r.json());
const wrappedKey = Uint8Array.from(
atob(keyData.wrappedKey), c => c.charCodeAt(0)
).buffer;

// 3. Unwrap AES key with user's RSA private key (LOCAL)
const rawAESBytes = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
userPrivateKey,
wrappedKey
);

// Import raw bytes as a usable AES-GCM CryptoKey
const symmetricKey = await crypto.subtle.importKey(
"raw", rawAESBytes,
{ name: "AES-GCM" },
false, // non-extractable after import
["decrypt"]
);

// 4. Decrypt the file
const plaintextBuffer = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
symmetricKey,
ciphertext
);
// If this succeeds, the key was correct AND the ciphertext wasn't tampered with
// (AES-GCM authentication tag is verified automatically)

// 5. Trigger browser download
const url = URL.createObjectURL(new Blob([plaintextBuffer]));
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);

console.log(" Decrypted and downloaded. Server saw zero plaintext.");
}

# Pseudo-code (any language)
function decryptAndDownload(blobCID, keyCID, filename, privateKey):
encryptedBlob = ipfs_fetch(blobCID)
iv, ciphertext = unpack(encryptedBlob) # skip version byte, split at byte 13

wrappedKey = ipfs_fetch(keyCID).wrappedKey
rawAESBytes = RSA_OAEP.decrypt(privateKey, base64_decode(wrappedKey))
symmetricKey = AES_GCM.import_key(rawAESBytes)

plaintext = AES_GCM.decrypt(symmetricKey, iv, ciphertext)
save_to_disk(filename, plaintext)

Part 7 — Revoking Access

When you revoke a user’s access, the server removes their entry from the document’s Access Control List.

// JavaScript
async function revokeAccess(docId, recipientDID, ownerDID) {
const response = await fetch("/api/share", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
docId,
recipientDid: recipientDID,
requesterDid: ownerDID // server verifies this matches the document owner
})
});

const result = await response.json();
if (result.success) {
console.log(`Access revoked for ${recipientDID}`);
}
}

# Pseudo-code
function revokeAccess(docId, recipientDID, ownerDID):
server_delete("/api/share", { docId, recipientDID, ownerDID })

What Revocation Does and Doesn’t Do

What it does:

  • Removes the recipient’s DID from the server’s ACL for this document
  • The GET /api/documents/:did endpoint no longer returns the document for the revoked user
  • Their wrapped key CID pointer is deleted from the server’s records
  • Future attempts to list or access the document via the API will fail with 403

What it doesn’t do:

  • IPFS content is immutable the wrapped key file (U2) that was previously uploaded to IPFS is not deleted. It’s addressed by content hash and pinned globally.
  • If the recipient already fetched and locally cached the wrapped key CID before revocation, they technically still have the bytes to decrypt the document.

Part 8 — The Backend API (Key Routes)

The server handles authentication, key registry, document metadata, and ACL management. Here are the routes you need to implement:

POST /api/auth/register

Request:
{
"email": "[email protected]",
"password": "supersecret123", ← hashed with bcrypt(12) server-side
"did": "did:local:8912c294b807...",
"publicKey": { "kty": "RSA", "alg": "RSA-OAEP-256", "n": "...", "e": "AQAB" },
"encryptedWallet": { "version": 1, "salt": "...", "iv": "...", "ciphertext": "..." }
}

Response 201:
{ "token": "eyJhbGci...", "user": { "id": "user_1", "email": "alice@...", "did": "..." } }

POST /api/auth/login

Request:
{ "email": "[email protected]", "password": "supersecret123" }

Response 200:
{
"token": "eyJhbGci...",
"user": { "id": "user_1", ... },
"encryptedWallet": { "version": 1, "salt": "...", "iv": "...", "ciphertext": "..." },
"publicKey": { "kty": "RSA", ... }
}
← Server returns encryptedWallet so the browser can decrypt it locally
← The plaintext private key is NEVER in this response

POST /api/documents

Request:
{
"docId": "doc_1779097856281_287z",
"hash": "6a1e70b95c82514041db571...",
"encryptedBlobCid": "bafybeifgc2voidpz2...",
"wrappedKeyCid": "bafkreidjmhvyxz5ck...",
"ownerDid": "did:local:8912c294...",
"filename": "my_will.pdf"
}
Response 200: { "success": true, "docId": "doc_1779097856281_287z" }

GET /api/documents/:did

Returns all documents the given DID has access to (either as owner or shared recipient).

Response 200:
{
"documents": [
{
"docId": "doc_1779...",
"filename": "my_will.pdf",
"hash": "6a1e70b9...",
"encryptedBlobCid": "bafybeifgc2...",
"wrappedKeyCid": "bafkreidjm...", ← the wrapped key for THIS DID
"isOwner": true
}
]
}

POST /api/share

Request:
{
"docId": "doc_1779...",
"recipientDid": "did:local:0ef56523...",
"wrappedKeyCid": "bafkreic22hrkc6vy...", ← CID of the new Wrapped Key (U2)
"requesterDid": "did:local:8912c294..." ← must be document owner
}
Response 200: { "success": true }

DELETE /api/share

Request:
{
"docId": "doc_1779...",
"recipientDid": "did:local:0ef56523...",
"requesterDid": "did:local:8912c294..."
}
Response 200: { "success": true }

Part 9 — The Zero-Knowledge Proof: Live Demo

The best way to prove the system works is to try to break it. Here’s what happens when someone tries to decrypt a document without authorization:

// JavaScript — attempting to decrypt without the correct wrapped key
async function demonstrateZeroKnowledge(blobCID) {
// Step 1: Fetch the encrypted blob from IPFS (always works — IPFS is public)
const response = await fetch(`https://gateway.pinata.cloud/ipfs/${blobCID}`);
const packedBuffer = await response.arrayBuffer();
const packed = new Uint8Array(packedBuffer);
const iv = packed.slice(1, 13);
const ciphertext = packed.slice(13).buffer;

// Step 2: Try to decrypt with a completely wrong AES key
const wrongKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 }, false, ["decrypt"]
);

try {
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, wrongKey, ciphertext);
console.error("This should NEVER happen — something is wrong");
} catch (err) {
// AES-GCM verifies an authentication tag on decrypt.
// A wrong key produces the wrong tag → hard cryptographic failure.
console.log("✅ Decryption FAILED as expected");
console.log(" Error:", err.name, "—", err.message);
// Output: OperationError — The operation failed for an operation-specific reason
console.log(" Without the correct wrapped key from the ACL,");
console.log(" decryption is mathematically impossible.");
console.log(" Not hard. Not unlikely. Impossible.");
}
}

The result: OperationError — The operation failed for an operation-specific reason

AES-GCM appends a 16-byte authentication tag to every ciphertext. Any decryption attempt with the wrong key produces the wrong tag — and the Web Crypto API throws immediately. You don’t get garbled output. You don’t get partial data. You get nothing.

This is the zero-knowledge guarantee in action. An attacker with full read access to the server database and full read access to IPFS has exactly the same problem as a random person on the internet: they can fetch the encrypted blobs, but without the correct wrapped key and a private key to unwrap it, the data is permanently inaccessible.

Part 10 — Implementing This in Other Languages

Every crypto primitive used here is available in all major languages. The concepts are identical only the syntax differs.

Python Example (Key Wrapping)

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os, base64

# Generate RSA key pair
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()

# Generate AES-256 key
aes_key = os.urandom(32)

# Encrypt file with AES-GCM
iv = os.urandom(12)
aesgcm = AESGCM(aes_key)
ciphertext = aesgcm.encrypt(iv, file_bytes, None)

# Wrap AES key with RSA public key
wrapped_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)

# Unwrap (decrypt) with private key
recovered_key = private_key.decrypt(
wrapped_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# recovered_key == aes_key ✓

The architecture is the same. Upload the encrypted blob and base64(wrapped_key) to Pinata, anchor the CIDs on the server.

Part 11 — Security Quick Tips

🔐 Never log or persist the private key in plaintext. Keep it in memory only. Wipe it on logout by overwriting the variable and triggering garbage collection.

🔐 Always generate a fresh IV per encryption. crypto.getRandomValues(new Uint8Array(12)) every single time. Reusing an IV with the same AES key breaks GCM confidentiality catastrophically.

🔐 Scope your Pinata JWT to pinFileToIPFS only. If leaked, an attacker can pin new files but cannot delete, query, or manage your existing pins.

🔐 Serve the app over HTTPS. Client-side crypto only protects data in transit on IPFS and the server. A MITM attacker who can inject JavaScript into your app page can steal the private key before it’s used. HTTPS prevents this.

🔐 Use environment variables for JWT_SECRET. Never hardcode it. A leaked JWT secret means token forgery. Generate with openssl rand -hex 64.

🔐 sessionStorage, not localStorage, for the session wallet. sessionStorage is cleared when the tab closes. localStorage persists indefinitely across sessions and is a much larger attack surface. The private key session copy should be tab-scoped.

🔐 Treat the DID registry as a trust anchor. In this implementation, the server hosts the public key registry. A compromised server could serve a malicious public key to a sharer, causing them to wrap a key for an attacker’s key rather than the intended recipient. For production, anchor public keys on an immutable ledger (blockchain, Hyperledger, or a transparency log).

What’s Next

This implementation is production-grade for a wide range of use cases. If you want to push it further: key rotation (re-wrap all document keys when a user regenerates their RSA pair), WebAuthn binding (tie private key decryption to a hardware security key, TouchID, or FaceID so even sessionStorage compromise can’t expose the key), and recovery mnemonics (give users a 12-word BIP39 phrase at registration currently, a forgotten password permanently loses the private key and all documents with it).

The full source code for this reference implementation is available on GitHub. If you build something with it, I’d love to hear about it.
Github Link : https://github.com/muhammadtalha198/ZeroKnowladge_Document_Upload

Conclusion

Here’s what you’ve built:

  • Files encrypted before they leave the browser with AES-256-GCM
  • AES keys wrapped with RSA-2048-OAEP and stored on IPFS
  • Document sharing via client-side key re-wrapping — the server is blind throughout
  • Access revocation enforced at ACL level
  • An attacker with full server + IPFS access gets mathematically nothing useful

The cryptographic primitives RSA-OAEP and AES-GCM are available in every modern language and runtime. The pseudo-code in this guide maps directly to Python’s cryptography library, Go's crypto/rsa and crypto/cipher packages, Rust's rsa and aes-gcm crates, and Java's javax.crypto.

The architecture is the idea. The language is just syntax.

Tagged: #cryptography #javascript #ipfs #security #zero-knowledge #encryption #fullstack #webdev


Build a Zero-Knowledge Encrypted Document Vault: Complete Developer Guide was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

Predict & Trade to Win Rewards

Predict & Trade to Win RewardsPredict & Trade to Win Rewards

Guaranteed rewards with $500,000 prize pool

Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact [email protected] for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.

RealStocks Now Live

RealStocks Now LiveRealStocks Now Live

Trade real U.S. stock via regulated brokerage