E-Library
Archived
Product · Web

E-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.

Built with
PHPPHP
MySQLMySQL
HTMLHTML
CSSCSS
JavaScriptJavaScript
Project Details
RESOURCES

STATUS
Archived
YEAR

2022

TYPE

Product · Web

TAGS
Authentication
Early Work

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, php and script subdirectories, over a shared global/ 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.php and email_validator.php are hit from the registration form on focusout rather 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_EMAIL for the address, alphanumeric usernames, and a password regex demanding eight characters with at least one digit, one lowercase and one uppercase - all run through a trim, stripslashes and htmlspecialchars filter first.
  • Form state preserved across a failed submit by echoing the posted values back as injected insert_value script calls, so a rejected registration does not blank the form.
  • The mail path split across send_verification_mail.php and a email_verification_sent.php confirmation 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 .htaccess turning 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
RESOURCES

STATUS
Archived
YEAR

2022

TYPE

Product · Web

TAGS
Authentication
Early Work