Omitly โ Public Threat Model
Status: Draft for review ยท Last updated: 2026-07-01 ยท Applies to: Omitly desktop (macOS; Windows at launch)
This document is Omitly's Public Threat Model (PTM). It exists so that customers, security reviewers, and the wider community can evaluate our security claims against the threats they care about โ rather than being asked to "trust us." It describes how Omitly is secured; it is not a guide to attacking it.
Why we publish this
Omitly's value proposition is itself a security claim: we remove the underlying data from a PDF, independently verify it is gone, and never send your documents anywhere. A claim like that should be falsifiable. Following the argument made by Loren Kohnfelder and Adam Shostack in "Publish Your Threat Models!" (2025) โ and the principle of Open Design (Saltzer & Schroeder) โ we believe the benefits of publishing far outweigh the risks. A threat model describes how a product is secured, not how to break it; if any part of it felt too dangerous to publish, that would be a signal of a real weakness to fix rather than hide.
This is also where the industry is heading: the EU Cyber Resilience Act will effectively require documented cybersecurity risk assessments, and CISA's Secure by Design and Australia's Secure by Demand both push toward this kind of transparency.
This model is organized around Shostack's Four Question Framework:
- What are we working on?
- What can go wrong?
- What are we going to do about it?
- Did we do a good enough job?
1. What are we working on?
1.1 Product summary
Omitly is a local-only desktop PDF reader and editor. Its headline capability is verifiable redaction: it removes the underlying text and image data for a redacted region (it does not merely draw a black box), then independently verifies on the output that the data is unrecoverable.
By default, Omitly makes no network calls. Network access occurs only after an explicit user action: requesting a trusted RFC 3161 timestamp while digitally signing a document (ยง1.3), enabling sealed-output anchoring (ยง1.3, B4), clicking Check for updates, downloading an update, or submitting the support form. Neither RFC 3161 path sends document content; each sends only a cryptographic digest. The updater sends only the app's version/platform to fetch the release manifest, and the support form sends only what the user submits plus app version/OS. See "Network surface" below ยง1.3 for the complete, exhaustive list; nothing outside that list reaches the network.
1.2 Components
| Component | Tech | Responsibility | Trust level |
|---|---|---|---|
| Webview UI | React + TypeScript, runs in the OS webview | Rendering (PDF.js), editing (pdf-lib), drawing redaction regions, email auto-detection, certificate import + sign UI. No direct filesystem access. | Lower-trust; treats PDF content as untrusted |
| Tauri/Rust core | Rust | The privileged process. Exposes IPC commands for file I/O, redaction, audit verification, and PAdES signing (read_file_bytes, write_file_bytes, redact_pdf, verify_audit, export_certificate, import_signing_certificate, sign_pdf, verify_signature). Owns all file I/O and both document-derived network boundaries (B3/B4). |
Trust boundary owner |
redaction-core crate |
Rust (lopdf) + bundled qpdf |
The redaction engine plus the PDF-structural half of PAdES signing (/Sig placeholder embedding and byte-range patching). Tauri-independent, clock/env-free, so it can be unit-tested in isolation and never itself touches the network. |
Trusted core logic |
PAdES signing (src-tauri/src/sign.rs) |
Rust (cryptographic-message-syntax, x509-certificate) |
Builds a CMS/PAdES signature over a user-supplied (BYOC) certificate; validates algorithm floors and certificate/key pairing; optionally contacts an RFC 3161 timestamp authority over HTTPS. This and sealed-output anchoring are the only paths that can send document-derived data, and each sends only a digest after explicit opt-in. | Trusted core logic + document-derived network boundary |
licensing crate |
Rust (ed25519-dalek, sha2) |
Offline Ed25519 license verification. Wired into the shipping app since the 2026-08-06 launch: gates the 14-day trial and entitlement checks on redact_pdf/sign_pdf. |
Trusted core logic |
omitly-mcp server |
Node/TypeScript (MCP SDK) | Published to npm (omitly-mcp) and functional โ exposes find_sensitive_regions, locate_text, redact_by_entity, redact_pdf, verify_redaction, create_pdf and check_redaction to local AI agents over stdio. Carries no crypto of its own; shells out to the same native engine the desktop app uses, so it inherits that engine's trust boundary and licensing/entitlement behavior. |
Future boundary โ see ยง4 |
1.3 Trust boundaries and data flow
flowchart TD
user([User]) -->|chooses file via OS dialog| webview
subgraph device[User's device]
webview[Webview UI<br/>PDF.js / pdf-lib<br/>no FS access]
core[Tauri/Rust core<br/>file/redact/verify/sign commands]
engine[redaction-core<br/>lopdf + Sig placeholder]
qpdf[qpdf sidecar<br/>native C++]
disk[(Local disk)]
webview -->|B1: IPC โ paths, PDF bytes, regions| core
core -->|B2: untrusted PDF bytes| engine
engine -->|process boundary| qpdf
qpdf -->|sandboxed: temp-only writes, no network| engine
core -->|redacted/signed PDF + audit.json| disk
end
tsa[(RFC 3161 Timestamp<br/>Authority โ third party)]
core -.->|B3: OPT-IN ONLY โ<br/>signature hash alone,<br/>never document content| tsa
core -.->|B4: OPT-IN ONLY โ<br/>sealed-output hash alone,<br/>never document content| tsa
classDef boundary fill:#fef3c7,stroke:#b45309;
classDef network fill:#fee2e2,stroke:#b91c1c,stroke-dasharray: 5 5;
class core,qpdf boundary;
class tsa network;
- B1 โ Webview โ Rust core (IPC). The lower-trust webview cannot touch the filesystem; it can only ask the Rust core to act on paths the user selected through an OS dialog. Enforced by Tauri capabilities (no
fs:capability granted to the webview) and a restrictive Content Security Policy. - B2 โ Rust core โ qpdf sidecar (process). Untrusted PDF bytes are processed by a native C++ binary โ the largest native attack surface. On macOS this runs under a deny-by-default seatbelt sandbox (read anywhere, write only to temp, no network). Arguments are passed as explicit argv, never through a shell.
- B3 โ PAdES timestamping, opt-in and disclosed. Added 2026-07 alongside PAdES digital signing: if (and only if) the user turns on "add a trusted timestamp" for a specific signature, Omitly sends an RFC 3161 request (a SHA-256/384 digest of the signature, plus the digest algorithm identifier โ never the document, filename, or any PDF bytes) to a Time-Stamp Authority over HTTPS. The TSA endpoint is user-configurable (default: a public TSA; an organization can point this at its own internal TSA to keep the request off third-party infrastructure entirely). Signing without a timestamp remains fully available and makes zero network calls. See "Network surface" below for the exhaustive local-vs-network map.
- B4 โ certificate anchoring, opt-in and disclosed. Added 2026-07 (issue #107) as default-OFF, default-ON from #561, and default-OFF again since #1060 so redaction makes no automatic outbound request. If the user enables "Timestamp proof" (a real opt-in, persisted across launches), Omitly sends one RFC 3161 request carrying the SHA-256 of the delivered, sealed output file โ never the document, filename, or any PDF bytes โ to the same user-configurable TSA machinery as B3, and stores the returned token in a
<output>.pdf.anchor.jsonsidecar (raw DER, independently verifiable with third-party tools). Leaving it off means zero network calls and byte-identical behaviour to before the feature existed. A sealed document cannot be retroactively upgraded, so leaving it off forgoes the only mechanism that can later witness a classical-era seal as pre-dating a future break. A failure is never silent: it produces an honest"anchored": falserecord AND a visible warning stating the document was not timestamped while the redaction itself is complete, verified and sealed. The request runs strictly after the output file is written, so an unreachable or hung TSA can never fail, alter or corrupt a redaction. It is not latency-free: the synchronous call can delay the UI returning to idle by up to the bounded 8-second timeout, after the delivered file is already complete on disk. What the anchor proves โ and deliberately does not โ is documented in SECURITY.md ("proves WHEN, never WHO"). - App-level (non-document) network paths, for completeness (added in 1.0.0, after this document's document-pipeline boundaries were written): the Tauri updater fetches a signed release manifest only after the user clicks Check for updates, and downloads an update only after a further explicit action. A local-only 14-day reminder tells users that Omitly does not check on their behalf. The About-box "Contact support" form POSTs the user's typed message plus app version/OS string to
https://omitly.app/api/support, only when the user submits it. Neither path touches PDF content, document bytes, hashes of documents, or redaction data โ the document pipeline (B1-B4) remains the complete map of anything document-derived. - Redacted/signed PDFs and the audit/anchor sidecars are written only to the user-chosen location on local disk โ this has not changed.
Network surface โ the complete local-vs-network map
Everything Omitly does with document-derived data, and whether any of it reaches the network. B3 and B4 above are the only code paths that can send anything derived from a document (each a digest and each opt-in); the app-level updater and support paths (previous section) carry no document-derived data at all. Anything not listed as reaching a network path is, structurally, local-only.
| Feature | Network call? | What's sent | When |
|---|---|---|---|
| Open / view / edit a PDF | No | โ | always |
| Manual or auto (email) redaction + verification | No | โ | always |
| Audit report (JSON/HTML) + Ed25519 tamper-evidence seal | No | โ | always |
| Redaction with "Timestamp proof" (anchoring) off | No | โ | default since #1060; zero network calls, byte-identical output |
| Redaction with "Timestamp proof" (anchoring) on | Yes (B4) | The SHA-256 of the delivered, sealed output file + a random nonce. Never: document content, filename, or any PDF bytes. | Only after the user opts in (the choice is remembered); runs after the file is already written |
| Check for updates | Yes | App version/platform needed to fetch the signed release manifest; never document data | Only after the user clicks Check for updates; the 14-day reminder itself is local-only |
| Submit the support form | Yes | User-entered message plus app version/OS; never document data | Only after the user submits the form |
Verify an anchor sidecar (verify_anchor) |
No | โ | always โ token signature, imprint, Merkle path and pinned-root chain (real RFC 5280 path validation โ basicConstraints/keyUsage/pathLenConstraint enforced on every certificate below the pinned root, omitly#615) are all checked offline |
| Verify a redacted PDF's audit report / export the certificate | No | โ | always |
| Import a signing certificate (PEM + PKCS#8) | No | โ | always โ validated and stored (OS keystore preferred) entirely on-device |
| PAdES-sign a document, timestamp off | No | โ | default; zero network calls |
| PAdES-sign a document, timestamp on | Yes (B3) | A SHA-256/384 digest of the CMS signature value + the digest algorithm OID. Never: document content, filename, page count, certificate holder's name/PII beyond what the digest algebraically can't avoid revealing (nothing), or the private key. | Only for that one signature, only if the user opted in |
Verify a PAdES signature (verify_signature) |
No | โ | always. Revocation (OCSP/CRL) checking is deliberately not implemented in this build โ so there is also no OCSP network call; this is a coverage gap (ยง4.2) and a network-minimalism property at the same time, and both are stated here rather than picking one framing |
Why an enabled timestamp needs a network call: a timestamp that actually means something under RFC 3161 requires an independent third party (the TSA) to attest to the time. What Omitly controls, and has minimized: (1) the request carries a digest only, never content; (2) B3 and B4 are both explicit opt-ins and never background calls; (3) the TSA is user-configurable, so the destination is the user's to change; and (4) declining is a fully supported, fully local alternative in both cases (PAdES-B-B signing; an unanchored seal).
1.4 Assets we protect
The user's document content (especially the data being redacted); the integrity of the redaction result; the integrity of the audit record; the integrity of the Omitly binary itself.
2 & 3. What can go wrong, and what we do about it (STRIDE)
Threats are enumerated with STRIDE against the elements and boundaries above. Mitigations marked โ Gap are not yet implemented and are tracked in ยง4.2.
Redaction engine (the core asset)
| STRIDE | Threat | Mitigation |
|---|---|---|
| Information disclosure | "Redacted" data remains recoverable underneath a black box (the category's defining failure). | Engine removes the glyphs and image XObjects, not just covers them; normalizes/flattens incremental updates and object streams via qpdf so prior revisions can't retain the data. |
| Information disclosure | Data survives in metadata, XMP, thumbnails, comments/annotations, pre-existing embedded files, or document-level auto-run actions. | Metadata scrub removes /Info, XMP /Metadata, /PieceInfo, and markup/comment annotations. Omitly also removes and independently re-verifies absence of page thumbnails (/Thumb), pre-existing embedded files/attachments (/Names/EmbeddedFiles, /EF streams, catalog /AF), and document-level auto-run actions (/OpenAction, catalog /AA, /Names/JavaScript) before signing/sealing โ each check re-inspects the reloaded output, not an internal "we removed it" flag, and forces an overall fail rather than a silent pass for anything it cannot confirm clean. Unreferenced objects are garbage-collected on finalize. Stated boundary (ยง4.2 item 8, omitly#592): the metadata scrub covers declared metadata only โ a stream reachable solely via a nonstandard key, with no /Type /Metadata of its own, is out of scope. |
| Information disclosure | A document contains a hidden (OFF-by-default) optional-content layer (a "toggleable" PDF layer some viewers hide by default) that a user never drew a redaction region over because they didn't know it was there. | Detected and blocked from a clean verdict: the default viewing configuration (/OCProperties/D) is parsed and any layer hidden by default forces an overall fail. Resolution covers the three ways that state is actually reached โ /OFF//BaseState, /D/AS usage auto-states applying /Usage /View /ViewState /OFF, and membership dictionaries (/Type /OCMD) whose /OCGs+/P policy or /VE visibility expression (/Not, /And, /Or) evaluates hidden even when every individual layer is on. Not yet removed or flattened โ detection only; see the boundaries noted in ยง4.2. |
| Information disclosure | Engine claims success it cannot actually prove (e.g. content it can't parse), or a redaction output silently regresses from a single revision back into a multi-revision file that could carry an unredacted prior copy. | Independent multi-part verification on the output: (1) re-interpret the page and fail if any text/image still renders into a redacted region; (2) decompress the output and substring-search for the removed fragments; (3) a structural, byte-level check that the delivered file is genuinely a single PDF revision (no leftover /Prev chain, no hybrid xref, no trailing junk) โ a regression here could otherwise let an earlier, unredacted revision remain recoverable underneath a collapsed-looking file. Unparseable pages and inline images (BI/ID/EI) are flagged as warnings that force an overall fail rather than a false pass. |
| Tampering / Spoofing | Over-redaction (removing neighboring content) or under-redaction. | Spatial, per-glyph content-stream editing computes each glyph box; covered by ~20 correctness tests asserting both removal and survival of neighbors, including rotated/multi-page cases. |
Audit log
| STRIDE | Threat | Mitigation |
|---|---|---|
| Tampering | The *.audit.json sidecar can be edited after the fact and still appears valid. |
โ Gap โ no integrity protection today. The audit file is plain JSON. A cryptographic signature is planned (see ยง4.2). |
| Repudiation | The recorded timestamp is taken from the local clock and is trivially spoofable. | โ Partial. The audit log's own created_at is still local-clock. A trusted-timestamp mechanism now exists (RFC 3161, added 2026-07) but today it's wired to the PAdES signature, not yet to the audit-log timestamp itself โ closing that gap is tracked in ยง4.2. |
Boundary B1 โ Webview โ Rust core
| STRIDE | Threat | Mitigation |
|---|---|---|
| Elevation of privilege | Compromised/hostile webview content reads or writes arbitrary files. | Webview has no filesystem capability; only a small, fixed set of narrow IPC commands exist (ยง1.2). Write targets are enforced server-side against a session-tracked allowlist populated ONLY by a real native save dialog Rust itself showed (pick_save_path โ ApprovedWritePaths, omitly#460) โ the path argument alone is never trusted, since any webview script can invoke any command with any string; non-regular files (symlinks/devices) are rejected on write; writes are atomic (temp + rename). Read targets (read_file_bytes) reject non-regular files but do not yet independently re-verify dialog provenance the same way โ lower severity (disclosure of files the process can already read, not corruption/execution) and tracked as a residual, not yet closed. |
| Tampering | Injected script alters behavior or exfiltrates content. | Strict CSP (script-src 'self', object-src 'none', connect-src limited to local IPC, frame-ancestors 'none'). No remote code; PDF.js worker is bundled locally (no CDN fetch). |
Viewer text layer (selectable text in the preview โ added 2026-07)
The preview now materializes the document's text as invisible DOM spans (a PDF.js text layer) so text can be selected and copied with the native OS gesture. This changes the in-webview surface and is bounded as follows:
| STRIDE | Threat | Mitigation |
|---|---|---|
| Information disclosure | Copy/paste leaks text that a redaction box visually covers โ the classic "black box you can copy-paste under," now via the preview rather than the output file. | Runs covered by a redaction are emptied from the DOM, not just visually hidden, so native selection/copy has nothing to pick up. Exclusion is whole-run and fail-safe: partial coverage excludes the entire run rather than trimming it (src/viewer/textSelection.ts, unit-tested). The layer may only ever expose what the canvas visibly shows. The output-file guarantee is unchanged โ actual removal still happens in the Rust engine on the real bytes (ยง Redaction engine), never in the preview. |
| Information disclosure | Document text now exists as DOM nodes, enlarging what hostile in-webview script could read. | No new trust level: the webview already holds the full document bytes and PDF.js text content in JS memory (find, OCR, auto-detect all read it); DOM spans add a representation, not access. Exfiltration remains blocked by the same B1 mitigations (strict CSP, connect-src limited to local IPC, no remote script). |
| Information disclosure | Copied text lands in the OS clipboard, subject to clipboard history/managers/cross-device sync. | Inherent to copy, same as any editor, and user-initiated: the app itself never calls a clipboard API โ the only clipboard write is the user's own OS copy gesture on a native selection. No auto-copy, no programmatic writes. |
Boundary B2 โ Native PDF processing (qpdf / lopdf)
| STRIDE | Threat | Mitigation |
|---|---|---|
| Elevation of privilege / DoS | A malicious PDF exploits a parser bug in native code. | qpdf runs under a macOS seatbelt sandbox (no network, writes confined to temp). Pure-Rust lopdf provides memory safety for the Rust-side parsing. โ Partial: on non-macOS platforms the sandbox is a no-op and relies on ambient OS sandboxing โ hardening tracked in ยง4.2. |
Application / supply chain
| STRIDE | Threat | Mitigation |
|---|---|---|
| Spoofing / Tampering | User runs a trojaned build of "Omitly." | โ Gap. Code signing / notarization config is not yet present. Planned for release (see ยง4.2). |
| Tampering | Compromised dependency in the build. | Pure-Rust core with a small dependency set; SBOM (and a cryptography bill of materials) planned for publication per release. |
| Information disclosure | Hidden/undisclosed network egress or telemetry contradicts the local-only claim. | Zero automatic egress: every network-capable path is named in ยง1.3 and requires an explicit user action. Document-derived data can leave only through two opt-in, digest-only RFC 3161 requests: PAdES timestamping (B3) and certificate anchoring (B4). Manual updater and support-form requests carry no document-derived data. No telemetry and no license-server call exist anywhere. Anything outside the network-surface table making a request would be a bug against this threat model. |
4. Did we do a good enough job?
4.1 How we validate (and how you can check)
- The redaction-correctness tests are treated as the product. ~20 tests assert that secrets (e.g. SSNs) are unrecoverable from the decompressed output, that neighboring content survives, that metadata and comment annotations are scrubbed, that image markers are removed, and that pages the engine can't fully reason about (inline images) are flagged rather than silently passed. These run with plain
cargo testagainst the Tauri-independent engine. - Verification is independent of redaction. The verify step re-derives its result from the output file, not from the engine's own bookkeeping, so a bug in removal does not produce a false "verified."
- The local-only claim is scoped and structurally enforced, not just asserted. Exactly two code paths can send document-derived data, both digest-only and opt-in โ B3 and B4 RFC 3161 timestamping; see the "Network surface" table in ยง1.3. The manual updater and support paths carry nothing document-derived. No path makes an automatic request.
4.2 Known gaps and roadmap (stated honestly)
Per Shostack's guidance, a published threat model should include the complete set of threats โ including those not yet fully mitigated. The following are known and tracked:
- Signed audit log โ not yet implemented. The audit sidecar is currently unsigned JSON with a local-clock timestamp. Planned: cryptographic signing (Ed25519 capability already exists in the
licensingcrate) and a tamper-evident format, ideally with a post-quantum / hybrid signature option. Until shipped, Omitly does not claim a "signed" audit log. - Code signing / notarization for distributed binaries โ to be configured before public release.
- Sandbox parity off macOS โ extend the qpdf sandboxing approach to Windows.
- MCP server (
omitly-mcp) is a non-functional stub. When built, it would widen the trust boundary from "a human selecting files through an OS dialog" to "any local AI agent able to drive redaction over arbitrary file paths." It will need its own threat-model section and access controls before release. - Published SBOM / CBOM to substantiate the supply-chain and cryptography claims.
- PAdES signing (added 2026-07) has two stated scope boundaries, not silent gaps: (a) no OCSP/CRL revocation checking โ a signature that was valid when checked could have since been revoked and this build won't know; (b) no CA trust-chain validation โ this is a bring-your-own-certificate (BYOC) self-signing feature, so a signer's reported identity is read directly from the certificate and is not confirmed against any external trust authority (the same "integrity, not identity" scope as the existing Ed25519 seal). Both are surfaced in every
verify_signatureresult's warnings, not just here. Planned: PAdES-B-LT/LTA (embeds revocation evidence) and, longer-term, hardware-backed signing keys (YubiKey/Secure Enclave/Windows CNG) so the private key never exists as an exportable file at all. - Hidden-content sanitization (added 2026-07) has four stated scope boundaries, not silent gaps: (a) a hidden optional-content layer is detected and blocked from a clean verdict, but its content is not (yet) removed or flattened from the file โ a viewer configured to ignore the default visibility state, or a future update to the layer's default state, could still expose it; full removal is planned as a follow-on. (b) PDF/A conformance is not preserved by any of this (it was already broken by the pre-existing metadata scrub, unrelated to this feature). (c)
/OutputIntents(the embedded ICC colour profile) is left in place โ it carries no document content. (d) The hidden-layer check resolves the default screen viewing configuration only; a layer that is visible on screen but hidden specifically when printing (or vice versa, via/Usage/Print) is not covered. Within that screen configuration, a/D/ASusage auto-state is resolved for the/Viewcategory (/ViewState /OFFhides); an auto-state naming only the/Zoom,/User, or/Languagecategories is deliberately not resolved and does not fail closed, because those depend on viewer state with no single correct default and blanket-failing them would reject ordinary layered CAD/GIS/InDesign exports. - Metadata scrub (
scrub_all_metadata) has one stated scope boundary, not a silent gap (omitly#592): it removes objects declared as metadata (/Type /Metadata) and everything reachable via the standard/Metadatakey at any depth. A stream that declares no/Type /Metadataand is referenced only by a nonstandard key is indistinguishable from ordinary document content, and is out of scope for a metadata scrub โ the claimmetadata_scrubbed = truesupports is "declared metadata is removed," not "no XML anywhere survives." Widening this to a content sniff risks deleting legitimate document content and is deliberately not done without a separate decision; tracked as the open half of omitly#592.
4.3 Scope and assumptions (non-goals)
This model assumes a trustworthy host: Omitly does not defend against a compromised operating system, malware already running with the user's privileges, physical access to an unlocked device, or screen-capture of content displayed on screen. It covers the desktop application and redaction engine; it does not cover the marketing website (separate codebase and trust boundary) or any future cloud service (none exists today).
Methodology & precedent
This document follows the Threat Modeling Manifesto (2020) and Shostack's Four Question Framework, with threats enumerated via STRIDE. We publish in the spirit of peer privacy-and-security tools that publish their own threat models, including SecureDrop, Tor, cURL, and Kubernetes. We welcome scrutiny: if you find a threat we've missed or a mitigation that doesn't hold, please contact us.
This is a living document and will be revised on architecture changes, new features, security incidents, and at least annually.