Skip to content

Latest commit

 

History

History
461 lines (375 loc) · 17.9 KB

File metadata and controls

461 lines (375 loc) · 17.9 KB

Password Manager

A single-user secret vault in plain PHP 8.3 that encrypts every credential with libsodium, derives its keys from a master password, and keeps zero plaintext at rest — the server never learns a stored secret.


Overview

Password Manager lets an authenticated user store, retrieve, update, and delete login secrets (site, username, password, notes). The defining property is envelope encryption tied to the master password: each secret's ciphertext is sealed with a random per-vault Data Encryption Key (DEK), and the DEK is itself wrapped by a Key Encryption Key (KEK) derived from the master password via a memory-hard KDF. Nothing readable is ever written to disk.

The threat model treats the database, its backups, and the filesystem as hostile — assume an attacker with a full DB dump. Against that we defend: secrets stay confidential because the DB holds only ciphertext, salts, nonces, and a wrapped DEK (A02 Cryptographic Failures); the login master hash is bcrypt/Argon2 via password_hash (A07); tampering is caught because libsodium's AEAD is authenticated (A08); SQL injection is closed by PDO prepared statements (A03); CSRF guards every mutation (A01); and XSS in site/notes fields is escaped on output (A03). Out of scope: a compromised live PHP process (which by design holds the KEK in memory during a session), client-side keyloggers, and TLS termination (assumed at the proxy). This build is deliberately single-user so the crypto stays legible; multi-user sharing is Exercise 1.


Architecture

Server-rendered MVC with one front controller. On login the master password derives the KEK, unwraps the DEK, and holds it in the session memory only; every secret is decrypted just-in-time for rendering and re-escaped before output.

flowchart LR
    B[Browser] -->|HTTPS| N[nginx]
    N -->|FastCGI| F[php-fpm: public/index.php]
    F --> R[Router]
    R --> G[Auth Guard]
    G --> C[VaultController]
    C --> K[Crypto: KDF + AEAD]
    C --> V[CSRF check + Validator]
    V --> M[SecretRepository - PDO]
    M --> D[(MySQL 8: ciphertext only)]
    K -->|DEK in session| C
    C --> T[View: htmlspecialchars]
    T --> B
Loading

Key hierarchy: master password --Argon2id--> KEK; KEK --secretbox--> unwraps DEK; DEK --AEAD XChaCha20-Poly1305--> each secret. Changing the master password only re-wraps the DEK — the bulk ciphertext is never touched.


Folder Structure

password-manager/
├── composer.json
├── public/
│   └── index.php              # front controller (only web-exposed file)
├── src/
│   ├── Core/
│   │   ├── Router.php
│   │   ├── Database.php        # PDO factory (singleton)
│   │   ├── Csrf.php
│   │   ├── Session.php
│   │   └── View.php
│   ├── Crypto/
│   │   ├── KeyDerivation.php   # Argon2id KEK from master password
│   │   └── Vault.php           # DEK wrap/unwrap + AEAD seal/open
│   ├── Controller/
│   │   ├── AuthController.php
│   │   └── VaultController.php
│   ├── Repository/
│   │   ├── UserRepository.php
│   │   └── SecretRepository.php
│   └── Middleware/
│       └── AuthGuard.php
├── views/
│   ├── layout.php
│   ├── vault.php
│   └── login.php
├── migrations/
│   └── 001_init.sql
├── tests/
│   └── VaultTest.php
├── .env.example
└── docker-compose.yml

Database Schema

CREATE TABLE users (
    id            BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    email         VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,           -- master pw, password_hash()
    kdf_salt      VARBINARY(16) NOT NULL,          -- Argon2id salt for the KEK
    wrapped_dek   VARBINARY(120) NOT NULL,         -- DEK sealed under the KEK
    dek_nonce     VARBINARY(24) NOT NULL,          -- nonce for the wrap
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE secrets (
    id          BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id     BIGINT UNSIGNED NOT NULL,
    label       VARCHAR(200) NOT NULL,             -- site name (plaintext, searchable)
    username    VARCHAR(255) NULL,                 -- plaintext, non-secret
    ciphertext  VARBINARY(2048) NOT NULL,          -- AEAD-sealed password+notes
    nonce       VARBINARY(24) NOT NULL,            -- per-record XChaCha20 nonce
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                    ON UPDATE CURRENT_TIMESTAMP,
    CONSTRAINT fk_secret_user FOREIGN KEY (user_id)
        REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_owner_label (user_id, label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

There is no plaintext password column anywhere. The DB stores only: the master-password hash, a KDF salt, a wrapped DEK, and per-record ciphertext + nonce. label/username are intentionally clear so the vault list is searchable without decryption.


Implementation

Key derivation — Argon2id turns the master password into a 256-bit KEK. The salt is per-user and stored; the KEK is never persisted.

<?php
declare(strict_types=1);

namespace App\Crypto;

final class KeyDerivation
{
    public static function newSalt(): string
    {
        return random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES); // 16 bytes
    }

    /** Derive a 32-byte KEK from the master password (memory-hard). */
    public static function kek(string $masterPassword, string $salt): string
    {
        return sodium_crypto_pwhash(
            SODIUM_CRYPTO_SECRETBOX_KEYBYTES,          // 32-byte output
            $masterPassword,
            $salt,
            SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
            SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
            SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
        );
    }
}

Vault crypto — a random DEK is generated once at registration and wrapped under the KEK; secrets are sealed with authenticated XChaCha20-Poly1305.

<?php
declare(strict_types=1);

namespace App\Crypto;

final class Vault
{
    /** At registration: make a DEK and wrap it under the KEK. */
    public static function wrapNewDek(string $kek): array
    {
        $dek   = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $wrapped = sodium_crypto_secretbox($dek, $nonce, $kek);
        sodium_memzero($dek);
        return ['wrapped' => $wrapped, 'nonce' => $nonce];
    }

    /** At login: recover the DEK; throws on wrong master password (auth tag fails). */
    public static function unwrapDek(string $wrapped, string $nonce, string $kek): string
    {
        $dek = sodium_crypto_secretbox_open($wrapped, $nonce, $kek);
        if ($dek === false) {
            throw new \RuntimeException('DEK unwrap failed');
        }
        return $dek;
    }

    /** Seal a secret; nonce is returned to store alongside the ciphertext. */
    public static function seal(string $plaintext, string $dek): array
    {
        $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
        $ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
            $plaintext, '', $nonce, $dek
        );
        return ['ciphertext' => $ct, 'nonce' => $nonce];
    }

    public static function open(string $ciphertext, string $nonce, string $dek): string
    {
        $pt = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
            $ciphertext, '', $nonce, $dek
        );
        if ($pt === false) {
            throw new \RuntimeException('secret decrypt/verify failed'); // tampered or wrong DEK
        }
        return $pt;
    }
}

PDO data layer — a singleton factory that forces true server-side prepares.

<?php
declare(strict_types=1);

namespace App\Core;

use PDO;

final class Database
{
    private static ?PDO $pdo = null;

    public static function conn(): PDO
    {
        if (self::$pdo === null) {
            $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4',
                $_ENV['DB_HOST'], $_ENV['DB_NAME']);
            self::$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        }
        return self::$pdo;
    }
}

Repository — binary columns are bound as parameters; nothing is concatenated.

<?php
declare(strict_types=1);

namespace App\Repository;

use App\Core\Database;

final class SecretRepository
{
    public function create(int $userId, string $label, ?string $user,
                           string $ciphertext, string $nonce): int
    {
        $stmt = Database::conn()->prepare(
            'INSERT INTO secrets (user_id, label, username, ciphertext, nonce)
             VALUES (:uid, :label, :user, :ct, :nonce)'
        );
        $stmt->execute([
            ':uid' => $userId, ':label' => $label, ':user' => $user,
            ':ct' => $ciphertext, ':nonce' => $nonce,
        ]);
        return (int) Database::conn()->lastInsertId();
    }

    public function forOwner(int $userId): array
    {
        $stmt = Database::conn()->prepare(
            'SELECT id, label, username, ciphertext, nonce
               FROM secrets WHERE user_id = :uid ORDER BY label'
        );
        $stmt->execute([':uid' => $userId]);
        return $stmt->fetchAll();
    }
}
// VULNERABLE — storing the secret in the clear defeats the entire threat model:
// $sql = "INSERT INTO secrets (user_id,label,password) VALUES ($uid,'$label','$pw')";
// A DB dump now leaks every credential AND is injectable.
// FIX: seal with Vault::seal($pw, $dek) and bind :ct/:nonce as above — DB sees ciphertext only.

Auth + KEK lifecycle — login verifies the master hash, derives the KEK, unwraps the DEK, and keeps only the DEK in the (regenerated) session.

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Core\{Csrf, Session};
use App\Crypto\{KeyDerivation, Vault};
use App\Repository\UserRepository;

final class AuthController
{
    public function __construct(private UserRepository $users) {}

    public function login(): void
    {
        Csrf::check($_POST['_token'] ?? null);
        $email  = trim((string)($_POST['email'] ?? ''));
        $master = (string)($_POST['password'] ?? '');

        $u = $this->users->findByEmail($email);
        if ($u !== null && password_verify($master, $u['password_hash'])) {
            $kek = KeyDerivation::kek($master, $u['kdf_salt']);
            $dek = Vault::unwrapDek($u['wrapped_dek'], $u['dek_nonce'], $kek);
            sodium_memzero($kek);
            sodium_memzero($master);

            session_regenerate_id(true);                 // defeat session fixation
            $_SESSION['uid'] = (int) $u['id'];
            $_SESSION['dek'] = base64_encode($dek);       // DEK lives only for the session
            sodium_memzero($dek);
            header('Location: /vault');
            return;
        }
        Session::flash('error', 'Invalid credentials.');   // uniform, no enumeration
        header('Location: /login');
    }
}

CSRF — per-session secret, constant-time compare.

<?php
declare(strict_types=1);

namespace App\Core;

final class Csrf
{
    public static function token(): string
    {
        return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
    }

    public static function check(?string $sent): void
    {
        if ($sent === null || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
            http_response_code(419);
            exit('CSRF token mismatch');
        }
    }
}

Output escaping — the decrypted password is escaped like any other untrusted string; there is no raw-echo path. Session cookie flags are set once at bootstrap:

session_set_cookie_params([
    'httponly' => true, 'secure' => true, 'samesite' => 'Strict', 'path' => '/',
]);
session_start();
// In vault.php:  <code><?= App\Core\View::e($decryptedPassword) ?></code>

Testing

Test the crypto invariants, not the framework: (1) a sealed secret round-trips to the same plaintext; (2) opening with the wrong DEK throws (AEAD auth tag rejects it — no silent garbage); (3) flipping one ciphertext byte is detected as tampering; (4) unwrapDek fails on the wrong master password. Run against libsodium in a disposable test process.

<?php
declare(strict_types=1);

use App\Crypto\Vault;
use PHPUnit\Framework\TestCase;

final class VaultTest extends TestCase
{
    public function testRoundTrip(): void
    {
        $dek = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
        $sealed = Vault::seal('hunter2', $dek);
        self::assertSame('hunter2', Vault::open($sealed['ciphertext'], $sealed['nonce'], $dek));
    }

    public function testTamperIsRejected(): void
    {
        $dek = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
        $sealed = Vault::seal('hunter2', $dek);
        $ct = $sealed['ciphertext'];
        $ct[0] = $ct[0] ^ "\x01";                      // flip one bit

        $this->expectException(\RuntimeException::class);
        Vault::open($ct, $sealed['nonce'], $dek);
    }
}

Security Review

Risk OWASP 2021 Mitigation in this build
DB dump leaks stored passwords A02 Envelope encryption; DB holds ciphertext + wrapped DEK only, no plaintext
Weak/reused master password brute-force A02 / A07 Argon2id KDF (memory-hard) for KEK; password_hash for the login gate
Ciphertext tampering / bit-flipping A08 Authenticated AEAD (XChaCha20-Poly1305); open() throws on bad tag
Nonce reuse A02 Fresh random_bytes nonce per seal, stored per-record
SQL injection A03 PDO prepared statements, EMULATE_PREPARES=false, bound binary params
Stored XSS in label/notes A03 htmlspecialchars(ENT_QUOTES) default in View::e; CSP header
CSRF on create/update/delete A01 Per-session token, hash_equals, SameSite=Strict cookie
Session fixation / hijacking A07 session_regenerate_id(true), HttpOnly+Secure+SameSite
Key material lingering in memory A02 sodium_memzero on KEK/DEK/master after use
Secrets/config committed to repo A05 .env gitignored; .env.example only; least-privilege DB user

Deployment

Run locally with Docker Compose: nginx fronts php-fpm (built with the sodium extension enabled), backed by mysql:8. nginx exposes only public/; src/, .env, and migrations/ are never web-reachable.

server {
    root /app/public;
    index index.php;
    location / { try_files $uri /index.php?$query_string; }
    location ~ \.php$ {
        fastcgi_pass php:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
    add_header Content-Security-Policy "default-src 'self'" always;
    add_header X-Content-Type-Options "nosniff" always;
}
cp .env.example .env           # set DB creds, never commit .env
docker compose up -d --build
docker compose exec php php -m | grep sodium   # confirm libsodium is loaded
docker compose exec php php migrations/run.php # applies 001_init.sql
# App at http://localhost:8080

Grant the PHP DB user only SELECT/INSERT/UPDATE/DELETE on the app schema (no DDL), run php-fpm as a non-root user with a read-only code mount, and serve strictly over TLS — the DEK lives in the server-side session store, but the master password crosses the wire on login.


Exercises

  1. Add multi-user vaults with sharing: give each secret its own DEK and re-wrap that DEK under each recipient's public key (sodium_crypto_box_seal) so sharing never exposes plaintext to the server.
  2. Implement master-password rotation that re-derives the KEK and re-wraps the DEK without re-encrypting every secret — prove the bulk ciphertext columns are untouched.
  3. Add a breach check that hashes stored passwords with SHA-1 and queries the k-anonymity HIBP range API, decrypting only in memory and never logging the result.
  4. Build a TOTP second factor for login so a stolen master hash alone cannot unwrap the vault.
  5. Add an auto-lock: zeroise the session DEK after N minutes idle and require master-password re-entry to re-derive it.

References

  • OWASP Top 10 (2021) — A02 Cryptographic Failures, A03 Injection, A07 Identification & Auth Failures, A08 Software & Data Integrity Failures
  • OWASP Cheat Sheets — Cryptographic Storage, Key Management, Password Storage, CSRF Prevention
  • libsodium documentation — crypto_pwhash (Argon2id), crypto_secretbox, crypto_aead_xchacha20poly1305
  • PHP Manual — Sodium functions, PDO prepared statements, password_hash, session_set_cookie_params
  • PHP-FIG — PSR-4 Autoloading, PSR-12 Coding Style

Related

  • Prepared-Statements — the PDO pattern that keeps the ciphertext data layer injection-proof
  • Password-Based-Login — the password_hash/password_verify gate that precedes KEK derivation
  • CSRF-Tokens — the token/hash_equals mechanism guarding every vault mutation
  • Role-Based-Access-Control — generalises single-user ownership into the shared vaults of Exercise 1
  • Docker-for-PHP — the nginx + php-fpm (with sodium) + MySQL stack this project deploys on
  • Security-Audit-Checklist — verifies the cryptographic and session mitigations in the review table
  • OWASP-Top-10-2021 — the A02/A08 categories this envelope-encryption design directly targets