backend requirements onthe xustomer app

This commit is contained in:
2026-09-07 10:55:11 +05:30
parent 35675d8a9b
commit 1b2690b21a
56 changed files with 13342 additions and 1071 deletions

View File

@@ -0,0 +1,181 @@
package controllers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"os"
"strings"
"doormile/utils"
)
// Format-preserving scrambling for the customer-facing identifiers.
//
// THE PROBLEM. Both identifiers come off a Postgres sequence, because a
// sequence is the only generator here that can promise uniqueness — the columns
// are UNIQUE, and a random 8-digit id collides with ~43% probability by the
// ten-thousandth parcel, which would be a rider unable to complete a pickup.
// But a sequence is also readable: DMX10000042 and DMX10000043 are visibly
// adjacent, so anyone holding two numbers learns the throughput between them,
// and anyone holding one can guess its neighbours.
//
// THE FIX. Keep the sequence — keep its uniqueness guarantee — and pass the
// index through a bijection before formatting it. Same one-to-one property, so
// no two parcels can ever collide; unrelated outputs, so nothing is guessable
// from a neighbour.
//
// WHY NOT THE OBVIOUS TRICK. Multiplying by a number coprime with the domain is
// also a bijection and is one line. It is wrong here: a multi-destination
// pickup hands ONE customer three consecutive sequence values, so the
// differences between their three tracking numbers are all exactly the
// multiplier. One booking leaks the key, and the whole range becomes walkable.
// The mapping has to be non-linear.
//
// WHAT THIS IS. A 4-round balanced Feistel network keyed with HMAC-SHA256, plus
// cycle-walking to keep the result inside the digit range. A Feistel is a
// bijection for ANY round function — that is its defining property — so
// uniqueness survives regardless of the key. Cycle-walking (re-encrypt until
// the output lands in range) preserves bijectivity on the subset.
//
// WHAT THIS IS NOT. Not a security boundary. Every route that resolves a
// tracking number is already authenticated and owner-scoped, and that is what
// actually stops a stranger reading someone's parcel. This removes the
// information leak in the identifier itself, so the authorisation check is not
// the only thing standing between an outsider and your volume figures.
const (
// Tracking numbers occupy DMX10000000..DMX99999999 — 90,000,000 values,
// always eight digits so the format never changes width.
cxTrackingBase = 10_000_000
cxTrackingDomain = 90_000_000
// 28 bits (two 14-bit halves) is the smallest even split covering the
// domain. Cycle-walking averages ~3 encryptions per id; each is four
// HMACs, so this is microseconds.
cxTrackingHalfBits = 14
// Booking references occupy DM-100000..DM-999999 — 900,000 values, always
// six digits.
cxBookingBase = 100_000
cxBookingDomain = 900_000
// 20 bits (two 10-bit halves). Cycle-walking averages ~1.2 encryptions.
cxBookingHalfBits = 10
// cxFeistelRounds. Four is the standard minimum for a Feistel to be a
// strong pseudorandom permutation (Luby–Rackoff). More rounds cost HMACs
// for no property this needs.
cxFeistelRounds = 4
// cxCycleWalkLimit bounds the walk so a pathological key can never hang a
// request. Reaching it is astronomically unlikely — each step has a ~2/3
// chance of landing in range for tracking numbers — and the caller falls
// back to the plain sequence rather than failing a booking.
cxCycleWalkLimit = 64
)
// cxScrambleKey keys the round function.
//
// Overridable via CX_ID_SCRAMBLE_KEY. Changing it changes every identifier
// minted AFTERWARDS and none already stored, so rotation is safe but leaves a
// visible discontinuity — there is no reason to rotate it, and a good reason
// not to.
//
// The built-in default is not a secret and is not pretending to be one. It
// exists so the scrambling works out of the box rather than being silently off
// on any deployment that forgot to set an env var — an identifier scheme that
// depends on configuration to be safe is one that will be unsafe somewhere.
var cxScrambleKey = func() []byte {
if k := strings.TrimSpace(os.Getenv("CX_ID_SCRAMBLE_KEY")); k != "" {
return []byte(k)
}
return []byte("doormile-cx-identifier-permutation-v1")
}()
// cxFeistelRound is the round function. It need not be invertible — a Feistel
// is a bijection whatever this returns — so any keyed mixing works, and HMAC
// gives good diffusion for four bytes of output.
func cxFeistelRound(half uint64, round int, key []byte) uint64 {
var buf [9]byte
binary.BigEndian.PutUint64(buf[:8], half)
buf[8] = byte(round)
mac := hmac.New(sha256.New, key)
mac.Write(buf[:])
sum := mac.Sum(nil)
return uint64(binary.BigEndian.Uint32(sum[:4]))
}
// cxFeistelEncrypt permutes a value within 2^(2*halfBits).
//
// Bijective by construction: every round is invertible because the half that is
// mixed is carried forward untouched, so the whole network can be run
// backwards. That is the property the UNIQUE constraint depends on.
func cxFeistelEncrypt(x uint64, halfBits uint, key []byte) uint64 {
mask := uint64(1)<<halfBits - 1
left := (x >> halfBits) & mask
right := x & mask
for round := 0; round < cxFeistelRounds; round++ {
left, right = right, left^(cxFeistelRound(right, round, key)&mask)
}
return (left << halfBits) | right
}
// cxPermuteIndex maps a sequence index onto a scattered index in the same
// domain, one-to-one.
//
// Cycle-walking: the Feistel operates on the whole power-of-two space, which is
// larger than the digit range, so an output that overshoots is re-encrypted
// until it lands inside. Re-encrypting a bijection is still a bijection on the
// subset, so no two indices can ever converge.
func cxPermuteIndex(index, domain uint64, halfBits uint) uint64 {
if index >= domain {
// Past the end of the fixed-width range. The caller handles this;
// returning the index unchanged keeps the function total.
return index
}
x := index
for i := 0; i < cxCycleWalkLimit; i++ {
x = cxFeistelEncrypt(x, halfBits, cxScrambleKey)
if x < domain {
return x
}
}
// Unreachable in practice. Falling back to the sequential index keeps the
// identifier unique — which is the property that must never break — and
// loses only the scattering.
utils.Warn("cxPermuteIndex: cycle walk did not converge, using the sequential index",
"index", index, "domain", domain)
return index
}
// cxScrambledTracking turns a sequence value into the eight digits after DMX.
//
// Returns ok=false once the sequence runs past the fixed-width range, so the
// caller can fall back to plain sequential formatting and let the identifier
// grow a digit rather than wrapping onto one already issued.
func cxScrambledTracking(seq int64) (uint64, bool) {
if seq < cxTrackingBase {
return 0, false
}
index := uint64(seq - cxTrackingBase)
if index >= cxTrackingDomain {
return 0, false
}
return cxTrackingBase + cxPermuteIndex(index, cxTrackingDomain, cxTrackingHalfBits), true
}
// cxScrambledBooking is the same for the six digits after DM-.
func cxScrambledBooking(seq int64) (uint64, bool) {
if seq < cxBookingBase {
return 0, false
}
index := uint64(seq - cxBookingBase)
if index >= cxBookingDomain {
return 0, false
}
return cxBookingBase + cxPermuteIndex(index, cxBookingDomain, cxBookingHalfBits), true
}