This document describes noma_chat's threat model, what the SDK does and does
not protect against, and how to harden a consumer app that adopts it. It is
the canonical answer to "is noma_chat safe to put in front of users?".
If you spot a security issue, please do not file a public GitHub issue.
Email the maintainers (security@nomasystems.com) and we will respond within
72 hours.
| Surface | In-scope | Out-of-scope |
|---|---|---|
| Network in transit | ✅ TLS to the backend (REST + WebSocket + SSE) is required. Plaintext URLs are rejected by _validate(...) in ChatConfig._. |
E2E encryption (the backend cannot decrypt messages). The backend is in the trust boundary. |
| Auth token handling | ✅ Bearer JWT obtained through a tokenProvider callback, kept in memory, never persisted to disk by the SDK. |
Where the consumer app stores the long-lived refresh credentials. |
| Client-side data at rest | ✅ Hive cache, optionally encrypted at rest with HiveAesCipher. |
Backups outside the app sandbox (iCloud, ADB backups). |
| Sensitive payload logging | ✅ HTTP debug logger redacts password / token / secret / Authorization / common variants before truncating. Binaries replaced with <binary N bytes>. URLs sanitised so UUID path params are not logged. |
Bodies the app passes through client.messages.send(...) (the SDK has no signal that user content is sensitive). |
| Transport pinning | ChatConfig.certificatePins exists but is an experimental no-op — it does NOT enforce pinning yet (see "Certificate pinning" below). |
Certificate / public-key pinning. Not provided by the SDK today; enforce at the OS/network layer if you need it. |
| Backend impersonation | Partial. TLS prevents network-level impersonation; WsTransport.notifyTokenRotated rotates auth without reconnect. |
A compromised backend that returns malicious payloads (the SDK trusts the wire format). |
| Replay attacks | Backend-side responsibility. SDK does not add nonces. | Idempotency keys for non-idempotent POSTs (see RetryInterceptor opt-in flag instead). |
- Auth tokens come from a consumer-provided
tokenProvider: Future<String> Function(). The SDK never asks for raw credentials. - Tokens live in memory only. The SDK never writes them to Hive, shared preferences, or any other persistent store.
HttpDebugLoggerredacts them from log lines before truncation. logout()anddispose()cancel every in-flight request before tearing things down; cancelled requests do not trigger a token refresh (BearerAuthInterceptorshort-circuits onDioExceptionType.cancel). This prevents the "logout → 401 on flight → refresh with revoked token → UI loop" race that earlier versions had.notifyTokenRotated()rotates the token transport-side: WS sends an inlineauth_refreshframe (cooldown 30 s on the backend); SSE disconnects and reconnects with the fresh token via thetokenProvider.
- The default
HiveChatDatasourcewrites JSON blobs to per-room and per-entity Hive boxes under the app's documents directory. - Encryption is opt-in via
NomaChat.create(encryptionCipher: HiveAesCipher(key)). When set, every box is opened with the cipher; reads on an unencrypted box silently recreate it (box_corruptedmetric is emitted). - The cipher key is the consumer's responsibility. Suggested wiring on iOS / Android: derive a stable key from
flutter_secure_storage, generate one on first launch, and rotate by invokingawait chat.dispose(); await Hive.deleteFromDisk();before re-creating the chat with a new cipher.
ChatConfig.loggeris opt-in. The SDK never callsprintand never writes to disk directly.- When the consumer wires the HTTP debug logger (
enableHttpLog: true), bodies and headers are redacted before logging. The redaction key set is inHttpDebugLogger._sensitiveKeys(case-insensitive substring match):password,passwd,secret,token,access_token,refresh_token,id_token,api_key,apikey,authorization,auth,credential,credentials,pin,otp. - URL path params that match a UUID pattern are partially redacted (
<UUID:abc12...>) so user / room ids do not end up verbatim in third-party log sinks. - Pen-tests covering the redactor live in
test/sdk/http/logger_pentest_test.dart. They use a sink fake plus a list of known-sensitive strings (plaintext-pwd,real-jwt-here, …) and fail the build if any of them ever surfaces in a log line.
⚠️ Not enforced yet. Certificate pinning is an experimental skeleton in the0.xline. The SDK does not currently validate certificates against the configured pins and gives you no MITM protection beyond the platform trust store. Do not rely on it as a security control. If you need pinning today, enforce it at the OS / network layer.
- Off by default. Cross-platform pinning (Android, iOS, macOS, web) is non-trivial and the right pin set is app-specific.
ChatConfig.certificatePins: List<String>?accepts SHA-256 fingerprints. When set, the SDK attachesCertificatePinningInterceptor(annotated@experimental). That interceptor only normalises and records the pins and re-labels a Dio-surfaced handshake error as a typedCertificatePinningException. It does not install the nativebadCertificateCallback/ HTTP adapter, so no certificate is ever compared against the pins. Setting the field emits awarnlog saying exactly this.- On Flutter web, pinning will always be a no-op (the browser is the TLS terminator; pinning has to happen via HSTS / OS keychain).
- Tracking: enforcement is a planned follow-up (see
ISSUES.mdin the SDK info docs). Until a CHANGELOG entry says pinning is enforced, treatcertificatePinsas documentation-only.
The SDK draws a hard line between reliability (best-effort, swallowed via metric / log) and security (failures surface as ChatFailure):
- A corrupt Hive box is reliability — it gets recreated, the consumer sees an empty cache instead of a crash.
- A token refresh that returns a 401 is security — the consumer's
onAuthFailureis invoked exactly once, after which the SDK stops trying to refresh.
(The intended "a failed certificate pin surfaces as a ChatFailure, the request never completes" example is not in force yet — pinning is not enforced, see the warning above.)
| Out of scope | Why | What to do instead |
|---|---|---|
| End-to-end encryption | Backend descarted (see ADR-057 in noma_chat_flutter/INTEGRATION.md). Backend needs to read messages for moderation, push, search. |
If E2EE is a hard requirement, pick a different SDK (Matrix, Signal protocol). |
| Push notifications | SDK does not configure FCM/APNs. | Consumer wires push, calls chat.refresh() on background-fetch events. |
| Secure key storage | SDK doesn't ship a default — keys vary per platform. | flutter_secure_storage (iOS Keychain / Android Keystore) is the conventional pair. |
| Replay protection on writes | Backend signs / nonces are out of scope. | Use idempotency hints (options.extra['idempotent'] = true) only for genuinely safe-to-replay POSTs. The default is no-retry for POST on transient connection errors. |
| Audit log of admin actions | Not tracked client-side. | Backend audit log + ack via MetricCallback. |
| Rate limiting | SDK can be enabled to retry with backoff; abuse prevention is server-side. | Backend rate limits + the consumer's onAuthFailure. |
Tick these before shipping noma_chat to production users:
- TLS only. Reject plaintext URLs. The SDK already does — confirm your config matches.
- Token storage. Long-lived refresh credentials live in
flutter_secure_storage, not inshared_preferencesor Hive. - Cipher key. If you opt into
encryptionCipher, the key is derived from / stored in the keychain. Don't hard-code. - Certificate pinning. The SDK does not enforce pinning yet (
certificatePinsis an experimental no-op). If you need pinning before SDK enforcement lands, do it at the OS / network layer (Android Network Security Config, iOS App Transport Security / a native pinning library). -
enableHttpLog: falsein release. Even with redaction the logger emits paths and statuses; in release that goes nowhere useful and increases attack surface. Guard withkDebugMode. - Sink discipline. Where you wire
ChatConfig.logger, do not forwarddebug/infoto remote sinks.warn/erroronly. - OnAuthFailure. Implement
onAuthFailure: () => signOut()— the SDK gives up after a single token refresh attempt. - Cancel on background. If the app supports backgrounding, call
chat.disconnect()onAppLifecycleState.pausedto release the WS socket cleanly (the SDK reconnects on resume). - Sanitise tap targets. A11y review covers WCAG AA tap targets (≥48 dp) in the composer and the recorder overlay; tests live in
test/a11y/.
- The
0.xline may change the threat model in any minor bump. Read the CHANGELOG before upgrading. - The HTTP debug logger redacts based on key names; payloads using non-standard key names (e.g.
pwdas a custom field) are not auto-redacted. Either rename to a canonical key or extend the redaction set via a fork. MockChatClientshort-circuits the redaction pipeline (it never goes throughHttpDebugLogger). In tests, do not rely on the mock to prove that redaction works — usetest/sdk/http/logger_pentest_test.dart.
- 2026-05-26 — Full external audit (Fases 1-4). Findings closed: HTTP body logger redaction, in-flight request cancellation on logout, idempotency-aware retry, URL sanitisation. Certificate pinning shipped only as an
@experimentalAPI skeleton (config field + typed exception); enforcement deferred. - 2026-05-26 — Fase 5: pen-tests added (
test/sdk/http/logger_pentest_test.dart),X-Noma-Chat-Versionheader, fullTELEMETRY.md. - 2026-06-17 — Pre-PR review: corrected this document to stop claiming certificate pinning is enforced (it is an experimental no-op); added a runtime
warnwhencertificatePinsis set. AutogeneratedclientMessageIdonmessages.sendso retried sends are de-duplicated server-side.
security@nomasystems.com — PGP key on request. Please include a proof of concept and the affected version. We will coordinate disclosure with a fix released as a patch on the active minor branch.