⏱ Estimated read time: 20 minutes · 🏷 Tags: cryptography, javascript, ipfs, security, web-dev, zero-knowledge
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.
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 │
└─────────────────────┘ └──────────────────────┘
You don’t need a math degree. You need two concepts.
RSA uses two linked keys:
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 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.
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.
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.
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.
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"]
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.
// 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
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
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]
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
}
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
// 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.
// 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
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.
// 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")
// 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")
// 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.
// 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 })
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.
① 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
// 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 })
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)
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 it does:
What it doesn’t do:
The server handles authentication, key registry, document metadata, and ACL management. Here are the routes you need to implement:
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": "..." } }
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
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" }
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
}
]
}
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 }
Request:
{
"docId": "doc_1779...",
"recipientDid": "did:local:0ef56523...",
"requesterDid": "did:local:8912c294..."
}
Response 200: { "success": true }
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.
Every crypto primitive used here is available in all major languages. The concepts are identical only the syntax differs.
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.
🔐 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).
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
Here’s what you’ve built:
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.


