An encrypted store for website form submissions on ordinary shared PHP hosting. You don't need root, a separate service or a paid KMS. If the database or a backup leaks, it holds ciphertext, keyed hashes and an audit trail, and nothing a reader can use without the keys.
- Record encryption: libsodium XChaCha20-Poly1305. Each record is bound to its UUID through the associated data, so a ciphertext copied onto another row fails to decrypt. With ext-sodium missing (common on older shared hosts), it falls back to the pure-PHP sodium_compat. The code is written to run on PHP 5.6 through 8.x.
- Blind index: a keyed BLAKE2b of the normalized email, so you can look a person up by email without storing the email in readable form.
- HMAC-chained audit log: every create, read, lookup, purge, backup and migration is recorded, without content. Each row's HMAC-SHA256 is computed over a typed, length-prefixed encoding of the previous row's MAC and of every field of the row (time, actor, action, record id, detail). It is keyed with an audit key that lives in a file named in the config (or in
SEALED_INTAKE_AUDIT_KEY), never in the database. The encoding can't fail or collapse: any bytes are encoded as they are, andNULLvs''andNvsN.9encode differently or are refused. The log only accepts fields of the expected shape (record ids must be lowercase UUIDs, actor and action names from a fixed character set, details printable ASCII; a trailing newline is refused everywhere), andverify-audittreats a stored row whose time isn't a canonical integer, or whose fields have the wrong type, as tampered. For someone who can write to the database but doesn't have the audit key,verify-auditfails on any change to a row, an inserted row, a reordering, or a deleted row other than the newest ones. It doesn't catch, on its own: removing the newest rows (compare against a saved head, see below), or anyone who has the audit key, who can rebuild a valid chain. Optional DB triggers also blockUPDATE/DELETEon the log and in-place edits of submissions. Logs written by earlier versions (unkeyed, or the v1 HMAC format) don't verify under this version; start a new log when you upgrade. - Retention purge: deletes records older than N days. It is a dry run unless you pass
--apply, and the audit log is never purged. - Sealed backups: the nightly backup is sealed (
crypto_box_seal) to an offline public key. The server can write backups but can't read them, and the matching secret key never lives on the server. - One-time migration: moves legacy plaintext JSON files into the store. It is a dry run unless
--apply. Each record is read back and decrypted before its file is touched, and only verified files are shredded, and only with--shred. - Inert admin CLI: submitted text is shown inert. Terminal escape sequences, C1 controls, zero-width characters and bidi overrides appear as visible
\x1B/\u202Eescapes, and every read needs--as NAMEand is audited.
flowchart LR
F[HTML form] -->|POST| W[public/submit.php]
W -->|validate, field allowlist, honeypot| I[Intake]
I -->|XChaCha20-Poly1305, AD = uuid| DB[(submissions)]
I -->|keyed BLAKE2b| DB
I --> A[(audit_log<br/>HMAC chain)]
C[bin/intake CLI<br/>--as NAME] -->|read = audit row| I
B[nightly backup] -->|crypto_box_seal to offline public key| S[(backup.sealed)]
K[(offline secret key)] -.->|only off-server| S
public/submit.php example form handler (the only file in the docroot)
public/.htaccess blocks direct access to data/key/backup file types
src/ library (Crypto, Keys, Store, AuditLog, Intake, Retention, Backup, Migrate, Inert)
bin/intake admin CLI
sql/schema.sqlite.sql tables + append-only triggers (SQLite)
sql/schema.mysql.sql tables + optional SIGNAL triggers (MySQL 5.6+ / MariaDB)
scripts/fetch-deps.sh pinned, checksum-verified sodium_compat + random_compat into vendor/
tests/run.php dependency-free test runner
./scripts/fetch-deps.sh # only needed without ext-sodium; verifies SHA-256
mkdir -m 700 -p ~/sealed-intake # keys and the SQLite file live here; keygen won't create it
bin/intake keygen --server-key ~/sealed-intake/server.key --offline-key ~/offline-backup.key --audit-key ~/sealed-intake/audit.key
# -> move offline-backup.key to offline storage (password manager, USB) and delete it from the server
cp intake-config.example.php intake-config.php # next to src/, one level above the docroot; edit paths, incl. audit_key_file
bin/intake init-db # SQLite; for MySQL/MariaDB apply sql/schema.mysql.sqlThe audit key is separate from the server key so you can give a copy to whoever checks the log without letting them decrypt records. Keep it out of the database and out of version control (*.key is in .gitignore). Instead of audit_key_file you can set SEALED_INTAKE_AUDIT_KEY to the base64 key through the host panel or SetEnv (it takes precedence over the config). Don't put it in a .htaccess inside the docroot. The tool refuses to start without an audit key and never prints it.
Upload public/submit.php to your docroot and keep src/, vendor/ and intake-config.php one level above it, or adjust the require paths. Both submit.php and bin/intake read intake-config.php from that directory by default, so no environment variable is needed.
To keep the config somewhere else, the web server has to be told where it is. A shell export SEALED_INTAKE_CONFIG=... is only seen by bin/intake, not by Apache or LiteSpeed. Set it with SetEnv SEALED_INTAKE_CONFIG /home/USER/sealed-intake/intake-config.php in the docroot .htaccess (or the host panel's environment settings) and pass --config to bin/intake. Point your form at it, and add a hidden website field as the honeypot. For MySQL, give the web user only SELECT, INSERT on both tables plus DELETE on submissions.
bin/intake list # ids, dates, form names: no content
bin/intake show <uuid> --as alex --reason "callback" # decrypts one record, writes an audit row
bin/intake find --email pat@example.com --as alex # blind-index lookup, audited
bin/intake purge --days 365 --as alex # DRY RUN
bin/intake purge --days 365 --as alex --apply
bin/intake backup --out ~/backups/intake-$(date +%F).sealed --as cron
bin/intake backup-open intake-2026-01-01.sealed --offline-key offline-backup.key # offline machine only
bin/intake migrate --from ~/old-submissions --form contact --as alex # DRY RUN
bin/intake migrate --from ~/old-submissions --form contact --as alex --apply --shred
bin/intake verify-audit # prints "head ROWS:MAC"; save it somewhere off the server
bin/intake verify-audit --expect 42:3f9c... # fails if the chain no longer contains that saved headbackup-open also prints the audit head as of that backup, so the offline backups double as saved heads.
./scripts/fetch-deps.sh && php tests/run.php46 tests, no test framework needed. They cover the encryption round trip, record binding, tamper and wrong-key failures, blind-index normalization, key file modes and the refusal to overwrite keys, the absence of plaintext from the raw SQLite file, auditing of every read with no content in the log, input validation, immutability triggers, detection of audit-chain edits and deletions, a row rewritten with the whole chain recomputed without the audit key (unkeyed SHA-256 and a wrong key both fail), the audit encoding (invalid UTF-8 never collapses the MAC to a constant; deleting earlier rows and rewriting a row holding invalid bytes is detected; NULL vs '' and N vs N.9 are told apart; append refuses malformed fields; non-UUID record ids are refused before anything is read; a v1-format chain doesn't verify; a trailing newline is refused in every anchored check, and every anchored regex uses the D flag; a form field that isn't valid UTF-8 gets a 400 with nothing stored; a corrupt audit head gives a 503 and no backup is written from undecodable data), the documented limits (a key holder can rebuild the chain; dropping the newest rows passes verify-audit but fails --expect), audit-key loading from the config or the environment with no key echoed in errors, output or debug dumps, refusal to run without an audit key, retention dry run and apply (and the CLI's dry-run wording), sealed backups opening only with the offline key, migration dry run, verify and shred, inert rendering of escape and bidi payloads, a CLI end-to-end run, and the web handler under php -S (405 for GET, 303 store, 400 bad input, honeypot drop, unknown fields dropped), config lookup via SetEnv and the default location with no environment variable, and the README setup steps on a fresh home directory. Keys are generated fresh for each run, and the repo holds no fixed secrets.
CI runs the suite on PHP 5.6, 7.4 and 8.3 with sodium_compat and on 8.3 with ext-sodium, and applies the MySQL schema to a throwaway MariaDB to prove the triggers reject UPDATE/DELETE.
- Protects against: a leaked database dump, a leaked backup, a read-only SQL injection elsewhere on the site, and casual browsing by anyone with DB-only access. Against someone who can write to the database but doesn't have the audit key,
verify-auditdetects a changed audit row, an inserted row, a reordering, and a deleted row other than the newest ones. Removing the newest audit rows is detected only if you compare against a head saved off the server (verify-audit --expect, or the head printed bybackup-open). This rests on the audit key staying secret and on HMAC-SHA256; it is tamper evidence, not tamper prevention: rows can still be destroyed, and you find out when you runverify-audit. - Doesn't protect against: an attacker who can run code as the web user. They can read the key files, including the audit key, so they can decrypt records and rewrite the audit chain, as on any server-side scheme. Heads you saved off the server before the attack still expose a rewrite of the rows they cover. Put the key files outside the docroot with mode 0600, and keep the backup secret key offline.
- The blind index lets someone holding the DB check whether two records share an email. That is the price of lookups.
- Shredding is best effort. SSDs, journaling file systems and host snapshots may keep old blocks.
- The DB-level triggers are optional because some shared hosts don't grant
TRIGGER. With or without them, the HMAC chain makes changes by someone without the audit key detectable byverify-audit, except removal of the newest rows, which only a saved head reveals. The triggers add prevention against casual edits, not against anyone with DDL rights, who can drop them.
MIT. See LICENSE. sodium_compat (ISC) and random_compat (MIT) are fetched at install time and are not vendored in this repo.