Skip to content

NWatch security

Doc status: Latest (rolling). See Versions.

Goals

  • Prevent unauthorized alerts, membership changes, and admin actions.
  • Minimise stored sensitive data (no saved address as a plaintext profile field; optional precise GPS only in encrypted incident envelopes).
  • Make abuse and spam harder than legitimate use.
  • Keep security controls server-side and auditable.
  • Preserve the invited-group model — every member belongs to a manager-controlled group with no public searchable directory or automatic admission.

The group as a security boundary

The most important security property of NWatch is structural, not cryptographic: every member belongs to an invited group whose manager controls membership.

Groups are small by design. Managers decide who joins (invite codes), who stays (active), and who is removed (pause/ban). There is no public searchable directory or self-service group admission, and no one can enter a group without an invitation from its manager. A manual introduction request for a participating community group does not bypass that decision.

This human-scale trust model means:

  • Automatic discovery and admission are removed — joining requires an invite issued by a manager.
  • Abuse is controllable — managers can immediately pause or ban any member.
  • Scale does not erode trust — no matter how many groups exist across the platform, your group stays small and curated.

This structure is a launch design constraint. Any change to discovery or membership boundaries requires explicit security and privacy review.

Threat model (practical)

Primary threats

  • Account takeover of a legitimate user (phishing, device compromise).
  • Malicious member (or ex-member) attempting spam/harassment via alerts.
  • Invite leakage leading to unauthorized joins.
  • API abuse (bot traffic, token replay, endpoint discovery).
  • Device-token abuse (registering many tokens, sending to wrong audience).

Non-goals

  • Defending against a fully compromised manager account beyond standard controls.
  • Guaranteeing critical-alert behavior on iOS without Apple entitlement approval.

Identity & authentication

Mobile users (Firebase Auth — native SDK)

  • Mobile users authenticate with @react-native-firebase/auth using Google, a passwordless email link, or Microsoft when configured; Sign in with Apple is also available on iOS. The email link is one-time and secure: it is not a numeric OTP, and NWatch does not issue or store an NWatch password.
  • Microsoft accounts are linked only through an explicit signed-in account-linking flow. NWatch does not silently merge accounts that merely share an email address.
  • The native SDK initializes via platform config files (google-services.json / GoogleService-Info.plist), avoiding JS-level initialization issues on Android new-arch/Hermes.
  • All mobile API calls are authenticated with a Firebase ID token: Authorization: Bearer <token>.
  • The Worker verifies the Firebase ID token server-side and derives the canonical uid.
  • Apple Sign-In uses auth.AppleAuthProvider.credential(identityToken, nonce) with a SHA-256 hashed nonce for replay protection.

Super-admin users (Cloudflare Zero Trust + Microsoft Entra ID)

  • The super-admin console is protected by Cloudflare Zero Trust Access.
  • Authentication is delegated to Microsoft Entra ID (Azure AD) via OIDC SSO.
  • CF Access injects a signed JWT (CF_Authorization cookie) after successful SSO.
  • The admin app forwards this JWT to the Worker API via the cf-access-jwt-assertion header.
  • The Worker verifies the JWT against the CF Access team's JWKS endpoint (<team>.cloudflareaccess.com/cdn-cgi/access/certs).
  • The Worker maps the Entra email to a Firestore user record (auto-provisions if new).
  • External collaborators can be accommodated via Entra B2B guest accounts or CF Access one-time PIN policies.

Common

  • Never trust uid/role claims from the client; always compute authorization server-side.

Authorization & privileges

Authorization is enforced in the Worker, using Firestore membership state:

  • Member actions

    • registerDevice: only for self (uid from token)
    • joinGroupViaInvite: only for self
    • triggerIncident: only if the user is an active member of the group
  • Manager actions (group scoped)

    • pause / ban member
    • change member roles while preserving at least one active manager
    • create/revoke invites
    • view group members, invites, readiness and licence state
    • reveal one incident's details through an audited workflow and resolve incidents
    • manage seats and billing through Stripe-hosted pages
  • Super admin actions (platform-wide)

    • view all users, groups, licenses, payments
    • set user roles (standard/manager/super_admin)
    • grant/revoke licenses

Membership status is enforced server-side:

  • active: may trigger incident
  • paused: cannot trigger incident
  • banned: cannot trigger incident or re-join (unless explicitly unbanned)
  • Portal plaintext reveal (GET /v1/portal/groups/:groupId/incidents/:incidentId/decrypt) requires an active manager membership. Administrative reveal (GET /v1/admin/groups/:groupId/incidents/:incidentId/decrypt) still requires a super_admin to hold direct active membership in the target group.

Data minimisation & PII policy

  • Do not persist exact home addresses as plaintext server fields.
  • Avoid storing exact latitude/longitude when possible.
  • Prefer coarse “zones” (e.g., suburb-level or grid cell identifiers) rather than precise locations.
  • Store only what is necessary to operate:
    • user identity: uid, optional email/displayName
    • device push token + platform
    • group membership + role + status
    • incident metadata (time, group, type)

Address privacy (per-recipient encryption with a system recovery key)

  • A user's saved address is held on their phone (in AsyncStorage). After an alert is sent, that snapshot is also retained server-side only inside encrypted envelopes.
  • When an alert is triggered, the app attempts on-device encryption independently for each returned recipient key using ML-KEM-768 / AES-256-GCM and submits only successfully sealed envelopes.
  • One of those recipients is nwatch-sys — a server-managed KEM identity whose public key is returned alongside member keys by /v1/groups/:groupId/keys.
  • The server stores opaque per-recipient envelopes and its own system envelope — it can decrypt only the system envelope using the PQC_KEM_SECRET_KEY secret held in Cloudflare Worker env vars.
  • Only the intended member can decrypt their personal envelope using their device-local KEM secret key.
  • The system envelope enables two privileged operations:
    • Manager decrypt: a manager can view alert text for an incident via the admin dashboard (the Worker decrypts using the system key, audit-logged).
    • Re-wrap after key rotation: when a user reinstalls and rotates their KEM key, a manager can re-wrap existing envelopes so the user's new key can decrypt them.
  • The backend never stores plaintext alert text persistently; decryption happens transiently per request.

Cryptography (PQC)

Implemented with a native-first mobile path plus JavaScript fallback:

  • @neighbourhoodwatch/pqc-native (iOS CryptoKit): AES-256-GCM on iOS 13+, ML-KEM-768 on iOS 18.2+
  • mlkem — ML-KEM-768 key encapsulation (MIT)
  • @noble/ciphers — AES-256-GCM symmetric encryption (MIT)

Locked crypto suite:

  • Payload: AES-256-GCM
  • Key transport (KEM): ML-KEM-768
  • Key derivation: no separate HKDF step in the current incident wire path
  • Signatures: no ML-DSA signing in the current incident wire path

The repository contains unwired ML-DSA interfaces, but they are not a shipped protection. The current path uses the ML-KEM shared secret directly for AES-256-GCM.

For forward compatibility, include a cryptoSuite and kid (key id) in encrypted payload metadata.

PQC implementation (mobile)

The mobile app uses a native-first strategy:

  1. iOS attempts native cryptography first via @neighbourhoodwatch/pqc-native (CryptoKit-backed).
  2. If a native API is unavailable, the app falls back automatically to pure JS (mlkem + @noble/ciphers).
  3. Android currently uses the pure-JS path (unchanged).

The JavaScript path requires globalThis.crypto.getRandomValues, polyfilled at app startup using expo-crypto for Hermes compatibility.

Users can manually rotate their KEM keypair at any time from the app's settings menu. Previous secret keys are retained by kid in secure storage so historical envelopes remain decryptable.

Server-side PQC (API Worker)

The Cloudflare Worker holds a system KEM identity (PQC_KEM_SECRET_KEY / PQC_KEM_KID in Cloudflare secrets):

  • Its public key is stored in Firestore at system/crypto and returned alongside member keys.
  • Current sending clients include a system-key recovery envelope when the configured system key is returned, alongside per-recipient envelopes. The API logs a missing system envelope but does not currently reject an otherwise valid member-recipient incident.
  • The Worker can decrypt the system envelope to enable manager operations:
    • Admin decrypt (GET /v1/admin/groups/:groupId/incidents/:incidentId/decrypt): returns plaintext for the incident (audit-logged).
    • Re-wrap (POST /v1/admin/groups/:groupId/rewrapUser): when a user rotates keys (reinstall), the server decrypts the system envelope and re-encrypts for the user's new key.
    • System key rotation (POST /v1/superadmin/rotateSystemKey): re-wraps all system envelopes with the new key.
  • All system-key operations are logged in the audit trail.

Key management & rotation

Device keys (per user)

  • Each device generates an ML-KEM-768 keypair on first sign-in.
  • 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 previously encrypted envelopes can still be decrypted after rotation.
  • The public key is uploaded to users/{uid}/crypto/{kid} at sign-in and at invite redemption.
  • Users can manually rotate their KEM keypair at any time from the app's settings menu.
  • One active device per account: simultaneous multi-device use is not supported. A replacement phone may sign in with the same linked provider; its new active key may supersede the previous device for new envelopes. Historical keys remain available on a device only while its secure storage retains them.

System key (server-managed)

  • Generated offline via key generation script (uses mlkem ML-KEM-768 keygen).
  • The secret key is stored as a Cloudflare Worker secret (PQC_KEM_SECRET_KEY). The kid is stored as PQC_KEM_KID.
  • The public key is bootstrapped to Firestore at system/crypto via POST /v1/superadmin/bootstrapSystemKey.
  • Rotation procedure:
    1. Generate a new keypair (scripts/generate-system-keys.mjs).
    2. Bootstrap the new public key via the API.
    3. Call POST /v1/superadmin/rotateSystemKey with the old secret key (the Worker uses the new key from env).
    4. Update the CF Worker secret with the new secret key via wrangler secret put PQC_KEM_SECRET_KEY.

Recipient scoping

  • The Worker computes recipients using server-side membership state (group + status).
  • The Worker includes only device tokens belonging to active members in a new push fan-out. Targeting a token or receiving push-service acceptance is not proof that a device displayed, sounded or decrypted the alert.
  • Ex-members are excluded from new fan-out and cannot fetch a new recipient envelope merely because they retained an old key.

Sender safety

  • When a member triggers an alert, their own device does not receive the loud alarm notification.
  • This protects users who may be under duress. The service then attempts delivery to eligible devices belonging to the other active members.
  • The sender sees the server's storage and push-acceptance result on-screen; this is not confirmation that another device displayed the alert.

Push notification safety

  • Push requests are submitted via FCM HTTP v1 from the Worker; FCM acceptance is not device receipt.
  • Fan-out targets only the devices of active members in the relevant group.
  • Android alert presentation: Notification channel alerts uses AudioAttributes.USAGE_ALARM, high importance, vibration and public lock-screen visibility. DND bypass and full-screen presentation remain subject to special access, user settings and Android policy. NWatch does not turn DND off or raise system volume.
  • iOS Critical Alerts: APNs payload for incident notifications includes aps.alert, apns-priority: 10, sound.critical: 1, custom sound name, and interruption-level: critical (requires Apple-granted Critical Alerts entitlement).
  • Permission upgrade flow: On iOS, the app explicitly re-requests critical-alert permission even when general notification permission is already granted, and prompts the user to open Settings when criticalAlertSetting remains disabled.
  • Device tokens are treated as secrets:
    • never log full tokens
    • rotate/re-register on app reinstall

Emergency alerts

  • Android: Uses high-importance alerts notifications, alarm audio attributes and a full-screen incident activity when Android permits it. The app continuously checks whether the incident alarm has stopped so the activity closes instead of remaining over other apps. Overlay permission is not requested.
  • iOS: FCM payloads request Critical Alert presentation. Bypassing silent or Focus modes requires both an Apple-granted Critical Alerts entitlement and the user's permission; ordinary push remains the fallback.

Abuse controls

Minimum controls (server-side):

  • Rate limiting for incident triggering, configured to fail open so a limiter fault cannot silence a real alert.
  • Idempotency / dedupe to avoid accidental double sends.
  • Invite controls
    • expiry
    • max uses
    • revoke capability

Operational controls:

  • Managers can pause/ban members.
  • Maintain an incident review workflow (even if manual early).

Secrets & configuration

  • Service account credentials and FCM credentials must be stored as Cloudflare secrets/env vars.
  • Stripe keys (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) must be stored as Cloudflare secrets (never in wrangler.toml).
  • Never commit secrets to git.
  • Keep separate environments for dev/staging/prod where practical.

Payment security (Stripe)

  • All payment processing uses Stripe Checkout (Stripe-hosted page); no card data touches our API.
  • Stripe webhooks are verified server-side using HMAC-SHA256 with timing-safe comparison and a 5-minute timestamp tolerance.
  • License activation occurs only after webhook verification of checkout.session.completed.
  • Payment records are stored server-side in Firestore (payments collection) for audit.

Logging & monitoring

  • Log security-relevant events (without sensitive payloads):

    • auth failures
    • authorization failures
    • invite use / join
    • incident triggers (metadata only)
    • admin actions
  • Avoid logging PII and never log device tokens.

Firestore rules

Firestore rules should be treated as defense-in-depth.

  • The Worker is the primary enforcement point for privileged actions.
  • Client direct access should be restricted to the minimum required (ideally none for privileged writes).

Checklist

  • Server-side token verification on every API request
  • Group-scoped authorization on every privileged action
  • No saved addresses stored as plaintext server profile fields
  • Device tokens never logged
  • Rate-limit incident triggering without making the limiter a delivery dependency
  • Invite expiry + max-uses + revocation

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