Skip to content

Doc status: Latest (rolling). See Versions.

High level

  • Mobile App (Expo / React Native + @react-native-firebase/auth native SDK)
  • Admin Web (Next.js)
  • API (Cloudflare Workers — Hono)
  • Data (Firestore)
  • Push (FCM → APNs/Android)
  • Payments (Stripe — REST API, no SDK)
  • PQC Crypto (@maatara/core-pqc — WASM, Apache-2.0)
mermaid
flowchart LR
  Mobile[Mobile App] -->|Firebase ID Token| API[Cloudflare Worker API]
  Admin[Admin Web] -->|CF Access JWT| API
  EntraID[Microsoft Entra ID] -->|OIDC SSO| CFA[Cloudflare Zero Trust Access]
  CFA -->|JWT cookie| Admin
  API -->|Firestore REST| DB[(Firestore)]
  API -->|FCM HTTP v1| FCM[FCM]
  API -->|REST| Stripe[Stripe]
  Stripe -->|Webhooks| API
  FCM --> APNs[APNs / iOS]
  FCM --> Droid[Android]

Request / auth flow

Mobile (Firebase Auth — native SDK)

  1. User signs in via @react-native-firebase/auth (native SDK) using Google Sign-In or Sign in with Apple.
  2. The native SDK initializes automatically via google-services.json (Android) and GoogleService-Info.plist (iOS) — no JS-level initializeAuth call.
  3. App calls the Worker API with Authorization: Bearer <FirebaseIdToken>.
  4. Worker verifies the Firebase ID token server-side.
  5. Worker enforces authorization (role + membership + status) server-side.

Admin (Cloudflare Zero Trust + Microsoft Entra ID)

  1. Admin visits the dashboard — Cloudflare Access intercepts and redirects to Microsoft Entra ID SSO.
  2. After SSO, CF Access sets a signed CF_Authorization JWT cookie.
  3. Admin app reads the cookie and forwards it as cf-access-jwt-assertion header to the Worker API.
  4. Worker verifies the CF Access JWT against the team's JWKS endpoint.
  5. Worker maps the Entra email to a Firestore user (auto-provisions a stub if new).
  6. Worker enforces authorization (role + membership + status) server-side.

Common

  • Worker reads/writes Firestore via REST using a service account.
  • Worker triggers push fan-out using FCM HTTP v1.

Authorization model

  • Standard users (mobile only) can:

    • register device
    • join group via invite
    • trigger incident (only for groups they are an active member of, with active license)
    • view their own profile and notifications
  • Managers can (scoped to their groups):

    • create groups and invite codes
    • broadcast messages
    • pause/ban members
    • view group members, invites, payments, license status
    • initiate Stripe checkout for group license
  • Super admins (platform-wide) can:

    • view all users, groups, licenses, payments
    • set user roles (standard/manager/super_admin)
    • grant and revoke licenses
    • all manager capabilities across all groups
  • Server-side checks are mandatory for all privileged actions.

Data model (logical)

This is the conceptual structure (implemented in Firestore collections/subcollections):

  • users/{uid}

    • profile: email, displayName, role, status
    • devices/{deviceId}: platform, token, createdAt, lastSeenAt
  • groups/{groupId}

    • metadata: name, zoneId, createdByUid, createdAt
    • members/{uid}: role (member/manager), status (active/paused/banned)
    • incidents/{incidentId}: incident records
  • invites/{inviteCode}

    • groupId, expiresAt, maxUses, uses, revoked
  • licenses/{licenseId}

    • groupId, status (active/expired/revoked/trial), type (paid/granted)
    • stripeSubscriptionId, stripeCustomerId, grantedByUid
    • startsAt, expiresAt
  • payments/{paymentId}

    • licenseId, groupId, adminUid
    • stripePaymentIntentId, amount, currency, status
  • notifications_log/{notifId}

    • type, targetUid, groupId, message, read

Security model

  • All privileged actions are checked server-side:
    • Mobile auth: Firebase ID token verification
    • Admin auth: Cloudflare Access JWT verification (JWKS), backed by Microsoft Entra ID SSO
    • Authorization: group manager role checks in Firestore
  • Admin dashboard is gated by Cloudflare Zero Trust — only authorised Entra ID users can reach it.
  • Avoid storing exact addresses; prefer coarse zones.

Platform ownership

Owned and maintained by RME Solutions Technology Australia.

Address handling (on-device + encrypted)

N-Watch does not store a user’s exact home address centrally.

  • The address is stored only on the user’s phone and can be edited at any time.
  • The address is sent only when an alert is triggered, as an encrypted payload.
  • The system should persist no plaintext address server-side (logs or database). If stored at all, store only an opaque encrypted blob.

Encrypted incident payload (PQC-ready, locked suite)

Locked crypto suite:

  • Key transport: ML-KEM-768
  • Signatures: ML-DSA-44 (optional but recommended for integrity)
  • Payload: AES-256-GCM

Important detail: ML-DSA is a signature algorithm (authenticity/integrity), not encryption. For encrypting/wrapping a per-incident symmetric key we use ML-KEM. Payload encryption remains AES-256-GCM.

Keys

  • Each device generates a KEM keypair on first sign-in using the @neighbourhoodwatch/pqc-native Expo module (Rust → FFI).
  • The secret key is stored in the OS secure enclave via expo-secure-store (Android Keystore / iOS Keychain) — never leaves the device, never in plain-text storage.
  • The public key is uploaded to Firestore at users/{uid}/crypto/{kid} during sign-in and again at invite redemption so other group members can encrypt for this user.

Per-user keys:

  • KEM keypair: ML-KEM-768 for wrapping a per-incident content key.
  • Signature keypair: ML-DSA-44 (optional, for integrity verification).

Firestore: users/{uid}/crypto/{kid}kemPublicKey, kid, createdAt

Payload flow (true E2E — server never sees plaintext)

Live in production since v0.3.0.

  1. On first sign-in the app generates an ML-KEM-768 keypair; the secret stays in Keychain/Keystore; the public key + kid are uploaded to users/{uid}/crypto/{kid}.
  2. When a user joins a group (invite redemption), the public key is also uploaded to ensure it's available.
  3. When the sender taps ALERT, the app calls GET /v1/groups/:groupId/keys to fetch every active member's KEM public key.
  4. The sender's app encrypts the address independently for each recipient on-device:
    • KEM-Encaps(recipientPK)kemCiphertext + sharedSecret
    • AES-256-GCM-Encrypt(sharedSecret, address)ciphertext + nonce
  5. All per-recipient envelopes { uid, kid, kemCiphertext, ciphertext, nonce } are sent to the Worker in the triggerIncident request.
  6. The Worker stores envelopes opaquely at groups/{groupId}/incidents/{incidentId}/envelopes/{uid}zero-knowledge: server never decrypts.
  7. FCM push contains only metadata: incidentId, groupId, kind, hasEnvelope. No secrets or ciphertext in the push.
  8. Recipient calls GET /v1/incidents/:groupId/:incidentId/envelope to fetch their opaque envelope.
  9. Recipient decrypts locally: KEM-DecapssharedSecretAES-256-GCM-Decrypt → plaintext address.
  10. Decrypted location shown in an in-app alert.

What never travels over the wire: private keys, plaintext addresses, shared secrets.

Emergency alert capabilities

  • Android: Channel alertsMAX importance, bypassDnd: true.
  • iOS: apns-priority: 10, Critical Alert (critical: 1).

See also: Security.

Static “waterfall” delivery plan

The requirements are intentionally clear and narrow; this supports a straightforward waterfall delivery sequence:

  1. Requirements sign-off (roles, invite join, alerting, admin controls, data minimisation)
  2. System design sign-off (API contracts + data model + security invariants)
  3. Implementation
  • Worker API + Firestore schema
  • Mobile minimal UX
  • Admin minimal UX
  1. Verification
  • Contract tests (local/staging)
  • Staging E2E tests
  1. Deployment
  • Worker deploy
  • Mobile release pipeline
  • Admin deploy
  1. Operational hardening
  • Monitoring + alerting
  • Incident review process

Content & diagrams

  • Put images in docs/public/images/ and reference as /images/....
  • Use Mermaid blocks (like above) for flow diagrams.

Neighbourhood Emergency Alert System