Repository: Yonyc/LauzHack-SBB-2025-app
Primary language: TypeScript

## Privacy-Preserving NFC Ticketing (LauzHack 2025)

This prototype shows how SBB could issue QR “bundles” that mint privacy-preserving travel tokens straight into a standard NFC card. We only rely on the tag UID (we do not have cards with writable secure storage), yet we still demonstrate the split-knowledge model: the salt that links a passenger to a ticket never leaves the card.

### Why the idea matters

- **Split knowledge**: server stores `Hash(salt + pub_k)`, while the card keeps the salt. A backend breach cannot deanonymize riders.
- **Offline controls**: the minted token contains route/class/validity. Controllers only need the token + salt + pub_k to validate.
- **User experience**: buy once (single, 10-trip, subscription), mint on tap, ride offline.

### Hardware reality

During the hackathon we only have blank NFC tags/phones that expose a UID. The app fakes the secure element: when you scan a card we treat the UID as the identity anchor, derive a pseudo pub-key hash, and show what would be written to the card’s active slot (token + salt).

![Protocol overview](schema.png)

## Data model snapshot

| Table | Purpose |
| --- | --- |
| `payment(payment_id, amount, time)` | Proof that a bundle was paid for; backs the QR code. |
| `issuedCards(uid_card, pub_key_hash)` | Registry of cards issued to riders; used to authenticate the NFC tap. |
| `ticket(ticket_id, ticket_type, remaining_trip, valid_from, valid_until, start_station, end_station, travel_class)` | “Bundle” inventory: single, 10-trip, subscription. `remaining_trip` hits zero → delete. |
| `activeTicket(token, card_data, start_station, end_station, start_date, end_date, travel_class)` | Minted rides. `card_data = Hash(salt + pub_k)` so the server never stores the salt. |

`card_data` in `activeTicket` and the salt that the card keeps form the privacy boundary.

## Lifecycle

1. **Payment / QR issuance**
	- Pay at kiosk/app → insert row in `payment` + `ticket` and return a QR with the `ticket_id`.
	- QR encodes proof of payment (bundle token), not passenger identity.
2. **Minting (“forge”)**
	- Rider scans NFC card; app authenticates `uid_card` via `issuedCards` and challenge/response (simulated here by checking the pseudo pub-key hash).
	- Scan QR. Backend checks `valid_from/valid_until` and `remaining_trip`.
	- Decrement `remaining_trip` (except subscriptions) and create a fresh row in `activeTicket` with:
	  - random `token`, random `salt`, `card_data = Hash(salt + pub_k)`
	  - route/class/time window copied from bundle.
	- Write `{token, salt}` to the card’s active slot (in demo we just show it on screen).
3. **Control / validation**
	- Controller reads `{token, salt, pub_k}` from card memory.
	- Fetch `activeTicket` by token, recompute `Hash(salt + pub_k)`, compare to `card_data`.
	- Enforce time window + route/zone + class locally. No backend lookup needed beyond the hash check.
4. **Card authentication**
	- Every tap first checks `uid_card` ↔ `pub_key_hash` from `issuedCards`.
	- Reader sends a challenge; card signs it; app verifies signature with `pub_k` (simulated by trusting the UID and pseudo hash).

## Protocol deep dive (handover notes for SBB)

### Card issuance protocol

1. Personalization backend generates an asymmetric key pair per card (`pub_k`, `priv_k`) and a random immutable `uid_card` (or uses the DESFire UID).
2. Persist `{uid_card, pub_key_hash = Hash(pub_k)}` inside `issuedCards`.
3. Inject the tuple `{uid_card, pub_k, priv_k}` into the secure element; the only writable memory exposed later is the “active slot” (token + salt).

### Bundle (ticket) issuance protocol

```
Passenger → Payment (kiosk/app)
Payment → payment table: insert(payment_id, amount, time)
Payment → ticket table: insert(ticket_id, type, remaining_trip, validity window, route, class)
Payment → Passenger: QR containing {ticket_id, signature(payment_id)}
```

No card interaction yet; the passenger walks away with a provable entitlement encoded in the QR.

### Minting / forging protocol

```
Reader ↔ Card: select card, obtain uid_card, run challenge/response using pub_k
Reader → Backend: requestMint(ticket_id, uid_card)
Backend:
	1. Fetch ticket row and verify validity window & remaining_trip
	2. Fetch issuedCards[uid_card] to obtain pub_key_hash
	3. Generate salt ← RNG(128 bits)
	4. Generate token ← RNG + route slug
	5. card_data = Hash(salt || pub_k)
	6. Insert into activeTicket(token, card_data, route, start_date, end_date, travel_class)
	7. Decrement remaining_trip (delete ticket when 0 for non-subscriptions)
Backend → Reader: {token, salt, start_date, end_date, route}
Reader → Card: write token & salt to active slot (overwriting previous value)
```

Because only the card knows the salt, backend observers cannot correlate minted rides back to bundles once the ticket table is cleaned up.

### Inspection protocol

```
Controller readers obtains {uid_card, pub_k, token, salt}
Reader → Backend (optional cache): fetch activeTicket[token]
Reader recomputes expected_hash = Hash(salt || pub_k)
Reader compares expected_hash with activeTicket.card_data
Reader enforces policy locally:
	- start_date ≤ now ≤ end_date
	- rider currently within {start_station, end_station} zone definition
	- travel_class matches carriage
Reader logs decision offline for dispute resolution
```

Because validation only needs a hash comparison, a controller can carry a short-lived cache of active tokens for full offline audits. When reconnecting, the controller syncs inspection logs to detect replay/fraud.

### Key and protocol resilience

- **Revocation**: mark `issuedCards.revoked_at` if a card is compromised. Minting rejects revoked cards, and controllers treat them as invalid.
- **Multi-trip prevention**: once a token is checked in by a controller, flip an `activeTicket.state` flag to prevent reuse; offline controllers queue the update.
- **Anonymity window**: delete exhausted `ticket` rows and expired `activeTicket` rows frequently (e.g., hourly) to minimize metadata.
- **Card memory tampering**: if `{token, salt}` are altered, hash verification fails because the backend never exposes the original salt.
- **Fallback**: passengers who lose cards can reassign bundles after KYC because the server never ties tokens to identity—only the payment record does.