SA
Sanchalan Docs
Security model

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

SurfaceMechanismNotes
Web adminsession cookie (Laravel auth:web)SSO via sb-iam in prod; dev login form for testing.
Mobile / 3rd-party APISanctum personal-access-tokensIssued by mobile SSO exchange; revocable per device.
SSOOAuth2 authorization-code + PKCE (S256)Public PKCE client; no client_secret stored on the device.
Health probesnone/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]

Cryptographic design

  • Per-device keypair: ECDSA P-256 (secp256r1). Generated once on first launch via cryptography package, persisted in Android KeyStore / iOS Keychain via flutter_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 knows device_uuid from 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 PunchSignatureVerifier converts back to DER before openssl_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

  1. Device-reported coordinates: cheap to spoof on a rooted device → not relied on alone.
  2. Polygon containment: GeoJSON polygons defined per location (main building / committee rooms / MP residences). Point-in-polygon ray-casting on the server.
  3. 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.
  4. Anti-spoof flags: mock_location + rooted_or_jailbroken + emulator reported by the client; in strict policy any one rejects.
  5. Future: hardware attestation (Play Integrity / DeviceCheck) replaces the soft anti-spoof signals once a Play Console account is provisioned.

Encryption at rest

DataApproach
DatabasePostgres on encrypted volumes; PII columns can be migrated to pgcrypto envelope encryption with Vault-managed DEK (pattern already in use in Vani Setu).
Vault secretsFile-store backend, manual unseal. Per-deployment salts (IMEI salts, audit signing key) stored as Vault secrets.
Mobile token storageAndroid: encryptedSharedPreferences. iOS: Keychain with first_unlock_this_device.
Audit chainEach 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

ActionAudit eventPayload includes
Device registeredattendance.device.registereddevice_id, platform
Punch (any verdict)attendance.punch.<verdict>punch_id, type, fence_id, spoof reason
Punch replayedattendance.punch.duplicateemployee_id, nonce
Leave appliedattendance.leave.appliedleave_id, type, days
Leave decidedattendance.leave.{approved|rejected}leave_id, approver, via=admin_web|api
Tour appliedattendance.tour.appliedtour_id, days
Tour decidedattendance.tour.{approved|rejected}tour_id, approver
Loginauth.loginvia=sso|sso_mobile|dev_form
CSV exportattendance.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-data via an entrypoint chown — replace dev chmod 777 on 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.