Appearance
Doc status: Latest (rolling). See Versions.
Goals
- Prevent unauthorized alerts, membership changes, and admin actions.
- Minimise stored sensitive data (no exact home addresses; avoid exact lat/lng when possible).
- Make abuse and spam harder than legitimate use.
- Keep security controls server-side and auditable.
- Preserve the curated group model — every member belongs to a small, vetted group managed by someone they know personally.
The group as a security boundary
The most important security property of N-Watch is structural, not cryptographic: every member has a known group manager who personally 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 discovery, no self-service sign-up, and no way for a stranger to enter a group without an invitation from its manager.
This human-scale trust model means:
- Social engineering is harder — you know everyone in your group.
- 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 deliberate design invariant and will never be compromised.
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— the React Native Firebase native SDK (Google Sign-In on both platforms, Sign in with Apple on iOS). - 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.
Admin users (Cloudflare Zero Trust + Microsoft Entra ID)
- The admin dashboard 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
- create/revoke invites
- broadcast
- view group members, invites, license, payments
- initiate Stripe checkout (redirects to Stripe-hosted page)
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)- Incident plaintext decrypt (
GET /v1/admin/groups/:groupId/incidents/:incidentId/decrypt) requires the actor to be an active member of the target group, includingsuper_adminusers.
Data minimisation & PII policy
- Do not store exact home addresses.
- 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 (on-device + E2E with system key)
- A user's exact home address / alert text is stored only on their phone (in
AsyncStorage). - When an alert is triggered, the plaintext is encrypted on-device for each recipient individually using ML-KEM-768 / AES-256-GCM.
- 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 using pure-JavaScript libraries compatible with Hermes engine (React Native):
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: HKDF-SHA256
- Signatures: ML-DSA-44 (optional but recommended)
Important detail: ML-DSA is a signature scheme (authenticity/integrity), not encryption. For key transport/encryption we use a KEM (ML-KEM).
For forward compatibility, include a cryptoSuite and kid (key id) in encrypted payload metadata.
PQC implementation (mobile)
The mobile app uses pure-JavaScript PQC libraries that run directly on the Hermes engine:
mlkem— ML-KEM-768 key encapsulation (keygen, encaps, decaps)@noble/ciphers— AES-256-GCM symmetric encryption
Both require globalThis.crypto.getRandomValues, which is 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. - Every alert is encrypted for the system key by the sending device (alongside per-recipient envelopes).
- 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 in the OS secure enclave via
expo-secure-store(Android Keystore / iOS Keychain) — never leaves the device, never in plaintext 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 device per account: multi-device is not supported — using a second device requires a separate email. On reinstall, a new active key is registered while historical keys remain available for envelope decryption.
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).
- Only active members receive the push payload.
- Ex-members do not receive pushes; even if they retained old keys, they cannot decrypt messages they do not receive.
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 — their phone stays silent while every other group member is alerted.
- The sender sees confirmation on-screen immediately; they do not need the push notification.
Push notification safety
- Push is sent via FCM HTTP v1 from the Worker.
- Fan-out targets only the devices of active members in the relevant group.
- Android DND bypass: Notification channel
alertsis created natively inMainApplication.onCreatevia thewithAlarmChannelExpo config plugin, usingAudioAttributes.USAGE_ALARM(alarm audio stream) +setBypassDnd(true). Custom 30-second siren sound (alarm.wav) plays through the alarm stream.ACCESS_NOTIFICATION_POLICYis manifest-declared; the app prompts for DND-override access on first sign-in. - iOS Critical Alerts: APNs payload includes
sound.critical: 1,volume: 1.0, andinterruption-level: critical(requires Apple-granted Critical Alerts entitlement). - Device tokens are treated as secrets:
- never log full tokens
- rotate/re-register on app reinstall
Emergency alerts
- Android: Uses notification channel
alertswithIMPORTANCE_HIGH,AudioAttributes.USAGE_ALARM,setBypassDnd(true), public lockscreen visibility. Sound routes through alarm audio stream — comparable to Find My Device. - iOS: FCM payloads include
apns-priority: 10and Critical Alert (critical: 1, volume: 1.0) so notifications sound at full volume even on silent. Requires Apple Critical Alerts entitlement.
Abuse controls
Minimum controls (server-side):
- Rate limiting for incident triggering and admin broadcast.
- 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 exact addresses stored
- Device tokens never logged
- Rate limit trigger/broadcast
- Invite expiry + max-uses + revocation

