16 · Security & Data Protection
Purpose: Define the threat model, security controls, data-protection features and operational security practices of Rasd Forms — what the library enforces by default, what it exposes as policy, what the host app must do, and how the project itself is secured (disclosure, patching, supply chain, testing).
Audience: Engineers building @rasd/*; security and data-protection focal points at UN/NGO organisations evaluating or deploying Rasd Forms; developers integrating it into a host app.
TL;DR
- Baseline: OWASP MASVS v2.1.0 (L2 for STORAGE/PLATFORM/AUTH/PRIVACY, verified with MASTG) and OWASP ASVS 5.0.0 for web/PWA (research/11).
- Enumerator devices talk only to the customer's backend over TLS and never contact Rasd; license verification is offline (15 · Licensing §16). No telemetry, fingerprinting or crash reporting built in.
- Encrypted at rest by default: AES-256-GCM with a non-extractable WebCrypto key on web, SQLCipher with the key in
expo-secure-store/Keychain/Keystore on native; crypto-shredding is the wipe primitive. - PII is declared, not guessed:
bind.sensitivedrives encryption, redaction, autofill/clipboard hardening and screen-capture prevention;consentrecords a versioned, withdrawable consent. - Optional end-to-end encryption (
settings.encryption.mode: "field" | "submission") uses standard JWE (ECDH-ES+A256KWorRSA-OAEP-256,A256GCM) — the modern equivalent of ODK encrypted forms. - Form definitions are untrusted: DOMPurify allow-list on web, markdown-to-native on RN, media allow-list, prototype-pollution rejection, size limits, REL sandbox with budgets and ReDoS lint.
- Safe defaults are one object (
SecurityPolicy); active controls are one report (securityReport()); residual risks are a written register. - Project security: 90-day coordinated disclosure with SLAs, pnpm 11 supply-chain defaults, OIDC trusted publishing with provenance, CycloneDX SBOM, SAST + fuzzing + MASTG automation, annual pen-test.
1. Scope, standards and invariants
Rasd Forms is a library, not a service: beneficiary data lives on the enumerator's device and on the customer's backend (self-hosted @rasd/server or Rasd Cloud). Controls therefore come in three kinds: the library implements and enables by default (MUST), implements behind policy (OPT), or can only document and lint for the host (DOC) — the legend from research/11 §3, used throughout.
Invariants every package honours:
| ID | Invariant |
|---|---|
| SEC-1 | Production end-user devices make zero requests to non-host origins (verified by network capture in CI). |
| SEC-2 | Every payload column and every attachment is encrypted at rest unless the host explicitly sets encryption.atRest: 'off' or a surfaced fallback fires (onSecureStoreUnavailable, red web no-key warning — §4); index columns are cleartext and documented. |
| SEC-3 | Form definitions, datasets and server responses are untrusted input; nothing in them can execute code, load remote content outside the allow-list, or exceed a size budget. |
| SEC-4 | Answers, dataset rows, bearer tokens and tus URLs never appear in logs, crash reports, notifications or error messages. |
| SEC-5 | No security control ever destroys unsynced field data silently; wipe is explicit (two-step locally, authenticated remotely) and export() works in every license state (09 · Offline storage §10). |
| SEC-6 | No custom cryptography: WebCrypto / SQLCipher / @noble/* primitives in standard constructions (AES-GCM, AES-KW, HKDF, PBKDF2, Ed25519 JWS, JWE). |
flowchart LR
subgraph Device["Enumerator device — host app + Rasd runtime"]
R[Renderer + engine]
S[(Encrypted storage)]
K[Key store<br/>WebCrypto / SecureStore]
R --> S
S --- K
end
subgraph Host["Customer backend — self-hosted or Rasd Cloud"]
RSP[RSP server]
DB[(Postgres + object store)]
RSP --> DB
end
subgraph Rasd["Rasd license service"]
L["/v1/tokens/refresh"]
end
Device -- "TLS · host bearer token · X-Rasd-Device" --> RSP
Host -- "org secret · server-to-server" --> L
Device -. "never" .- Rasd
2. Assets and data classification
| Class | Examples | Default handling |
|---|---|---|
| Restricted | Values of bind.sensitive elements, consent values, attachments (photos, audio, signatures), audit GPS trail, DEK/DB keys, host bearer token, E2E private keys | Encrypted at rest; never indexed, logged or shown in lists; excluded from OS backups; field-encrypted E2E when settings.encryption.mode ≠ "none"; export encrypted-only |
| Confidential | All other answers, drafts, finalized-unsynced submissions, dataset rows with beneficiary data, records | Encrypted at rest; purged after sync; datasets projected to referenced columns |
| Internal | Form definitions, choice lists, non-PII datasets, sync log, device policy, cleartext index columns (formId, status, timestamps, instanceName) | Integrity-protected (definitionHash, optional JWS); size-capped; may be cleartext |
| Public | RLT license token, Rasd public keys, @rasd/* bundles | Signed / provenance-attested; assume visible in the client bundle |
bind.sensitive: true is the single PII flag (04 · Form schema §9). It fans out to: field-level at-rest encryption when SQLCipher is unavailable (09 §8.3); E2E field encryption (§8); masking in readOnly views and the "sent" list; redaction from instanceName (W_SENSITIVE_IN_INSTANCE_NAME), logs and redacted exports; importantForAutofill="no", autoComplete="off", autoCorrect={false}, textContentType="none", contextMenuHidden on inputs; and preventCaptureOn: 'sensitiveFields'. bind.saveIncomplete: false keeps a half-typed identifier out of storage; bind.index: true on a sensitive field warns (W_INDEX_ON_SENSITIVE) because index columns are cleartext. Form-level metadata (legal basis, purpose, controller, retention) lives in settings.ext["dev.rasd.dataProtection"] (§9).
3. Threat model
3.1 Actors and scenarios
| # | Actor / scenario | Assets | Primary controls (owner) |
|---|---|---|---|
| T1 | Lost/stolen phone, screen-locked | drafts, finalized, attachments, datasets, keys, tokens | Encryption at rest + OS key store, backup exclusion, purge-after-sync, remote wipe (MUST); app lock (OPT) |
| T2 | Second enumerator on a shared device | drafts, finalized, datasets | Per-user namespaces + logout() without loss, redacted "sent" list (MUST); admin PIN (OPT) |
| T3 | Seized device, forensic extraction | all | Crypto-shredding wipe, secure_delete+VACUUM on plaintext fallback, no PII in logs (MUST); strong PIN/MDM (DOC) — residual |
| T4 | Malicious/compromised server, malicious form author | renderer, drafts | Sanitizer allow-list, no HTML on RN, media allow-list, size caps, prototype-pollution rejection, REL budgets (MUST); JWS-signed definitions (OPT) |
| T5 | Compromised host app / third-party script on the origin | keys, tokens, data | Non-extractable WebCrypto key (limits key theft, not data reads), token in memory (MUST); CSP/Trusted Types, host SBOM (DOC) — residual on RN |
| T6 | Network attacker (captive Wi-Fi, rogue CA) | tokens, payloads | requireHttps, no redirects on writes (MUST); fetch injection for SPKI pinning (OPT); user CAs untrusted on Android ≥ 7 (platform) |
| T7 | Malicious insider with export/supervisor rights | finalized, attachments, datasets | Encrypted-only export with audit event (MUST); admin PIN, E2E encryption so operators cannot read (OPT); server RBAC and access logs (DOC) |
| T8 | OS/cloud backup leakage | everything on disk | dataExtractionRules/allowBackup=false via config plugin, iOS isExcludedFromBackup, …ThisDeviceOnly Keychain class (MUST) |
| T9 | Side channels: logs, crash reporters, clipboard, autofill, thumbnails, notifications | drafts, datasets | Redacting logger, createSentryScrubber(), sensitive-input hardening, count-only notifications (MUST); FLAG_SECURE (OPT) |
| T10 | Supply chain: malicious dependency, CI token theft | every customer | Lockfiles, minimumReleaseAge, Socket/pnpm audit, OIDC provenance, SBOM, SHA-pinned Actions (§20) |
3.2 STRIDE summary
| Category | Representative threat | Control |
|---|---|---|
| Spoofing | Device impersonation; forged form pushed to devices | Host bearer + X-Rasd-Device bound server-side (10 · Sync); JWS-signed definitions verified against the server's JWKS (GET /v1/.well-known/jwks.json, cached 7 days — 10 §7) |
| Tampering | Submission altered on disk or in transit; ciphertext transplanted between rows | checksum (SHA-256, canonical JSON) verified before push and echoed in the ack; AES-GCM AAD = <table>/<primaryKey>; TLS |
| Repudiation | Enumerator denies an entry; admin denies a wipe | Audit trail with device/user/HLC inside the checksummed payload (§10); wipe orders logged with actor and reason |
| Information disclosure | Device loss, backups, logs, server operators | Encryption at rest, backup exclusion, redaction, E2E |
| Denial of service | Expression CPU/memory bombs, giant datasets, quota exhaustion, retry storms | REL budgets, size caps, estimate() + RASD_STORAGE_QUOTA, jittered backoff + idempotency |
| Elevation of privilege | XSS via labels, prototype pollution, expression escape, WebView bridge abuse | DOMPurify + Trusted Types, null-prototype trees, no eval, local-only WebView with typed messages |
4. Encryption at rest — design and limits
The full mechanism is specified in 09 · Offline storage §8; this section fixes the security-relevant decisions.
| Topic | Web (@rasd/storage-dexie) | Native (@rasd/storage-sqlite) |
|---|---|---|
| Primitive | AES-256-GCM per payload column and blob; 96-bit random IV; AAD binds table + key | SQLCipher whole database (AES-256-CBC + HMAC-SHA-512 per page, incl. WAL/journal); attachments AES-256-GCM per file |
| Key | Non-extractable CryptoKey in IndexedDB; optional wrapped mode (AES-KW under a host KEK from deriveKeyFromPassphrase, PBKDF2-HMAC-SHA-256, 600 000 iterations) | 256-bit random key in expo-secure-store (WHEN_UNLOCKED_THIS_DEVICE_ONLY), optional requireAuthentication |
| Fallback | Unencrypted with a red console warning; securityReport().encryption = 'none' | Field-level AES-GCM for bind.sensitive values (enc:v1: envelopes) when SQLCipher is absent; onSecureStoreUnavailable — never silent |
| Rotation | rotateKey() chunked re-encrypt (500 rows/tx) | PRAGMA rekey in a backup-first migration |
| Wipe | Delete key row → Dexie.delete | SecureStore.deleteItemAsync → delete DB + WAL/SHM + files |
Limits (state these to customers verbatim): encryption at rest defends against disk inspection, other apps and casual backup restore. It does not defend against script in the same origin (T5), a rooted device with a debugger, a coerced unlock, or forensic recovery outside the sandbox (keyboard cache, thumbnails). Cleartext: index columns (formId, status, timestamps, instanceName unless disabled, dataset keys/labels/filter columns, attachment sizes and hashes), database names (rasd__<namespace>), row counts. Web storage is best-effort until navigator.storage.persist() is granted; eviction is not a secure erase — crypto-shredding is.
5. Key management
| Key | Where it lives | Lifetime / rotation | Notes |
|---|---|---|---|
Web DEK (k1…) | IndexedDB _rasd_keys, extractable: false | Until wipe; rotate on staff turnover or suspected compromise | Usable by same-origin script, never readable; wrapped mode makes it ciphertext at rest |
| Host KEK / passphrase key | Memory only (unwrap at open()) | Per session; host prompts via { unlock() } | Shared browsers/kiosks; PBKDF2 600 000 it., 16-byte salt |
| Native DB key | SecureStore rasd.dbkey.<namespace> | Until wipe; survives iOS reinstall ⇒ deleted explicitly | Never MMKV/AsyncStorage; ≤ 2 KB |
| Host bearer token | Memory (web); SecureStore (RN, opt-in persistHostToken) | Host refresh via getAuthToken() | Never localStorage; redacted |
| RLT (license) | storage.kv | 60 d + 30 d grace | Public by design; Ed25519 with embedded keys (15) |
| E2E recipient public keys | storage.kv['enc.pub.<publicKeyId>'], pinned by RFC 7638 thumbprint | Per form version; rotate via new publicKeyId | Fetched through the host keyProvider on form pull; never in the RFD |
| E2E private keys | Never on devices or the RSP server | Customer HSM/laptop; rasd decrypt | Loss = permanent data loss; document escrow |
| Definition signing key | Server private; public key served from the RSP origin's GET /v1/.well-known/jwks.json (client caches 7 days — 10 §7) | Yearly | Optional JWS over canonical RFD |
| Rasd license signing keys | Rasd HSM; public keys embedded by kid | Yearly, JWKS fallback | 15 §3 |
Rules: keys come from crypto.getRandomValues / expo-crypto, never from device identifiers; no key crosses the RSP wire except wrapped E2E content keys and the public definition-signing key; securityReport() states the active key store and whether biometrics bind the key (expo-local-authentication alone is UX-only and fails MASTG event-bound tests — research/11 §4.1).
6. Transport security
- TLS only.
createSyncEngine({ policy: { requireHttps: true } })is the default;http:is refused withRASD_TRANSPORT_INSECUREunless the host islocalhost,127.0.0.1,10.0.2.2or*.local(dev — 10 §1.2) andrequireHttps: false. No redirects onPOST/PATCH. On AndroidusesCleartextTrafficstays false and user CAs are untrusted (API ≥ 24). - Pinning is opt-in via
fetchinjection, not built in: agencies with stable, self-controlled certificates plugreact-native-ssl-public-key-pinning(≥ 2 SPKI hashes) or a pinnedfetch; backends behind managed TLS should not pin — a pin failure is a field outage (research/11 §6). - Tokens and capability URLs. Bearer on every RSP and tus request; tus URLs are capability secrets, logged as
att:<id-prefix>; a 401 mid-sync triggersgetAuthToken()without losing tus offsets. - Web CSP. No
unsafe-evalanywhere; constructed stylesheets orcssNonce;worker-src 'self';img-src 'self' blob:; Trusted Types viaRETURN_TRUSTED_TYPE. Policy table and IIFE SRI in 11 · PWA & embedding §12. - Integrity end-to-end. SHA-256 per submission (echoed in the ack) and per attachment (tus
Upload-Metadata.sha256, whole-file check,412 checksum_mismatch).
7. Device policies
7.1 SecurityPolicy
One object configures every OPT control. It is passed as security on <RasdProvider>; storage, sync, media and renderers read their slices. Values shown are the defaults.
interface SecurityPolicy {
encryption: {
atRest: 'required' | 'preferred' | 'off'; // 'preferred': encrypt when possible, emit onSecureStoreUnavailable otherwise; 'required': refuse to open
keyAuth: 'none' | 'biometric'; // 'biometric' ⇒ SecureStore requireAuthentication (re-enrolment invalidates the key)
keyProvider?: EncryptionKeyProvider; // E2E public keys (§8)
};
backup: { excludeFromOsBackup: true; excludeFromDeviceTransfer: true };
retention: {
purgeFinalizedAfterSync: true; purgeSyncedAfterDays: 7; keepSentMetadataDays: 90;
draftMaxAgeDays: 30; datasetTtlHours: 168; auditTrail: 'off' | 'events' | 'events+gps'; // 'events'
};
lock: { autoLockAfterMs: 300_000; lockOnBackground: true; requireForExport: true; adminPinHash?: string };
screen: { preventCaptureOn: 'none' | 'sensitiveFields' | 'all' }; // 'sensitiveFields'
content: {
markdown: 'safe' | 'off'; mediaAllowList: string[]; // 'safe'; [syncOrigin]
maxDefinitionBytes: 2_097_152; maxDatasetRows: 100_000; maxChoiceListSize: 10_000; maxStringBytes: 65_536;
exprBudget: { steps: 100_000; deadlineMs: 10 }; regexMode: 'warn' | 'reject'; // 'warn'
};
transport: { requireHttps: true; fetch?: typeof fetch };
tokens: { persistHostToken: false };
logging: { level: 'error' | 'warn' | 'info' | 'debug'; redactor?: (rec: LogRecord) => LogRecord }; // 'warn'
export: { mode: 'encrypted-only' | 'allow-plaintext'; recipientKeys?: JsonWebKey[] }; // 'encrypted-only'
profiles: { enabled: true };
}
Events on the provider: onLock(reason: 'idle'|'background'|'switch'|'policy'), onUnlockRequired(next), onRemoteWipe({ reason, unsyncedCount }), onLocalWipeRequested({ unsyncedCount, confirm }), onPolicyViolation({ kind: 'html'|'url'|'size'|'expr'|'proto'|'transport'|'regex', detail }), onSecureStoreUnavailable({ fallback }), onBackupExclusionMissing(platform). securityReport() (exported by @rasd/react/@rasd/native; the storage and native-module variants are storage.securityReport() and the RasdSecurity module report — 09 §3, 07 §16) aggregates the storage, sync, media and native-module reports into { encryption, keyStore, backupExcluded, persisted, ephemeral, https, sanitizer, lock, purge, notices[] } for the vendor pack and the host's CI.
7.2 Lock, profiles, purge, wipe
stateDiagram-v2
[*] --> unlocked
unlocked --> locked: idle >= autoLockAfterMs / app background / switchProfile / server policy
locked --> unlocked: host unlock (PIN or key-bound biometric)
locked --> wiped: X-Rasd-Wipe validated, or local wipe confirmed twice
unlocked --> wiped: X-Rasd-Wipe validated
wiped --> [*]
- Lock hides the renderer and blocks storage reads until
onUnlockRequiredresolves; the host owns the UI (PIN, key-bound biometric, SSO). Sync continues while locked. - Profiles:
namespace = <orgSlug>.<userId>gives each enumerator a separate database, key and file directory;logout()closes, never deletes; a supervisor override requires the admin PIN and writes audit eventadmin.override. ODK-style access control (hide "delete saved"/"edit sent"/settings) is a host recipe overreadOnly,requireForExportandadminPinHash; MDM-pushedserverUrl/policyProfilearrive via Android managed configurations through a host adapter. - Purge-after-sync: finalized submissions and attachments are removed
purgeSyncedAfterDaysafter the ACK (0 = immediately); the "sent" list keeps metadata only (id,formId,instanceNameunless sensitive,syncedAt) forkeepSentMetadataDays. ServerDevicePolicy.retentionoverrides whenenforce: true(10 §2.2). - Remote wipe:
X-Rasd-Wipe: <nonce>on an authenticated 2xx orpolicy.wipe; nonce checked againstkv['wipe.seen'];mode: 'sync-first'runs a 60 s submissions-only drain; thenstorage.wipe()crypto-shreds keys, deletes DB/blobs/profiles and emitsonRemoteWipe. Web servers addClear-Site-Data: "storage"on the next navigation. Local wipe with unsynced items is always two-step with the count shown. - Backups: the
@rasd/nativeconfig plugin writesdataExtractionRules(cloud-backup and device-transfer) excludingdatabase/and the attachments dir, merged withexpo-secure-store's rules; theRasdSecuritymodule setsisExcludedFromBackupon iOS;onBackupExclusionMissingfires otherwise (07 · Native renderer §16). - Screen capture:
preventScreenCaptureAsyncwhile a screen with abind.sensitiveelement is mounted (sensitiveFields) or always (all); the screenshot listener is never used (needs Play-restrictedREAD_MEDIA_IMAGES).
8. Submission-level encryption (E2E)
settings.encryption in the RFD (04 §4.3) declares intent; this section is the mechanism. It is compatible in spirit with ODK encrypted forms (random per-submission content key, asymmetric wrapping, server can only store/forward, offline decryption with the private key) but uses standards-based WebCrypto constructions instead of RSA/AES-CFB, and is an optional capability (requires.features: ["cap:encryption"]).
Modes. field: only bind.sensitive values are encrypted; validation, OData and records keep working for everything else. submission: the finalized data, audit and all attachments are enveloped; the server skips content validation (steps 4–5 of 10 §3) and records/OData are unavailable for that form.
Envelope. One JWE (RFC 7516, JSON serialization) per submission with protected = { alg, enc: "A256GCM", kid: <publicKeyId>, "dev.rasd/v": 1, "dev.rasd/sub": { id, formId, formVersion, definitionHash } }, alg ∈ ECDH-ES+A256KW (default; P-256 + Concat KDF with SHA-256 per RFC 7518, pure JS on RN via @noble/curves + @noble/ciphers) or RSA-OAEP-256 (2048/3072-bit; WebCrypto, react-native-quick-crypto on RN). The protected header is the AAD, binding ciphertext to submission id and form version. The JWE plaintext is a manifest:
{ "data": { /* full or sensitive-only answers */ }, "audit": [ /* submission mode only */ ],
"attachments": [ { "id": "0198…", "field": "photo", "key": "<base64url 32 bytes>", "sha256Plain": "…", "sha256Enc": "…", "bytes": 182334 } ] }
In field mode the manifest carries only the sensitive values and each such value in data is replaced by the marker "e2e:v1:<sha256-prefix>" (one JWE, one CEK per submission). Attachments are encrypted as a chunked AEAD stream with the per-attachment key from the manifest: 1 MiB chunks, AES-256-GCM, nonce = 8-byte random prefix ‖ 4-byte big-endian counter, AAD = "rasd-att:v1:" + attachmentId + ":" + index (+ ":last"), file header RASDENC1 — the age/Tink streaming-AEAD construction, so even an attachment at the 100 MiB native/server ceiling (00 §7.1) never sits in memory. The wire Submission keeps cleartext id, formId, formVersion, definitionHash, meta (minus instanceName when sensitive), attachments[].{id, field, mime, bytes, sha256} (hash of the ciphertext) and checksum (over the envelope), and adds encrypted: { v: 1, mode, publicKeyId, jwe }.
sequenceDiagram
participant D as Device
participant KP as Host keyProvider
participant S as RSP server
participant A as Analyst with private key
D->>KP: getPublicKey("wfp-jo-2026") on form pull, cached + thumbprint-pinned
D->>D: finalize(): CEK, JWE(manifest), chunked-AEAD attachments, checksum
D->>S: POST /v1/submissions:batch (encrypted envelope)
D->>S: tus upload (ciphertext, sha256Enc)
S-->>D: accepted + serverRev + checksum echo (no content validation)
A->>S: export bundle
A->>A: rasd decrypt --key private.jwk → JSONL + files
Rules and failure modes: the host supplies security.encryption.keyProvider: { getPublicKey(publicKeyId: string): Promise<JsonWebKey> }; the sync engine calls it and caches the JWK in kv when a form referencing publicKeyId is pulled; without the key a form can be drafted but not finalized (RASD_ENCRYPTION_KEY_UNAVAILABLE). Encryption happens at finalize, so encrypted rows are opaque to the local "sent" view too. rejected can only be envelope-level (spec_unsupported, size). rasd decrypt (@rasd/cli, Node) and a browser decrypt page in the reference dashboard verify sha256Plain after decryption. @rasd/server never holds private keys; the customer documents escrow. submission-mode forms cannot use records or server-side instanceName (in field mode both keep working for non-sensitive values).
9. Consent capture patterns
The consent element (04 §10.12) stores { granted, at, textVersion, locale, method, signature? }, is always audited (even with settings.audit.enabled: false) and is field-encrypted like any bind.sensitive value.
| Pattern | How |
|---|---|
| Informed consent, tap | method: "tap", required: true; refusal path via trigger when: "${consent}.granted = false" → complete so the interview ends without collecting PII |
| Written consent | method: "signature" stores a trimmed PNG attachment |
| Verbal consent, witnessed | method: "verbal"; enumerator attests; meta.userId recorded in the audit event |
| Guardian / proxy consent | Second consent element with relevant: "age(${dob}) < 18"; both records kept |
| Withdrawal | allowWithdraw: true shows a control on later pages; onWithdraw: "clearSensitive" clears sensitive values and writes consent.withdrawn; the original grant event is never deleted (GDPR Art. 7 demonstrability) |
| Not consent-based | Emergencies often lack a valid consent basis (ICRC Handbook 3rd ed., research/09 §7); declare settings.ext["dev.rasd.dataProtection"] = { legalBasis: "vital_interest" | "public_interest" | "legitimate_interest" | "consent", purpose, controller, retentionDays } and still show an information note |
Consent text must be shown in the respondent's locale, versioned (textVersion bumps on any change — W_CONSENT_TEXT_CHANGED_SAME_VERSION), and readable aloud (audio media supported). Consent reporting: server exports (10 §9.5) and storage.export() carry per submission granted/at/textVersion/locale/method/withdrawnAt.
10. Audit trail integrity
submission.audit[] uses the ODK event vocabulary (form start/exit/resume/save/finalize, value, jump, add repeat, delete repeat, constraint error, warning, trigger, consent.granted, consent.withdrawn, admin.override, export) with ISO timestamps, field, old/new (when trackChanges), optional lat/lng/accuracy and userId. Guarantees: (1) the trail is inside the checksummed payload, so post-finalize modification is detected by the server; (2) events are append-only (no public API removes them); (3) encrypted at rest and, in submission mode, E2E; (4) never logged. Non-guarantees: before finalize the device owner can alter anything (the submission is their attestation); device clocks may be wrong — the server stores receivedAt and the HLC. Caps: 5 000 events or 512 KiB per submission, then one audit.truncated event. Post-sync edits go through records (10 §2.8) with changeReasons: "onEdit" and a server-side trail.
11. Data minimisation and retention defaults
| Data | Default | Rationale |
|---|---|---|
| Finalized submissions + attachments | Purged 7 days after ACK; metadata 90 days | Least data on the device (T1–T3) |
| Drafts | Warn at 30 days (draftMaxAgeDays), never auto-deleted | Never lose field data |
| Datasets | Only columns referenced by installed forms are requested (?columns=); TTL 168 h; replaced on cursor_expired | Beneficiary lists are the largest PII surface |
| Audit GPS trail | Off unless settings.audit.location.enabled and auditTrail: 'events+gps' | Store policy + proportionality |
| Photo EXIF | Stripped; geotag kept as sidecar only when props.geotag | Location leakage |
| Sync log | 500 entries × ≤ 512 B, no answers/tokens | Diagnostics without PII |
| Trial/local license state | Timestamp only | No fingerprinting |
| Server (reference) | Idempotency keys 48 h; unlinked tus objects 7 days; audits per customer policy | Documented in 10 §2.7 and §9.6 |
12. Untrusted content and secure coding rules
- Markdown/HTML. Web renders label/hint/guidance/note/consent text through DOMPurify 3.4.13:
ALLOWED_TAGSp, br, b, strong, i, em, u, s, ul, ol, li, a, span, img, h3–h6, blockquote, code, pre, table, thead, tbody, tr, th, td, sup, sub, bdi, bdo;ALLOWED_ATTRhref, title, alt, src, dir, lang, class, colspan, rowspan;ALLOWED_URI_REGEXP^(?:https?|mailto|tel|data:image\/(?:png|jpeg|webp|gif);base64,);RETURN_TRUSTED_TYPE: true; auponSanitizeAttributehook dropssrcoutsidemediaAllowListand forcesrel="noopener noreferrer" target="_blank".dangerouslySetInnerHTMLis ESLint-banned elsewhere. RN parses markdown (html: false) to an AST and rendersText/Image; raw HTML tokens are dropped; links open only via hostonOpenLink;react-native-markdown-displayis not used (unmaintained, opens links implicitly). - JSON hardening. Definitions, datasets, submissions and RSP responses are parsed with a reviver that rejects
__proto__,constructorandprototypekeys (RASD_SCHEMA_INVALID,onPolicyViolation({ kind: 'proto' })); data trees are null-prototype; compiled definitions are frozen; lookups useMap. - Size limits. Definition ≤ 2 MiB, dataset ≤ 100 000 rows, choice list ≤ 10 000, string ≤ 64 KiB, REL source ≤ 8 KiB, AST ≤ 5 000 nodes, depth ≤ 64; oversize is rejected before parsing where possible (
Content-Length,File.size). Attachment ceilings are not set here — 00 §7.1 is the single normative table: 100 MiB is the server-side per-attachment cap (and the native single-file cap), while a single blob on web stops at 25 MB and the per-submission budget is 10 MB. A hardening rule may tighten those numbers for a deployment, never raise them. - Expression sandbox. No
eval/new Function; interpreter over frozen AST;Object.hasOwnaccess; ≤ 10 ms and ≤ 100 000 steps per expression (RASD_EXPR_BUDGET, node poisoned until a dependency changes); host functions under the same budget; ≤ 10 trigger cascades (05 · Logic §13). - ReDoS. JavaScript regex execution cannot be interrupted, so the step budget does not protect it. Controls: pattern ≤ 500 chars, input ≤ 64 KiB (typically
maxLength ≤ 4 000); a load-time analyser flags star-height > 1, quantified overlapping alternations, backreferences and quantified lookarounds (W_REGEX_UNSAFE);content.regexMode: 'reject'turns the warning intoRASD_EXPR_PARSEfor hosts accepting forms from many authors; the builder lints inline; a corpus of known-bad patterns is a CI test. - Media and files.
mediaAllowListdefaults to the sync origin; other URLs are blocked and reported (kind: 'url'). Files pass a MIME allow-list, a deny list (image/svg+xml,text/html, executables) and magic-byte sniffing (14 · Media); attachments render from blob/file URLs, never remote. - WebView widgets (only if a host chooses a WebView signature pad): local asset,
originWhitelist=['about:blank'],allowFileAccess=false,mixedContentMode="never", everyonMessagepayload validated with zod. - Deep links / postMessage. No deep-link handler in the library;
<rasd-form>and the iframe bridge validateevent.originagainst an allowlist and ignore*(11 §15). - Logging.
loggeris a structured redacting logger: answers, dataset rows, tokens, tus URLs and sensitiveinstanceNames are never emitted;debugis stripped from production builds;createSentryScrubber()returnsbeforeSend/beforeBreadcrumbthat drop form data and forceattachScreenshot: false,sendDefaultPii: false.
13. Host-app integration guidance
| Concern | Guidance |
|---|---|
| Authentication | Rasd never manages identity. Provide getAuthToken(); keep tokens in memory (web) or SecureStore (RN); on 401 the engine calls it again; short-lived tokens plus refresh cookies; never localStorage. |
| CSP | Adopt the 11 §12 policy: default-src 'self', no unsafe-eval, nonce or constructed stylesheets, worker-src 'self', img-src 'self' blob:; Trusted Types; SRI on the CDN IIFE; run the CSP test against your origin. |
| Origins | Set mediaAllowList explicitly; keep the RLT apps[] claim tight; iframe targetOrigin never *; minimal service-worker scope. |
| Third-party scripts | Anything on the form origin can read decrypted data in memory. Serve forms from a dedicated origin (forms.example.org) without analytics or tag managers. |
| Native manifest | Keep the plugin's backup rules; allowBackup=false if nothing else needs backup; no READ_MEDIA_* or background-location permissions unless core; ship the SQLCipher notice from securityReport().notices. |
| Shared devices | Profiles + admin PIN; Android Enterprise ephemeral users or lock-task kiosk; MDM pushes serverUrl/policyProfile via managed configurations. |
| Server | TLS ≥ 1.2 + HSTS; validate X-Rasd-Device against the token's user; rate-limit POST /v1/devices; log wipe orders with actor; object-store SSE; feed rasd doctor output into CI. |
| Logout | Send Clear-Site-Data: "storage" only when the last device registration reported no pending items — otherwise you destroy field data. |
14. Telemetry: none — and what license checks send
No analytics, crash reporting, usage beacon or update check exists in any @rasd/* runtime package (INV-5 in 02 · Requirements; privacy table in 15 §16). Enumerator devices send nothing to Rasd: license verification is offline against embedded Ed25519 keys. Refresh happens from the customer's backend (tokenEndpoint proxy) or via X-Rasd-License on the customer's RSP responses; that backend-to-Rasd request carries the current token (sub, jti, plan, apps — no PII), the app pattern and the SDK version. siteKey mode (device → Rasd) is prototyping-only and warns on any non-dev host. What a device sends to the customer's server: RSP payloads, X-Rasd-Device, X-Rasd-Client, and optional diagnostics in POST /v1/devices (usageBytes, pending counts) — configurable off. CI network capture (Playwright, Maestro) fails on any non-host request from a field build.
15. Privacy-by-design mapping
15.1 UN Personal Data Protection and Privacy Principles (HLCM, 11 Oct 2018)
| Principle | Rasd Forms feature |
|---|---|
| Fair and legitimate processing | consent element; legalBasis metadata; hidden/calculate values listed in the builder's data-protection panel |
| Purpose specification | settings.ext["dev.rasd.dataProtection"].purpose; per-form E2E keys |
| Proportionality and necessity | W_SENSITIVE_WITHOUT_ENCRYPTION, dataset column projection, EXIF stripping, GPS trail off by default |
| Retention | Purge-after-sync, draftMaxAgeDays, datasetTtlHours, server retention, wipe |
| Accuracy | Constraints/validators, records with HLC and conflict queue, edit audit with reasons |
| Confidentiality | Encryption at rest, E2E, redaction, profiles, screen-capture prevention |
| Security | This document; MASVS/ASVS mapping; securityReport(); SBOM |
| Transparency | Data-flow diagram, "what is sent" (§14), open-source core |
| Transfers | Self-hosting/residency (§16), encrypted-only export, E2E for third-party servers |
| Accountability | Audit trail, wipe/export/override events, DPIA template, vendor pack |
15.2 IASC / OCHA data responsibility
The IASC Operational Guidance on Data Responsibility (Feb 2021, rev. Apr 2023) and OCHA's Data Responsibility Guidelines (Oct 2021, upd. Jan 2025) expect a data-responsibility diagnostic, an Information Sharing Protocol (ISP), incident management and retention/destruction rules (research/09 §7). Rasd supplies: securityReport() and the DPIA template as diagnostic inputs; sensitivity classification via bind.sensitive and export redaction (export({ redact: 'sensitive' })) so ISP tiers can be honoured; the incident playbooks in §17; purge/wipe/export for destruction and hand-over. Rasd does not decide what may be shared — the organisation's ISP does.
15.3 GDPR and equivalent regimes
- Roles. The customer is the controller of beneficiary data. Self-hosted: Rasd is not a processor — no beneficiary data reaches Rasd. Rasd Cloud (hosted RSP): Rasd is a processor under a Data Processing Agreement (Art. 28) with subprocessor list, TOMs (Art. 32 = §§4–13), data-subject-request assistance, deletion/return at contract end and audit rights. License service: Rasd is controller of org-admin account data only (15 §16).
- Consent (Art. 7) is demonstrable, distinguishable, plain-language and withdrawable via the
consentelement; other Art. 6/9 bases vialegalBasis. - Breach (Art. 33/34). Rasd notifies affected Rasd Cloud customers within 48 hours of confirming an incident so they can meet the 72-hour supervisory deadline; self-hosted customers receive advisories per §18.
- DPIA (Art. 35). Template in the vendor pack (processing description, necessity, risks per §3, measures per this document, residual risks).
- Data-subject rights. Export per submission via
storage.export({ formId })or server export; deletion is a controller-side server operation (RSP v1 has no client-initiated delete — see Open questions).
16. Data residency and self-hosting
@rasd/server(Node ≥ 20, Hono, Postgres, S3-compatible via tus) runs anywhere: agency data centre, national cloud, air-gapped network. The only optional outbound call is license refresh from the backend; with an offline license file there are none.- Rasd Cloud offers region pinning (EU first, others on demand) with per-org buckets and databases; data and encrypted backups stay in-region.
- Devices are pinned to one
baseUrl; definitions and datasets never reference other origins unless listed inmediaAllowList. - Air-gapped builds: offline RLT (12 months), self-hosted ZXing WASM/fonts/maps, no CDN.
17. Incident response for customers
| Scenario | Playbook |
|---|---|
| Device lost/stolen | 1) Revoke the device (device.status = revoked ⇒ 403 device_revoked) and issue policy.wipe { mode: 'immediate' }; 2) revoke the user's host token; 3) size the exposure from the last POST /v1/devices pending counts; 4) if securityReport().encryption was 'none', treat as disclosure; 5) record the wipe ack. |
| Suspected server compromise | Rotate host session keys, the definition-signing key and object-store credentials; force device re-registration; E2E content is unaffected; review audit and idempotency tables. |
Leaked org secret (rsk_live_…) | Rotate in the license dashboard; old tokens expire naturally (≤ 60 d); no device action. |
| Leaked E2E private key | Publish a new form version with a new publicKeyId; re-encrypt archives; device caches update on next pull. |
| Vulnerability in Rasd | Advisory via GitHub Security Advisories + mailing list stating whether field data could be affected; upgrade per §18. |
| Malicious form published | Unpublish; devices refuse forms failing JWS or policy; review onPolicyViolation logs. |
Every playbook ends with an entry in the customer's incident register (IASC data-incident management) and, for Rasd Cloud, a Rasd incident report within 5 business days.
18. Vulnerability disclosure and patch policy
- Reporting:
security@rasd.dev(PGP key published) or GitHub private vulnerability reporting;SECURITY.mdin the repo,security.txton the docs site. Acknowledgement within 2 business days, triage and CVSS within 7 days. - Fix SLAs from triage: Critical (CVSS ≥ 9) 7 days, High 30, Medium 90, Low next minor. Coordinated disclosure after 90 days or on fix release, whichever is first; CVE via GitHub CNA; the advisory lists affected versions and whether field data was exposed.
- Supported versions: latest minor of the current major gets all fixes; previous minor gets Critical/High for 6 months; previous major gets Critical for 12 months after the next major. Security fixes ship as patch versions with no API change.
- Safe harbour for good-faith research; no bounty at launch (see Open questions).
19. Secure defaults checklist
-
encryption.atRest: 'preferred';securityReport().encryption ≠ 'none'on real devices;onSecureStoreUnavailablehandled. - Backup exclusion active on Android (
dataExtractionRules) and iOS (isExcludedFromBackup);onBackupExclusionMissingnever fires in release builds. -
purgeFinalizedAfterSync: true; "sent" list shows metadata only. -
requireHttps: true; nohttp:base URL; SRI on CDN bundle; CSP withoutunsafe-eval. - Host token in memory (web) / SecureStore (RN);
persistHostToken: falseon web. -
mediaAllowList = [syncOrigin];markdown: 'safe';regexModechosen deliberately. -
autoLockAfterMs: 300000,lockOnBackground: true,requireForExport: truefor shared devices. -
export.mode: 'encrypted-only'; plaintext export requires an explicit opt-in and produces an audit event. -
logging.level: 'warn';createSentryScrubber()wired if Sentry is used;attachScreenshotoff. - Sensitive fields flagged in every form (
W_SENSITIVE_WITHOUT_ENCRYPTIONreviewed). - Network capture of a field build shows only the host origin.
20. Dependency and supply-chain security
- Lockfiles:
pnpm-lock.yamlcommitted; CI installs with--frozen-lockfile; pnpm 11 security defaults kept and tightened —minimumReleaseAge: 4320minutes (3 days, raised from the 1440 default; own scope excluded viaminimumReleaseAgeExclude),blockExoticSubdeps,strictDepBuilds, explicitallowBuildsallow-list,trustPolicy: no-downgrade(18 · Engineering practices §2, research/08 §11). - Review: Socket and
pnpm audit(GHSA) on every PR; Renovate withminimumReleaseAge ≥ 3 days, grouped RN/Expo bumps, lockfile maintenance, patch automerge after checks; Dependabot alerts on. - Publishing: Changesets + npm Trusted Publishing (OIDC) with provenance attestations, tokens disallowed and 2FA required per package; release workflow gated on a protected environment with required reviewers; signed tags.
- CI hygiene: Actions pinned to full SHAs;
permissions: contents: readby default;zizmor; OpenSSF Scorecard; Node 24 runners. - SBOM:
pnpm sbom(CycloneDX + SPDX) attached to every GitHub Release and shipped in the vendor pack; consumers verify withnpm audit signatures. - Runtime footprint: field packages depend only on
zod,dexie,@noble/{ed25519,hashes,ciphers,curves},workbox-*and peers;xlsxstays confined to@rasd/xlsform(CLI/builder import), never in the field runtime; DOMPurify pinned and updated within the §18 SLA; every new runtime dependency needs an ADR entry. - Third-party notices: SQLCipher attribution and all licences generated in CI (
LICENSES/), exposed viasecurityReport().notices.
21. Security test plan
| Layer | Tests | Cadence |
|---|---|---|
| SAST | CodeQL (JS/TS); ESLint bans on eval/new Function, dangerouslySetInnerHTML outside the sanitizer, console.* outside logger, Linking.openURL, localStorage for tokens; type test that SecurityPolicy defaults equal §7.1 | Every PR |
| Dependency | pnpm audit, Socket, OSV scan of the SBOM | Every PR + nightly |
| Fuzzing | REL grammar fuzzer (no crash, hang > 10 ms or prototype access); prototype-pollution corpus; DOMPurify mXSS corpus; ReDoS corpus against the analyser; schema-driven RFD fuzz | Nightly, 30 min |
| Crypto | NIST/RFC known-answer tests for AES-GCM, AES-KW, HKDF, PBKDF2; JWE interop with jose both directions; AAD-transplant and truncated-stream negatives; exportKey on the DEK throws | Every PR |
| MASTG automation | 0216/0262 backup rules; 0215 iOS exclusion attribute; 0203/0231 no console.* in release bundle; 0258/0313 sensitive-input props; 0291–0294 FLAG_SECURE; 0266–0271/0326–0328 key-bound biometrics | Release |
| Runtime evidence | securityReport() per platform; Android bmgr backupnow → reinstall → no DB; PRAGMA cipher_version; zero-third-party network capture; CSP violation run; offline license states | Release |
| Pen-test | External, before 1.0 then yearly: @rasd/server reference deployment, license service, web renderer/builder (ASVS 5.0 L2), Expo example (MASVS L2 STORAGE/PLATFORM/AUTH/PRIVACY) | Yearly |
| Threat-model review | Update §3 for every minor touching storage/sync/media/license; full STRIDE review every 6 months; residual-risk register in the vendor pack | Per minor / 6 months |
Acceptance criteria
- All MUST controls in §3.1 are implemented and enabled by default;
securityReport()reflects each. - Field build network capture: zero non-host requests in every license state (SEC-1).
- Encrypted-at-rest verified on Android (SQLCipher), iOS and Chromium/Safari (envelopes, non-extractable key); wipe leaves no key, DB, WAL or blobs.
- E2E:
submissionandfieldmodes round-trip throughrasd decryptandjose; server accepts encrypted items without content validation; missing key blocks finalize, not drafting. - Sanitizer, prototype-pollution, size-limit and REL-budget suites pass; ReDoS analyser flags the corpus;
regexMode: 'reject'refuses it. - Consent grant/withdraw events are present with
settings.audit.enabled: false; withdrawal clears sensitive values and keeps the grant. - MASTG automation and the backup-restore test pass in release CI; SBOM and provenance attached to the release.
-
SECURITY.md, DPA, DPIA template, residual-risk register and data-flow diagram exist in the vendor pack.
Open questions
- Should RSP v1.1 add a controller-initiated per-submission delete/tombstone endpoint to support data-subject erasure end-to-end, or leave deletion purely server-side?
- Default E2E
alg:ECDH-ES+A256KW(pure JS on RN, smaller) vsRSA-OAEP-256(closer to ODK tooling); shouldrasd decryptalso read ODK Briefcase-style RSA keys for migration? - Should
regexModedefault to'reject'for forms pulled from a server (untrusted authors) and'warn'only for local/builder previews? - Bug bounty at launch, or only after the first external pen-test?
- Rasd Cloud regions beyond the EU, and whether a customer-managed KMS key (BYOK) for the object store is worth offering.
- Should definition JWS signing be mandatory in RSP v1 rather than optional, given the malicious-form-author threat?
Related documents
- 00 · Decisions & conventions — normative names, storage/sync/license contracts
- 02 · Requirements — FR-014, FR-053…FR-057, FR-120…FR-128, FR-141, FR-143; NFR-040…NFR-043; INV-5
- 03 · Architecture — trust boundaries, provider hooks
- 04 · Form schema spec —
bind.sensitive,settings.encryption,consent, size limits - 05 · Logic & expressions — REL sandbox limits and diagnostics
- 07 · Renderer (native) — config plugin,
RasdSecuritymodule, sensitive-input props - 09 · Offline storage — encryption at rest, keys, export, wipe
- 10 · Sync protocol — TLS, device policy, remote wipe, integrity
- 11 · PWA & embedding — CSP, SRI, iframe/postMessage rules
- 14 · Media & field capture — file allow/deny lists, EXIF, self-hosted WASM
- 15 · Licensing & billing — privacy of license checks, anti-abuse posture
- 18 · Engineering practices — CI, fuzzing, release process
- Research: 11 · Threat model & device posture, 09 · Field features & expression engine, 04 · Offline storage & sync, 06 · Licensing token, 08 · Engineering best practices, 01 · UN field data-collection landscape