Security model
Threat model + the controls that address each threat. The reasoning is more important than the list — these decisions should survive future redesigns.
Authentication
| Surface | Mechanism | Notes |
|---|---|---|
| Web admin | session cookie (Laravel auth:web) | SSO via sb-iam in prod; dev login form for testing. |
| Mobile / 3rd-party API | Sanctum personal-access-tokens | Issued by mobile SSO exchange; revocable per device. |
| SSO | OAuth2 authorization-code + PKCE (S256) | Public PKCE client; no client_secret stored on the device. |
| Health probes | none | /api/healthz only — minimal info disclosure. |
Punch integrity — defence in depth
flowchart TD
Punch["incoming punch"] --> G1{Sanctum
token valid?} G1 -- no --> Drop1[401] G1 -- yes --> G2{token has
employee_id?} G2 -- no --> Drop2[401] G2 -- yes --> G3{device active
+ owned by this employee?} G3 -- no --> Drop3[404] G3 -- yes --> G4["ECDSA P-256 verify
(message = nonce + uuid + punched_at)"] G4 -- bad --> Reject1[verdict=rejected_signature] G4 -- ok --> G5["point-in-polygon
+ Wi-Fi SSID allowlist"] G5 -- outside --> Reject2[verdict=rejected_geofence] G5 -- inside --> G6{anti-spoof:
mock loc / rooted / emulator?} G6 -- yes (strict) --> Reject3[verdict=rejected_spoof] G6 -- no --> G7{nonce already used
by this employee?} G7 -- yes --> Reject4[verdict=duplicate] G7 -- no --> Accept[verdict=accepted]
token valid?} G1 -- no --> Drop1[401] G1 -- yes --> G2{token has
employee_id?} G2 -- no --> Drop2[401] G2 -- yes --> G3{device active
+ owned by this employee?} G3 -- no --> Drop3[404] G3 -- yes --> G4["ECDSA P-256 verify
(message = nonce + uuid + punched_at)"] G4 -- bad --> Reject1[verdict=rejected_signature] G4 -- ok --> G5["point-in-polygon
+ Wi-Fi SSID allowlist"] G5 -- outside --> Reject2[verdict=rejected_geofence] G5 -- inside --> G6{anti-spoof:
mock loc / rooted / emulator?} G6 -- yes (strict) --> Reject3[verdict=rejected_spoof] G6 -- no --> G7{nonce already used
by this employee?} G7 -- yes --> Reject4[verdict=duplicate] G7 -- no --> Accept[verdict=accepted]
Cryptographic design
- Per-device keypair: ECDSA P-256 (secp256r1). Generated once on first launch via
cryptographypackage, persisted in Android KeyStore / iOS Keychain viaflutter_secure_storage. Private key never leaves the device. - Public-key registration: device_register sends the SubjectPublicKeyInfo PEM. Backend stores it as-is and keeps the historical record even after device deactivation, so old punches remain verifiable.
- Signed message:
nonce + device_uuid + punched_at_iso— no implicit-trust fields. The server independently knowsdevice_uuidfrom the lookup, so a forged punch can't substitute a different device's UUID and still pass. - Signature wire format: 64-byte raw r||s, standard base64. Server side
PunchSignatureVerifierconverts back to DER beforeopenssl_verify. - Nonce: 32 hex chars (16 bytes random) per punch. Replay protection via partial unique index.
Device fingerprinting (no raw IMEI)
Android 10+ blocks raw IMEI for non-system apps. We use a derivation that's stable per installation but doesn't carry hardware identity off-device:
raw_id = android.id ":" android.fingerprint ":" android.serialNumber // (or iOS identifierForVendor)
imei_hash_frontend = sha256(raw_id + IMEI_SALT_FRONTEND) // sent to server as 'imei' field
imei_hash_backend = sha256(imei_hash_frontend + "|" + IMEI_SALT_SERVER) // stored in attendance_devices.device_imei_hash
Two salts (one in the APK build, one in server config) means a leaked APK doesn't let an attacker reverse-engineer the persisted hash.
Geofencing — defense layers
- Device-reported coordinates: cheap to spoof on a rooted device → not relied on alone.
- Polygon containment: GeoJSON polygons defined per location (main building / committee rooms / MP residences). Point-in-polygon ray-casting on the server.
- Wi-Fi SSID allowlist: each fence has an allowed SSID list. The phone reports current SSID; server requires it be in the list. SSIDs aren't trivially mock-able from a remote attacker.
- Anti-spoof flags:
mock_location+rooted_or_jailbroken+emulatorreported by the client; instrictpolicy any one rejects. - Future: hardware attestation (Play Integrity / DeviceCheck) replaces the soft anti-spoof signals once a Play Console account is provisioned.
Encryption at rest
| Data | Approach |
|---|---|
| Database | Postgres on encrypted volumes; PII columns can be migrated to pgcrypto envelope encryption with Vault-managed DEK (pattern already in use in Vani Setu). |
| Vault secrets | File-store backend, manual unseal. Per-deployment salts (IMEI salts, audit signing key) stored as Vault secrets. |
| Mobile token storage | Android: encryptedSharedPreferences. iOS: Keychain with first_unlock_this_device. |
| Audit chain | Each row includes prev_hash + hash (sha256 of canonicalised content). Daily anchor signature + timestamp by sds-ops/audit-chain-anchor-sign.sh. |
Audit trail — what's captured
| Action | Audit event | Payload includes |
|---|---|---|
| Device registered | attendance.device.registered | device_id, platform |
| Punch (any verdict) | attendance.punch.<verdict> | punch_id, type, fence_id, spoof reason |
| Punch replayed | attendance.punch.duplicate | employee_id, nonce |
| Leave applied | attendance.leave.applied | leave_id, type, days |
| Leave decided | attendance.leave.{approved|rejected} | leave_id, approver, via=admin_web|api |
| Tour applied | attendance.tour.applied | tour_id, days |
| Tour decided | attendance.tour.{approved|rejected} | tour_id, approver |
| Login | auth.login | via=sso|sso_mobile|dev_form |
| CSV export | attendance.report.csv_export (planned) | actor, range, row_count |
RBAC checklist
- Permission keys, not role strings, are the contract — see
config/permissions.php. - Routes use
->middleware('rbac:<permission_key>'); controllers never branch on raw role strings. - Adding a new role only edits
config/permissions.php, never the routes.
What still needs hardening before prod
- uid alignment between host bind-mount owner and container
www-datavia an entrypoint chown — replace devchmod 777on storage. - Vault-pulled secrets at boot via an entrypoint (currently provisioned out-of-band by the wire script).
- Hardware attestation (Play Integrity / DeviceCheck) replacing soft anti-spoof signals.
- Punch payload size cap on the signature field (defence against DoS via huge base64).
- OpenAPI annotations finished on Sso + Admin API controllers.
- Tailwind built locally (not CDN-loaded) for offline-resilience and CSP-strict deployments.