Chrome Extension Password Manager — Developer Guide

11 min read

Build a Password Manager Extension

Overview

Build a Chrome extension that saves credentials, auto-fills login forms, and generates passwords — all with client-side encryption.

Manifest

{
  "manifest_version": 3,
  "name": "SecureVault",
  "version": "1.0.0",
  "permissions": ["storage", "activeTab", "contextMenus", "unlimitedStorage", "idle"],
  "action": { "default_popup": "popup.html" },
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "run_at": "document_idle"
  }],
  "background": { "service_worker": "background.js" }
}

Encryption Layer

// crypto.ts — client-side encryption with Web Crypto API
async function deriveKey(masterPassword: string, salt: Uint8Array): Promise<CryptoKey> {
  const encoder = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw', encoder.encode(masterPassword), 'PBKDF2', false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

async function encrypt(key: CryptoKey, plaintext: string): Promise<string> {
  const encoder = new TextEncoder();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, encoder.encode(plaintext)
  );
  const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
  combined.set(iv);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return btoa(String.fromCharCode(...combined));
}

async function decrypt(key: CryptoKey, encoded: string): Promise<string> {
  const data = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
  const iv = data.slice(0, 12);
  const ciphertext = data.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv }, key, ciphertext
  );
  return new TextDecoder().decode(plaintext);
}

Storage with @theluckystrike/webext-storage

import { createStorage, defineSchema } from '@theluckystrike/webext-storage';

const schema = defineSchema({
  encryptedVault: 'string',   // Encrypted JSON of all credentials
  salt: 'string',              // Base64-encoded PBKDF2 salt
  isLocked: 'boolean',
  autoLockMinutes: 'number',
  lastActivity: 'number'
});
const storage = createStorage(schema, 'local');

Messaging with @theluckystrike/webext-messaging

import { createMessenger } from '@theluckystrike/webext-messaging';

type Messages = {
  UNLOCK: { request: { masterPassword: string }; response: { success: boolean } };
  LOCK: { request: {}; response: { ok: boolean } };
  GET_CREDENTIALS: { request: { url: string }; response: { entries: Array<{ username: string; password: string }> } };
  SAVE_CREDENTIALS: { request: { url: string; username: string; password: string }; response: { ok: boolean } };
  GENERATE_PASSWORD: { request: { length: number; options: { upper: boolean; lower: boolean; digits: boolean; symbols: boolean } }; response: { password: string } };
  AUTOFILL: { request: { username: string; password: string }; response: { ok: boolean } };
};

const m = createMessenger<Messages>();

Service Worker (background.ts)

let cryptoKey: CryptoKey | null = null;

m.onMessage('UNLOCK', async ({ masterPassword }) => {
  const saltB64 = await storage.get('salt');
  if (!saltB64) {
    // First time — create salt
    const salt = crypto.getRandomValues(new Uint8Array(16));
    await storage.set('salt', btoa(String.fromCharCode(...salt)));
    cryptoKey = await deriveKey(masterPassword, salt);
    await storage.set('encryptedVault', await encrypt(cryptoKey, '{}'));
    await storage.set('isLocked', false);
    return { success: true };
  }
  const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0));
  cryptoKey = await deriveKey(masterPassword, salt);
  try {
    const vaultData = await storage.get('encryptedVault');
    await decrypt(cryptoKey, vaultData!); // Test decrypt
    await storage.set('isLocked', false);
    return { success: true };
  } catch {
    cryptoKey = null;
    return { success: false };
  }
});

m.onMessage('GET_CREDENTIALS', async ({ url }) => {
  if (!cryptoKey) return { entries: [] };
  const vaultData = await storage.get('encryptedVault');
  const vault = JSON.parse(await decrypt(cryptoKey, vaultData!));
  const domain = new URL(url).hostname;
  return { entries: vault[domain] || [] };
});

m.onMessage('GENERATE_PASSWORD', async ({ length, options }) => {
  let chars = '';
  if (options.upper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  if (options.lower) chars += 'abcdefghijklmnopqrstuvwxyz';
  if (options.digits) chars += '0123456789';
  if (options.symbols) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
  const array = crypto.getRandomValues(new Uint8Array(length));
  const password = Array.from(array, b => chars[b % chars.length]).join('');
  return { password };
});

Content Script (content.ts)

// Detect login forms
function findLoginForm(): { usernameInput: HTMLInputElement | null; passwordInput: HTMLInputElement | null } {
  const passwordInput = document.querySelector<HTMLInputElement>('input[type="password"]');
  if (!passwordInput) return { usernameInput: null, passwordInput: null };
  const form = passwordInput.closest('form');
  const usernameInput = form?.querySelector<HTMLInputElement>(
    'input[type="email"], input[type="text"], input[name*="user"], input[name*="email"], input[autocomplete="username"]'
  ) || null;
  return { usernameInput, passwordInput };
}

// Auto-fill on message
m.onMessage('AUTOFILL', async ({ username, password }) => {
  const { usernameInput, passwordInput } = findLoginForm();
  if (usernameInput) {
    usernameInput.value = username;
    usernameInput.dispatchEvent(new Event('input', { bubbles: true }));
  }
  if (passwordInput) {
    passwordInput.value = password;
    passwordInput.dispatchEvent(new Event('input', { bubbles: true }));
  }
  return { ok: true };
});

// Detect form submission to save credentials
document.addEventListener('submit', async (e) => {
  const form = e.target as HTMLFormElement;
  const passwordInput = form.querySelector<HTMLInputElement>('input[type="password"]');
  if (!passwordInput?.value) return;
  const usernameInput = form.querySelector<HTMLInputElement>(
    'input[type="email"], input[type="text"], input[name*="user"]'
  );
  if (usernameInput?.value) {
    m.sendMessage('SAVE_CREDENTIALS', {
      url: location.href,
      username: usernameInput.value,
      password: passwordInput.value
    });
  }
});

Auto-Lock on Idle

// In background.ts
chrome.idle.setDetectionInterval(300); // 5 minutes
chrome.idle.onStateChanged.addListener(async (state) => {
  if (state === 'idle' || state === 'locked') {
    cryptoKey = null;
    await storage.set('isLocked', true);
  }
});

Context Menu for Fill

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: 'fill-credentials',
    title: 'Fill login credentials',
    contexts: ['page', 'editable']
  });
  chrome.contextMenus.create({
    id: 'generate-password',
    title: 'Generate password',
    contexts: ['editable']
  });
});

Security Best Practices

Cross-References

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.


Turn Your Extension Into a Business

Ready to monetize? The Extension Monetization Playbook covers freemium models, Stripe integration, subscription architecture, and growth strategies for Chrome extension developers.

No previous article
No next article