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