
{"id":177341,"date":"2026-06-09T07:51:04","date_gmt":"2026-06-09T07:51:04","guid":{"rendered":"https:\/\/mycryptomania.com\/?p=177341"},"modified":"2026-06-09T07:51:04","modified_gmt":"2026-06-09T07:51:04","slug":"build-a-zero-knowledge-encrypted-document-vault-complete-developer-guide","status":"publish","type":"post","link":"https:\/\/mycryptomania.com\/?p=177341","title":{"rendered":"Build a Zero-Knowledge Encrypted Document Vault: Complete Developer Guide"},"content":{"rendered":"<p><strong><em>By the end of this guide, you\u2019ll have built a file-sharing system where you the developer running the server cannot read your users\u2019\u00a0files.<\/em><\/strong><\/p>\n<p>\u23f1 <em>Estimated read time: 20 minutes<\/em> \u00b7 \ud83c\udff7 <em>Tags: cryptography, javascript, ipfs, security, web-dev, zero-knowledge<\/em><\/p>\n<h3>Introduction<\/h3>\n<p>Most \u201csecure\u201d file apps encrypt data <strong>at rest on the server<\/strong>. That means the server holds the encryption key. Which means the server or anyone who compromises it can read your files. That\u2019s not security. That\u2019s\u00a0trust.<\/p>\n<p>A <strong>Zero-Knowledge<\/strong> 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\u2019s RSA public key and stored separately on IPFS. The private key <strong>never leaves the user\u2019s device<\/strong>.\u00a0Ever.<\/p>\n<p>This guide walks through a complete, working implementation called <strong>SECUREDOC<\/strong>. We\u2019ll 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 <strong>real JavaScript<\/strong> and <strong>language-agnostic pseudo-code<\/strong> so you can implement this in Python, Go, Rust, or any runtime with standard crypto libraries.<\/p>\n<p><strong><em>TL;DR<\/em><\/strong>Every user has an RSA-2048 key pair generated in their\u00a0browserFiles are encrypted with a random AES-256-GCM key per\u00a0documentThat AES key is RSA-encrypted (wrapped) with the user\u2019s public key and stored on\u00a0IPFSSharing = re-wrapping the AES key for the recipient\u2019s public key, locally in the\u00a0browserThe server only stores: encrypted blobs (IPFS CIDs), wrapped key CIDs, and\u00a0metadata<strong>The server cannot decrypt anything\u00a0ever<\/strong><\/p>\n<h3>The Architecture at a\u00a0Glance<\/h3>\n<p>Before diving into code, here\u2019s the full system\u00a0map:<\/p>\n<p>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510<br \/>\u2502                        USER&#8217;S BROWSER                           \u2502<br \/>\u2502                                                                 \u2502<br \/>\u2502  RSA Key Pair \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 stays here (private key never leaves)    \u2502<br \/>\u2502  AES Encrypt  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 file encrypted before any network call   \u2502<br \/>\u2502  Key Wrapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 AES key encrypted with RSA public key    \u2502<br \/>\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518<br \/>               \u2502                                      \u2502<br \/>               \u25bc                                      \u25bc<br \/>    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510              \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510<br \/>    \u2502   PINATA \/ IPFS     \u2502              \u2502   EXPRESS BACKEND    \u2502<br \/>    \u2502                     \u2502              \u2502                      \u2502<br \/>    \u2502 Encrypted Blob CID  \u2502              \u2502 Document metadata    \u2502<br \/>    \u2502 Wrapped Key CID     \u2502              \u2502 Access Control List  \u2502<br \/>    \u2502 (can&#8217;t read either) \u2502              \u2502 Public key registry  \u2502<br \/>    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518              \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518<\/p>\n<h3>Part 1\u200a\u2014\u200aThe Cryptography, Plain\u00a0English<\/h3>\n<p>You don\u2019t need a math degree. You need two concepts.<\/p>\n<h3>RSA-OAEP: Asymmetric Encryption<\/h3>\n<p>RSA uses <strong>two linked\u00a0keys<\/strong>:<\/p>\n<p><strong>Public key<\/strong>\u200a\u2014\u200ashare this freely. Anyone can use it to encrypt data <em>for\u00a0you<\/em>.<strong>Private key<\/strong>\u200a\u2014\u200akeep this secret. Only you can use it to decrypt data encrypted with your public\u00a0key.<\/p>\n<p>The golden rule: <strong>data encrypted with a public key can only be decrypted with its matching private key.<\/strong> Even the person who encrypted it can\u2019t decrypt it again without the private\u00a0key.<\/p>\n<p>We use <strong>RSA-OAEP with SHA-256<\/strong> the modern, padding-safe variant. Never use raw RSA (textbook RSA) in production.<\/p>\n<h3>AES-GCM: Symmetric Encryption<\/h3>\n<p>AES is <strong>symmetric<\/strong> the same key encrypts and decrypts. It\u2019s extremely fast for large\u00a0files.<\/p>\n<p>We use <strong>AES-256-GCM<\/strong> specifically because GCM mode provides both confidentiality <em>and<\/em> authentication. If anyone tampers with the encrypted bytes, decryption throws a hard error you can\u2019t get garbled output, you get\u00a0nothing.<\/p>\n<h3>The Hybrid\u00a0Pattern<\/h3>\n<p>RSA is slow on large data. AES is fast but requires sharing the key securely. Solution: use\u00a0both.<\/p>\n<p># Pseudo-code \u2014 the core pattern<br \/>symmetricKey = AES_GCM.generate_256bit_key()          # fast key for the file<br \/>encryptedFile = AES_GCM.encrypt(symmetricKey, file)   # encrypt the big file<br \/>wrappedKey = RSA_OAEP.encrypt(userPublicKey, symmetricKey)  # protect the small key# What gets stored \/ transmitted:<br \/>store(encryptedFile)  # safe \u2014 useless without the AES key<br \/>store(wrappedKey)     # safe \u2014 only the private key holder can open this<\/p>\n<p>That\u2019s the entire vault in four lines. Everything else is implementation detail.<\/p>\n<h3>Part 2\u200a\u2014\u200aSetting Up Pinata (IPFS\u00a0Storage)<\/h3>\n<p><strong>IPFS<\/strong> is a decentralized content-addressed storage network. <strong>Pinata<\/strong> 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.<\/p>\n<h3>Step 1: Create a Pinata Account and Generate a\u00a0JWT<\/h3>\n<p>Sign up at <a href=\"https:\/\/app.pinata.cloud\/\">app.pinata.cloud<\/a> (free tier\u00a0works)Go to <strong>API Keys<\/strong> \u2192 <strong>New\u00a0Key<\/strong>Under <strong>Scopes<\/strong>, enable <strong>pinFileToIPFS only<\/strong>\u200a\u2014\u200athis is the only scope you\u00a0needClick <strong>Generate<\/strong> copy the <strong>JWT token<\/strong> immediately (it won\u2019t be shown\u00a0again)<em>\u26a0\ufe0f <\/em><strong><em>Principle of least privilege:<\/em><\/strong><em> Enable only <\/em><em>pinFileToIPFS. If this JWT is ever leaked, an attacker can pin new files but cannot delete or manage your existing pins. Never use an Admin key in a client-side app.<\/em><\/p>\n<h3>Step 2: The Gateway\u00a0URL<\/h3>\n<p>Every file pinned to IPFS gets a CID. Fetch it back\u00a0using:<\/p>\n<p>GET https:\/\/gateway.pinata.cloud\/ipfs\/&lt;CID&gt;<\/p>\n<p>Because every file in this system is <strong>AES-encrypted before upload<\/strong>, anyone who fetches the raw bytes from IPFS gets an unreadable blob. The CID being public is completely fine.<\/p>\n<h3>Step 3: The Core Upload\u00a0Function<\/h3>\n<p>We use pinFileToIPFS for everything both the large encrypted blobs and the small wrapped key JSON\u00a0files.<\/p>\n<p>\/\/ JavaScript (browser)<br \/>async function pinataPinFile(fileBlob, filename, metadata = {}) {<br \/>  const form = new FormData();<br \/>  form.append(&#8220;file&#8221;, fileBlob, filename);<br \/>  form.append(&#8220;pinataMetadata&#8221;, JSON.stringify({<br \/>    name: filename,<br \/>    keyvalues: metadata           \/\/ optional searchable tags<br \/>  }));<br \/>  form.append(&#8220;pinataOptions&#8221;, JSON.stringify({ cidVersion: 1 }));  \/\/ CIDv1 recommended  const response = await fetch(&#8220;https:\/\/api.pinata.cloud\/pinning\/pinFileToIPFS&#8221;, {<br \/>    method: &#8220;POST&#8221;,<br \/>    headers: { Authorization: `Bearer ${PINATA_JWT}` },<br \/>    body: form<br \/>    \/\/ Note: do NOT set Content-Type header manually \u2014 <br \/>    \/\/ the browser sets it automatically with the correct boundary for multipart<br \/>  });  if (!response.ok) {<br \/>    const err = await response.text();<br \/>    throw new Error(`Pinata upload failed (${response.status}): ${err}`);<br \/>  }  const data = await response.json();<br \/>  return data.IpfsHash;  \/\/ e.g. &#8220;bafybeifgc2voidpz2rh773vkvqutkpu&#8230;&#8221;<br \/>}# Pseudo-code (Python \/ Go \/ Rust \/ any language)<br \/>function pinataPinFile(fileBytes, filename, metadata):<br \/>  form = new MultipartForm()<br \/>  form.add_file_field(&#8220;file&#8221;, fileBytes, filename)<br \/>  form.add_text_field(&#8220;pinataMetadata&#8221;, json_encode({ name: filename, keyvalues: metadata }))<br \/>  form.add_text_field(&#8220;pinataOptions&#8221;, json_encode({ cidVersion: 1 }))  response = http_post(<br \/>    url     = &#8220;https:\/\/api.pinata.cloud\/pinning\/pinFileToIPFS&#8221;,<br \/>    headers = { &#8220;Authorization&#8221;: &#8220;Bearer &#8221; + PINATA_JWT },<br \/>    body    = form<br \/>  )<br \/>  return response.json()[&#8220;IpfsHash&#8221;]<strong><em>CORS note:<\/em><\/strong><em> Pinata\u2019s API supports browser-side CORS out of the box. You can call it directly from client-side JavaScript with no proxy. If you\u2019re calling from a server-side backend (Node, Python, Go), standard HTTP requests work no special\u00a0setup.<\/em><\/p>\n<h3>Part 3\u200a\u2014\u200aWallet Creation (Key Pair Generation)<\/h3>\n<p>Each user\u2019s \u201cwallet\u201d is an RSA-2048 key pair. Generated in the browser. The private key never touches the network\u200a\u2014\u200anot even encrypted at first generation.<\/p>\n<h3>Generate the RSA Key\u00a0Pair<\/h3>\n<p>\/\/ JavaScript (browser \u2014 Web Crypto API)<br \/>async function generateKeyPair() {<br \/>  const keyPair = await crypto.subtle.generateKey(<br \/>    {<br \/>      name: &#8220;RSA-OAEP&#8221;,<br \/>      modulusLength: 2048,                          \/\/ 2048-bit \u2014 solid for 2025+<br \/>      publicExponent: new Uint8Array([1, 0, 1]),    \/\/ 65537 \u2014 standard safe exponent<br \/>      hash: &#8220;SHA-256&#8221;<br \/>    },<br \/>    true,                                            \/\/ extractable: yes (needed to export to JWK)<br \/>    [&#8220;encrypt&#8221;, &#8220;decrypt&#8221;]<br \/>  );<br \/>  return keyPair;  \/\/ { publicKey: CryptoKey, privateKey: CryptoKey }<br \/>}# Pseudo-code<br \/>function generateKeyPair():<br \/>  return RSA.generate(<br \/>    algorithm = &#8220;RSA-OAEP&#8221;,<br \/>    key_size  = 2048,<br \/>    exponent  = 65537,<br \/>    hash      = &#8220;SHA-256&#8221;,<br \/>    usages    = [&#8220;encrypt&#8221;, &#8220;decrypt&#8221;]<br \/>  )<br \/>  # Returns: { publicKey, privateKey }<br \/>  # privateKey NEVER leaves this function&#8217;s caller context to the network<\/p>\n<h3>Export the Public Key as\u00a0JWK<\/h3>\n<p>JWK (JSON Web Key) is the standard interoperable format for sharing cryptographic keys.<\/p>\n<p>\/\/ JavaScript<br \/>const pubKeyJWK = await crypto.subtle.exportKey(&#8220;jwk&#8221;, keyPair.publicKey);<br \/>\/\/ Result: { kty: &#8220;RSA&#8221;, alg: &#8220;RSA-OAEP-256&#8221;, n: &#8220;qW80Lgi&#8230;&#8221;, e: &#8220;AQAB&#8221;, &#8230; }<br \/>\/\/ Safe to send to the server \u2014 this is the PUBLIC key only<\/p>\n<h3>Derive a User Identity from the Public\u00a0Key<\/h3>\n<p>Instead of random UUIDs, the user\u2019s identity is <strong>derived from their public key<\/strong>. This means the identity is cryptographically bound you can\u2019t claim someone else\u2019s identity without their\u00a0key.<\/p>\n<p>\/\/ JavaScript<br \/>async function deriveUserDID(publicKey) {<br \/>  const pubJWKString = JSON.stringify(await crypto.subtle.exportKey(&#8220;jwk&#8221;, publicKey));<\/p>\n<p>  \/\/ SHA-256 hash of the public key JSON<br \/>  const hashBuffer = await crypto.subtle.digest(<br \/>    &#8220;SHA-256&#8221;,<br \/>    new TextEncoder().encode(pubJWKString)<br \/>  );<\/p>\n<p>  const hex = Array.from(new Uint8Array(hashBuffer))<br \/>    .map(b =&gt; b.toString(16).padStart(2, &#8220;0&#8221;))<br \/>    .join(&#8220;&#8221;);<\/p>\n<p>  return `did:local:${hex.slice(0, 32)}`;<br \/>  \/\/ e.g. &#8220;did:local:8912c294b807019086b09342da9f7cf0&#8221;<br \/>}# Pseudo-code<br \/>function deriveUserDID(publicKey):<br \/>  pubKeyStr = json_encode(export_jwk(publicKey))<br \/>  hash      = SHA256(utf8_encode(pubKeyStr))<br \/>  return &#8220;did:local:&#8221; + hex_encode(hash)[0:32]<\/p>\n<h3>Register with the\u00a0Backend<\/h3>\n<p>After creating the wallet locally, send the <strong>public key<\/strong> and the <strong>encrypted wallet<\/strong> to the server. The encrypted wallet is the private key locked with the user\u2019s password the server stores it but cannot open\u00a0it.<\/p>\n<p>\/\/ JavaScript<br \/>async function registerUser(email, password, keyPair, did, encryptedWallet) {<br \/>  const publicKeyJWK = await crypto.subtle.exportKey(&#8220;jwk&#8221;, keyPair.publicKey);  const response = await fetch(&#8220;\/api\/auth\/register&#8221;, {<br \/>    method: &#8220;POST&#8221;,<br \/>    headers: { &#8220;Content-Type&#8221;: &#8220;application\/json&#8221; },<br \/>    body: JSON.stringify({<br \/>      email,<br \/>      password,             \/\/ hashed with bcrypt server-side<br \/>      did,<br \/>      publicKey: publicKeyJWK,    \/\/ \u2705 safe to send \u2014 public key only<br \/>      encryptedWallet             \/\/ \u2705 private key AES-locked with user&#8217;s password<br \/>      \/\/ privateKey               \/\/ \u274c NEVER include this<br \/>    })<br \/>  });<br \/>  return response.json(); \/\/ returns JWT token<br \/>}<strong><em>What is <\/em><\/strong><strong><em>encryptedWallet?<\/em><\/strong><em> Before sending, the private key is exported to JWK format and encrypted with AES-GCM using a key derived from the user&#8217;s password via PBKDF2. The server stores this encrypted blob (<\/em><em>{ salt, iv, ciphertext }). On login, the server returns the blob and the browser decrypts it locally using the password. The password itself is <\/em><strong><em>never sent to the server in the wallet derivation step<\/em><\/strong><em> only the hash sent for <\/em><em>bcrypt password verification.<\/em><\/p>\n<h3>Part 4\u200a\u2014\u200aUpload &amp; Encrypt a\u00a0Document<\/h3>\n<p>This is the core of the vault. Here\u2019s the full pipeline:<\/p>\n<p>File<br \/> \u2502<br \/> \u251c\u2500[1]\u2500 Generate AES-256-GCM key (K)  \u2190\u2500\u2500 unique per document<br \/> \u2502<br \/> \u251c\u2500[2]\u2500 Encrypt file with K  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2192  Encrypted Blob<br \/> \u2502<br \/> \u251c\u2500[3]\u2500 SHA-256(original file)  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2192  Integrity Hash<br \/> \u2502<br \/> \u251c\u2500[4]\u2500 RSA_OAEP_encrypt(publicKey, K)  \u2192 Wrapped Key (U1)<br \/> \u2502<br \/> \u251c\u2500[5]\u2500 Upload Encrypted Blob \u2192 Pinata  \u2192 Blob CID<br \/> \u2502<br \/> \u251c\u2500[6]\u2500 Upload Wrapped Key   \u2192 Pinata  \u2192 Key CID<br \/> \u2502<br \/> \u2514\u2500[7]\u2500 POST metadata to server  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2192 Document registered<\/p>\n<h3>Step 1: Generate a Symmetric Key<\/h3>\n<p>\/\/ JavaScript<br \/>async function generateSymmetricKey() {<br \/>  return crypto.subtle.generateKey(<br \/>    { name: &#8220;AES-GCM&#8221;, length: 256 },<br \/>    true,                            \/\/ extractable \u2014 needed for key wrapping<br \/>    [&#8220;encrypt&#8221;, &#8220;decrypt&#8221;]<br \/>  );<br \/>}# Pseudo-code<br \/>function generateSymmetricKey():<br \/>  return AES_GCM.generate_key(bits=256, extractable=true)<\/p>\n<p><strong>Every document gets its own unique key.<\/strong> This is critical: if one key is ever compromised, only that document is affected.<\/p>\n<h3>Step 2: Encrypt the\u00a0File<\/h3>\n<p>\/\/ JavaScript<br \/>async function encryptFile(symmetricKey, fileArrayBuffer) {<br \/>  \/\/ 12-byte random IV \u2014 MUST be unique for every encryption with the same key<br \/>  const iv = crypto.getRandomValues(new Uint8Array(12));  const ciphertext = await crypto.subtle.encrypt(<br \/>    { name: &#8220;AES-GCM&#8221;, iv },<br \/>    symmetricKey,<br \/>    fileArrayBuffer<br \/>  );  return { ciphertext, iv };<br \/>  \/\/ ciphertext: ArrayBuffer (file bytes, fully encrypted)<br \/>  \/\/ iv: Uint8Array(12) (must be stored alongside ciphertext to decrypt)<br \/>}# Pseudo-code<br \/>function encryptFile(symmetricKey, fileBytes):<br \/>  iv         = random_bytes(12)       # CRITICAL: never reuse IV with same key<br \/>  ciphertext = AES_GCM.encrypt(<br \/>    key  = symmetricKey,<br \/>    iv   = iv,<br \/>    data = fileBytes<br \/>  )<br \/>  return { ciphertext, iv }<br \/>  # AES-GCM appends a 16-byte auth tag to ciphertext automatically<br \/>  # If ciphertext is tampered with, decryption will throw \u2014 not return garbage<em>\u26a0\ufe0f <\/em><strong><em>The IV is not a secret.<\/em><\/strong><em> Store it alongside the ciphertext. What\u2019s critical is that you <\/em><strong><em>never reuse the same IV with the same key<\/em><\/strong><em>\u200a\u2014\u200adoing so completely breaks AES-GCM confidentiality. Since every document gets a new random key, this is automatically safe.<\/em><\/p>\n<h3>Step 3: Wrap the AES Key with\u00a0RSA<\/h3>\n<p>This step protects the symmetric key. The wrapped key can be stored publicly on IPFS\u200a\u2014\u200aonly the private key holder can open\u00a0it.<\/p>\n<p>\/\/ JavaScript<br \/>async function wrapSymmetricKey(symmetricKey, rsaPublicKey) {<br \/>  \/\/ Export the AES key to raw bytes<br \/>  const rawKeyBytes = await crypto.subtle.exportKey(&#8220;raw&#8221;, symmetricKey);<br \/>  \/\/ rawKeyBytes is 32 bytes (256 bits)  \/\/ Encrypt those bytes with RSA-OAEP<br \/>  const wrappedKey = await crypto.subtle.encrypt(<br \/>    { name: &#8220;RSA-OAEP&#8221; },<br \/>    rsaPublicKey,<br \/>    rawKeyBytes<br \/>  );  return wrappedKey;<br \/>  \/\/ wrappedKey: ArrayBuffer \u2014 256 bytes for RSA-2048<br \/>  \/\/ Safe to store publicly \u2014 only the RSA private key can reverse this<br \/>}# Pseudo-code<br \/>function wrapSymmetricKey(symmetricKey, rsaPublicKey):<br \/>  rawBytes   = export_raw_bytes(symmetricKey)        # 32 bytes<br \/>  wrappedKey = RSA_OAEP.encrypt(rsaPublicKey, rawBytes)<br \/>  return wrappedKey<br \/>  # Result: ~256 bytes. Useless without the matching private key.<\/p>\n<h3>Step 4: Upload Encrypted Blob to Pinata\/IPFS<\/h3>\n<p>\/\/ JavaScript<br \/>async function uploadEncryptedBlob(iv, ciphertext, originalFilename) {<br \/>  \/\/ Pack into a single binary blob: [1 byte version][12 bytes IV][N bytes ciphertext]<br \/>  const ivBytes = new Uint8Array(iv);<br \/>  const ctBytes = new Uint8Array(ciphertext);<br \/>  const packed  = new Uint8Array(1 + ivBytes.length + ctBytes.length);<br \/>  packed[0] = 1;                          \/\/ version byte \u2014 for future format changes<br \/>  packed.set(ivBytes, 1);<br \/>  packed.set(ctBytes, 1 + ivBytes.length);  const blob = new Blob([packed.buffer], { type: &#8220;application\/octet-stream&#8221; });<br \/>  return pinataPinFile(blob, `${originalFilename}.enc`, { type: &#8220;zk-enc-blob&#8221; });<br \/>  \/\/ Returns CID \u2014 e.g. &#8220;bafybeifgc2voidpz2rh773&#8230;&#8221;<br \/>}# Pseudo-code<br \/>function uploadEncryptedBlob(iv, ciphertext, filename):<br \/>  packed = concat_bytes([byte(1), iv, ciphertext])   # version + IV + data<br \/>  return ipfs_upload(packed, filename + &#8220;.enc&#8221;)<\/p>\n<h3>Step 5: Upload Wrapped Key to Pinata\/IPFS<\/h3>\n<p>\/\/ JavaScript<br \/>async function uploadWrappedKey(wrappedKeyBuffer, ownerDID) {<br \/>  const payload = {<br \/>    wrappedKey: btoa(String.fromCharCode(&#8230;new Uint8Array(wrappedKeyBuffer))),<br \/>    \/\/ base64-encoded wrapped key \u2014 safe to store publicly<br \/>    owner: ownerDID<br \/>  };<br \/>  const blob = new Blob([JSON.stringify(payload)], { type: &#8220;application\/json&#8221; });<br \/>  return pinataPinFile(blob, `wrapped_key.json`, { type: &#8220;zk-wrapped-key&#8221; });<br \/>  \/\/ Returns CID \u2014 e.g. &#8220;bafkreidjmhvyxz5ckzi5ouuo2&#8230;&#8221;<br \/>}# Pseudo-code<br \/>function uploadWrappedKey(wrappedKeyBytes, ownerDID):<br \/>  payload = json_encode({<br \/>    wrappedKey: base64_encode(wrappedKeyBytes),<br \/>    owner: ownerDID<br \/>  })<br \/>  return ipfs_upload(payload, &#8220;wrapped_key.json&#8221;)<\/p>\n<h3>Step 6: Anchor Metadata on the\u00a0Backend<\/h3>\n<p>\/\/ JavaScript<br \/>async function anchorDocument(docId, hash, blobCID, keyCID, ownerDID, filename) {<br \/>  const response = await fetch(&#8220;\/api\/documents&#8221;, {<br \/>    method: &#8220;POST&#8221;,<br \/>    headers: { &#8220;Content-Type&#8221;: &#8220;application\/json&#8221; },<br \/>    body: JSON.stringify({<br \/>      docId,<br \/>      hash,                       \/\/ SHA-256 of the ORIGINAL plaintext file<br \/>      encryptedBlobCid: blobCID,  \/\/ IPFS CID of the encrypted blob<br \/>      wrappedKeyCid:    keyCID,   \/\/ IPFS CID of the wrapped key JSON<br \/>      ownerDid:         ownerDID,<br \/>      filename<br \/>    })<br \/>  });<br \/>  if (!response.ok) throw new Error(&#8220;Failed to anchor document on server&#8221;);<br \/>}<\/p>\n<p>The server stores <strong>only CIDs and metadata<\/strong> never file contents, never keys in plaintext.<\/p>\n<h3>The Full Upload\u00a0Function<\/h3>\n<p>\/\/ JavaScript \u2014 full upload pipeline<br \/>async function uploadDocument(file, userPublicKey, userDID) {<br \/>  const fileBuffer = await file.arrayBuffer();  \/\/ 1. Generate a unique AES key for this document<br \/>  const symKey = await generateSymmetricKey();  \/\/ 2. Encrypt the file<br \/>  const { ciphertext, iv } = await encryptFile(symKey, fileBuffer);  \/\/ 3. SHA-256 hash of original file (integrity anchor)<br \/>  const hashBuf = await crypto.subtle.digest(&#8220;SHA-256&#8221;, fileBuffer);<br \/>  const hash = Array.from(new Uint8Array(hashBuf))<br \/>    .map(b =&gt; b.toString(16).padStart(2, &#8220;0&#8221;)).join(&#8220;&#8221;);  \/\/ 4. Wrap the AES key with the user&#8217;s RSA public key<br \/>  const wrappedKey = await wrapSymmetricKey(symKey, userPublicKey);  \/\/ 5. Upload encrypted blob to IPFS<br \/>  const blobCID = await uploadEncryptedBlob(iv, ciphertext, file.name);  \/\/ 6. Upload wrapped key to IPFS<br \/>  const keyCID = await uploadWrappedKey(wrappedKey, userDID);  \/\/ 7. Register document on backend<br \/>  const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;<br \/>  await anchorDocument(docId, hash, blobCID, keyCID, userDID, file.name);  console.log(&#8221; Upload complete.&#8221;);<br \/>  console.log(&#8221;   The server stored: blobCID, keyCID, SHA-256 hash, filename&#8221;);<br \/>  console.log(&#8221;   The server did NOT see: file contents, AES key, or private key&#8221;);<br \/>}# Pseudo-code (any language)<br \/>function uploadDocument(file, publicKey, userDID):<br \/>  fileBytes   = read(file)<br \/>  symKey      = AES_GCM.generate_key(256)<br \/>  iv, cipher  = AES_GCM.encrypt(symKey, fileBytes)<br \/>  hash        = SHA256(fileBytes)<br \/>  wrappedKey  = RSA_OAEP.encrypt(publicKey, export_raw(symKey))<br \/>  blobCID     = ipfs_upload(pack(iv, cipher))<br \/>  keyCID      = ipfs_upload(json({ wrappedKey: base64(wrappedKey) }))<br \/>  server_post(&#8220;\/api\/documents&#8221;, { blobCID, keyCID, hash, userDID, filename })<\/p>\n<h3>Part 5\u200a\u2014\u200aSharing a Document (The Re-Wrap\u00a0Flow)<\/h3>\n<p>This is the most technically interesting part of the entire system. When User 1 shares a document with User 2, <strong>the server never sees the AES key in plaintext<\/strong>. The re-encryption is done entirely in User 1\u2019s\u00a0browser.<\/p>\n<h3>The 6-Step Share\u00a0Flow<\/h3>\n<p>\u2460 Fetch User 2&#8217;s public key from the server&#8217;s key registry<br \/>   (safe \u2014 public keys are meant to be shared)\u2461 Fetch Wrapped Key (U1) from IPFS<br \/>   (encrypted with User 1&#8217;s public key \u2014 safe to fetch over network)\u2462 [LOCAL] Decrypt Wrapped Key (U1) with User 1&#8217;s PRIVATE KEY<br \/>   \u21b3 Recovers raw AES key bytes in browser memory<br \/>   \u21b3 Private key never leaves the browser. Raw AES key never hits the network.\u2463 [LOCAL] Re-encrypt raw AES key bytes with User 2&#8217;s PUBLIC KEY<br \/>   \u21b3 Creates Wrapped Key (U2)<br \/>   \u21b3 Only User 2&#8217;s private key can open this\u2464 Upload Wrapped Key (U2) to Pinata\/IPFS<br \/>   \u21b3 Returns new CID\u2465 Update Access Control List on server<br \/>   \u21b3 Server links User 2&#8217;s DID to the new key CID for this document<\/p>\n<h3>The Code<\/h3>\n<p>\/\/ JavaScript \u2014 full share flow<br \/>async function shareDocument(<br \/>  docId, senderPrivateKey, senderDID,<br \/>  recipientDID, existingWrappedKeyCID<br \/>) {<br \/>  \/\/ Step 1: Fetch recipient&#8217;s public key from server registry<br \/>  const pkRes = await fetch(`\/api\/did\/${encodeURIComponent(recipientDID)}\/publickey`);<br \/>  if (!pkRes.ok) throw new Error(&#8220;Recipient not found. Have they created their wallet?&#8221;);  const { publicKey: recipientJWK } = await pkRes.json();<br \/>  const recipientPublicKey = await crypto.subtle.importKey(<br \/>    &#8220;jwk&#8221;,<br \/>    recipientJWK,<br \/>    { name: &#8220;RSA-OAEP&#8221;, hash: &#8220;SHA-256&#8221; },<br \/>    false,        \/\/ non-extractable \u2014 we only use it for encrypt<br \/>    [&#8220;encrypt&#8221;]<br \/>  );  \/\/ Step 2: Fetch Wrapped Key (U1) from IPFS<br \/>  const keyData = await fetch(<br \/>    `https:\/\/gateway.pinata.cloud\/ipfs\/${existingWrappedKeyCID}`<br \/>  ).then(r =&gt; r.json());<\/p>\n<p>  const wrappedKeyU1 = Uint8Array.from(<br \/>    atob(keyData.wrappedKey), c =&gt; c.charCodeAt(0)<br \/>  ).buffer;  \/\/ Step 3: LOCAL ONLY \u2014 Unwrap the AES key with sender&#8217;s private key<br \/>  \/\/ The raw AES bytes live only in browser memory. Never sent anywhere.<br \/>  const rawAESKeyBytes = await crypto.subtle.decrypt(<br \/>    { name: &#8220;RSA-OAEP&#8221; },<br \/>    senderPrivateKey,   \/\/ stays on device<br \/>    wrappedKeyU1<br \/>  );<br \/>  \/\/ rawAESKeyBytes: 32 bytes in memory \u2014 the original AES-256 key  \/\/ Step 4: LOCAL ONLY \u2014 Re-encrypt with recipient&#8217;s public key<br \/>  const wrappedKeyU2 = await crypto.subtle.encrypt(<br \/>    { name: &#8220;RSA-OAEP&#8221; },<br \/>    recipientPublicKey,<br \/>    rawAESKeyBytes       \/\/ same 32 bytes, now encrypted for recipient<br \/>  );  \/\/ Step 5: Upload new wrapped key to IPFS<br \/>  const newKeyCID = await uploadWrappedKey(wrappedKeyU2, recipientDID);  \/\/ Step 6: Update ACL on server<br \/>  await fetch(&#8220;\/api\/share&#8221;, {<br \/>    method: &#8220;POST&#8221;,<br \/>    headers: { &#8220;Content-Type&#8221;: &#8220;application\/json&#8221; },<br \/>    body: JSON.stringify({<br \/>      docId,<br \/>      recipientDid: recipientDID,<br \/>      wrappedKeyCid: newKeyCID,<br \/>      requesterDid: senderDID     \/\/ server verifies this is the document owner<br \/>    })<br \/>  });  console.log(&#8221; Access granted to&#8221;, recipientDID);<br \/>  console.log(&#8221;   Server stored: new key CID for recipient&#8221;);<br \/>  console.log(&#8221;   Server did NOT see: raw AES key or any private key&#8221;);<br \/>}# Pseudo-code (any language)<br \/>function shareDocument(docId, senderPrivKey, senderDID, recipientDID, keyCID):<br \/>  recipPubKey  = fetch_from_server(&#8220;\/api\/did\/&#8221; + recipientDID + &#8220;\/publickey&#8221;)<br \/>  wrappedU1    = ipfs_fetch(keyCID).wrappedKey          # still encrypted<br \/>  rawAESBytes  = RSA_OAEP.decrypt(senderPrivKey, wrappedU1)  # LOCAL ONLY<br \/>  wrappedU2    = RSA_OAEP.encrypt(recipPubKey, rawAESBytes)  # LOCAL ONLY<br \/>  newCID       = ipfs_upload(json({ wrappedKey: base64(wrappedU2) }))<br \/>  server_post(&#8220;\/api\/share&#8221;, { docId, recipientDID, wrappedKeyCid: newCID, senderDID })<em>\ud83d\udca1 <\/em><strong><em>Key insight:<\/em><\/strong><em> The <\/em><strong><em>encrypted file on IPFS is never touched during a share<\/em><\/strong><em>. Only a new wrapped key is created and uploaded. You can share a document with 100 users and the file is stored on IPFS exactly once. Each user gets their own personally-addressed wrapped copy of the AES\u00a0key.<\/em><\/p>\n<h3>Part 6\u200a\u2014\u200aDecrypting &amp; Downloading a\u00a0Document<\/h3>\n<p>When a user opens a document, their browser fetches the encrypted blob and their wrapped key, decrypts everything locally, and triggers a download.<\/p>\n<p>\/\/ JavaScript \u2014 full decrypt flow<br \/>async function decryptAndDownload(blobCID, wrappedKeyCID, filename, userPrivateKey) {<br \/>  \/\/ 1. Fetch encrypted blob from IPFS<br \/>  const blobResponse = await fetch(`https:\/\/gateway.pinata.cloud\/ipfs\/${blobCID}`);<br \/>  const packedBuffer = await blobResponse.arrayBuffer();  \/\/ Unpack: skip version byte (index 0), read 12-byte IV, read remaining ciphertext<br \/>  const packed   = new Uint8Array(packedBuffer);<br \/>  const iv       = packed.slice(1, 13);          \/\/ bytes 1\u201312<br \/>  const ciphertext = packed.slice(13).buffer;     \/\/ bytes 13 to end  \/\/ 2. Fetch wrapped key from IPFS<br \/>  const keyData = await fetch(`https:\/\/gateway.pinata.cloud\/ipfs\/${wrappedKeyCID}`)<br \/>    .then(r =&gt; r.json());<br \/>  const wrappedKey = Uint8Array.from(<br \/>    atob(keyData.wrappedKey), c =&gt; c.charCodeAt(0)<br \/>  ).buffer;  \/\/ 3. Unwrap AES key with user&#8217;s RSA private key (LOCAL)<br \/>  const rawAESBytes = await crypto.subtle.decrypt(<br \/>    { name: &#8220;RSA-OAEP&#8221; },<br \/>    userPrivateKey,<br \/>    wrappedKey<br \/>  );  \/\/ Import raw bytes as a usable AES-GCM CryptoKey<br \/>  const symmetricKey = await crypto.subtle.importKey(<br \/>    &#8220;raw&#8221;, rawAESBytes,<br \/>    { name: &#8220;AES-GCM&#8221; },<br \/>    false,                  \/\/ non-extractable after import<br \/>    [&#8220;decrypt&#8221;]<br \/>  );  \/\/ 4. Decrypt the file<br \/>  const plaintextBuffer = await crypto.subtle.decrypt(<br \/>    { name: &#8220;AES-GCM&#8221;, iv },<br \/>    symmetricKey,<br \/>    ciphertext<br \/>  );<br \/>  \/\/ If this succeeds, the key was correct AND the ciphertext wasn&#8217;t tampered with<br \/>  \/\/ (AES-GCM authentication tag is verified automatically)  \/\/ 5. Trigger browser download<br \/>  const url = URL.createObjectURL(new Blob([plaintextBuffer]));<br \/>  const a   = document.createElement(&#8220;a&#8221;);<br \/>  a.href = url;<br \/>  a.download = filename;<br \/>  document.body.appendChild(a);<br \/>  a.click();<br \/>  document.body.removeChild(a);<br \/>  setTimeout(() =&gt; URL.revokeObjectURL(url), 1000);  console.log(&#8221; Decrypted and downloaded. Server saw zero plaintext.&#8221;);<br \/>}# Pseudo-code (any language)<br \/>function decryptAndDownload(blobCID, keyCID, filename, privateKey):<br \/>  encryptedBlob = ipfs_fetch(blobCID)<br \/>  iv, ciphertext = unpack(encryptedBlob)       # skip version byte, split at byte 13  wrappedKey    = ipfs_fetch(keyCID).wrappedKey<br \/>  rawAESBytes   = RSA_OAEP.decrypt(privateKey, base64_decode(wrappedKey))<br \/>  symmetricKey  = AES_GCM.import_key(rawAESBytes)  plaintext     = AES_GCM.decrypt(symmetricKey, iv, ciphertext)<br \/>  save_to_disk(filename, plaintext)<strong><em>On SHA-256 integrity:<\/em><\/strong><em> After decryption, you can optionally verify the file by computing <\/em><em>SHA256(plaintext) and comparing it to the <\/em><em>hash field stored in the server&#8217;s document registry during upload. If they match, the file is bit-for-bit identical to what was originally uploaded. Any IPFS-level tampering would also cause the AES-GCM auth tag check to fail first, so this is a secondary defense-in-depth measure.<\/em><\/p>\n<h3>Part 7\u200a\u2014\u200aRevoking\u00a0Access<\/h3>\n<p>When you revoke a user\u2019s access, the server removes their entry from the document\u2019s Access Control\u00a0List.<\/p>\n<p>\/\/ JavaScript<br \/>async function revokeAccess(docId, recipientDID, ownerDID) {<br \/>  const response = await fetch(&#8220;\/api\/share&#8221;, {<br \/>    method: &#8220;DELETE&#8221;,<br \/>    headers: { &#8220;Content-Type&#8221;: &#8220;application\/json&#8221; },<br \/>    body: JSON.stringify({<br \/>      docId,<br \/>      recipientDid: recipientDID,<br \/>      requesterDid: ownerDID      \/\/ server verifies this matches the document owner<br \/>    })<br \/>  });  const result = await response.json();<br \/>  if (result.success) {<br \/>    console.log(`Access revoked for ${recipientDID}`);<br \/>  }<br \/>}# Pseudo-code<br \/>function revokeAccess(docId, recipientDID, ownerDID):<br \/>  server_delete(&#8220;\/api\/share&#8221;, { docId, recipientDID, ownerDID })<\/p>\n<h3>What Revocation Does and Doesn\u2019t\u00a0Do<\/h3>\n<p><strong>What it\u00a0does:<\/strong><\/p>\n<p>Removes the recipient\u2019s DID from the server\u2019s ACL for this\u00a0documentThe GET \/api\/documents\/:did endpoint no longer returns the document for the revoked\u00a0userTheir wrapped key CID pointer is deleted from the server\u2019s\u00a0recordsFuture attempts to list or access the document via the API will fail with\u00a0403<\/p>\n<p><strong>What it doesn\u2019t\u00a0do:<\/strong><\/p>\n<p>IPFS content is <strong>immutable <\/strong>the wrapped key file (U2) that was previously uploaded to IPFS is not deleted. It\u2019s addressed by content hash and pinned globally.If the recipient already fetched and locally cached the wrapped key CID <em>before revocation<\/em>, they technically still have the bytes to decrypt the document.<strong><em>For strong cryptographic revocation:<\/em><\/strong><em> Re-encrypt the document with a <\/em><strong><em>brand new AES key<\/em><\/strong><em> after revocation. Then re-wrap the new key for every remaining authorized user and update all the CIDs. This is computationally more expensive but makes the old wrapped keys useless. For most real-world applications documents, file sharing, medical records ACL-level revocation is standard and sufficient.<\/em><\/p>\n<h3>Part 8\u200a\u2014\u200aThe Backend API (Key\u00a0Routes)<\/h3>\n<p>The server handles authentication, key registry, document metadata, and ACL management. Here are the routes you need to implement:<\/p>\n<h3>POST \/api\/auth\/register<\/h3>\n<p>Request:<br \/>{<br \/>  &#8220;email&#8221;: &#8220;alice@example.com&#8221;,<br \/>  &#8220;password&#8221;: &#8220;supersecret123&#8221;,        \u2190 hashed with bcrypt(12) server-side<br \/>  &#8220;did&#8221;: &#8220;did:local:8912c294b807&#8230;&#8221;,<br \/>  &#8220;publicKey&#8221;: { &#8220;kty&#8221;: &#8220;RSA&#8221;, &#8220;alg&#8221;: &#8220;RSA-OAEP-256&#8221;, &#8220;n&#8221;: &#8220;&#8230;&#8221;, &#8220;e&#8221;: &#8220;AQAB&#8221; },<br \/>  &#8220;encryptedWallet&#8221;: { &#8220;version&#8221;: 1, &#8220;salt&#8221;: &#8220;&#8230;&#8221;, &#8220;iv&#8221;: &#8220;&#8230;&#8221;, &#8220;ciphertext&#8221;: &#8220;&#8230;&#8221; }<br \/>}Response 201:<br \/>{ &#8220;token&#8221;: &#8220;eyJhbGci&#8230;&#8221;, &#8220;user&#8221;: { &#8220;id&#8221;: &#8220;user_1&#8221;, &#8220;email&#8221;: &#8220;alice@&#8230;&#8221;, &#8220;did&#8221;: &#8220;&#8230;&#8221; } }<\/p>\n<h3>POST \/api\/auth\/login<\/h3>\n<p>Request:<br \/>{ &#8220;email&#8221;: &#8220;alice@example.com&#8221;, &#8220;password&#8221;: &#8220;supersecret123&#8221; }Response 200:<br \/>{<br \/>  &#8220;token&#8221;: &#8220;eyJhbGci&#8230;&#8221;,<br \/>  &#8220;user&#8221;: { &#8220;id&#8221;: &#8220;user_1&#8221;, &#8230; },<br \/>  &#8220;encryptedWallet&#8221;: { &#8220;version&#8221;: 1, &#8220;salt&#8221;: &#8220;&#8230;&#8221;, &#8220;iv&#8221;: &#8220;&#8230;&#8221;, &#8220;ciphertext&#8221;: &#8220;&#8230;&#8221; },<br \/>  &#8220;publicKey&#8221;: { &#8220;kty&#8221;: &#8220;RSA&#8221;, &#8230; }<br \/>}<br \/>\u2190 Server returns encryptedWallet so the browser can decrypt it locally<br \/>\u2190 The plaintext private key is NEVER in this response<\/p>\n<h3>POST \/api\/documents<\/h3>\n<p>Request:<br \/>{<br \/>  &#8220;docId&#8221;: &#8220;doc_1779097856281_287z&#8221;,<br \/>  &#8220;hash&#8221;: &#8220;6a1e70b95c82514041db571&#8230;&#8221;,<br \/>  &#8220;encryptedBlobCid&#8221;: &#8220;bafybeifgc2voidpz2&#8230;&#8221;,<br \/>  &#8220;wrappedKeyCid&#8221;: &#8220;bafkreidjmhvyxz5ck&#8230;&#8221;,<br \/>  &#8220;ownerDid&#8221;: &#8220;did:local:8912c294&#8230;&#8221;,<br \/>  &#8220;filename&#8221;: &#8220;my_will.pdf&#8221;<br \/>}<br \/>Response 200: { &#8220;success&#8221;: true, &#8220;docId&#8221;: &#8220;doc_1779097856281_287z&#8221; }<\/p>\n<h3>GET \/api\/documents\/:did<\/h3>\n<p>Returns all documents the given DID has access to (either as owner or shared recipient).<\/p>\n<p>Response 200:<br \/>{<br \/>  &#8220;documents&#8221;: [<br \/>    {<br \/>      &#8220;docId&#8221;: &#8220;doc_1779&#8230;&#8221;,<br \/>      &#8220;filename&#8221;: &#8220;my_will.pdf&#8221;,<br \/>      &#8220;hash&#8221;: &#8220;6a1e70b9&#8230;&#8221;,<br \/>      &#8220;encryptedBlobCid&#8221;: &#8220;bafybeifgc2&#8230;&#8221;,<br \/>      &#8220;wrappedKeyCid&#8221;: &#8220;bafkreidjm&#8230;&#8221;,   \u2190 the wrapped key for THIS DID<br \/>      &#8220;isOwner&#8221;: true<br \/>    }<br \/>  ]<br \/>}<\/p>\n<h3>POST \/api\/share<\/h3>\n<p>Request:<br \/>{<br \/>  &#8220;docId&#8221;: &#8220;doc_1779&#8230;&#8221;,<br \/>  &#8220;recipientDid&#8221;: &#8220;did:local:0ef56523&#8230;&#8221;,<br \/>  &#8220;wrappedKeyCid&#8221;: &#8220;bafkreic22hrkc6vy&#8230;&#8221;,   \u2190 CID of the new Wrapped Key (U2)<br \/>  &#8220;requesterDid&#8221;: &#8220;did:local:8912c294&#8230;&#8221;     \u2190 must be document owner<br \/>}<br \/>Response 200: { &#8220;success&#8221;: true }<\/p>\n<h3>DELETE \/api\/share<\/h3>\n<p>Request:<br \/>{<br \/>  &#8220;docId&#8221;: &#8220;doc_1779&#8230;&#8221;,<br \/>  &#8220;recipientDid&#8221;: &#8220;did:local:0ef56523&#8230;&#8221;,<br \/>  &#8220;requesterDid&#8221;: &#8220;did:local:8912c294&#8230;&#8221;<br \/>}<br \/>Response 200: { &#8220;success&#8221;: true }<\/p>\n<h3>Part 9\u200a\u2014\u200aThe Zero-Knowledge Proof: Live\u00a0Demo<\/h3>\n<p>The best way to prove the system works is to try to break it. Here\u2019s what happens when someone tries to decrypt a document without authorization:<\/p>\n<p>\/\/ JavaScript \u2014 attempting to decrypt without the correct wrapped key<br \/>async function demonstrateZeroKnowledge(blobCID) {<br \/>  \/\/ Step 1: Fetch the encrypted blob from IPFS (always works \u2014 IPFS is public)<br \/>  const response     = await fetch(`https:\/\/gateway.pinata.cloud\/ipfs\/${blobCID}`);<br \/>  const packedBuffer = await response.arrayBuffer();<br \/>  const packed       = new Uint8Array(packedBuffer);<br \/>  const iv           = packed.slice(1, 13);<br \/>  const ciphertext   = packed.slice(13).buffer;  \/\/ Step 2: Try to decrypt with a completely wrong AES key<br \/>  const wrongKey = await crypto.subtle.generateKey(<br \/>    { name: &#8220;AES-GCM&#8221;, length: 256 }, false, [&#8220;decrypt&#8221;]<br \/>  );  try {<br \/>    await crypto.subtle.decrypt({ name: &#8220;AES-GCM&#8221;, iv }, wrongKey, ciphertext);<br \/>    console.error(&#8220;This should NEVER happen \u2014 something is wrong&#8221;);<br \/>  } catch (err) {<br \/>    \/\/ AES-GCM verifies an authentication tag on decrypt.<br \/>    \/\/ A wrong key produces the wrong tag \u2192 hard cryptographic failure.<br \/>    console.log(&#8220;\u2705 Decryption FAILED as expected&#8221;);<br \/>    console.log(&#8221;   Error:&#8221;, err.name, &#8220;\u2014&#8221;, err.message);<br \/>    \/\/ Output: OperationError \u2014 The operation failed for an operation-specific reason<br \/>    console.log(&#8221;   Without the correct wrapped key from the ACL,&#8221;);<br \/>    console.log(&#8221;   decryption is mathematically impossible.&#8221;);<br \/>    console.log(&#8221;   Not hard. Not unlikely. Impossible.&#8221;);<br \/>  }<br \/>}<\/p>\n<p><strong>The result:<\/strong> OperationError\u200a\u2014\u200aThe operation failed for an operation-specific reason<\/p>\n<p>AES-GCM appends a <strong>16-byte authentication tag<\/strong> to every ciphertext. Any decryption attempt with the wrong key produces the wrong tag\u200a\u2014\u200aand the Web Crypto API throws immediately. You don\u2019t get garbled output. You don\u2019t get partial data. You get\u00a0nothing.<\/p>\n<p>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.<\/p>\n<h3>Part 10\u200a\u2014\u200aImplementing This in Other Languages<\/h3>\n<p>Every crypto primitive used here is available in all major languages. The concepts are identical only the syntax\u00a0differs.<\/p>\n<h3>Python Example (Key Wrapping)<\/h3>\n<p>from cryptography.hazmat.primitives.asymmetric import rsa, padding<br \/>from cryptography.hazmat.primitives import hashes<br \/>from cryptography.hazmat.primitives.ciphers.aead import AESGCM<br \/>import os, base64# Generate RSA key pair<br \/>private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)<br \/>public_key  = private_key.public_key()# Generate AES-256 key<br \/>aes_key = os.urandom(32)# Encrypt file with AES-GCM<br \/>iv          = os.urandom(12)<br \/>aesgcm      = AESGCM(aes_key)<br \/>ciphertext  = aesgcm.encrypt(iv, file_bytes, None)# Wrap AES key with RSA public key<br \/>wrapped_key = public_key.encrypt(<br \/>  aes_key,<br \/>  padding.OAEP(<br \/>    mgf=padding.MGF1(algorithm=hashes.SHA256()),<br \/>    algorithm=hashes.SHA256(),<br \/>    label=None<br \/>  )<br \/>)# Unwrap (decrypt) with private key<br \/>recovered_key = private_key.decrypt(<br \/>  wrapped_key,<br \/>  padding.OAEP(<br \/>    mgf=padding.MGF1(algorithm=hashes.SHA256()),<br \/>    algorithm=hashes.SHA256(),<br \/>    label=None<br \/>  )<br \/>)<br \/># recovered_key == aes_key \u2713<\/p>\n<p>The architecture is the same. Upload the encrypted blob and base64(wrapped_key) to Pinata, anchor the CIDs on the\u00a0server.<\/p>\n<h3>Part 11\u200a\u2014\u200aSecurity Quick\u00a0Tips<\/h3>\n<p>\ud83d\udd10 <strong>Never log or persist the private key in plaintext.<\/strong> Keep it in memory only. Wipe it on logout by overwriting the variable and triggering garbage collection.<\/p>\n<p>\ud83d\udd10 <strong>Always generate a fresh IV per encryption.<\/strong> crypto.getRandomValues(new Uint8Array(12)) every single time. Reusing an IV with the same AES key breaks GCM confidentiality catastrophically.<\/p>\n<p>\ud83d\udd10 <strong>Scope your Pinata JWT to <\/strong><strong>pinFileToIPFS only.<\/strong> If leaked, an attacker can pin new files but cannot delete, query, or manage your existing\u00a0pins.<\/p>\n<p>\ud83d\udd10 <strong>Serve the app over HTTPS.<\/strong> 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\u2019s used. HTTPS prevents\u00a0this.<\/p>\n<p>\ud83d\udd10 <strong>Use environment variables for <\/strong><strong>JWT_SECRET.<\/strong> Never hardcode it. A leaked JWT secret means token forgery. Generate with openssl rand -hex\u00a064.<\/p>\n<p>\ud83d\udd10 <strong>sessionStorage, not localStorage, for the session wallet.<\/strong> 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.<\/p>\n<p>\ud83d\udd10 <strong>Treat the DID registry as a trust anchor.<\/strong> 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\u2019s key rather than the intended recipient. For production, anchor public keys on an immutable ledger (blockchain, Hyperledger, or a transparency log).<\/p>\n<h3>What\u2019s Next<\/h3>\n<p>This implementation is production-grade for a wide range of use cases. If you want to push it further: <strong>key rotation<\/strong> (re-wrap all document keys when a user regenerates their RSA pair), <strong>WebAuthn binding<\/strong> (tie private key decryption to a hardware security key, TouchID, or FaceID so even sessionStorage compromise can\u2019t expose the key), and <strong>recovery mnemonics<\/strong> (give users a 12-word BIP39 phrase at registration currently, a forgotten password permanently loses the private key and all documents with\u00a0it).<\/p>\n<p>The full source code for this reference implementation is available on GitHub. If you build something with it, I\u2019d love to hear about it.<\/p>\n<p>Github Link\u00a0: <a href=\"https:\/\/github.com\/muhammadtalha198\/ZeroKnowladge_Document_Upload\">https:\/\/github.com\/muhammadtalha198\/ZeroKnowladge_Document_Upload<\/a><\/p>\n<h3>Conclusion<\/h3>\n<p>Here\u2019s what you\u2019ve\u00a0built:<\/p>\n<p>Files encrypted <strong>before<\/strong> they leave the browser with AES-256-GCMAES keys wrapped with <strong>RSA-2048-OAEP<\/strong> and stored on\u00a0IPFSDocument sharing via <strong>client-side key re-wrapping<\/strong>\u200a\u2014\u200athe server is blind throughoutAccess revocation enforced at <strong>ACL\u00a0level<\/strong>An attacker with full server + IPFS access gets <strong>mathematically nothing\u00a0useful<\/strong><\/p>\n<p>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\u2019s cryptography library, Go&#8217;s crypto\/rsa and crypto\/cipher packages, Rust&#8217;s rsa and aes-gcm crates, and Java&#8217;s javax.crypto.<\/p>\n<p>The architecture is the idea. The language is just\u00a0syntax.<\/p>\n<p><em>Tagged: #cryptography #javascript #ipfs #security #zero-knowledge #encryption #fullstack #webdev<\/em><\/p>\n<p><a href=\"https:\/\/medium.com\/coinmonks\/build-a-zero-knowledge-encrypted-document-vault-complete-developer-guide-97b5fe7d8a4e\">Build a Zero-Knowledge Encrypted Document Vault: Complete Developer Guide<\/a> was originally published in <a href=\"https:\/\/medium.com\/coinmonks\">Coinmonks<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>","protected":false},"excerpt":{"rendered":"<p>By the end of this guide, you\u2019ll have built a file-sharing system where you the developer running the server cannot read your users\u2019\u00a0files. \u23f1 Estimated read time: 20 minutes \u00b7 \ud83c\udff7 Tags: cryptography, javascript, ipfs, security, web-dev, zero-knowledge Introduction Most \u201csecure\u201d file apps encrypt data at rest on the server. That means the server holds [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":177342,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-177341","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-interesting"],"_links":{"self":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/177341"}],"collection":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=177341"}],"version-history":[{"count":0,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/177341\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/media\/177342"}],"wp:attachment":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=177341"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=177341"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=177341"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}