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.
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.
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
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.
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
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.
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>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);
}
}| 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 |
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:8080Grant 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.
- 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. - 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.
- 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.
- Build a TOTP second factor for login so a stolen master hash alone cannot unwrap the vault.
- Add an auto-lock: zeroise the session DEK after N minutes idle and require master-password re-entry to re-derive it.
- 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
- Prepared-Statements — the PDO pattern that keeps the ciphertext data layer injection-proof
- Password-Based-Login — the
password_hash/password_verifygate that precedes KEK derivation - CSRF-Tokens — the token/
hash_equalsmechanism 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