Appearance
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/authusing 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_Authorizationcookie) after successful SSO. - The admin app forwards this JWT to the Worker API via the
cf-access-jwt-assertionheader. - 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 (uidfrom token)joinGroupViaInvite: only for selftriggerIncident: 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 incidentpaused: cannot trigger incidentbanned: 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 asuper_adminto 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, optionalemail/displayName - device push token + platform
- group membership + role + status
- incident metadata (time, group, type)
- user identity:
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_KEYsecret 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:
- iOS attempts native cryptography first via
@neighbourhoodwatch/pqc-native(CryptoKit-backed). - If a native API is unavailable, the app falls back automatically to pure JS (
mlkem+@noble/ciphers). - 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/cryptoand 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.
- Admin decrypt (
- 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-storein OS-protected Android Keystore / iOS Keychain storage — never uploaded or placed in ordinary plaintext app storage. - Secret keys are retained by
kidin 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
mlkemML-KEM-768 keygen). - The secret key is stored as a Cloudflare Worker secret (
PQC_KEM_SECRET_KEY). The kid is stored asPQC_KEM_KID. - The public key is bootstrapped to Firestore at
system/cryptoviaPOST /v1/superadmin/bootstrapSystemKey. - Rotation procedure:
- Generate a new keypair (
scripts/generate-system-keys.mjs). - Bootstrap the new public key via the API.
- Call
POST /v1/superadmin/rotateSystemKeywith the old secret key (the Worker uses the new key from env). - Update the CF Worker secret with the new secret key via
wrangler secret put PQC_KEM_SECRET_KEY.
- Generate a new keypair (
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
alertsusesAudioAttributes.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, andinterruption-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
criticalAlertSettingremains disabled. - Device tokens are treated as secrets:
- never log full tokens
- rotate/re-register on app reinstall
Emergency alerts
- Android: Uses high-importance
alertsnotifications, 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 (
paymentscollection) 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