Build a Zero-Knowledge Password Vault

Build a browser-encrypted password vault that stores ciphertext on its server.

Introduction

30 Second Summary

Putting all your passwords in one place is convenient. The difficult part is knowing who can read them after you press save.

In this project, you will build a local password vault with a zero-knowledge architecture powered by the browser's Web Crypto API. Your browser will encrypt each credential before a Node.js server stores it as ciphertext.

What You'll Build

You will watch a saved credential return after a server restart while Microsoft Edge displays readable details beside a server payload containing only ciphertext.

By the end of this project, you'll have:

  • A polished local vault where you can unlock, save, reveal, delete, or lock credential records.
  • A visible security proof showing decrypted cards beside the encrypted payload. The persisted data/vault.json file shows only ciphertext.
  • A portfolio-ready Git repository with a documented threat model. Its local commit excludes the runtime vault file.
  • Secret Mission: Package the vault as a non-root Docker image. Prove that a named volume preserves encrypted records after container replacement.

Are there any prerequisites?

You need basic familiarity with HTML, CSS, and JavaScript. No previous cryptography or server-side security experience is required.

Before We Start

Before any hands-on work, commit to the boundary that makes this vault a zero-knowledge security prototype. The browser may handle plaintext only while unlocked, while the Node.js server must receive and store only an encrypted vault envelope.

Set Up the Portfolio Repository

Your vault's security promise depends on a clear repository boundary. Runtime ciphertext must stay outside the source history.

A credible security project also names the tools that reproduce it. This step checks your Windows toolchain before any secrets or application code exist.

You will also create a local Git repository whose ignored paths separate source code from future encrypted runtime data. That boundary keeps the portfolio history clean.

In this step, get ready to:
  • Use Windows PowerShell to verify Node.js, Git, Docker Desktop, Visual Studio Code, and Microsoft Edge.
  • Create the zero-knowledge-vault repository on your Desktop with main as its initial branch.
  • Create the project paths and exclude future runtime data from source control.
Verify the Windows toolchain

The vault uses the exact Node.js LTS release recorded by the project. Checking the version now prevents runtime differences from weakening later security tests.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open Windows PowerShell.
  • Check the installed Node.js version by running this command:
node --version

What does this check prove?

The node executable prints the installed runtime version. This project uses v24.22.1 so every learner runs the same Node.js APIs.

✔️ I see the required version

Your Node.js runtime matches v24.22.1. That exact runtime is ready for the vault server.

ⓧ I see a different version

The project is pinned to Node.js v24.22.1. Install that LTS release before continuing.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js v24.22.1.
  • Run the downloaded installer with its default options.
  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to reopen Windows PowerShell.
  • Verify the replacement runtime by running this command:
node --version

What should I see?

PowerShell should print v24.22.1. The new terminal session loads the runtime installed on your Windows path.

ⓧ Command not found

Windows cannot currently find the Node.js executable. Installing Node.js adds the runtime to your system path.

  • Visit the official Node.js download page.
  • Download the Windows installer for Node.js v24.22.1.
  • Run the downloaded installer with its default options.
  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to reopen Windows PowerShell.
  • Confirm that Windows can find Node.js by running this command:
node --version

What should I see?

PowerShell should now print v24.22.1. The fresh terminal session picks up the updated system path.

Still seeing the wrong Node.js version?

Close every PowerShell window after installation. A terminal that stayed open can keep using the previous system path.

If the mismatch continues, help me troubleshoot the Node.js version on Windows.

Docker commands need the Docker Desktop engine to be running. Its first startup can take a moment while Windows prepares the local engine.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop into the search field.
  • Press Enter to start Docker Desktop.
  • Wait for Docker Desktop to report that its engine is running.
  • Return to PowerShell from earlier.
  • Check Git, Docker Desktop, and VS Code by running these commands:
git --version
docker version
code --version

What do these checks prove?

  • The git executable prints its installed version information.
  • The docker executable prints client and server sections when Docker Desktop is ready.
  • The code executable prints the installed VS Code version information.

Missing a tool result?

If Docker prints client information without a server section, return to Docker Desktop and wait for its engine to finish starting. Repeat the checks after it is running.

If Git or VS Code cannot be found, help me restore the missing Windows command.

The browser is part of the test environment because later steps use its built-in cryptography features. Confirm Microsoft Edge now so your browser results stay consistent.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search field.
  • Press Enter to test whether Microsoft Edge opens.

✔️ Microsoft Edge opens

Microsoft Edge is available for the vault tests. Your complete Windows toolchain is now accounted for.

ⓧ Microsoft Edge is missing

Install Microsoft Edge from Microsoft's official download page before continuing.

  • Use an available browser to visit the Microsoft Edge download page.
  • Download the Windows installer.
  • Run the downloaded installer.
  • Press the Windows key after installation.
  • Type Microsoft Edge into the search field.
  • Press Enter to confirm that Microsoft Edge opens.

Microsoft Edge is now ready for the browser-side security checks.

Create the local Git repository

The repository belongs on your Desktop so you can find it throughout the project. Git creates the folder and establishes the source history in one operation.

  • Return to PowerShell from earlier.
  • Move PowerShell to your Desktop by running this command:
Set-Location $HOME\Desktop

What does this command do?

PowerShell changes its current location to the Desktop inside your Windows home directory. The next command creates the repository there.

  • Create the repository with main as its initial branch by running this command:
git init -b main zero-knowledge-vault

What does Git create?

Git creates the zero-knowledge-vault folder on your Desktop. The -b main option sets main as the initial branch.

PowerShell reports that it initialized an empty repository. Your source history now has a defined starting point.

VS Code may ask whether you trust the folder after it opens. This is the local folder you just created.

  • Open the repository in VS Code by running this command:
code zero-knowledge-vault

What does this command do?

The code command opens the named folder as the active VS Code workspace. The Explorer sidebar now points at your repository.

  • Confirm that you trust the folder if VS Code presents a trust prompt.

The zero-knowledge-vault folder should now appear at the top of the VS Code Explorer. Your local portfolio repository is open on its main branch.

Repository folder missing?

Check the PowerShell output from the Git command for a folder conflict. An existing zero-knowledge-vault folder may contain an earlier attempt.

If VS Code did not open the repository, help me open my local Git repository in VS Code.

Create the project paths

The repository separates browser files from the Node.js server. Empty source files establish that structure without pulling application code into this setup step.

  • Use the New Folder control in the VS Code Explorer to create public inside zero-knowledge-vault.
  • Use the New File control to create public/index.html.

The Explorer should now show public with index.html nested inside it.

  • Use the New File control to create public/styles.css.
  • Use the New File control to create public/app.js.

The public folder should now contain three empty browser source files.

  • Use the New File control to create server.mjs inside zero-knowledge-vault.
  • Use the New File control to create .gitignore inside zero-knowledge-vault.

The Explorer should show server.mjs and .gitignore beside the public folder.

  • Use the New File control to create README.md inside zero-knowledge-vault.

The repository now has every path needed for the server, browser interface, and portfolio documentation.

The future data/ directory holds the encrypted runtime vault. Ignoring it keeps machine-specific ciphertext outside the portfolio history.

  • Select .gitignore in the VS Code Explorer.
  • Add the ignore configuration by pasting this content:
data/
.vscode/
*.log

What does this configuration exclude?

  • The data/ rule excludes the future encrypted runtime vault.
  • The .vscode/ rule excludes editor-specific workspace settings.
  • The *.log rule excludes generated log files.
  • Save .gitignore.
  • Confirm that the editor shows all three ignore rules.

Ignore rules look different?

Place each rule on its own line. Keep the trailing slash on data/ and .vscode/ so Git treats them as directories.

If the file still looks wrong, help me check my Git ignore configuration.

✔️ Awesome, I've got everything!

Your repository structure is ready. Save .gitignore before completing the final tool check.

ⓧ I'd like to double check the full code

Your .gitignore file should contain exactly these rules.

data/
.vscode/
*.log

The files README.md, public/app.js, public/index.html, public/styles.css, and server.mjs should be empty at this point.

Before you rerun the checks, what do you expect each command to confirm?

  • Return to PowerShell from earlier.
  • Run the final tool verification with these commands:
node --version
git --version
docker version
code --version

What should I see?

  • Node.js prints v24.22.1.
  • Git prints its installed version information.
  • Docker prints both client and server sections.
  • VS Code prints its installed version information.

That completes the environment check. Your browser fallback is also resolved because Microsoft Edge opened successfully or was installed through its official download page.

Does a final check fail?

Return to the matching outcome or troubleshooting guidance above. Docker must remain running for its server section to appear.

For an unfamiliar result, help me diagnose my final Windows tool check.

Your portfolio repository now has a verified toolchain and a clean boundary for runtime ciphertext. Next, you will turn these empty source files into a working vault that exposes its first persistence problem.

Build the In-Memory Vault

Your repository now has a clear boundary for source files and future runtime data. The next challenge is turning those empty files into a vault you can use in Microsoft Edge.

You will build the first working version with Node.js serving the interface. Credentials will live in browser memory so you can experience the persistence gap before adding storage.

In this step, get ready to:
  • Serve the vault interface from a dependency-free Node.js server.
  • Build the polished unlock panel and credential workspace.
  • Add an in-memory credential and observe it disappear after a refresh.
Serve the vault files with Node.js

The browser needs an HTTP server so each interface file has a stable URL. A fixed route map keeps this first server small by connecting three request paths to three files.

  • Switch back to the open server.mjs tab in VS Code.
  • Replace the placeholder contents with this static-file server:
import { createServer } from "node:http";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { argv } from "node:process";

const host = argv[2] ?? "127.0.0.1";
const port = 3000;

const staticRoutes = new Map([
  ["/", { file: new URL("./public/index.html", import.meta.url), type: "text/html; charset=utf-8" }],
  ["/styles.css", { file: new URL("./public/styles.css", import.meta.url), type: "text/css; charset=utf-8" }],
  ["/app.js", { file: new URL("./public/app.js", import.meta.url), type: "text/javascript; charset=utf-8" }],
]);

const server = createServer(async (request, response) => {
  const pathname = (request.url ?? "/").split("?")[0];

  if (request.method === "GET" && staticRoutes.has(pathname)) {
    const asset = staticRoutes.get(pathname);
    const body = await readFile(asset.file);
    response.writeHead(200, { "Content-Type": asset.type });
    response.end(body);
    return;
  }
});

server.listen(port, host, () => {
  console.log(`Zero-knowledge vault running at http://${host}:${port}`);
});

What does this server do?

  • The route map connects / to the HTML document.
  • The other routes provide the stylesheet and browser JavaScript with the correct content types.
  • The listener binds the server to the loopback address on port 3000.
  • Save server.mjs.
  • Return to the PowerShell terminal in the zero-knowledge-vault repository.
  • Start the local server by running:
node server.mjs

You should see a message containing http://127.0.0.1:3000. The process keeps control of this terminal while it serves requests.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search field.
  • Press Enter to open Microsoft Edge.
  • Enter http://127.0.0.1:3000 in the address bar.

You should see a blank page without a connection error. That blank response proves the server found the empty public/index.html file.

Does the page fail to connect?

Check that PowerShell still shows the running server process. Confirm that you entered the loopback URL with port 3000.

If PowerShell reports a syntax problem, compare the quotes and brackets in server.mjs with the block above. Help me debug the local server.

Build the vault interface

A visible trust boundary makes the security model easier to demonstrate. The interface places decrypted browser content beside the payload area reserved for server-visible ciphertext.

  • Switch back to the open public/index.html tab in VS Code.
  • Replace the placeholder contents with the complete vault structure below:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta
      name="description"
      content="A local zero-knowledge password vault that encrypts credentials in the browser."
    />
    <title>Local Zero-Knowledge Vault</title>
    <link rel="stylesheet" href="/styles.css" />
    <script src="/app.js" defer></script>
  </head>
  <body>
    <header class="site-header">
      <a class="brand" href="/" aria-label="Zero-Knowledge Vault home">
        <span class="brand-mark" aria-hidden="true">ZK</span>
        <span>Zero-Knowledge Vault</span>
      </a>
      <span class="local-badge">Local prototype</span>
    </header>

    <main>
      <section class="hero">
        <div>
          <p class="eyebrow">Browser-encrypted credential storage</p>
          <h1>Your server stores ciphertext, not passwords.</h1>
          <p class="hero-copy">
            The master passphrase stays in this browser. Unlock the vault, save a record,
            and compare the decrypted card with the encrypted payload sent to the server.
          </p>
        </div>
        <div class="trust-card" aria-label="Trust boundary summary">
          <span>Browser</span>
          <strong>Derives key and handles plaintext</strong>
          <span>Node.js server</span>
          <strong>Stores salt, IVs, IDs, and ciphertext</strong>
        </div>
      </section>

      <section id="unlock-panel" class="panel unlock-panel">
        <div>
          <p class="eyebrow">Step 1</p>
          <h2>Unlock your local vault</h2>
          <p>
            A new vault accepts your first passphrase. After a record exists, a different
            passphrase cannot decrypt it.
          </p>
        </div>
        <form id="unlock-form" class="stack-form">
          <label for="master-passphrase">Master passphrase</label>
          <input
            id="master-passphrase"
            name="masterPassphrase"
            type="password"
            autocomplete="current-password"
            required
          />
          <button id="unlock-button" class="primary-button" type="submit">Unlock vault</button>
          <p id="unlock-status" class="status" role="status"></p>
        </form>
      </section>

      <section id="vault-panel" class="vault-layout" hidden>
        <div class="panel">
          <div class="panel-heading">
            <div>
              <p class="eyebrow">Unlocked in this browser</p>
              <h2>Add a credential</h2>
            </div>
            <button id="lock-button" class="secondary-button" type="button">Lock</button>
          </div>

          <form id="credential-form" class="credential-form">
            <label for="site">Site or service</label>
            <input id="site" name="site" type="text" autocomplete="off" required />

            <label for="username">Username</label>
            <input id="username" name="username" type="text" autocomplete="off" required />

            <label for="password">Password</label>
            <input
              id="password"
              name="password"
              type="password"
              autocomplete="new-password"
              required
            />

            <label for="notes">Notes</label>
            <textarea id="notes" name="notes" rows="3"></textarea>

            <button class="primary-button" type="submit">Encrypt and save</button>
            <p id="vault-status" class="status" role="status"></p>
          </form>
        </div>

        <div class="panel records-panel">
          <div class="panel-heading">
            <div>
              <p class="eyebrow">Decrypted browser view</p>
              <h2>Saved credentials</h2>
            </div>
            <span id="record-count" class="count-badge">0 records</span>
          </div>
          <p id="empty-state" class="empty-state">No credentials yet. Add one to initialize the vault.</p>
          <div id="credential-list" class="credential-list"></div>
        </div>

        <aside class="panel payload-panel">
          <p class="eyebrow">Server-visible envelope</p>
          <h2>Encrypted payload</h2>
          <p>
            This is the JSON shape sent to the API. Site names, usernames, passwords, notes,
            and the master passphrase should not appear here.
          </p>
          <pre id="payload-output">Unlock the vault to inspect its encrypted envelope.</pre>
        </aside>
      </section>

      <section class="security-grid" aria-label="Security model">
        <article>
          <span>01</span>
          <h2>Derive</h2>
          <p>PBKDF2 turns the passphrase and random salt into a non-extractable browser key.</p>
        </article>
        <article>
          <span>02</span>
          <h2>Encrypt</h2>
          <p>Every record receives a fresh AES-GCM IV before it crosses the API boundary.</p>
        </article>
        <article>
          <span>03</span>
          <h2>Persist</h2>
          <p>The server validates an encrypted envelope and writes only that envelope to disk.</p>
        </article>
      </section>
    </main>
  </body>
</html>

How is the interface organized?

  • The hero explains which component handles plaintext.
  • The unlock form collects the master passphrase for browser-side processing.
  • The hidden vault workspace contains the credential form and decrypted record list.
  • The payload panel reserves a visible place for the encrypted envelope you will create later.
  • Save public/index.html.
  • Refresh Microsoft Edge to render the new document.

You should see the vault heading and its forms as unstyled browser content. This confirms the HTML route works before visual styling is added.

  • Switch back to the open public/styles.css tab in VS Code.
  • Replace the placeholder contents with the complete vault design below:
:root {
  color-scheme: dark;
  --background: #08111f;
  --surface: rgba(15, 29, 49, 0.86);
  --surface-strong: #12243d;
  --border: rgba(147, 197, 253, 0.2);
  --text: #eef6ff;
  --muted: #9eb1c7;
  --accent: #5eead4;
  --accent-strong: #2dd4bf;
  --danger: #fda4af;
  --shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  color: var(--text);
  background:
    radial-gradient(circle at 15% 0%, rgba(45, 212, 191, 0.16), transparent 30rem),
    radial-gradient(circle at 90% 20%, rgba(59, 130, 246, 0.16), transparent 28rem),
    var(--background);
}

button,
input,
textarea {
  font: inherit;
}

button {
  cursor: pointer;
}

.site-header,
main {
  width: min(1180px, calc(100% - 2rem));
  margin-inline: auto;
}

.site-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-block: 1.25rem;
}

.brand {
  display: inline-flex;
  gap: 0.75rem;
  align-items: center;
  color: var(--text);
  font-weight: 800;
  text-decoration: none;
}

.brand-mark,
.local-badge,
.count-badge {
  border: 1px solid var(--border);
  border-radius: 999px;
}

.brand-mark {
  display: grid;
  width: 2.5rem;
  height: 2.5rem;
  place-items: center;
  color: #06241f;
  background: var(--accent);
}

.local-badge,
.count-badge {
  padding: 0.45rem 0.75rem;
  color: var(--muted);
  background: rgba(8, 17, 31, 0.7);
}

.hero {
  display: grid;
  grid-template-columns: 1.5fr 1fr;
  gap: 2rem;
  align-items: end;
  padding: 5rem 0 3rem;
}

.eyebrow {
  margin: 0 0 0.55rem;
  color: var(--accent);
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  max-width: 760px;
  margin-bottom: 1rem;
  font-size: clamp(2.6rem, 7vw, 5.6rem);
  line-height: 0.98;
  letter-spacing: -0.055em;
}

h2 {
  margin-bottom: 0.65rem;
  font-size: 1.25rem;
}

p {
  color: var(--muted);
  line-height: 1.65;
}

.hero-copy {
  max-width: 700px;
  font-size: 1.05rem;
}

.trust-card,
.panel,
.security-grid article {
  border: 1px solid var(--border);
  border-radius: 1.25rem;
  background: var(--surface);
  box-shadow: var(--shadow);
  backdrop-filter: blur(16px);
}

.trust-card {
  display: grid;
  gap: 0.35rem;
  padding: 1.5rem;
}

.trust-card span {
  margin-top: 0.8rem;
  color: var(--accent);
  font-size: 0.78rem;
  font-weight: 800;
  text-transform: uppercase;
}

.panel {
  padding: 1.5rem;
}

.unlock-panel {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2rem;
  margin-bottom: 2rem;
}

.stack-form,
.credential-form {
  display: grid;
  gap: 0.75rem;
}

label {
  color: #d7e6f7;
  font-size: 0.9rem;
  font-weight: 700;
}

input,
textarea {
  width: 100%;
  border: 1px solid var(--border);
  border-radius: 0.8rem;
  padding: 0.85rem 1rem;
  color: var(--text);
  background: rgba(4, 13, 25, 0.78);
  outline: none;
}

input:focus,
textarea:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(94, 234, 212, 0.12);
}

.primary-button,
.secondary-button,
.card-button {
  border-radius: 0.8rem;
  padding: 0.75rem 1rem;
  font-weight: 800;
}

.primary-button {
  border: 0;
  color: #06241f;
  background: var(--accent);
}

.primary-button:hover {
  background: var(--accent-strong);
}

.secondary-button,
.card-button {
  border: 1px solid var(--border);
  color: var(--text);
  background: transparent;
}

.card-button.danger {
  color: var(--danger);
}

.status {
  min-height: 1.5rem;
  margin: 0;
  font-size: 0.9rem;
}

.status[data-tone="success"] {
  color: var(--accent);
}

.status[data-tone="error"] {
  color: var(--danger);
}

.vault-layout {
  display: grid;
  grid-template-columns: minmax(270px, 0.85fr) minmax(320px, 1.15fr);
  gap: 1rem;
}

.panel-heading,
.credential-card header,
.password-row,
.card-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.records-panel {
  min-height: 440px;
}

.payload-panel {
  grid-column: 1 / -1;
}

.payload-panel pre {
  max-height: 300px;
  overflow: auto;
  margin: 1rem 0 0;
  border-radius: 0.8rem;
  padding: 1rem;
  color: #b9f7ed;
  background: #030a13;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.credential-list {
  display: grid;
  gap: 0.85rem;
}

.credential-card {
  border: 1px solid var(--border);
  border-radius: 1rem;
  padding: 1rem;
  background: var(--surface-strong);
}

.credential-card h3 {
  margin: 0;
}

.credential-card p {
  margin-bottom: 0.7rem;
}

.password-row input {
  flex: 1;
}

.card-actions {
  justify-content: flex-end;
  margin-top: 0.85rem;
}

.empty-state {
  border: 1px dashed var(--border);
  border-radius: 0.8rem;
  padding: 1rem;
  text-align: center;
}

.security-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
  padding: 2rem 0 5rem;
}

.security-grid article {
  padding: 1.35rem;
}

.security-grid span {
  color: var(--accent);
  font-weight: 900;
}

[hidden] {
  display: none !important;
}

@media (max-width: 800px) {
  .hero,
  .unlock-panel,
  .vault-layout,
  .security-grid {
    grid-template-columns: 1fr;
  }

  .hero {
    padding-top: 3rem;
  }

  .payload-panel {
    grid-column: auto;
  }

  .site-header {
    align-items: flex-start;
  }

  .local-badge {
    display: none;
  }
}

What does this design establish?

Shared color variables keep the panels and controls visually consistent. The two-column vault layout separates data entry from the decrypted browser view.

The responsive rule collapses the interface into one column on narrower screens. The same trust-boundary story remains readable at different window sizes.

  • Save public/styles.css.
  • Refresh Microsoft Edge to apply the vault design.

You should see a dark teal interface with a large security statement and an unlock panel. The browser/server trust summary should sit beside the heading on a wide window.

Does the page still look unstyled?

Confirm that public/index.html links to /styles.css. Confirm that the server route points to ./public/styles.css.

Refresh the page after saving both files. Help me find why the vault stylesheet is not loading.

Add an in-memory credential

The first interaction uses an in-memory array as temporary state. The DOM renderer assigns user-controlled values through textContent so they appear as text inside each card.

  • Switch back to the open public/app.js tab in VS Code.
  • Replace the placeholder contents with this temporary browser application:
// Keep the first vault records in browser memory only.
const state = {
  decryptedEntries: [],
};

const unlockPanel = document.querySelector("#unlock-panel");
const vaultPanel = document.querySelector("#vault-panel");
const unlockForm = document.querySelector("#unlock-form");
const masterPassphrase = document.querySelector("#master-passphrase");
const unlockStatus = document.querySelector("#unlock-status");
const credentialForm = document.querySelector("#credential-form");
const lockButton = document.querySelector("#lock-button");
const vaultStatus = document.querySelector("#vault-status");
const credentialList = document.querySelector("#credential-list");
const emptyState = document.querySelector("#empty-state");
const recordCount = document.querySelector("#record-count");
const payloadOutput = document.querySelector("#payload-output");

// Show short feedback beside the form that triggered it.
function setStatus(element, message, tone = "neutral") {
  element.textContent = message;
  element.dataset.tone = tone;
}

// Build each record with DOM nodes so form values stay plain text.
function createCredentialCard(credential) {
  const card = document.createElement("article");
  card.className = "credential-card";

  const header = document.createElement("header");
  const title = document.createElement("h3");
  title.textContent = credential.site;
  const username = document.createElement("p");
  username.textContent = credential.username;
  header.append(title, username);

  const passwordRow = document.createElement("div");
  passwordRow.className = "password-row";
  const passwordInput = document.createElement("input");
  passwordInput.type = "password";
  passwordInput.value = credential.password;
  passwordInput.readOnly = true;

  const revealButton = document.createElement("button");
  revealButton.className = "card-button";
  revealButton.type = "button";
  revealButton.textContent = "Reveal";
  revealButton.addEventListener("click", () => {
    const revealing = passwordInput.type === "password";
    passwordInput.type = revealing ? "text" : "password";
    revealButton.textContent = revealing ? "Hide" : "Reveal";
  });
  passwordRow.append(passwordInput, revealButton);

  card.append(header, passwordRow);

  if (credential.notes) {
    const notes = document.createElement("p");
    notes.textContent = credential.notes;
    card.append(notes);
  }

  return card;
}

// Rebuild the visible list from the current in-memory records.
function renderCredentials() {
  credentialList.replaceChildren();

  for (const credential of state.decryptedEntries) {
    credentialList.append(createCredentialCard(credential));
  }

  const count = state.decryptedEntries.length;
  emptyState.hidden = count > 0;
  recordCount.textContent = `${count} ${count === 1 ? "record" : "records"}`;
  payloadOutput.textContent = JSON.stringify(
    { storage: "browser memory", recordCount: count },
    null,
    2,
  );
}

unlockForm.addEventListener("submit", (event) => {
  event.preventDefault();
  masterPassphrase.value = "";
  unlockPanel.hidden = true;
  vaultPanel.hidden = false;
  renderCredentials();
  setStatus(vaultStatus, "Temporary in-memory vault unlocked.", "success");
  setStatus(unlockStatus, "");
});

credentialForm.addEventListener("submit", (event) => {
  event.preventDefault();

  const formData = new FormData(credentialForm);
  const credential = {
    site: formData.get("site"),
    username: formData.get("username"),
    password: formData.get("password"),
    notes: formData.get("notes"),
  };

  state.decryptedEntries.push(credential);
  credentialForm.reset();
  renderCredentials();
  setStatus(vaultStatus, "Credential stored in browser memory.", "success");
});

lockButton.addEventListener("click", () => {
  state.decryptedEntries = [];
  credentialForm.reset();
  credentialList.replaceChildren();
  payloadOutput.textContent = "Vault locked. The temporary plaintext view was cleared.";
  vaultPanel.hidden = true;
  unlockPanel.hidden = false;
  setStatus(vaultStatus, "");
  setStatus(unlockStatus, "Vault locked.", "success");
  masterPassphrase.focus();
});

masterPassphrase.focus();

What does this temporary application do?

  • The state object holds decrypted records only for the current page session.
  • The card builder uses textContent for the site and username fields.
  • The reveal control changes the password input between hidden and visible modes.
  • The payload panel reports the temporary storage type without displaying the credential values.
  • Save public/app.js.
  • Refresh Microsoft Edge to load the browser application.
  • Enter a temporary master passphrase in the Master passphrase field.
  • Click Unlock vault to reveal the workspace.
  • Enter Example Mail in the Site or service field.
  • Enter learner@example.com in the Username field.
  • Enter vault-demo-password in the Password field.
  • Click Encrypt and save to add the in-memory record.

You should see one credential card and a record count of 1 record. The payload panel should identify browser memory without listing your entered values.

  • Click Reveal on the credential card.

You should see the sample password inside the card. That visible plaintext exists only in the current browser page.

Does the form stay locked?

Confirm that public/index.html loads /app.js with the defer attribute. Check that each selector in public/app.js matches the corresponding HTML ID.

Use the browser developer console to check for a JavaScript syntax error. Help me debug the in-memory vault controls.

Before you test the page boundary, do you think the card will survive a browser refresh?

  • Refresh Microsoft Edge while the credential card is visible.

You should return to the locked interface. Unlocking it again shows 0 records because the in-memory array was recreated when the page loaded.

The disappearing record is intentional

The browser rendered a working credential card without writing it anywhere durable. Refreshing discarded the JavaScript state that held the record.

You have now exposed the persistence gap directly. The next security decision is how to preserve records without sending plaintext to the server.

✔️ Awesome, I've got everything!

Great work. Confirm that server.mjs and all three files inside public/ are saved.

ⓧ I'd like to double check the full code

Compare server.mjs with the static server block from the first substep. It should map the three public routes and listen on the loopback address at port 3000.

Compare public/index.html and public/styles.css with their complete full-file blocks above. The interface should include the unlock panel and vault workspace.

Compare public/app.js with the temporary full-file block above. It should keep credentials in memory and render user values through DOM properties.

Your first vault now runs as a polished local application. Next, you will encrypt each credential before it can cross the browser boundary.

Encrypt Credentials in the Browser

Your vault already exposes the persistence problem. A refresh removes every in-memory credential.

Saving that plaintext on the Node.js server would break the security boundary. This step uses the browser's Web Crypto API to turn each credential into ciphertext before any storage layer receives it.

In this step, get ready to:
  • Derive a non-extractable browser key from the master passphrase.
  • Encrypt each credential with a fresh IV.
  • Clear the key plus plaintext when the vault locks.
Derive a key from the master passphrase

A master passphrase is difficult to use directly as an encryption key. PBKDF2 repeatedly processes the passphrase with a random salt to derive key material with the required length.

Why derive the key in the browser?

The browser is the plaintext side of this vault's trust boundary. Deriving the key there keeps the master passphrase away from the server.

The derived key is non-extractable. Application code can use it for cryptographic operations without exporting its raw bytes.

  • Switch back to public/app.js in VS Code.
  • Replace the temporary constants plus DOM references at the top of the file with this code:
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const PBKDF2_ITERATIONS = 600_000;

const state = {
  key: null,
  salt: null,
  encryptedEntries: [],
  decryptedEntries: [],
};

const unlockPanel = document.querySelector("#unlock-panel");
const vaultPanel = document.querySelector("#vault-panel");
const unlockForm = document.querySelector("#unlock-form");
const unlockButton = document.querySelector("#unlock-button");
const masterPassphrase = document.querySelector("#master-passphrase");
const unlockStatus = document.querySelector("#unlock-status");
const credentialForm = document.querySelector("#credential-form");
const lockButton = document.querySelector("#lock-button");
const vaultStatus = document.querySelector("#vault-status");
const credentialList = document.querySelector("#credential-list");
const emptyState = document.querySelector("#empty-state");
const recordCount = document.querySelector("#record-count");
const payloadOutput = document.querySelector("#payload-output");

What does this setup do?

  • The encoder converts passphrases plus credential JSON into bytes that the cryptographic operations can process.
  • The decoder converts decrypted bytes back into credential JSON.
  • The state object separates encrypted entries from the temporary decrypted browser view.
  • Save public/app.js.
  • Refresh Microsoft Edge at http://127.0.0.1:3000.

You should still see the unlock panel. This confirms the new state declarations load without a syntax error.

Did the vault page turn blank?

Check that every DOM selector still includes its leading #. Confirm that the closing brace after decryptedEntries is followed by a comma-free semicolon.

Ask for help with the first code section: help me find the syntax error in my browser vault setup code

  • Add the status plus base64 helpers below the DOM references by copying this code:
function setStatus(element, message, tone = "neutral") {
  element.textContent = message;
  element.dataset.tone = tone;
}

function bytesToBase64(value) {
  const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
  let binary = "";

  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }

  return btoa(binary);
}

function base64ToBytes(value) {
  return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
}

What do these helpers do?

  • The setStatus() helper writes progress or error messages with DOM textContent.
  • The bytesToBase64() helper converts binary cryptographic output into text that fits inside JSON.
  • The base64ToBytes() helper reverses that conversion before decryption.
  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should still see the complete unlock panel with no status error.

Seeing an encoding error?

Check the capitalization of Uint8Array. Confirm that bytesToBase64() uses btoa() after building the binary string.

Ask for help with the encoding helpers: help me debug my base64 conversion helpers

The derivation function imports the passphrase as temporary PBKDF2 key material. It combines that material with a random 16-byte salt through 600_000 HMAC-SHA-256 iterations.

  • Add the key derivation function below the base64 helpers by copying this code:
async function deriveVaultKey(passphrase, salt) {
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    encoder.encode(passphrase),
    "PBKDF2",
    false,
    ["deriveKey"],
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: PBKDF2_ITERATIONS,
      hash: "SHA-256",
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

What does key derivation produce?

  • The first false keeps the imported passphrase material non-extractable.
  • The salt makes the same passphrase derive different key material for different vaults.
  • The second false makes the resulting 256-bit AES-GCM key non-extractable.
  • The final usage list permits encryption plus decryption with that key.
  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should see the unlock form load normally. The derivation function is now available to the unlock handler.

Does the page report a crypto problem?

Confirm that crypto.subtle.importKey() receives PBKDF2 as its algorithm. Check that the derived key type uses AES-GCM with a length of 256.

Ask for help with key derivation: help me debug my PBKDF2 deriveVaultKey function

Encrypt each credential into an envelope

Each credential needs authenticated encryption before it can cross the server boundary. A fresh 12-byte IV keeps every AES-GCM operation unique under the active key.

  • Add the credential encryption function below deriveVaultKey() by copying this code:
async function encryptCredential(credential) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = encoder.encode(JSON.stringify(credential));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    state.key,
    plaintext,
  );

  return {
    id: crypto.randomUUID(),
    iv: bytesToBase64(iv),
    ciphertext: bytesToBase64(ciphertext),
  };
}

What crosses the boundary?

  • The iv receives 12 cryptographically random bytes for this encryption operation.
  • The credential object becomes bytes before crypto.subtle.encrypt() processes it.
  • The returned object contains only a UUID plus base64-encoded IV and ciphertext.
  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should see the unlock panel load with no browser error.

Does encryption fail immediately?

Confirm that the IV uses new Uint8Array(12). Check that state.key appears as the second argument passed to encryption.

Ask for help with credential encryption: help me debug encryptCredential

Decryption needs the same key plus the IV stored beside the ciphertext. AES-GCM rejects the operation when the key is wrong or the encrypted data has changed.

  • Add the matching decryption function below encryptCredential() by copying this code:
async function decryptCredential(entry) {
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
    state.key,
    base64ToBytes(entry.ciphertext),
  );

  return {
    id: entry.id,
    ...JSON.parse(decoder.decode(plaintext)),
  };
}

How does decryption recover a record?

  • The stored IV returns to bytes before decryption.
  • The ciphertext returns to bytes before decryption.
  • The decoder converts the recovered plaintext bytes into JSON.
  • The original entry ID joins the decrypted fields for rendering plus deletion.
  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should see the unlock panel with its passphrase field ready.

Seeing a decryption syntax error?

Check that entry.iv and entry.ciphertext both pass through base64ToBytes(). Confirm that the returned object spreads the parsed JSON after its ID.

Ask for help with credential decryption: help me debug decryptCredential

The encrypted envelope is the complete server-visible representation of the vault. Its top level contains a format version plus the salt and encrypted entries.

  • Add the envelope helpers below decryptCredential() by copying this code:
function buildEnvelope() {
  return {
    version: 1,
    salt: bytesToBase64(state.salt),
    entries: state.encryptedEntries,
  };
}

function updatePayload(envelope = buildEnvelope()) {
  payloadOutput.textContent = JSON.stringify(envelope, null, 2);
}

Why build an envelope?

The envelope separates data needed for future recovery from plaintext fields. The payload panel renders the same JSON shape that the storage API receives in the next step.

Using textContent displays the JSON as text. It prevents the payload from becoming executable HTML.

  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should see the unlock panel with the encrypted-payload panel still hidden.

Does the payload helper fail?

Confirm that buildEnvelope() reads state.salt only after the vault has been unlocked. Check that the payload output uses JSON.stringify().

Ask for help with the envelope: help me debug buildEnvelope and updatePayload

Clear plaintext when the vault locks

Encryption protects credentials before storage. Locking protects the temporary browser state by dropping the active key plus every decrypted record.

  • Replace the temporary unlock submit listener with this browser key derivation flow:
unlockForm.addEventListener("submit", async (event) => {
  event.preventDefault();

  const salt = crypto.getRandomValues(new Uint8Array(16));
  const key = await deriveVaultKey(masterPassphrase.value, salt);

  state.key = key;
  state.salt = salt;
  state.encryptedEntries = [];
  state.decryptedEntries = [];

  masterPassphrase.value = "";
  unlockPanel.hidden = true;
  vaultPanel.hidden = false;
  updatePayload();
  renderCredentials();
  setStatus(unlockStatus, "");
  setStatus(vaultStatus, "New vault ready for its first record.", "success");
});

What happens during unlock?

  • The browser generates a random 16-byte salt.
  • The passphrase plus salt produce the active non-extractable key.
  • The passphrase input is cleared immediately after derivation.
  • The initial encrypted envelope becomes visible with an empty entries array.
  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.
  • Enter a test-only master passphrase in the Master passphrase field.
  • Click Unlock vault.

You should see the credential form plus an encrypted envelope containing a version, a salt, and an empty entries array. The passphrase field is cleared.

Does the unlock button stop working?

Confirm that the listener calls event.preventDefault(). Check that state.salt receives the random salt before updatePayload() runs.

Ask for help with the unlock flow: help me debug the browser unlock listener

  • Replace the temporary credential submit listener with this encrypted save flow:
credentialForm.addEventListener("submit", async (event) => {
  event.preventDefault();

  const formData = new FormData(credentialForm);
  const credential = {
    site: formData.get("site"),
    username: formData.get("username"),
    password: formData.get("password"),
    notes: formData.get("notes"),
  };

  setStatus(vaultStatus, "Encrypting in the browser...");

  const encryptedEntry = await encryptCredential(credential);
  state.encryptedEntries.push(encryptedEntry);
  state.decryptedEntries.push({ id: encryptedEntry.id, ...credential });
  updatePayload();
  credentialForm.reset();
  renderCredentials();
  setStatus(vaultStatus, "Credential encrypted and persisted.", "success");
});

How does the encrypted save flow work?

  • The form values stay in the browser as a temporary credential object.
  • The encrypted entry enters state.encryptedEntries for the server-visible envelope.
  • The plaintext copy enters state.decryptedEntries for the unlocked card view.
  • The form resets after the encrypted payload and card are rendered.
  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should return to the locked view because a refresh clears all browser memory. That refresh behavior remains intentional until the API stores the encrypted envelope.

Does saving produce an error?

Unlock the vault before submitting the credential form. The encryption function requires an active key in state.key.

Ask for help with encrypted saving: help me debug the encrypted credential submit handler

The existing card builder already uses DOM textContent plus a password input for safe rendering. Its delete control now needs to remove both representations of a credential.

  • Replace the current delete-button listener inside createCredentialCard() with this code:
deleteButton.addEventListener("click", () => {
  state.encryptedEntries = state.encryptedEntries.filter((entry) => entry.id !== credential.id);
  state.decryptedEntries = state.decryptedEntries.filter((entry) => entry.id !== credential.id);
  updatePayload();
  renderCredentials();
  setStatus(vaultStatus, "Credential deleted from the encrypted vault.", "success");
});

Why delete from both arrays?

The encrypted array controls the server-visible envelope. The decrypted array controls the cards visible in the unlocked browser.

Filtering both arrays by the shared UUID keeps those two views synchronized.

  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should see the locked vault without a browser error.

Does Delete leave stale payload data?

Confirm that both filters compare each entry ID with credential.id. Check that updatePayload() runs before the cards render again.

Ask for help with synchronized deletion: help me debug encrypted credential deletion

  • Replace the temporary lock behavior with this state-clearing function:
function lockVault() {
  state.key = null;
  state.salt = null;
  state.encryptedEntries = [];
  state.decryptedEntries = [];
  credentialForm.reset();
  credentialList.replaceChildren();
  payloadOutput.textContent = "Vault locked. The in-memory key and plaintext view were cleared.";
  vaultPanel.hidden = true;
  unlockPanel.hidden = false;
  setStatus(vaultStatus, "");
  setStatus(unlockStatus, "Vault locked.", "success");
  masterPassphrase.focus();
}

lockButton.addEventListener("click", lockVault);
masterPassphrase.focus();

What disappears on lock?

  • The active key plus salt references become null.
  • The encrypted and decrypted in-memory arrays become empty.
  • The credential form plus rendered cards are cleared.
  • The locked interface replaces the unlocked vault panel.
  • Save public/app.js.
  • Refresh the vault page in Microsoft Edge.

You should see the master-passphrase field focused in the locked panel.

Does Lock leave a card visible?

Confirm that credentialList.replaceChildren() runs inside lockVault(). Check that the click listener passes the function name without calling it immediately.

Ask for help with locking: help me debug lockVault

✔️ Awesome, I've got everything!

Great. Save public/app.js before testing the complete encryption flow.

ⓧ I'd like to double check the full code

Compare your cumulative public/app.js with this version.

const encoder = new TextEncoder();
const decoder = new TextDecoder();
const PBKDF2_ITERATIONS = 600_000;

const state = {
  key: null,
  salt: null,
  encryptedEntries: [],
  decryptedEntries: [],
};

const unlockPanel = document.querySelector("#unlock-panel");
const vaultPanel = document.querySelector("#vault-panel");
const unlockForm = document.querySelector("#unlock-form");
const unlockButton = document.querySelector("#unlock-button");
const masterPassphrase = document.querySelector("#master-passphrase");
const unlockStatus = document.querySelector("#unlock-status");
const credentialForm = document.querySelector("#credential-form");
const lockButton = document.querySelector("#lock-button");
const vaultStatus = document.querySelector("#vault-status");
const credentialList = document.querySelector("#credential-list");
const emptyState = document.querySelector("#empty-state");
const recordCount = document.querySelector("#record-count");
const payloadOutput = document.querySelector("#payload-output");

function setStatus(element, message, tone = "neutral") {
  element.textContent = message;
  element.dataset.tone = tone;
}

function bytesToBase64(value) {
  const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
  let binary = "";

  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }

  return btoa(binary);
}

function base64ToBytes(value) {
  return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
}

async function deriveVaultKey(passphrase, salt) {
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    encoder.encode(passphrase),
    "PBKDF2",
    false,
    ["deriveKey"],
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: PBKDF2_ITERATIONS,
      hash: "SHA-256",
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

async function encryptCredential(credential) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = encoder.encode(JSON.stringify(credential));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    state.key,
    plaintext,
  );

  return {
    id: crypto.randomUUID(),
    iv: bytesToBase64(iv),
    ciphertext: bytesToBase64(ciphertext),
  };
}

async function decryptCredential(entry) {
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
    state.key,
    base64ToBytes(entry.ciphertext),
  );

  return {
    id: entry.id,
    ...JSON.parse(decoder.decode(plaintext)),
  };
}

function buildEnvelope() {
  return {
    version: 1,
    salt: bytesToBase64(state.salt),
    entries: state.encryptedEntries,
  };
}

function updatePayload(envelope = buildEnvelope()) {
  payloadOutput.textContent = JSON.stringify(envelope, null, 2);
}

function createCredentialCard(credential) {
  const card = document.createElement("article");
  card.className = "credential-card";

  const header = document.createElement("header");
  const title = document.createElement("h3");
  title.textContent = credential.site;
  const username = document.createElement("p");
  username.textContent = credential.username;
  header.append(title, username);

  const passwordRow = document.createElement("div");
  passwordRow.className = "password-row";
  const passwordInput = document.createElement("input");
  passwordInput.type = "password";
  passwordInput.value = credential.password;
  passwordInput.readOnly = true;
  passwordInput.setAttribute("aria-label", `Password for ${credential.site}`);

  const revealButton = document.createElement("button");
  revealButton.className = "card-button";
  revealButton.type = "button";
  revealButton.textContent = "Reveal";
  revealButton.addEventListener("click", () => {
    const revealing = passwordInput.type === "password";
    passwordInput.type = revealing ? "text" : "password";
    revealButton.textContent = revealing ? "Hide" : "Reveal";
  });
  passwordRow.append(passwordInput, revealButton);

  card.append(header, passwordRow);

  if (credential.notes) {
    const notes = document.createElement("p");
    notes.textContent = credential.notes;
    card.append(notes);
  }

  const actions = document.createElement("div");
  actions.className = "card-actions";
  const deleteButton = document.createElement("button");
  deleteButton.className = "card-button danger";
  deleteButton.type = "button";
  deleteButton.textContent = "Delete";
  deleteButton.addEventListener("click", () => {
    state.encryptedEntries = state.encryptedEntries.filter((entry) => entry.id !== credential.id);
    state.decryptedEntries = state.decryptedEntries.filter((entry) => entry.id !== credential.id);
    updatePayload();
    renderCredentials();
    setStatus(vaultStatus, "Credential deleted from the encrypted vault.", "success");
  });
  actions.append(deleteButton);
  card.append(actions);

  return card;
}

function renderCredentials() {
  credentialList.replaceChildren();

  for (const credential of state.decryptedEntries) {
    credentialList.append(createCredentialCard(credential));
  }

  const count = state.decryptedEntries.length;
  emptyState.hidden = count > 0;
  recordCount.textContent = `${count} ${count === 1 ? "record" : "records"}`;
}

function lockVault() {
  state.key = null;
  state.salt = null;
  state.encryptedEntries = [];
  state.decryptedEntries = [];
  credentialForm.reset();
  credentialList.replaceChildren();
  payloadOutput.textContent = "Vault locked. The in-memory key and plaintext view were cleared.";
  vaultPanel.hidden = true;
  unlockPanel.hidden = false;
  setStatus(vaultStatus, "");
  setStatus(unlockStatus, "Vault locked.", "success");
  masterPassphrase.focus();
}

unlockForm.addEventListener("submit", async (event) => {
  event.preventDefault();

  const salt = crypto.getRandomValues(new Uint8Array(16));
  const key = await deriveVaultKey(masterPassphrase.value, salt);

  state.key = key;
  state.salt = salt;
  state.encryptedEntries = [];
  state.decryptedEntries = [];

  masterPassphrase.value = "";
  unlockPanel.hidden = true;
  vaultPanel.hidden = false;
  updatePayload();
  renderCredentials();
  setStatus(unlockStatus, "");
  setStatus(vaultStatus, "New vault ready for its first record.", "success");
});

credentialForm.addEventListener("submit", async (event) => {
  event.preventDefault();

  const formData = new FormData(credentialForm);
  const credential = {
    site: formData.get("site"),
    username: formData.get("username"),
    password: formData.get("password"),
    notes: formData.get("notes"),
  };

  setStatus(vaultStatus, "Encrypting in the browser...");

  const encryptedEntry = await encryptCredential(credential);
  state.encryptedEntries.push(encryptedEntry);
  state.decryptedEntries.push({ id: encryptedEntry.id, ...credential });
  updatePayload();
  credentialForm.reset();
  renderCredentials();
  setStatus(vaultStatus, "Credential encrypted and persisted.", "success");
});

lockButton.addEventListener("click", lockVault);
masterPassphrase.focus();

Before you test the complete flow, predict whether the encrypted payload will contain any recognizable form value.

  • Enter a test-only master passphrase in the Master passphrase field.
  • Click Unlock vault.
  • Enter Portfolio Demo in the Site or service field.
  • Enter demo-user in the Username field.
  • Enter not-a-real-password in the Password field.
  • Enter browser-only note in the Notes field.
  • Click Encrypt and save.

You should see one decrypted card plus an encrypted entry containing an ID, IV, and ciphertext. The sample site, username, password, notes, and master passphrase should be absent from the payload.

Can you see plaintext in the payload?

Confirm that buildEnvelope() returns state.encryptedEntries. It must never return state.decryptedEntries.

Ask for help checking the trust boundary: help me find why plaintext appears in my encrypted payload

  • Click Lock.

You should return to the unlock panel. The card disappears and the payload states that the in-memory key plus plaintext view were cleared.

That is the browser side of your zero-knowledge boundary working. Next, you will persist this encrypted envelope without giving the server access to the key or plaintext.

Persist the Encrypted Vault

Your browser now encrypts each credential before it crosses the trust boundary. Refreshing still discards the encrypted envelope because the server has no durable storage layer.

This step fixes that gap with ciphertext persistence. A strict API stores the encrypted envelope without receiving the master passphrase or plaintext credentials.

In this step, get ready to:
  • Persist encrypted vault envelopes in data/vault.json.
  • Connect browser unlocks and updates to /api/vault.
  • Restart the server to prove that only the correct passphrase recovers the saved credential.
Add encrypted-envelope persistence to the server

The server needs a strict JSON boundary before it can write anything to disk. Each accepted record is rebuilt from its encrypted fields so unexpected properties never reach the runtime file.

  • Return to the PowerShell terminal running the local server.
  • Stop the process by pressing Ctrl+C.
  • Switch back to server.mjs in Visual Studio Code.
  • Replace the imports and path constants at the top of server.mjs with this opening section:
import { createServer } from "node:http";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { argv } from "node:process";
import { fileURLToPath } from "node:url";

const host = argv[2] ?? "127.0.0.1";
const port = 3000;
const dataDirectory = fileURLToPath(new URL("./data/", import.meta.url));
const dataFile = join(dataDirectory, "vault.json");

What does this setup do?

  • The filesystem imports let the server create data/ before writing the encrypted vault.
  • The path helpers locate vault.json correctly on Windows.
  • The optional command argument lets the finished server accept a different bind address later.
  • Keep the existing staticRoutes map directly below the new path constants.
  • Add the empty-envelope factory and response-header helper below staticRoutes.
function emptyVault() {
  return { version: 1, salt: "", entries: [] };
}

function setSecurityHeaders(response) {
  response.setHeader(
    "Content-Security-Policy",
    "default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self';",
  );
  response.setHeader("X-Content-Type-Options", "nosniff");
  response.setHeader("Referrer-Policy", "no-referrer");
}

What do these helpers protect?

A new vault starts with one fixed encrypted-envelope shape. The security policy permits only the same-origin resources used by this application.

The remaining headers require declared content types. They also remove referrer information from outgoing requests.

  • Add the JSON response helper below setSecurityHeaders().
function sendJson(response, statusCode, payload) {
  setSecurityHeaders(response);
  response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(payload));
}

What does this helper do?

The sendJson() helper applies the defensive headers before returning a JSON response. This keeps API errors under the same response policy as successful requests.

  • Add the request-body reader below sendJson().
function readRequestJson(request) {
  return new Promise((resolve, reject) => {
    const chunks = [];

    request
      .on("data", (chunk) => chunks.push(chunk))
      .on("end", () => {
        try {
          const body = Buffer.concat(chunks).toString("utf8");
          resolve(JSON.parse(body));
        } catch (error) {
          reject(error);
        }
      })
      .on("error", reject);
  });
}

How is the request body read?

The server collects incoming chunks before parsing the complete body. Invalid JSON reaches the shared error handler instead of the storage function.

  • Add the first part of sanitizeVault() below readRequestJson().
  • Keep the file unsaved until you add the continuation immediately afterward.
function sanitizeVault(candidate) {
  if (
    candidate?.version !== 1 ||
    typeof candidate.salt !== "string" ||
    !Array.isArray(candidate.entries)
  ) {
    throw new Error("Invalid vault envelope");
  }

  const entries = candidate.entries.map((entry) => {
    if (
      typeof entry?.id !== "string" ||
      typeof entry.iv !== "string" ||
      typeof entry.ciphertext !== "string"
    ) {
      throw new Error("Invalid encrypted entry");
    }

    return {
      id: entry.id,
      iv: entry.iv,
      ciphertext: entry.ciphertext,
    };
  });

Why is this code continued?

This first display block contains the validation half of one function. Add the continuation below before saving so sanitizeVault() remains complete and runnable.

  • Complete sanitizeVault() by adding this continuation directly below the previous code:

  return {
    version: 1,
    salt: candidate.salt,
    entries,
  };
}

Why sanitize the envelope?

The function accepts version 1 with a salt string. It also requires an array of encrypted entries.

Each stored entry is rebuilt with only id, iv, and ciphertext. Plaintext fields have no path to disk.

  • Add the disk-loading helper below sanitizeVault().
async function loadVault() {
  try {
    const contents = await readFile(dataFile, "utf8");
    return sanitizeVault(JSON.parse(contents));
  } catch (error) {
    if (error.code === "ENOENT") {
      return emptyVault();
    }
    throw error;
  }
}

How is the vault loaded?

The first request returns an empty envelope when no runtime file exists. Later requests sanitize the stored envelope again before returning it.

  • Add the disk-saving helper below loadVault().
async function saveVault(vault) {
  await mkdir(dataDirectory, { recursive: true });
  await writeFile(dataFile, `${JSON.stringify(vault, null, 2)}\n`, "utf8");
}

How is the vault saved?

The helper creates data/ when the first credential is saved. It then replaces vault.json with the latest sanitized envelope.

  • Find the try block inside the existing server callback.
  • Add these API route checks at the start of that try block:
    if (request.method === "GET" && pathname === "/api/vault") {
      sendJson(response, 200, await loadVault());
      return;
    }

    if (request.method === "POST" && pathname === "/api/vault") {
      const vault = sanitizeVault(await readRequestJson(request));
      await saveVault(vault);
      setSecurityHeaders(response);
      response.writeHead(204);
      response.end();
      return;
    }

What do the API routes allow?

The GET route returns the encrypted envelope currently stored on disk. The POST route sanitizes a submitted envelope before saving it.

A successful save returns status code 204 with an empty response body.

  • Replace the static-file branch and fallback response with this secured version:
    if (request.method === "GET" && staticRoutes.has(pathname)) {
      const asset = staticRoutes.get(pathname);
      const body = await readFile(asset.file);
      setSecurityHeaders(response);
      response.writeHead(200, { "Content-Type": asset.type });
      response.end(body);
      return;
    }

    sendJson(response, 404, { error: "Not found" });

Why secure static responses?

The HTML, CSS, and JavaScript responses now receive the same defensive headers as the API. Unknown routes use the shared JSON response path.

  • Save server.mjs.

Before you start the server, do you expect the empty API request to return an error or a valid empty envelope?

  • Start the updated server by running this command:
node server.mjs

PowerShell prints the local vault URL. This confirms that the persistence helpers and API routes contain valid syntax.

  • Return to Microsoft Edge.
  • Load http://127.0.0.1:3000/api/vault in the address bar.

You should see version 1 with an empty salt and an empty entries array. The server can now represent a vault before its first save.

Does the API request fail?

Confirm that PowerShell still shows the running server process. Check that the API route checks appear before the static-file route.

Compare loadVault() with the supplied code. Help me debug the encrypted vault API.

Connect the browser to the vault API

Encryption and decryption stay inside Microsoft Edge. The browser now transfers only the encrypted envelope through same-origin requests.

  • Switch back to public/app.js in Visual Studio Code.
  • Add the API helpers below updatePayload().
async function fetchVault() {
  const response = await fetch("/api/vault");

  if (!response.ok) {
    throw new Error("Could not load the encrypted vault");
  }

  return response.json();
}

async function persistVault() {
  const envelope = buildEnvelope();
  const response = await fetch("/api/vault", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(envelope),
  });

  if (!response.ok) {
    throw new Error("Could not persist the encrypted vault");
  }

  updatePayload(envelope);
}

What crosses the API boundary?

The fetchVault() function downloads the stored envelope before key derivation. The persistVault() function posts the envelope built from encrypted browser state.

Both requests use a relative URL. Microsoft Edge sends them to the server that delivered the page.

  • Find the try block inside the unlock submit handler.
  • Replace its envelope-loading and state-assignment section with this code:
    const envelope = await fetchVault();
    const salt = envelope.salt
      ? base64ToBytes(envelope.salt)
      : crypto.getRandomValues(new Uint8Array(16));
    const key = await deriveVaultKey(masterPassphrase.value, salt);
    const decryptedEntries = [];

    for (const entry of envelope.entries) {
      const previousKey = state.key;
      state.key = key;
      try {
        decryptedEntries.push(await decryptCredential(entry));
      } finally {
        state.key = previousKey;
      }
    }

    state.key = key;
    state.salt = salt;
    state.encryptedEntries = envelope.entries;
    state.decryptedEntries = decryptedEntries;

How does unlock recover records?

A new vault generates its first salt when the downloaded salt is empty. An existing vault decodes the stored salt before deriving the key.

Every encrypted entry must decrypt with that key. A wrong passphrase causes authenticated decryption to fail before plaintext enters application state.

  • Replace the success section below those state assignments with this code:
    masterPassphrase.value = "";
    unlockPanel.hidden = true;
    vaultPanel.hidden = false;
    updatePayload({ ...envelope, salt: bytesToBase64(salt) });
    renderCredentials();
    setStatus(unlockStatus, "");
    setStatus(
      vaultStatus,
      envelope.entries.length ? "Vault decrypted in this browser." : "New vault ready for its first record.",
      "success",
    );

What changes after unlock?

The passphrase field is cleared before the encrypted payload is rendered. The status distinguishes a recovered vault from a new empty vault.

  • Save public/app.js.
  • Return to the vault page in Microsoft Edge.
  • Refresh the page.
  • Enter a test passphrase in the Master passphrase field.
  • Click Unlock vault.

You should see the unlocked vault with a status saying it is ready for its first record. The encrypted-payload panel shows a generated salt with an empty entries array.

Does unlock report a loading failure?

Confirm that the Node.js process is still running. Check that both request helpers use /api/vault.

A missing leading slash sends the request to a different path. Help me debug the browser request.

  • Find the try block inside the credential submit handler.
  • Replace that block with this persistent save flow:
  try {
    const encryptedEntry = await encryptCredential(credential);
    state.encryptedEntries.push(encryptedEntry);
    state.decryptedEntries.push({ id: encryptedEntry.id, ...credential });
    await persistVault();
    credentialForm.reset();
    renderCredentials();
    setStatus(vaultStatus, "Credential encrypted and persisted.", "success");
  } catch (error) {
    setStatus(vaultStatus, error.message, "error");
  }

When does a save complete?

The browser encrypts the credential before adding it to the envelope. The success message appears only after the server accepts the POST request.

  • Find the delete button listener inside createCredentialCard().
  • Replace that listener with this persistent deletion flow:
  deleteButton.addEventListener("click", async () => {
    const previousEncrypted = state.encryptedEntries;
    const previousDecrypted = state.decryptedEntries;
    state.encryptedEntries = state.encryptedEntries.filter((entry) => entry.id !== credential.id);
    state.decryptedEntries = state.decryptedEntries.filter((entry) => entry.id !== credential.id);

    try {
      await persistVault();
      renderCredentials();
      setStatus(vaultStatus, "Credential deleted from the encrypted vault.", "success");
    } catch (error) {
      state.encryptedEntries = previousEncrypted;
      state.decryptedEntries = previousDecrypted;
      setStatus(vaultStatus, error.message, "error");
    }
  });

Why keep the previous arrays?

Deletion removes the record from browser state before sending the smaller envelope. A failed request restores the previous arrays so the browser stays consistent with disk.

  • Save public/app.js.
  • Refresh the vault page.
  • Unlock the vault with the same test passphrase.
  • Enter NextWork Test in the Site or service field.
  • Enter vault-user in the Username field.
  • Enter a test-only value in the Password field.
  • Click Encrypt and save.

You should see one decrypted credential card with a success message. The encrypted-payload panel shows one entry containing an ID, IV, and ciphertext.

  • Return to the Explorer sidebar in Visual Studio Code.
  • Expand the new data folder.

You should see vault.json inside data/. That file proves the encrypted envelope reached durable storage.

Was the runtime file not created?

Confirm that the browser showed the persisted success message. Check PowerShell for a failed POST request or a filesystem error.

Make sure saveVault() creates dataDirectory before writing the file. Help me trace the missing runtime file.

✔️ Awesome, I've got everything!

Your server and browser now exchange encrypted envelopes. Make sure server.mjs and public/app.js are saved.

ⓧ I'd like to double check the full code

Compare your two updated files with these exact cumulative versions.

import { createServer } from "node:http";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { argv } from "node:process";
import { fileURLToPath } from "node:url";

const host = argv[2] ?? "127.0.0.1";
const port = 3000;
const dataDirectory = fileURLToPath(new URL("./data/", import.meta.url));
const dataFile = join(dataDirectory, "vault.json");

const staticRoutes = new Map([
  ["/", { file: new URL("./public/index.html", import.meta.url), type: "text/html; charset=utf-8" }],
  ["/styles.css", { file: new URL("./public/styles.css", import.meta.url), type: "text/css; charset=utf-8" }],
  ["/app.js", { file: new URL("./public/app.js", import.meta.url), type: "text/javascript; charset=utf-8" }],
]);

function emptyVault() {
  return { version: 1, salt: "", entries: [] };
}

function setSecurityHeaders(response) {
  response.setHeader(
    "Content-Security-Policy",
    "default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self';",
  );
  response.setHeader("X-Content-Type-Options", "nosniff");
  response.setHeader("Referrer-Policy", "no-referrer");
}

function sendJson(response, statusCode, payload) {
  setSecurityHeaders(response);
  response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(payload));
}

function readRequestJson(request) {
  return new Promise((resolve, reject) => {
    const chunks = [];

    request
      .on("data", (chunk) => chunks.push(chunk))
      .on("end", () => {
        try {
          const body = Buffer.concat(chunks).toString("utf8");
          resolve(JSON.parse(body));
        } catch (error) {
          reject(error);
        }
      })
      .on("error", reject);
  });
}

function sanitizeVault(candidate) {
  if (
    candidate?.version !== 1 ||
    typeof candidate.salt !== "string" ||
    !Array.isArray(candidate.entries)
  ) {
    throw new Error("Invalid vault envelope");
  }

  const entries = candidate.entries.map((entry) => {
    if (
      typeof entry?.id !== "string" ||
      typeof entry.iv !== "string" ||
      typeof entry.ciphertext !== "string"
    ) {
      throw new Error("Invalid encrypted entry");
    }

    return {
      id: entry.id,
      iv: entry.iv,
      ciphertext: entry.ciphertext,
    };
  });

  return {
    version: 1,
    salt: candidate.salt,
    entries,
  };
}

async function loadVault() {
  try {
    const contents = await readFile(dataFile, "utf8");
    return sanitizeVault(JSON.parse(contents));
  } catch (error) {
    if (error.code === "ENOENT") {
      return emptyVault();
    }
    throw error;
  }
}

async function saveVault(vault) {
  await mkdir(dataDirectory, { recursive: true });
  await writeFile(dataFile, `${JSON.stringify(vault, null, 2)}\n`, "utf8");
}

const server = createServer(async (request, response) => {
  const pathname = (request.url ?? "/").split("?")[0];

  try {
    if (request.method === "GET" && pathname === "/api/vault") {
      sendJson(response, 200, await loadVault());
      return;
    }

    if (request.method === "POST" && pathname === "/api/vault") {
      const vault = sanitizeVault(await readRequestJson(request));
      await saveVault(vault);
      setSecurityHeaders(response);
      response.writeHead(204);
      response.end();
      return;
    }

    if (request.method === "GET" && staticRoutes.has(pathname)) {
      const asset = staticRoutes.get(pathname);
      const body = await readFile(asset.file);
      setSecurityHeaders(response);
      response.writeHead(200, { "Content-Type": asset.type });
      response.end(body);
      return;
    }

    sendJson(response, 404, { error: "Not found" });
  } catch (error) {
    console.error(error);
    sendJson(response, 500, { error: "Request failed" });
  }
});

server.listen(port, host, () => {
  console.log(`Zero-knowledge vault running at http://${host}:${port}`);
});
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const PBKDF2_ITERATIONS = 600_000;

const state = {
  key: null,
  salt: null,
  encryptedEntries: [],
  decryptedEntries: [],
};

const unlockPanel = document.querySelector("#unlock-panel");
const vaultPanel = document.querySelector("#vault-panel");
const unlockForm = document.querySelector("#unlock-form");
const unlockButton = document.querySelector("#unlock-button");
const masterPassphrase = document.querySelector("#master-passphrase");
const unlockStatus = document.querySelector("#unlock-status");
const credentialForm = document.querySelector("#credential-form");
const lockButton = document.querySelector("#lock-button");
const vaultStatus = document.querySelector("#vault-status");
const credentialList = document.querySelector("#credential-list");
const emptyState = document.querySelector("#empty-state");
const recordCount = document.querySelector("#record-count");
const payloadOutput = document.querySelector("#payload-output");

function setStatus(element, message, tone = "neutral") {
  element.textContent = message;
  element.dataset.tone = tone;
}

function bytesToBase64(value) {
  const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
  let binary = "";

  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }

  return btoa(binary);
}

function base64ToBytes(value) {
  return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
}

async function deriveVaultKey(passphrase, salt) {
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    encoder.encode(passphrase),
    "PBKDF2",
    false,
    ["deriveKey"],
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: PBKDF2_ITERATIONS,
      hash: "SHA-256",
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

async function encryptCredential(credential) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = encoder.encode(JSON.stringify(credential));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    state.key,
    plaintext,
  );

  return {
    id: crypto.randomUUID(),
    iv: bytesToBase64(iv),
    ciphertext: bytesToBase64(ciphertext),
  };
}

async function decryptCredential(entry) {
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: base64ToBytes(entry.iv) },
    state.key,
    base64ToBytes(entry.ciphertext),
  );

  return {
    id: entry.id,
    ...JSON.parse(decoder.decode(plaintext)),
  };
}

function buildEnvelope() {
  return {
    version: 1,
    salt: bytesToBase64(state.salt),
    entries: state.encryptedEntries,
  };
}

function updatePayload(envelope = buildEnvelope()) {
  payloadOutput.textContent = JSON.stringify(envelope, null, 2);
}

async function fetchVault() {
  const response = await fetch("/api/vault");

  if (!response.ok) {
    throw new Error("Could not load the encrypted vault");
  }

  return response.json();
}

async function persistVault() {
  const envelope = buildEnvelope();
  const response = await fetch("/api/vault", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(envelope),
  });

  if (!response.ok) {
    throw new Error("Could not persist the encrypted vault");
  }

  updatePayload(envelope);
}

function createCredentialCard(credential) {
  const card = document.createElement("article");
  card.className = "credential-card";

  const header = document.createElement("header");
  const title = document.createElement("h3");
  title.textContent = credential.site;
  const username = document.createElement("p");
  username.textContent = credential.username;
  header.append(title, username);

  const passwordRow = document.createElement("div");
  passwordRow.className = "password-row";
  const passwordInput = document.createElement("input");
  passwordInput.type = "password";
  passwordInput.value = credential.password;
  passwordInput.readOnly = true;
  passwordInput.setAttribute("aria-label", `Password for ${credential.site}`);

  const revealButton = document.createElement("button");
  revealButton.className = "card-button";
  revealButton.type = "button";
  revealButton.textContent = "Reveal";
  revealButton.addEventListener("click", () => {
    const revealing = passwordInput.type === "password";
    passwordInput.type = revealing ? "text" : "password";
    revealButton.textContent = revealing ? "Hide" : "Reveal";
  });
  passwordRow.append(passwordInput, revealButton);

  card.append(header, passwordRow);

  if (credential.notes) {
    const notes = document.createElement("p");
    notes.textContent = credential.notes;
    card.append(notes);
  }

  const actions = document.createElement("div");
  actions.className = "card-actions";
  const deleteButton = document.createElement("button");
  deleteButton.className = "card-button danger";
  deleteButton.type = "button";
  deleteButton.textContent = "Delete";
  deleteButton.addEventListener("click", async () => {
    const previousEncrypted = state.encryptedEntries;
    const previousDecrypted = state.decryptedEntries;
    state.encryptedEntries = state.encryptedEntries.filter((entry) => entry.id !== credential.id);
    state.decryptedEntries = state.decryptedEntries.filter((entry) => entry.id !== credential.id);

    try {
      await persistVault();
      renderCredentials();
      setStatus(vaultStatus, "Credential deleted from the encrypted vault.", "success");
    } catch (error) {
      state.encryptedEntries = previousEncrypted;
      state.decryptedEntries = previousDecrypted;
      setStatus(vaultStatus, error.message, "error");
    }
  });
  actions.append(deleteButton);
  card.append(actions);

  return card;
}

function renderCredentials() {
  credentialList.replaceChildren();

  for (const credential of state.decryptedEntries) {
    credentialList.append(createCredentialCard(credential));
  }

  const count = state.decryptedEntries.length;
  emptyState.hidden = count > 0;
  recordCount.textContent = `${count} ${count === 1 ? "record" : "records"}`;
}

function lockVault() {
  state.key = null;
  state.salt = null;
  state.encryptedEntries = [];
  state.decryptedEntries = [];
  credentialForm.reset();
  credentialList.replaceChildren();
  payloadOutput.textContent = "Vault locked. The in-memory key and plaintext view were cleared.";
  vaultPanel.hidden = true;
  unlockPanel.hidden = false;
  setStatus(vaultStatus, "");
  setStatus(unlockStatus, "Vault locked.", "success");
  masterPassphrase.focus();
}

unlockForm.addEventListener("submit", async (event) => {
  event.preventDefault();

  if (!window.isSecureContext) {
    setStatus(unlockStatus, "Web Crypto requires a secure context. Use the local server URL.", "error");
    return;
  }

  unlockButton.disabled = true;
  setStatus(unlockStatus, "Deriving the browser key...");

  try {
    const envelope = await fetchVault();
    const salt = envelope.salt
      ? base64ToBytes(envelope.salt)
      : crypto.getRandomValues(new Uint8Array(16));
    const key = await deriveVaultKey(masterPassphrase.value, salt);
    const decryptedEntries = [];

    for (const entry of envelope.entries) {
      const previousKey = state.key;
      state.key = key;
      try {
        decryptedEntries.push(await decryptCredential(entry));
      } finally {
        state.key = previousKey;
      }
    }

    state.key = key;
    state.salt = salt;
    state.encryptedEntries = envelope.entries;
    state.decryptedEntries = decryptedEntries;

    masterPassphrase.value = "";
    unlockPanel.hidden = true;
    vaultPanel.hidden = false;
    updatePayload({ ...envelope, salt: bytesToBase64(salt) });
    renderCredentials();
    setStatus(unlockStatus, "");
    setStatus(
      vaultStatus,
      envelope.entries.length ? "Vault decrypted in this browser." : "New vault ready for its first record.",
      "success",
    );
  } catch {
    state.key = null;
    setStatus(
      unlockStatus,
      "Unlock failed. The passphrase is wrong or the encrypted data was modified.",
      "error",
    );
  } finally {
    unlockButton.disabled = false;
  }
});

credentialForm.addEventListener("submit", async (event) => {
  event.preventDefault();

  const formData = new FormData(credentialForm);
  const credential = {
    site: formData.get("site"),
    username: formData.get("username"),
    password: formData.get("password"),
    notes: formData.get("notes"),
  };

  setStatus(vaultStatus, "Encrypting in the browser...");

  try {
    const encryptedEntry = await encryptCredential(credential);
    state.encryptedEntries.push(encryptedEntry);
    state.decryptedEntries.push({ id: encryptedEntry.id, ...credential });
    await persistVault();
    credentialForm.reset();
    renderCredentials();
    setStatus(vaultStatus, "Credential encrypted and persisted.", "success");
  } catch (error) {
    setStatus(vaultStatus, error.message, "error");
  }
});

lockButton.addEventListener("click", lockVault);
masterPassphrase.focus();
Restart and prove encrypted persistence

The visible card proves that the browser can post ciphertext. Restarting the Node.js process tests whether the encrypted server file is durable.

  • Keep the test credential saved in the vault.
  • Return to the PowerShell terminal running server.mjs.
  • Stop the process by pressing Ctrl+C.

Before you restart the server, do you expect the saved card to return after the browser downloads the encrypted file again?

  • Restart the local vault by running this command:
node server.mjs

PowerShell prints the local vault URL again. The browser key is gone while data/vault.json remains on disk.

  • Refresh http://127.0.0.1:3000 in Microsoft Edge.

You should see the locked interface. The decrypted credential card stays hidden until key derivation succeeds.

Before you try a different passphrase, do you expect authenticated decryption to accept it?

  • Enter a different test passphrase in the Master passphrase field.
  • Click Unlock vault.

You should see an unlock failure. The stored ciphertext cannot be authenticated with a key derived from the different passphrase.

Before the final unlock, do you expect the original passphrase to restore the same credential card?

  • Enter the original test passphrase in the Master passphrase field.
  • Click Unlock vault.

You should see the saved credential card return. The encrypted-payload panel shows the stored salt, ID, IV, and ciphertext without the master passphrase.

Does the correct passphrase still fail?

Use the exact passphrase that created the first saved record. Passphrase matching is case-sensitive.

Confirm that state.salt comes from the downloaded envelope before key derivation. Help me trace the failed recovery.

That is the persistence boundary working. Your server survives restarts with ciphertext while only the correct browser-derived key restores the credential.

Next, you will inspect the runtime file for plaintext and package the security proof into a portfolio-ready repository.

Document and Commit the Security Proof

Your vault now survives a server restart while keeping its plaintext inside the browser. You have direct evidence that the encrypted storage boundary works.

A portfolio security project needs evidence that its zero-knowledge architecture keeps plaintext away from the server. This step packages that evidence in documentation and a local Git commit.

In this step, get ready to:
  • Prove that recognizable plaintext is absent from the persisted vault file.
  • Document the architecture, security boundaries, limitations, and deployment roadmap.
  • Create a source-only Git commit that excludes the runtime vault file.
Prove the ciphertext boundary

The Node.js server stores a ciphertext-only envelope in data/vault.json. Comparing this file with the decrypted browser view proves where plaintext can exist.

  • Return to the existing Microsoft Edge tab at http://127.0.0.1:3000.
  • Unlock the vault with the master passphrase from the previous step.
  • Compare the decrypted credential card with the encrypted-payload panel.
  • Switch back to the PowerShell terminal running the server.
  • Stop the server by pressing Ctrl+C.

Before you inspect the persisted file, do you expect any recognizable credential value to appear in it?

  • Display the complete persisted vault file by running this command:
Get-Content -Path .\data\vault.json -Raw

What does this command prove?

The command reads data/vault.json as one string. This lets you inspect the exact envelope that survives on the server's disk.

You should see version 1, a base64 salt, and encrypted entries containing only id, iv, and ciphertext values.

  • Scan the output for the recognizable site name from your sample credential.
  • Scan the output for the sample username.
  • Scan the output for the sample password.
  • Scan the output for the sample notes.
  • Scan the output for the master passphrase.

That is the core security boundary proven. Your browser can recover the credential while the persisted server file exposes only encrypted material.

Cannot read the vault file?

Make sure the PowerShell prompt points to the zero-knowledge-vault repository. The file exists only after you save at least one credential.

If the path still fails, help me locate the encrypted vault file.

Write the security story

A strong README.md explains what the vault proves. It also states the threats that remain outside this prototype's protection.

  • Select README.md in the VS Code Explorer.
  • Replace the documentation placeholder with this project overview:
# Zero-Knowledge Password Vault

A local, dependency-free security prototype that encrypts credentials in the browser before a Node.js server persists them.

## Why this project exists

The project demonstrates a concrete trust boundary. The browser temporarily handles plaintext while the vault is unlocked. The server stores only a version number, a salt, record IDs, AES-GCM IVs, and ciphertext.

This is an educational prototype, not a production password manager.

## Features

- Master-passphrase key derivation in the browser
- PBKDF2-HMAC-SHA-256 with 600,000 iterations
- Non-extractable 256-bit AES-GCM key
- Fresh 96-bit IV for every encrypted record
- Unlock, add, reveal, delete, lock, restart, and recover flows
- Ciphertext-only JSON persistence
- Content Security Policy and defensive response headers
- No npm dependencies
- Docker-ready bind argument and persistent data directory

What does this section establish?

The opening states the browser-to-server trust boundary. The feature list turns the implementation into concrete security capabilities that someone can evaluate quickly.

  • Save README.md.
  • Use the preview control in the editor toolbar to render the Markdown.

You should see the project title followed by its purpose and feature list.

Does the preview show plain symbols?

Check that the heading lines start with the same hash characters shown in the reference. Keep a blank line between each heading and its body.

If the formatting still looks wrong, help me fix the README formatting.

  • Append the architecture section below the feature list by pasting this content:
## Architecture

```text
Master passphrase
      |
      v
Browser Web Crypto API
PBKDF2 + salt -> non-extractable AES-GCM key
      |
      v
Credential JSON -> AES-GCM ciphertext + IV
      |
      v
POST /api/vault
      |
      v
Node.js server -> data/vault.json
```

The master passphrase and derived key are never included in the API request. Decryption occurs in the browser after an encrypted envelope is downloaded.

Why include the data flow?

The diagram traces sensitive data from passphrase entry to ciphertext persistence. It makes the browser and server responsibilities visible without requiring someone to read the source first.

  • Save README.md.
  • Refresh the rendered Markdown preview.

You should see a fixed-width data-flow diagram ending at data/vault.json.

Is the diagram running into other text?

Confirm that the diagram has one opening text fence and one closing fence. Each fence must sit on its own line.

If the diagram still renders incorrectly, help me repair the Markdown code fence.

  • Append the local run instructions and security proof by pasting this content:
## Run locally on Windows

```powershell
node server.mjs
```

Open `http://127.0.0.1:3000` in Microsoft Edge.

## Security proof

1. Unlock the vault and save a recognizable test credential.
2. Compare the decrypted card with the encrypted-payload panel.
3. Inspect the server file:

```powershell
Get-Content -Path .\data\vault.json -Raw
```

4. Confirm that the site, username, password, notes, and master passphrase are absent.
5. Restart the server, reload the page, and unlock with the original passphrase.
6. Try a different passphrase. Once the vault contains a record, authenticated decryption fails.

Why document the proof steps?

The proof is reproducible because it names the exact comparison and file inspection. A reviewer can repeat the demo without relying on a claim about how the vault works.

  • Save README.md.
  • Refresh the rendered Markdown preview.

You should see two PowerShell examples with a six-part security proof between them.

Are the numbered steps restarting?

Keep every numbered proof line aligned at the left edge. Preserve the blank lines around the PowerShell examples.

If the sequence still renders incorrectly, help me fix the security proof list.

  • Append the security invariants and limitations by pasting this content:
## Security invariants

- The master passphrase is not sent to the server.
- The derived key is non-extractable and kept only in browser memory while unlocked.
- A new random IV is generated for every AES-GCM encryption operation.
- User-controlled text is rendered with DOM `textContent`, not HTML injection.
- The server accepts and persists only the encrypted envelope fields.
- The runtime vault file is excluded from Git.

## Threat model and limitations

This prototype demonstrates protection against immediate plaintext disclosure when the server-side JSON file is copied or inspected. Security still depends on a strong master passphrase.

It does not protect against:

- A malicious server that changes the JavaScript delivered to the browser
- Browser extensions, malware, or an already-compromised operating system
- Cross-site scripting introduced by future unsafe code changes
- Offline passphrase guessing against a stolen encrypted vault
- Traffic interception after deployment without HTTPS
- Missing authentication, authorization, rate limiting, recovery, and multi-user isolation
- Concurrent writes from multiple clients

An empty vault has no encrypted record to validate a passphrase. Passphrase validation becomes observable after the first record is saved.

Why state the limitations?

The invariants define what the current design must preserve. The threat model prevents the prototype from promising protection against compromised code delivery, devices, or future production risks.

  • Save README.md.
  • Refresh the rendered Markdown preview.

You should see six security invariants followed by the explicit threat-model boundaries.

Are limitations missing from the preview?

Check that the sentence introducing the limitations ends with a colon. Keep each limitation on its own bullet line.

If a section still disappears, help me diagnose the missing README section.

  • Append the deployment roadmap and portfolio demo by pasting this content:
## Deployment roadmap

1. Put the container behind an HTTPS reverse proxy.
2. Replace the educational KDF choice with an audited memory-hard KDF implementation and benchmark it on target devices.
3. Add authentication, authorization, rate limiting, and multi-user vault isolation.
4. Replace the single JSON file with concurrency-safe storage and tested migrations.
5. Add key rotation, recovery design, backups, and restore drills.
6. Add unit tests, integration tests, CI, image scanning, and release artifacts.
7. Deploy the same container contract to a self-hosted machine or cloud platform.

## Portfolio demo

A short demo should show:

- A credential visible in the unlocked browser
- Ciphertext in the payload panel and `data/vault.json`
- A failed unlock with the wrong passphrase
- A successful restart and recovery with the correct passphrase
- The threat model and deployment roadmap in this README

How does this finish the story?

The roadmap converts known limitations into concrete production work. The demo list gives you a short sequence for showing the trust boundary to someone else.

  • Save README.md.
  • Refresh the rendered Markdown preview.

You should see a seven-part deployment roadmap followed by five portfolio demo moments.

Does the README end early?

Scroll to the bottom of the editor. Confirm that the final line mentions the threat model and deployment roadmap.

If content is missing, help me compare my README with the complete version.

Exclude runtime data and commit the source

The encrypted vault file belongs to the running application. The portfolio commit should contain reproducible source and documentation without carrying a learner-specific runtime envelope.

  • Select .gitignore in the VS Code Explorer.
  • Replace its placeholder with these ignore rules:
data/
.vscode/
*.log

What do these rules exclude?

  • The data/ rule keeps the runtime vault envelope outside the repository.
  • The .vscode/ rule excludes local editor settings.
  • The *.log rule excludes generated log files.
  • Save .gitignore.
  • Confirm that the editor shows exactly three ignore rules.

The runtime envelope can remain on disk while Git leaves it outside the portfolio history.

Is the vault file still listed for commit?

Confirm that the first rule is exactly data/. The trailing slash applies the rule to the entire runtime directory.

If the file still appears, help me verify why data/vault.json is not ignored.

  • Use the search control in the VS Code left sidebar to check the source for any real password.
  • Repeat the repository search for your master passphrase.
  • Repeat the repository search for any real API key or access token.
  • Remove any real credential found in a source or documentation file.
  • Select the branch-shaped control in the VS Code left sidebar to review the pending files.
  • Confirm that data/vault.json does not appear among the files waiting to be committed.

✔️ Awesome, I've got everything!

Your documentation and ignore rules are ready. The runtime vault remains available locally without entering the repository history.

ⓧ I'd like to double check the full code

Compare your two documentation files with these complete versions.

README.md

# Zero-Knowledge Password Vault

A local, dependency-free security prototype that encrypts credentials in the browser before a Node.js server persists them.

## Why this project exists

The project demonstrates a concrete trust boundary. The browser temporarily handles plaintext while the vault is unlocked. The server stores only a version number, a salt, record IDs, AES-GCM IVs, and ciphertext.

This is an educational prototype, not a production password manager.

## Features

- Master-passphrase key derivation in the browser
- PBKDF2-HMAC-SHA-256 with 600,000 iterations
- Non-extractable 256-bit AES-GCM key
- Fresh 96-bit IV for every encrypted record
- Unlock, add, reveal, delete, lock, restart, and recover flows
- Ciphertext-only JSON persistence
- Content Security Policy and defensive response headers
- No npm dependencies
- Docker-ready bind argument and persistent data directory

## Architecture

```text
Master passphrase
      |
      v
Browser Web Crypto API
PBKDF2 + salt -> non-extractable AES-GCM key
      |
      v
Credential JSON -> AES-GCM ciphertext + IV
      |
      v
POST /api/vault
      |
      v
Node.js server -> data/vault.json
```

The master passphrase and derived key are never included in the API request. Decryption occurs in the browser after an encrypted envelope is downloaded.

## Run locally on Windows

```powershell
node server.mjs
```

Open `http://127.0.0.1:3000` in Microsoft Edge.

## Security proof

1. Unlock the vault and save a recognizable test credential.
2. Compare the decrypted card with the encrypted-payload panel.
3. Inspect the server file:

```powershell
Get-Content -Path .\data\vault.json -Raw
```

4. Confirm that the site, username, password, notes, and master passphrase are absent.
5. Restart the server, reload the page, and unlock with the original passphrase.
6. Try a different passphrase. Once the vault contains a record, authenticated decryption fails.

## Security invariants

- The master passphrase is not sent to the server.
- The derived key is non-extractable and kept only in browser memory while unlocked.
- A new random IV is generated for every AES-GCM encryption operation.
- User-controlled text is rendered with DOM `textContent`, not HTML injection.
- The server accepts and persists only the encrypted envelope fields.
- The runtime vault file is excluded from Git.

## Threat model and limitations

This prototype demonstrates protection against immediate plaintext disclosure when the server-side JSON file is copied or inspected. Security still depends on a strong master passphrase.

It does not protect against:

- A malicious server that changes the JavaScript delivered to the browser
- Browser extensions, malware, or an already-compromised operating system
- Cross-site scripting introduced by future unsafe code changes
- Offline passphrase guessing against a stolen encrypted vault
- Traffic interception after deployment without HTTPS
- Missing authentication, authorization, rate limiting, recovery, and multi-user isolation
- Concurrent writes from multiple clients

An empty vault has no encrypted record to validate a passphrase. Passphrase validation becomes observable after the first record is saved.

## Deployment roadmap

1. Put the container behind an HTTPS reverse proxy.
2. Replace the educational KDF choice with an audited memory-hard KDF implementation and benchmark it on target devices.
3. Add authentication, authorization, rate limiting, and multi-user vault isolation.
4. Replace the single JSON file with concurrency-safe storage and tested migrations.
5. Add key rotation, recovery design, backups, and restore drills.
6. Add unit tests, integration tests, CI, image scanning, and release artifacts.
7. Deploy the same container contract to a self-hosted machine or cloud platform.

## Portfolio demo

A short demo should show:

- A credential visible in the unlocked browser
- Ciphertext in the payload panel and `data/vault.json`
- A failed unlock with the wrong passphrase
- A successful restart and recovery with the correct passphrase
- The threat model and deployment roadmap in this README

.gitignore

data/
.vscode/
*.log

Before you create the commit, do you expect data/vault.json to appear in the recorded files?

  • Stage the repository source by running these commands:
git add .
git commit -m "Build zero-knowledge password vault"

What do these commands record?

The first command stages repository content while respecting .gitignore. The second command records that staged snapshot with the project message.

You should see Git report a new commit on main with source and documentation files. The runtime data/vault.json file should be absent from the recorded file list.

Your security proof is now packaged as a reproducible local repository. The commit captures the working vault, its evidence, and its limitations without capturing the encrypted runtime data.

Did the commit stop before completing?

Git may require a local author identity before it can create the first commit. Follow the identity instructions shown in the terminal before running the commit command again.

If Git reports another problem, help me finish the local vault commit.

Secret mission

Run the Vault as a Persistent Docker Container

Package the vault as a non-root Docker container with loopback-only port publishing. Then replace the container and prove that its encrypted records survive inside a named volume.

Clean Up Your Resources

Clean Up Your Resources

This personal local setup has no ongoing usage costs. Your repository plus Docker Desktop resources use local disk space.

Resources you used:

  • Your local Git repository is named zero-knowledge-vault.
  • The ignored host runtime file is data/vault.json.
  • The replaceable Docker Desktop container is named zero-knowledge-vault.
  • The local Docker Desktop image is named zero-knowledge-vault:local.
  • The persistent Docker Desktop volume is named zero-knowledge-vault-data.

Keep everything running

No action is needed. Choose this if you want to continue developing or demonstrating the vault.

  • Keep the zero-knowledge-vault repository for future changes.
  • Leave the zero-knowledge-vault container running if you want the vault to remain available locally.
  • Keep the zero-knowledge-vault:local image so you can replace the container without rebuilding it.
  • Keep the zero-knowledge-vault-data volume so its encrypted vault envelope remains available.

Pause - I'll come back to this later

Shut down running processes to free local resources while preserving the project files and encrypted data.

  • Stop the host Node.js server if it is still running by pressing Ctrl+C in its PowerShell terminal.
  • Stop the foreground zero-knowledge-vault container if it is still running by pressing Ctrl+C in its PowerShell terminal.
  • Close Docker Desktop after the running processes stop.

The container disappears after it stops because it uses --rm. Your repository remains on disk.

The Docker Desktop image remains available. The named volume keeps its encrypted vault envelope.

Delete - I don't want to use this again

Remove every local project resource when you are finished with the vault. Permanent deletion can feel drastic, so continue only if you no longer need the source or encrypted vault copies.

  • Stop any running host server by pressing Ctrl+C in its PowerShell terminal.
  • Stop the foreground zero-knowledge-vault container by pressing Ctrl+C in its PowerShell terminal.

Docker removes the container when it stops because it was started with --rm.

  • Remove the Docker-stored vault from PowerShell by running this command:
docker volume rm zero-knowledge-vault-data

What does this command remove?

The command permanently deletes the Docker-managed volume containing the encrypted vault.json envelope.

  • Confirm PowerShell reports that zero-knowledge-vault-data was removed.

Volume Still in Use?

  • Stop the foreground zero-knowledge-vault container by pressing Ctrl+C.
  • Run the volume removal command again.

Help me identify what is still using my Docker volume.

  • Switch to the local image list in Docker Desktop.
  • Find the image named zero-knowledge-vault:local.
  • Use the image removal control to delete it.
  • Confirm the image list no longer contains zero-knowledge-vault:local.
  • Close VS Code.
  • Use File Explorer to select the zero-knowledge-vault folder from the location where you created it.
  • Press Shift+Delete to permanently delete the selected folder.
  • Confirm the permanent deletion when Windows prompts you.
  • Confirm the parent location no longer contains the zero-knowledge-vault folder.

Deleting the repository folder also removes the ignored host file at data/vault.json.

Nice Work!

Nice Work!

You made it! You built a local password vault that proves a zero-knowledge architecture by keeping plaintext in the browser while the Node.js server stores ciphertext.

You've learned how to:

  • Built a browser vault with unlock, add, reveal, delete, and lock flows. Its live payload panel exposes the trust boundary during a demo.
  • Derived a non-extractable browser key with PBKDF2 through the Web Crypto API. Encrypted every credential with a fresh AES-GCM IV before the data crossed the API boundary.
  • Persisted only the encrypted envelope through a dependency-free Node.js API. Packaged the proof in a portfolio-ready Git repository with documented limitations and ignored runtime data.
  • Completed the Secret Mission by packaging the vault as a non-root Docker container with loopback-only host publishing. Proved the ciphertext survives container replacement in a persistent named volume.

Ready to quiz yourself?