More Projects
59 in totalE-Library
2022
Early PHP web app built around a hand-rolled account system: registration, login and an email-verification flow whose token is an AES-GCM encrypted username/timestamp pair, decrypted in verify.php and rejected after ten minutes. Only the auth shell and a stub dashboard were finished.
Project Details
2022
Product · Web
This started as a book-lending site and turned into an exercise in writing an account system from scratch. The library part never really happened. The auth shell did, and it is more careful than the year it was written in would suggest.
The thing that makes an email verification link interesting is that it has to be trustworthy while sitting in plain sight, in a URL bar, in somebody's inbox, for however long they take to click it. The lazy version is a random string in a database row, which works but means the link means nothing on its own - every property you care about lives in the lookup. The version here goes the other way and puts the proof inside the link, then stores the result behind a short code so the URL stays short. It is a hybrid, and both halves are doing work.
The link is two halves
str_encryptaesgcm encrypts the string <username>^$^user^$^<timestamp> under AES-256-GCM,
derives the key with PBKDF2-SHA512 over 20,000 iterations to 32 bytes, prepends a random 16-byte
salt and IV, appends the 16-byte authentication tag, and emits the whole thing base64url-encoded so
it survives being a URL. That ciphertext is then filed in a short_url row against a nine-character
random code, and the code - not the ciphertext - is what goes in the mail, behind a single
.htaccess rewrite mapping /verify_email/<code> onto verify.php?value=<code>.
verify.php reverses it - looks the code up to get the ciphertext back, slices the salt, IV and tag
off it, decrypts under the verifymail passphrase, splits the plaintext on the literal
^$^user^$^ separator, checks the username exists, matches the timestamp against the stored token,
and accepts only if under 600 seconds have elapsed. GCM means a tampered link fails to decrypt at
all rather than decrypting into something plausible, which is the property that makes the whole
approach work. The short code is only an addressing scheme; it carries no authority of its own, so
guessing one gets you a ciphertext you still cannot forge.
What the two tables hold
users is flat: first and last name, email address, username, a password hash, and beside it two
more columns - access_token and hash_key - which together with the username are the inputs to
the password encryption. The eighth column, email_verify, does double duty. While a verification is
pending it holds the unix timestamp that was baked into the encrypted link, so the timestamp in the
plaintext and the timestamp in the row have to agree; once the link is used it is overwritten with
true. There is no separate tokens table because the column is the token.
short_url holds the base URL, the coded URL, a creation date and the username, and it is rate
limited by hand. The helper that decides whether to insert or update counts a user's existing rows:
under five it inserts a new one, at five or more it overwrites the oldest row it finds rather than
growing the table. That is a crude ring buffer, and it is the only thing standing between the
endpoint and somebody spamming resend.
What exists
- Registration, a home route and a dashboard route, each as its own folder with
css,html,phpandscriptsubdirectories, over a sharedglobal/layer. - A shared
global/layer holding the database connection, session and login-state check, user getters and updaters, and the crypto helpers. - Live field validation -
username_validator.phpandemail_validator.phpare hit from the registration form onfocusoutrather than after submit, with the failing field outlined red and shaken via a CSS animation that is reset by forcing a reflow. - Server-side validation that mirrors it - letters only for names,
FILTER_VALIDATE_EMAILfor the address, alphanumeric usernames, and a password regex demanding eight characters with at least one digit, one lowercase and one uppercase - all run through atrim,stripslashesandhtmlspecialcharsfilter first. - Form state preserved across a failed submit by echoing the posted values back as injected
insert_valuescript calls, so a rejected registration does not blank the form. - The mail path split across
send_verification_mail.phpand aemail_verification_sent.phpconfirmation screen, the latter showing the address the mail went to behind a two-minute countdown before resend unlocks. - A full HTML email, table-layout and inline-styled the way mail clients require, with the verification link on a call-to-action button.
- AES-256-GCM tokens with PBKDF2 key derivation and a hard ten-minute window.
- Password encryption through
encrypt_password, using AES-128-CTR keyed on the user id combined with a per-user token. - Random material generators -
openssl_random_pseudo_bytes(16)for tokens, plus date/time/microtime and alphanumeric string helpers. - base64url encode and decode helpers with padding fixups, so ciphertext can sit in a query string untouched.
- An
.htaccessturning directory listing off, pointing 403 and 404 at custom pages, and carrying the single verification rewrite. - A shared chrome layer where the navigation and footer are jQuery-loaded into placeholder divs rather than duplicated into every page.
The password path goes out of process
The one genuinely odd decision is that password verification does not happen in this codebase.
verify_password reads the stored hash, access token and hash key out of the row, then POSTs the
username, the submitted password, the key and the token to return_auth_token.php on localhost via
file_get_contents with a stream context, and compares the string that comes back against the stored
hash. That file is not in the repo. The intent is visible - keep the material that turns a password
into a hash on the other side of a process boundary, so a leak of the web root is not a leak of the
scheme - but as built it means the app has an undocumented dependency it cannot start without, and
the comparison is a plain == on the returned string rather than a constant-time one.
Where it stopped
Two things date it hard. The require calls in verify.php are absolute Windows paths
pointing at E:\Projects\HTML projects\E-Library\..., so the file only ever ran on the
machine it was written on. And the verification handler echoes the short code, the stored
URL and the fully decrypted plaintext to the page - debug output left in a route whose
entire job is to not leak the token.
Beyond that, the CTR password path has no authentication tag while the GCM path does, which
reads like the encryption was learned in order across the same file. Every query in the data layer
is built by string interpolation with the username dropped straight in, so the careful crypto sits
on top of an injection surface. The database credentials are committed. And the two ends of the
verification flag never agreed on a spelling: registration writes email_verify as "0" while the
mail initiator's guard tests it against the string "false", which puts a fresh registration down
the error branch rather than the send branch.
The routes that were meant to sit behind the gate are stubs. check_login_state.php, the dashboard
entry point and the home entry point are all empty files. It was archived with the registration and
verification flow written and the catalogue, borrowing and returns never started - still carrying
the placeholder branding, "Logicstics", in the page titles and the mail template.
Project Details
2022
Product · Web