- Customer-service notes joined to the account by comparing encrypted phone
columns. Both notes tables now carry phone_key, written when a note is
saved, and the three joins match on it.
- The email_verifications join was comparing a plaintext column against an
encrypted one, so it never matched and `verified` was always NULL in both
passenger and driver sign-in. It is now resolved in PHP against the
decrypted address, which fixes a pre-existing bug rather than only
preparing for GCM.
- auth/sendVerifyEmail.php built all three of its statements by interpolating
the request values into SQL. Any caller could inject through the email or
token field. Now parameterised.
- serviceapp/register.php duplicate detection consults the users indexes and
writes them with the row.
Sweep confirms no join or lookup compares two encrypted columns any more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Storing the verification phone as a keyed HMAC fixed OTP lookups but broke
every query that joined those tables back to the account, because
phone_verification*.phone_number no longer holds the same value as
driver.phone / passengers.phone. Six joins were affected, and four of them
feed the `verified` flag that the rider and driver apps check at sign-in — so
this was already failing under the current CBC mode, not only after a switch
to GCM.
Accounts now carry phone_key, computed exactly as otpPhoneKey() does, and the
joins match on it. It is written at registration for both apps and populated
for existing rows by the backfill.
The backfill also covers the columns added for the remaining lookups:
users.email_bidx/phone_bidx and driver.national_bidx, which were migrated but
never populated, and honours a per-field prefix so phone_key reproduces
otpPhoneKey's exact output.
Insert column/value counts verified with a paren-aware parser after editing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the set of queries that matched a freshly encrypted value against a
stored one, which only works while encryption is deterministic. Each keeps its
original comparison and adds an index comparison in the same WHERE, so nothing
changes today.
- passenger sign-in by email, service-staff sign-in, Firebase token lookup
- driver lookup by phone and by national number
- admin ride lookup and ride monitor (both tables)
- nabeh: driver status, user resolution, ride history, complaint submission
transit_org_admins lives in the transit database and has no index column, so
login there falls back to decrypting the small set of active admins and
comparing normalised numbers.
Schema: adds users.email_bidx/phone_bidx and driver.national_bidx with their
indexes.
Verified that every :*_bidx placeholder introduced is actually bound — an
unbound one is a fatal error at request time, not a silent miss.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Storing the verification phone as a keyed HMAC made OTP lookups independent
of the encryption mode, but the hash is one-way — and customer service reads
those same rows to chase people who requested a code and never finished
registering. That workflow would have lost the number entirely.
The verification tables now carry both forms: phone_number holds the lookup
key, and a new phone_enc column holds the encrypted number, which is
decryptable when a human needs to call.
The two follow-up queries also compared the verification row against the
driver/passengers tables and the notes tables by matching ciphertext, which
only ever worked because encryption was deterministic. Under GCM every number
would have looked unregistered and every note would have disappeared. Both now
read the number from phone_enc and match on normalised plaintext, so they are
correct under either mode.
Rows written before phone_enc existed are skipped rather than shown without a
number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verification tables (token_verification*, phone_verification*) use the
phone number as a lookup key: written when the code is sent, read when it is
checked. Storing it encrypted worked only because encryptData() is
deterministic — under AES-GCM the two sides would produce different
ciphertexts and no code would ever verify, locking every user out of
registration and OTP sign-in.
otpPhoneKey() stores a keyed HMAC of the normalised number instead. No schema
change is needed since the column is textual, local and international formats
now resolve to the same key, and the value cannot be reversed without the
pepper. It falls back to the previous behaviour when no pepper is configured.
Applied to both sides of every affected flow — request/verify, and the driver
and passenger send/verify pairs — including the OTP value itself where it is
compared by equality rather than decrypted. auth/otp/verify.php already
decrypts the token before comparing, so it needed no change there.
Also adds ENCRYPTION_MODE to EncryptionHelper: encryptData() writes GCM when
set to 'gcm', CBC otherwise. Verified in both directions — rows written under
CBC stay readable after switching, and rows written under GCM stay readable
after rolling back — so the switch is reversible by an environment variable.
The admin console's own OTP is unaffected: it keys the table by the stored
ciphertext read from adminUser, identical on both sides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These are the paths that must stop depending on deterministic encryption
before storage can move to AES-GCM. Each keeps its original ciphertext
comparison in the same statement, so behaviour is unchanged today and no
account becomes unreachable during the transition.
Lookups:
- auth/login.php — passenger sign-in matched the raw value against the
encrypted column, which only works because encryptData() is CBC with a
fixed IV.
- auth/passenger/register.php and auth/driver/register.php — duplicate
detection. Without the index these would stop detecting existing accounts
under GCM and allow the same phone to register twice.
Writes now populate the index in the same statement as the value:
- both registration paths write phone/email/name indexes with the row;
driver indexes are computed before the encryption pass, since the raw
values are unavailable afterwards.
- passenger profile update and admin driver update refresh the index when
the underlying field changes. For the composite name index the untouched
half is read back from the row.
Adds --audit to the backfill script: recomputes every index from its
encrypted value and reports missing or stale entries. Drift here is silent
by nature — it surfaces only when a real search fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confirms a value is findable through the blind index after backfilling,
without opening the console or the database. Prints matched row ids only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration and backfill scripts load bootstrap.php outside a request, where
$_SERVER['REQUEST_METHOD'] does not exist, so every CLI run printed a warning
before its output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
phpMyAdmin is unreachable on this deployment, so schema changes need a path
that does not depend on it. migrate.php reuses core/Database, meaning no
credentials are passed on the command line, and checks information_schema
before each ALTER so re-running is safe and never fails on a duplicate
column. Supports --status and --dry-run.
Covers the blind-index columns and the missing adminUser status/approved_by/
approved_at columns. Every change is additive and nullable, so applying it to
a running database changes no behaviour on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Searching encrypted columns currently works only because encryptData() is
AES-CBC with a fixed IV, i.e. deterministic. That determinism is what leaks
equality and shared prefixes, and it is why moving storage to AES-GCM would
break every lookup. This separates the two concerns.
- core/Security/BlindIndex.php: HMAC-SHA256 over a normalised value, keyed by
a secret pepper. Phone numbers have a small keyspace, so a bare SHA-256
would be reversible by enumeration; the pepper lives in the environment, not
the database. The scope string includes table and field so the same number
does not produce a matching index across tables.
Normalisation unifies local/international phone forms, lowercases emails and
folds Arabic alef/ya/ta-marbuta and diacritics for names.
- migrations/: nullable *_bidx columns plus indexes, and the missing
adminUser.status/approved_by/approved_at columns that admin approvals need.
- scripts/backfill_blind_index.php: restartable, batched, --dry-run capable,
touches only index columns.
- Admin lookups by phone/email now match the index, keeping the old ciphertext
comparison in the same query so search keeps working until the backfill runs.
bootstrap exposes $blindIndex as null when no pepper is configured.
Also: AdminCaptain/getCaptainDetailsById.php selected driver.education, a
column absent from this schema. The PDOException was uncaught, so the client
received an empty body with HTTP 200 — the "non-JSON response" seen when
opening a captain. It now omits the column, catches the error, reports it as
JSON, and requires an admin role.
Console: opening any sidebar section refetches its data instead of showing
what was loaded when the console started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Authorisation: driver/deleteCaptain.php permanently deletes a captain and had
no role check at all — any valid token, including a driver's or passenger's,
could delete captains. It now requires super_admin. The two unblacklist
endpoints had no role check either and now require an admin role.
Console:
- Blacklist & removal screen (super admin only): current blocks, permanent
removal, and lifting a block. Deletion additionally requires the phone
number to be retyped, because the row is deleted outright.
- Arabic interface with full RTL layout and a language toggle. Sidebar,
content offset, input affordances, toasts and directional icons all mirror;
numbers, identifiers and the diagnostics output stay LTR.
- Sign-in failures now render in the form and stay there. They were only
shown as a toast, which disappears before it can be read — a locked
account or a rejected device looked like the page simply returning to
the login screen with no explanation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
serviceapp/updatePackages.php built its UPDATE by interpolating the request
values straight into the SQL string, so any caller with a valid token could
execute arbitrary SQL through the version field. It now uses bound
parameters, requires an admin role, validates the version format, and writes
an audit entry.
trigger_campaign.php gains dry_run=1: it performs the same Gemini analysis
and target selection but returns before creating the promo code and before
dispatching any notification. Launching without previewing was the only
option before, and a launch writes a seven-day discount and pushes to every
passenger in the country.
Console:
- Campaign launcher with a mandatory preview. Launching stays disabled until
the current parameters have been previewed, and re-locks if any parameter
changes afterwards or once a launch completes.
- App version manager with the same version-format check as the server and a
confirmation naming the old and new values.
Cache busting: assets are served straight off a bind mount with no version,
so browsers kept running the previously cached build after a deploy. Both
asset links now carry ?v=, and the build id is shown in Session & Security
and printed in the diagnostics report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Driver documents: paged list from auth/driver/drivers_pending_list.php, a
detail panel showing each uploaded document as a thumbnail linking to the
full image, and activation via Admin/driver/updateDriverFromAdmin.php. The
confirmation states how many documents were reviewed and warns explicitly
when a captain has none on file, since approving then activates an
unverified account.
Details are requested as a POST body. The mobile app calls this endpoint as
GET "?id=", which filterRequest() never reads, so its detail lookup cannot
be receiving an id at all.
Staff: pending admin/service accounts with per-account activation via
Staff/activate.php, the employee list, and a creation form posting to
Staff/add.php. Administrator accounts are offered only to super admins,
matching add.php's own check; passwords are rejected below 8 characters and
cleared from the form after submission.
Both screens mask phone numbers for plain admins and never render
token/password/fingerprint fields.
Also stop .btn-primary stretching to full width when used inline in a card
header — it is styled for the login form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Broadcast: ride/firebase/send_fcm.php is an internal service guarded by a
shared secret, so the browser cannot call it — holding that key client-side
would expose it, and the endpoint cannot tell who the sender is. A new
Admin/notifications/broadcast.php sits in front of it: it runs behind
connect.php, requires super_admin, restricts the target to the two topics the
apps actually subscribe to ('drivers'/'passengers') so it cannot be used to
push to an arbitrary topic or a single device token, bounds the title and
body, writes an audit entry before dispatching, and only then forwards the
call internally with the shared secret.
The composer shows a live push preview and an explicit confirmation naming
the audience, since a broadcast cannot be recalled.
Route approvals: draft routes render with their stops, distance and stop
count, and approve/reject posts to transit/route/approve.php behind a
confirmation stating the consequence. Available to admins and super admins,
matching the endpoint's own role check.
Also render user-supplied text with unicode-bidi: plaintext — Arabic names,
addresses and messages were being laid out left-to-right inside the
English UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the console to 18 modules across 26 navigation entries.
- Fleet performance (best captains, per-captain card charges), invoice
totals and promo codes as read-only panels.
- Ride monitor: monitorRide.php and getDriverGiftPayment.php answer only for
a given phone number, so they get an input rather than a panel that would
render an error on load.
- Status-like values in generated tiles use the same label mapping as the
tables, so raw values such as cancelled_by_passenger no longer leak into
the UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hotfix: a comment added to the dashboard SQL contained double quotes inside
the double-quoted PHP string, terminating it and making dashbord.php fail to
parse. Production was returning a parse error for every dashboard request.
Authorisation gaps closed — connect.php only proves a token is valid, it does
not check what the caller is allowed to do:
- Admin/ggg.php decrypts any database field and was authorised solely by an
admin phone number sent in the request body. Anyone who knew a listed
number could decrypt platform data without signing in. It now runs behind
connect.php, requires super_admin, keeps the phone list as a second factor,
and records every use.
- ride/kazan/update.php, kazan/add.php and ride/promo/{add,update,delete}.php
changed live pricing and discount codes with no role check at all, so any
valid token — including a driver's or passenger's — could rewrite the fare
table. All now require super_admin.
Staff/pending.php: adminUser has no `status` column in this deployment, so
the query failed with an opaque "unavailable". It now checks for the column
and reports the actual reason.
Console: Kazan tariff editor for super admins — sends only changed fields,
shows an old → new confirmation before saving, and stays read-only with an
explanatory notice for plain admins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ride table holds two generations of status values: the legacy CamelCase
set ('Finished', 'CancelFromPassenger') and the lowercase set written by
backend/ride/rides/* today ('completed', 'cancelled_by_passenger'). Admin
queries only matched the legacy set, so on live data:
- get_rides_by_status.php returned nothing meaningful for every filter, and
the "in progress" default masked it.
- dashbord.php reported total_driver_earnings as NULL, completed_rides as a
fraction of the real count, and cancelled_rides as 0.
- driver_avg_duration averaged in negative durations, yielding "-00h 22m".
All three now match on LOWER(status) across both families.
Staff/pending.php ran with no authentication at all, exposing pending
admins' names and phone numbers to any caller; it now goes through
connect.php with a role check. It also returned HTTP 400 for everything when
the `users` table was absent — each source is queried independently and
reports its own availability.
Console:
- Render rides from either schema generation (price/date/time and
start_location coordinates, or the older address/created_at columns).
- Null aggregates render as "—" rather than a measured 0.00.
- Add tariff/promo, WhatsApp send and encryption modules, all super-admin
gated; pricing remains read-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>