Skip to main content

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.sensitive drives encryption, redaction, autofill/clipboard hardening and screen-capture prevention; consent records a versioned, withdrawable consent.
  • Optional end-to-end encryption (settings.encryption.mode: "field" | "submission") uses standard JWE (ECDH-ES+A256KW or RSA-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:

IDInvariant
SEC-1Production end-user devices make zero requests to non-host origins (verified by network capture in CI).
SEC-2Every 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-3Form 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-4Answers, dataset rows, bearer tokens and tus URLs never appear in logs, crash reports, notifications or error messages.
SEC-5No 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-6No 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

ClassExamplesDefault handling
RestrictedValues of bind.sensitive elements, consent values, attachments (photos, audio, signatures), audit GPS trail, DEK/DB keys, host bearer token, E2E private keysEncrypted at rest; never indexed, logged or shown in lists; excluded from OS backups; field-encrypted E2E when settings.encryption.mode ≠ "none"; export encrypted-only
ConfidentialAll other answers, drafts, finalized-unsynced submissions, dataset rows with beneficiary data, recordsEncrypted at rest; purged after sync; datasets projected to referenced columns
InternalForm 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
PublicRLT license token, Rasd public keys, @rasd/* bundlesSigned / 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 / scenarioAssetsPrimary controls (owner)
T1Lost/stolen phone, screen-lockeddrafts, finalized, attachments, datasets, keys, tokensEncryption at rest + OS key store, backup exclusion, purge-after-sync, remote wipe (MUST); app lock (OPT)
T2Second enumerator on a shared devicedrafts, finalized, datasetsPer-user namespaces + logout() without loss, redacted "sent" list (MUST); admin PIN (OPT)
T3Seized device, forensic extractionallCrypto-shredding wipe, secure_delete+VACUUM on plaintext fallback, no PII in logs (MUST); strong PIN/MDM (DOC) — residual
T4Malicious/compromised server, malicious form authorrenderer, draftsSanitizer allow-list, no HTML on RN, media allow-list, size caps, prototype-pollution rejection, REL budgets (MUST); JWS-signed definitions (OPT)
T5Compromised host app / third-party script on the originkeys, tokens, dataNon-extractable WebCrypto key (limits key theft, not data reads), token in memory (MUST); CSP/Trusted Types, host SBOM (DOC) — residual on RN
T6Network attacker (captive Wi-Fi, rogue CA)tokens, payloadsrequireHttps, no redirects on writes (MUST); fetch injection for SPKI pinning (OPT); user CAs untrusted on Android ≥ 7 (platform)
T7Malicious insider with export/supervisor rightsfinalized, attachments, datasetsEncrypted-only export with audit event (MUST); admin PIN, E2E encryption so operators cannot read (OPT); server RBAC and access logs (DOC)
T8OS/cloud backup leakageeverything on diskdataExtractionRules/allowBackup=false via config plugin, iOS isExcludedFromBackup, …ThisDeviceOnly Keychain class (MUST)
T9Side channels: logs, crash reporters, clipboard, autofill, thumbnails, notificationsdrafts, datasetsRedacting logger, createSentryScrubber(), sensitive-input hardening, count-only notifications (MUST); FLAG_SECURE (OPT)
T10Supply chain: malicious dependency, CI token theftevery customerLockfiles, minimumReleaseAge, Socket/pnpm audit, OIDC provenance, SBOM, SHA-pinned Actions (§20)

3.2 STRIDE summary

CategoryRepresentative threatControl
SpoofingDevice impersonation; forged form pushed to devicesHost 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)
TamperingSubmission altered on disk or in transit; ciphertext transplanted between rowschecksum (SHA-256, canonical JSON) verified before push and echoed in the ack; AES-GCM AAD = <table>/<primaryKey>; TLS
RepudiationEnumerator denies an entry; admin denies a wipeAudit trail with device/user/HLC inside the checksummed payload (§10); wipe orders logged with actor and reason
Information disclosureDevice loss, backups, logs, server operatorsEncryption at rest, backup exclusion, redaction, E2E
Denial of serviceExpression CPU/memory bombs, giant datasets, quota exhaustion, retry stormsREL budgets, size caps, estimate() + RASD_STORAGE_QUOTA, jittered backoff + idempotency
Elevation of privilegeXSS via labels, prototype pollution, expression escape, WebView bridge abuseDOMPurify + 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.

TopicWeb (@rasd/storage-dexie)Native (@rasd/storage-sqlite)
PrimitiveAES-256-GCM per payload column and blob; 96-bit random IV; AAD binds table + keySQLCipher whole database (AES-256-CBC + HMAC-SHA-512 per page, incl. WAL/journal); attachments AES-256-GCM per file
KeyNon-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
FallbackUnencrypted 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
RotationrotateKey() chunked re-encrypt (500 rows/tx)PRAGMA rekey in a backup-first migration
WipeDelete key row → Dexie.deleteSecureStore.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

KeyWhere it livesLifetime / rotationNotes
Web DEK (k1…)IndexedDB _rasd_keys, extractable: falseUntil wipe; rotate on staff turnover or suspected compromiseUsable by same-origin script, never readable; wrapped mode makes it ciphertext at rest
Host KEK / passphrase keyMemory only (unwrap at open())Per session; host prompts via { unlock() }Shared browsers/kiosks; PBKDF2 600 000 it., 16-byte salt
Native DB keySecureStore rasd.dbkey.<namespace>Until wipe; survives iOS reinstall ⇒ deleted explicitlyNever MMKV/AsyncStorage; ≤ 2 KB
Host bearer tokenMemory (web); SecureStore (RN, opt-in persistHostToken)Host refresh via getAuthToken()Never localStorage; redacted
RLT (license)storage.kv60 d + 30 d gracePublic by design; Ed25519 with embedded keys (15)
E2E recipient public keysstorage.kv['enc.pub.<publicKeyId>'], pinned by RFC 7638 thumbprintPer form version; rotate via new publicKeyIdFetched through the host keyProvider on form pull; never in the RFD
E2E private keysNever on devices or the RSP serverCustomer HSM/laptop; rasd decryptLoss = permanent data loss; document escrow
Definition signing keyServer private; public key served from the RSP origin's GET /v1/.well-known/jwks.json (client caches 7 days — 10 §7)YearlyOptional JWS over canonical RFD
Rasd license signing keysRasd HSM; public keys embedded by kidYearly, JWKS fallback15 §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 with RASD_TRANSPORT_INSECURE unless the host is localhost, 127.0.0.1, 10.0.2.2 or *.local (dev — 10 §1.2) and requireHttps: false. No redirects on POST/PATCH. On Android usesCleartextTraffic stays false and user CAs are untrusted (API ≥ 24).
  • Pinning is opt-in via fetch injection, not built in: agencies with stable, self-controlled certificates plug react-native-ssl-public-key-pinning (≥ 2 SPKI hashes) or a pinned fetch; 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 triggers getAuthToken() without losing tus offsets.
  • Web CSP. No unsafe-eval anywhere; constructed stylesheets or cssNonce; worker-src 'self'; img-src 'self' blob:; Trusted Types via RETURN_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 onUnlockRequired resolves; 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 event admin.override. ODK-style access control (hide "delete saved"/"edit sent"/settings) is a host recipe over readOnly, requireForExport and adminPinHash; MDM-pushed serverUrl/policyProfile arrive via Android managed configurations through a host adapter.
  • Purge-after-sync: finalized submissions and attachments are removed purgeSyncedAfterDays after the ACK (0 = immediately); the "sent" list keeps metadata only (id, formId, instanceName unless sensitive, syncedAt) for keepSentMetadataDays. Server DevicePolicy.retention overrides when enforce: true (10 §2.2).
  • Remote wipe: X-Rasd-Wipe: <nonce> on an authenticated 2xx or policy.wipe; nonce checked against kv['wipe.seen']; mode: 'sync-first' runs a 60 s submissions-only drain; then storage.wipe() crypto-shreds keys, deletes DB/blobs/profiles and emits onRemoteWipe. Web servers add Clear-Site-Data: "storage" on the next navigation. Local wipe with unsynced items is always two-step with the count shown.
  • Backups: the @rasd/native config plugin writes dataExtractionRules (cloud-backup and device-transfer) excluding database/ and the attachments dir, merged with expo-secure-store's rules; the RasdSecurity module sets isExcludedFromBackup on iOS; onBackupExclusionMissing fires otherwise (07 · Native renderer §16).
  • Screen capture: preventScreenCaptureAsync while a screen with a bind.sensitive element is mounted (sensitiveFields) or always (all); the screenshot listener is never used (needs Play-restricted READ_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 } }, algECDH-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).


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.

PatternHow
Informed consent, tapmethod: "tap", required: true; refusal path via trigger when: "${consent}.granted = false"complete so the interview ends without collecting PII
Written consentmethod: "signature" stores a trimmed PNG attachment
Verbal consent, witnessedmethod: "verbal"; enumerator attests; meta.userId recorded in the audit event
Guardian / proxy consentSecond consent element with relevant: "age(${dob}) < 18"; both records kept
WithdrawalallowWithdraw: 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-basedEmergencies 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

DataDefaultRationale
Finalized submissions + attachmentsPurged 7 days after ACK; metadata 90 daysLeast data on the device (T1–T3)
DraftsWarn at 30 days (draftMaxAgeDays), never auto-deletedNever lose field data
DatasetsOnly columns referenced by installed forms are requested (?columns=); TTL 168 h; replaced on cursor_expiredBeneficiary lists are the largest PII surface
Audit GPS trailOff unless settings.audit.location.enabled and auditTrail: 'events+gps'Store policy + proportionality
Photo EXIFStripped; geotag kept as sidecar only when props.geotagLocation leakage
Sync log500 entries × ≤ 512 B, no answers/tokensDiagnostics without PII
Trial/local license stateTimestamp onlyNo fingerprinting
Server (reference)Idempotency keys 48 h; unlinked tus objects 7 days; audits per customer policyDocumented in 10 §2.7 and §9.6

12. Untrusted content and secure coding rules

  1. Markdown/HTML. Web renders label/hint/guidance/note/consent text through DOMPurify 3.4.13: ALLOWED_TAGS p, 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_ATTR href, 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; a uponSanitizeAttribute hook drops src outside mediaAllowList and forces rel="noopener noreferrer" target="_blank". dangerouslySetInnerHTML is ESLint-banned elsewhere. RN parses markdown (html: false) to an AST and renders Text/Image; raw HTML tokens are dropped; links open only via host onOpenLink; react-native-markdown-display is not used (unmaintained, opens links implicitly).
  2. JSON hardening. Definitions, datasets, submissions and RSP responses are parsed with a reviver that rejects __proto__, constructor and prototype keys (RASD_SCHEMA_INVALID, onPolicyViolation({ kind: 'proto' })); data trees are null-prototype; compiled definitions are frozen; lookups use Map.
  3. 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.
  4. Expression sandbox. No eval/new Function; interpreter over frozen AST; Object.hasOwn access; ≤ 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).
  5. 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 into RASD_EXPR_PARSE for hosts accepting forms from many authors; the builder lints inline; a corpus of known-bad patterns is a CI test.
  6. Media and files. mediaAllowList defaults 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.
  7. WebView widgets (only if a host chooses a WebView signature pad): local asset, originWhitelist=['about:blank'], allowFileAccess=false, mixedContentMode="never", every onMessage payload validated with zod.
  8. Deep links / postMessage. No deep-link handler in the library; <rasd-form> and the iframe bridge validate event.origin against an allowlist and ignore * (11 §15).
  9. Logging. logger is a structured redacting logger: answers, dataset rows, tokens, tus URLs and sensitive instanceNames are never emitted; debug is stripped from production builds; createSentryScrubber() returns beforeSend/beforeBreadcrumb that drop form data and force attachScreenshot: false, sendDefaultPii: false.

13. Host-app integration guidance

ConcernGuidance
AuthenticationRasd 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.
CSPAdopt 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.
OriginsSet mediaAllowList explicitly; keep the RLT apps[] claim tight; iframe targetOrigin never *; minimal service-worker scope.
Third-party scriptsAnything 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 manifestKeep 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 devicesProfiles + admin PIN; Android Enterprise ephemeral users or lock-task kiosk; MDM pushes serverUrl/policyProfile via managed configurations.
ServerTLS ≥ 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.
LogoutSend 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)

PrincipleRasd Forms feature
Fair and legitimate processingconsent element; legalBasis metadata; hidden/calculate values listed in the builder's data-protection panel
Purpose specificationsettings.ext["dev.rasd.dataProtection"].purpose; per-form E2E keys
Proportionality and necessityW_SENSITIVE_WITHOUT_ENCRYPTION, dataset column projection, EXIF stripping, GPS trail off by default
RetentionPurge-after-sync, draftMaxAgeDays, datasetTtlHours, server retention, wipe
AccuracyConstraints/validators, records with HLC and conflict queue, edit audit with reasons
ConfidentialityEncryption at rest, E2E, redaction, profiles, screen-capture prevention
SecurityThis document; MASVS/ASVS mapping; securityReport(); SBOM
TransparencyData-flow diagram, "what is sent" (§14), open-source core
TransfersSelf-hosting/residency (§16), encrypted-only export, E2E for third-party servers
AccountabilityAudit 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 consent element; other Art. 6/9 bases via legalBasis.
  • 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 in mediaAllowList.
  • Air-gapped builds: offline RLT (12 months), self-hosted ZXing WASM/fonts/maps, no CDN.

17. Incident response for customers

ScenarioPlaybook
Device lost/stolen1) 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 compromiseRotate 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 keyPublish a new form version with a new publicKeyId; re-encrypt archives; device caches update on next pull.
Vulnerability in RasdAdvisory via GitHub Security Advisories + mailing list stating whether field data could be affected; upgrade per §18.
Malicious form publishedUnpublish; 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.md in the repo, security.txt on 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; onSecureStoreUnavailable handled.
  • Backup exclusion active on Android (dataExtractionRules) and iOS (isExcludedFromBackup); onBackupExclusionMissing never fires in release builds.
  • purgeFinalizedAfterSync: true; "sent" list shows metadata only.
  • requireHttps: true; no http: base URL; SRI on CDN bundle; CSP without unsafe-eval.
  • Host token in memory (web) / SecureStore (RN); persistHostToken: false on web.
  • mediaAllowList = [syncOrigin]; markdown: 'safe'; regexMode chosen deliberately.
  • autoLockAfterMs: 300000, lockOnBackground: true, requireForExport: true for 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; attachScreenshot off.
  • Sensitive fields flagged in every form (W_SENSITIVE_WITHOUT_ENCRYPTION reviewed).
  • Network capture of a field build shows only the host origin.

20. Dependency and supply-chain security

  • Lockfiles: pnpm-lock.yaml committed; CI installs with --frozen-lockfile; pnpm 11 security defaults kept and tightened — minimumReleaseAge: 4320 minutes (3 days, raised from the 1440 default; own scope excluded via minimumReleaseAgeExclude), blockExoticSubdeps, strictDepBuilds, explicit allowBuilds allow-list, trustPolicy: no-downgrade (18 · Engineering practices §2, research/08 §11).
  • Review: Socket and pnpm audit (GHSA) on every PR; Renovate with minimumReleaseAge ≥ 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: read by 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 with npm audit signatures.
  • Runtime footprint: field packages depend only on zod, dexie, @noble/{ed25519,hashes,ciphers,curves}, workbox-* and peers; xlsx stays 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 via securityReport().notices.

21. Security test plan

LayerTestsCadence
SASTCodeQL (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.1Every PR
Dependencypnpm audit, Socket, OSV scan of the SBOMEvery PR + nightly
FuzzingREL grammar fuzzer (no crash, hang > 10 ms or prototype access); prototype-pollution corpus; DOMPurify mXSS corpus; ReDoS corpus against the analyser; schema-driven RFD fuzzNightly, 30 min
CryptoNIST/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 throwsEvery PR
MASTG automation0216/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 biometricsRelease
Runtime evidencesecurityReport() per platform; Android bmgr backupnow → reinstall → no DB; PRAGMA cipher_version; zero-third-party network capture; CSP violation run; offline license statesRelease
Pen-testExternal, 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 reviewUpdate §3 for every minor touching storage/sync/media/license; full STRIDE review every 6 months; residual-risk register in the vendor packPer 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: submission and field modes round-trip through rasd decrypt and jose; 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) vs RSA-OAEP-256 (closer to ODK tooling); should rasd decrypt also read ODK Briefcase-style RSA keys for migration?
  • Should regexMode default 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?