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>