Skip to content

Architecture

Doc status: Latest (rolling). See Versions.

Design principle

NWatch is architecturally designed so membership remains scoped to invited groups. The manager controls membership and the API enforces it. The mobile home surface operates one primary group at a time; the manager portal can operate multiple groups. See Security for the full trust model.

High level

  • Mobile App (Expo SDK 55 / React Native + @react-native-firebase/auth native SDK)
  • Admin Web (Next.js on Cloudflare Workers via vinext)
  • API (Cloudflare Workers — Hono)
  • Data (Firestore)
  • Push (FCM → APNs/Android)
  • Payments (Stripe — REST API, no SDK)
  • PQC Crypto (iOS native CryptoKit via @neighbourhoodwatch/pqc-native + JS fallback mlkem/@noble/ciphers)
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, a passwordless email link, or Microsoft when configured; Sign in with Apple is also available on iOS.
  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.

Super-admin console (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
    • pause/remove members and manage member roles
    • review member device readiness
    • reveal one incident's encrypted details through an audited workflow and resolve incidents
    • view group members, invites and licence status
    • manage seats, billing and Stripe checkout for group licences
  • Super admins (platform-wide) can:

    • view all users, groups, licenses, payments
    • set user roles (standard/manager/super_admin)
    • grant and revoke licenses
    • perform platform administration across groups; plaintext incident reveal remains separately gated by direct active group membership
  • 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
  • system/crypto

    • kemPublicKey, kid, updatedAt — the server-managed system KEM identity

Security model

  • All privileged actions are checked server-side:
    • Mobile auth: Firebase ID token verification
    • Manager portal auth: Firebase ID token verification
    • Super-admin auth: Cloudflare Access JWT verification (JWKS), backed by Microsoft Entra ID SSO
    • Authorization: group manager role checks in Firestore
  • The super-admin console is gated by Cloudflare Zero Trust — only authorised Entra ID users can reach it.
  • Do not persist saved addresses as plaintext profile fields; use coarse zones for group organisation and encrypted incident envelopes for sent address/GPS snapshots.

Platform ownership

The current contracting entity and operator details are maintained in the public Terms and Privacy Policy.

Address handling (on-device + encrypted)

NWatch does not store a user's saved alert address as a plaintext profile field.

  • The saved address is stored on the user's phone and can be edited at any time. Once sent, that alert snapshot is also retained server-side only inside encrypted envelopes.
  • The alert text is sent only when an alert is triggered, as an encrypted payload.
  • The server stores opaque per-recipient envelopes and, when supplied by the client, a system recovery envelope that supports audited reveal and re-wrap operations.
  • Plaintext is never persisted server-side — decryption is transient per request and audit-logged.

Encrypted incident payload (PQC-ready, locked suite)

Locked crypto suite:

  • Key transport: ML-KEM-768
  • Payload: AES-256-GCM

The current wire path uses the ML-KEM shared secret directly as the AES-256-GCM key. It does not currently use an HKDF step or ML-DSA signatures; those must not be represented as shipped protections.

Keys

  • Each device generates a KEM keypair on first sign-in using a native-first strategy: iOS uses CryptoKit via @neighbourhoodwatch/pqc-native when available (AES-GCM on iOS 13+, ML-KEM-768 on iOS 18.2+), with automatic fallback to pure-JS (mlkem + @noble/ciphers) when native APIs are unavailable.
  • The secret key is stored through expo-secure-store in OS-protected Android Keystore / iOS Keychain storage — never uploaded or placed in ordinary plaintext app storage.
  • Secret keys are retained by kid in secure storage so older envelopes can still be decrypted after key rotation.
  • Users can manually rotate their keys at any time from the settings menu.
  • 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.
  • One active device per account — simultaneous multi-device use is not supported. A replacement phone may sign in with the same linked provider; registering its active key may supersede the previous device for new envelopes.

Per-user keys:

  • KEM keypair: ML-KEM-768 for wrapping a per-incident content key.

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

System key (server identity)

  • The server holds its own ML-KEM-768 keypair for system-level operations.
  • Secret key → Cloudflare Worker secret PQC_KEM_SECRET_KEY (+ PQC_KEM_KID).
  • Public key → Firestore system/crypto document, bootstrapped via POST /v1/superadmin/bootstrapSystemKey.
  • Returned alongside member keys by /v1/groups/:groupId/keys (uid nwatch-sys). Current clients include a service-recovery envelope when that key is available; missing recovery envelopes are not proof that an incident was not stored for member recipients.
  • Enables: admin decrypt (view alert text), manager re-wrap (after user key rotation), and system key rotation.

Payload flow (per-recipient encryption with a service-recovery envelope when available)

Live in production since v0.3.0.

  1. On first sign-in the app generates an ML-KEM-768 keypair (native-first with pure-JS fallback); 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 slides the alert control right and holds it for two seconds, completing the hold makes the app call GET /v1/groups/:groupId/keys to fetch active member KEM public keys and the system key (uid nwatch-sys) when configured.
  4. The sender's app attempts to encrypt the alert text independently for each returned recipient key on-device, including the system key when available:
    • KEM-Encaps(recipientPK)kemCiphertext + sharedSecret
    • AES-256-GCM-Encrypt(sharedSecret, alertText)ciphertext + nonce
  5. Only successfully sealed per-recipient envelopes { uid, kid, kemCiphertext, ciphertext, nonce } are sent to the Worker in the triggerIncident request. A failed recipient seal is reported to the sender as a skipped key; if every recipient seal fails, the incident is not submitted.
  6. The Worker stores returned envelopes at groups/{groupId}/incidents/{incidentId}/envelopes/{uid}. A current client normally includes nwatch-sys when the system key was available.
  7. FCM push contains only metadata: incidentId, groupId, kind, hasEnvelope. No secrets or ciphertext in the push.
  8. The sender is excluded from the FCM fan-out — the sender already sees the alert on-screen, and alerting their own phone could be dangerous if they are under duress.
  9. An eligible recipient for whom an envelope was successfully sealed can call GET /v1/incidents/:groupId/:incidentId/envelope to fetch that opaque envelope.
  10. That recipient device attempts local decryption: KEM-DecapssharedSecretAES-256-GCM-Decrypt → plaintext.
  11. Decrypted location shown in an in-app alert. GPS coordinates are rendered as tappable links that open in the native maps app.
  12. Manager operations: when an incident has a valid nwatch-sys envelope, an active group manager can explicitly reveal it in the manager portal; every reveal is audit logged. Controlled administrative tooling can also re-wrap for a user who rotated keys.

What never leaves the device: member private keys and ML-KEM shared secrets. Alert plaintext enters the service only transiently when an authorised manager explicitly requests an audit-logged reveal over TLS; it is not persisted as plaintext.

Emergency alert capabilities

  • Android: Channel alerts is created natively with AudioAttributes.USAGE_ALARM, high importance, public lock-screen visibility, and custom siren alarm.wav. Full-screen and DND behavior depends on Android policy, user settings and special access. The app neither changes global DND mode nor raises system alarm volume, and it no longer requests overlay permission.
  • iOS: Incident pushes use APNs alert payload (aps.alert) with apns-priority: 10 and Critical Alert sound object (sound.critical: 1, name: alarm.wav, volume: 1.0) plus interruption-level: critical.
  • Permission hardening: Mobile startup runs a readiness check for ordinary notifications, iOS critical-alert authorisation, Android full-screen intent access and channel settings. The home screen preserves a visible readiness warning until required device settings are reviewed.

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.

Emergency alerts for trusted groups — not a replacement for local emergency services