Omitly โ Public Threat Model
Version: 2.2 ยท Status: Published ยท Last updated: 2026-10-19 ยท Applies to: Omitly 4.1.x โ the desktop app (macOS and Windows), the engine CLI, and the
omitly-mcpserverThis 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 and makes no network calls and no wall-clock reads; its only environment inputs are the QPDF_BIN / QPDF_TIMEOUT_SECS overrides for locating and time-limiting the bundled qpdf (a monotonic deadline). It can be unit-tested in isolation. |
Trusted core logic |
omitly-seal crate |
Rust (p384, fips204, RustCrypto ml-dsa, ed25519-dalek) |
The tamper-evidence seal: mints seal v2 (hybrid ECDSA P-384 + ML-DSA-87, ยง1.5) and verifies both seal v2 and the legacy v1 (Ed25519) seal through one dispatch; also the audit-report checks and the "Check any PDF" verdict. Shared by the desktop app, the engine CLI (and so the npm package and omitly-mcp, which run that CLI), and โ for Check only โ the bundled WebAssembly checker. |
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 PDF tools (among them find_sensitive_regions, locate_text, check_redaction, extract_pdf_text, verify_redaction, redact_by_entity, redact_pdf, verify_seal, verify_document, create_pdf) to local AI agents over stdio. Carries no crypto of its own. The write tools (redact_pdf, redact_by_entity) and both seal tools always run the same native engine the desktop app uses, so they inherit that engine's trust boundary and licensing behaviour. Five read-only tools (find_sensitive_regions, locate_text, check_redaction, verify_redaction, extract_pdf_text) fall back to a bundled WebAssembly build of the detector when no native engine is configured; that fallback never reaches the write tools or seal verification. File access is confined to one directory (OMITLY_ALLOWED_DIR, else the directory the server started in). |
Separate boundary โ its threat-model section is still to be written (ยง4.2 item 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) + tamper-evidence seal (seal v2; legacy v1 on the paths named in ยง1.5), including key generation and storage | No | โ | always |
| Verify a seal (v1 or v2) | 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. Exposed in the UI since omitly#923: the Verify panel's "Digital signature" section runs this command on the same click as the seal check and renders every warning in its result verbatim, so the scope boundaries below reach the person reading the verdict, not just this document |
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 seal keys on each install; the integrity of the Omitly binary itself.
1.5 The tamper-evidence seal (seal v2)
Since Omitly 4.0, a redaction output is sealed with seal v2, specified in docs/SEAL-SPEC-v2.md (version 1.0.1, frozen 2026-10-08). This section summarises what it is; the threats against it are in "Tamper-evidence seal" under ยง2 & 3.
What a valid seal means. Every byte of the delivered file, apart from the ranges the spec explicitly excludes, is byte-for-byte unchanged since it was sealed by the holder of the sealing keys. "Byte-for-byte" is literal: a re-save, linearisation or re-compression of the same document does not verify. The excluded ranges are the seal's own region and, for a PAdES-signed file only, the PAdES signature value (see below).
What a valid seal does not mean. It does not identify a person or organisation, it does not mean "produced by Omitly", and it does not prove when the document was sealed โ time evidence is the separate, opt-in RFC 3161 anchor (B4), which is off by default. It also proves nothing about the redaction itself: the seal covers the embedded audit report, which is the producer's own record of what it checked (see "Audit log"). We do not describe the seal as "quantum-safe".
Construction.
- Two signatures ("legs") over one signed message: ECDSA P-384 (deterministic nonce per RFC 6979, low-S enforced) and ML-DSA-87 (FIPS 204, hedged, with a fixed context string). Both sign a deterministic-CBOR core that carries a SHA-512 digest of the file, the region's position, both public keys and, when a report is embedded, the SHA-384 of the exact report bytes. Both legs must verify; a leg that fails makes the seal invalid, never "partly valid".
- Carrier: the signatures live in an uncompressed embedded file named
omitly-seal.envinside the PDF, and the file ends with a short tail line,%%OMITLY-SEAL v2 <offset> <length>. The verifier locates the region from the tail and the core; it does not parse the PDF to find or check the seal. Nothing may follow the tail: one extra byte, including a line-ending conversion, makes the seal invalid. - Keys: each sealing identity is one 32-byte master seed, from which both legs' private keys are derived (HKDF-SHA-512). The desktop app keeps one seed per install in the OS keystore โ macOS Keychain or Windows Credential Manager, service
app.omitly.signing-key, accountseal-v2โ written once. The engine CLI uses a fresh seed for every run, held only in memory. If the desktop cannot load its key, that one output is sealed with a one-time key instead and labelled as such. No Omitly-held (vendor) key is involved in sealing at all. - Minting fails closed. Before a seal is published, each public key is derived by two independent implementations and must match; the ML-DSA-87 signature is checked by two implementations other than the one that made it, and the P-384 signature by one other implementation; and the finished file must then pass the shipping verifier. Any disagreement refuses the output and nothing is published. There is no fallback to a v1 seal for the same output.
- PAdES-signed output: when a document is digitally signed (PAdES) and sealed, the seal covers everything except the PAdES signature value, and the PAdES signature covers everything except its own value โ including the seal. The verifier checks that the excluded range really is the signature field's hex value, so the exclusion cannot be pointed at document content. The verifier's result says the seal covers the document "except its digital-signature value" and to check the digital signature separately.
Policy epochs. Which legs a seal needs is decided by the verifier, not the file. Today (epoch 1) a seal is valid only if its P-384 leg verifies, and every other declared leg that this build implements (today: ML-DSA-87) must verify too. A published plan moves the requirement to ML-DSA-87 at a cutover release no later than the end of 2029 (epoch 2). How epoch 2 will treat seals made before the cutover is not decided yet.
Verdicts. valid; policy_insufficient ("signatures check out, but this seal does not meet the current requirement" โ never shown as valid); invalid (any structural, digest or signature failure, content after the tail, or a stripped tail); unsupported (a newer format or feature this build cannot check โ indeterminate, and unauthenticated, because anyone can write such bytes); and not sealed. The desktop Verify screen, the engine CLI's verify_seal command (which the npm package's omitly verify-seal and omitly-mcp's verify_seal / verify_document run), and the redactor's own pre-publish check all use the same dispatch and verifier code. There is no WebAssembly seal verifier.
Where the legacy v1 seal remains. v1 (Ed25519, %%OMITLY-SEAL v1) seals keep verifying exactly as before, permanently. Omitly still mints a v1 seal in one place: PAdES signing of a document that already carries a v1 seal and v1 report from before 4.0 (a v2 seal could not keep that report verifiable, because the report names the old key). Until 4.1 a replacement-stamp ("restamp") of a delivery also minted v1; since omitly#1571 the engine draws Replace and pseudonym labels before the report is built, and those outputs carry the v2 report and seal. A password-protected export is not sealed at all, because encryption changes the bytes the seal would cover; the report says so.
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. Since 4.1.0, an annotation with an appearance that a redaction region covers is removed from the output, not only reported, and so is a form field with a covered widget: the field goes with its value, together with any fields that share the value-holding parent removed with it. Field structures the engine cannot prove removed โ XFA forms, field trees it cannot walk, and direct (non-conforming) field dictionaries โ are left in place and keep the output from a clean verdict (omitly#568). 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. After 4.1.0 (in the next release), a region on a page whose text uses a Type3 font is refused before any file is written: the verifier cannot place Type3 glyphs, so it cannot prove what is under the box (omitly#568). |
| Information disclosure | A check reads Safe to Send while a value is still readable: the text is drawn somewhere the check did not read, under a cover it misjudged, or in a part of the file it skipped. | The survivor check, which runs on every redaction output and in Check, was widened in 4.1.0: it reads text painted inside Form XObjects, recursively, with each form's glyphs placed by its matrix, and a form without its own /Resources inheriting the resources (fonts included) of the stream that invokes it (omitly#1528, omitly#1533); it measures how much of a glyph a curved or clipped cover hides by polygon-area integration instead of an 8ร8 sample grid, with curves flattened to a 0.25-point tolerance and conservative handling where the geometry cannot be fully measured (omitly#1529); it treats strokes (marker, pen, a thick line) and annotation ink as covers, so text under them is examined like text under a filled box (omitly#1607, omitly#1459); and it scans every object an earlier incremental revision defines differently from the current file; a revision that cannot be fully read makes the scan incomplete, which the audit-v2 report records as a technical failure, so the file cannot read Safe to Send (omitly#1518, omitly#1532). The engine's raw scan output can still say clean: true beside scan_complete: false โ no findings on what it could read โ and every surface shows the incomplete scan first. Check also names the page a surviving copy is on, not the page it was removed from (omitly#1601). After 4.1.0 (in the next release): text in objects that nothing in the file refers to is reported (omitly#1610), and, for embedded simple TrueType, Type1 and CFF fonts whose /ToUnicode maps a code to exactly one character, a code whose glyph is the font's .notdef is flagged as not verified as drawn, alongside the extracted text (omitly#1519; codes without such a mapping, and the remaining font cases, are ยง4.2 item 11). |
| Tampering / Spoofing | Over-redaction (removing neighboring content) or under-redaction. | Spatial, per-glyph content-stream editing computes each glyph box; covered by the engine's correctness suite (over 1,100 Rust tests in redaction-core), which asserts both removal and survival of neighbors, including rotated/multi-page cases. |
Audit log
| STRIDE | Threat | Mitigation |
|---|---|---|
| Tampering | The *.audit.json / *.audit.html sidecars can be edited after the fact and still appear valid. |
โ Partial. Both redaction sidecars are plain, unsigned files. (Check's own saved report is different: since 4.1.0 it is sealed with a detached seal v2 envelope in its own signing domain, omitly#1570.) The same report is embedded in the delivered PDF, and that copy is covered by the whole-file seal (seal v2, ยง1.5, which also binds the exact report bytes; a v1 seal on the legacy paths): editing it breaks the seal. That is integrity since sealing, under a per-install key โ or, for the engine CLI and for a desktop whose key could not be loaded, a key generated for that one output and labelled so in the report โ never identity. Verify the PDF, not the sidecars. Since 4.1.0, a password-protected export embeds no report: encryption replaces the seal and qpdf re-serialises on decrypt, so no report inside it could be verified; the side files are still written and the export says it carries no verifiable report (omitly#1497). |
| Tampering / Spoofing | A sealed report claims more than the producer checked, or a verifier trusts a verdict written into the report. | Since omitly#1104 every sealed redaction embeds an omitly.audit/v2 report. Every verifier (desktop Verify, CLI verify_seal, npm, MCP) re-checks the seal, that the report's key is the seal key (and, under seal v2, that the seal was made over exactly these report bytes), the report's semantics (docs/SAFE-TO-SEND-PHASE0-SPEC.md ยง5.1), and recomputes which checks apply from the delivered bytes; a report failing any of these is Cannot Verify, never a pass. What this does not do: a verifier does not re-run the checks โ the statuses are the producer's record, sealed; Verify says so on every v2 result. The producer verifies its own sealed output this way before publishing, again over the bytes reread from the staging file, and publishes nothing whose result it cannot verify. After 4.1.0 (in the next release): Replace and pseudonymised outputs also carry the v2 report and seal, with the drawn labels declared in the report (omitly#1571); and an edited copy saved, extracted or signed in the app never keeps a report or seal that no longer verifies over its bytes โ they are removed, or the save is refused and nothing is written (omitly#1103). |
| Repudiation | The recorded timestamp is taken from the local clock and is trivially spoofable. | โ Partial. The report's own created_at is still the local clock. With "Timestamp proof" (B4) turned on, the RFC 3161 anchor covers the SHA-256 of the complete delivered file โ which embeds the sealed report โ so the report is then both sealed and time-witnessed. What the anchor witnesses is that the file existed at the anchor time, not when the report claims it was created. Anchoring is off by default (#1060), so most outputs carry only the local-clock time. |
Tamper-evidence seal (seal v2)
What the seal is and is not is in ยง1.5. Four attackers are considered: a tamperer who edits a sealed file; a forger who wants a valid seal under keys they do not hold; a key holder who seals files with keys of their own (anyone can: the CLI makes one-time keys on every run); and a parser-differential attacker who builds a file that different PDF readers, or different parts of Omitly, see differently.
| STRIDE | Threat | Mitigation |
|---|---|---|
| Tampering | A tamperer changes content in a sealed file โ edits bytes, re-saves or linearises it, appends bytes, or converts line endings โ and hopes it still verifies. | Any change to a covered byte breaks the SHA-512 digest in the signed core: invalid. Changing the seal region itself breaks a signature or the rule that the region's padding is all zeros: invalid. Bytes after the tail, including a line-ending conversion of the tail, are invalid, never "not sealed". The CBOR the signatures cover must be in one exact (deterministic) encoding with closed schemas, so it has no second spelling to swap in. A re-save or linearisation never verifies. Whether it reads invalid or "not sealed" depends on whether the rewriting tool kept the envelope attachment (next row). The spec's qpdf rewrite vector is in the test suite (omitly#1587): every qpdf rewrite mode keeps the envelope, so a re-saved v2 file reads invalid. A re-saved v1 file loses its seal appendix, and since 4.0.1 reads as a removed seal when it carries Omitly's sealed report (omitly#1591). |
| Tampering | Tail stripping and downgrade: remove the v2 tail so the file reads "not sealed", or make it dispatch to the older v1 seal. | The dispatch order is fixed: an actionable v1 seal is checked first, then a v2 tail, then any other version marker. A file that still carries the omitly-seal.env attachment or the envelope's magic bytes but has no valid tail is invalid ("seal envelope present but the seal tail is missing"), never "not sealed". A genuine v1 seal followed by appended v2- or v3-looking bytes stays on the v1 path and is invalid. A v2 seal with an appended v3 tail is invalid. Limit: an attacker who rewrites the whole PDF without the envelope produces an unsealed file, which verifies as "not sealed". A seal can be removed; it cannot be moved onto changed content. A recipient who expects a sealed file must treat "not sealed" as a failure. |
| Spoofing | A forger without the keys produces a valid seal, or drops the stronger leg. | Every declared leg must verify, so a forgery needs valid signatures from both P-384 and ML-DSA-87. Removing a leg from the signature list while the core still declares it is invalid, and removing it from the core as well invalidates the remaining P-384 signature, which signed the original core. Signatures use a domain-separation prefix, and ML-DSA a fixed context string. High-S or out-of-range ECDSA values and off-curve or identity keys are rejected. Verifier implementations are pinned past the 2025โ2026 ML-DSA advisories named in the spec; FIPS 204 ACVP, Wycheproof ECDSA and advisory-regression vectors run in CI. |
| Spoofing | A bug in a young ML-DSA verifier accepts a bad ML-DSA signature, or rejects a good one. | In epoch 1 the P-384 leg is required, so a wrong ML-DSA accept alone cannot produce a valid verdict. A wrong reject makes the seal invalid: it fails loudly, never silently. Every ML-DSA-87 signature is verified by two independent implementations (RustCrypto ml-dsa and fips204), and if they disagree the seal is invalid. Limit: the reverse case matters after the cutover (epoch 2), when ML-DSA-87 carries the guarantee alone. |
| Spoofing | A key holder seals a file โ perhaps carrying a report they wrote โ and presents it as produced by Omitly or by someone else. | Not prevented, by design. Keys are per install or per run, and there is no Omitly signing identity in a seal, so anyone can make a valid seal over any bytes. Verify shows a key fingerprint (SHA-384 over the suite and both public keys) for out-of-band comparison with the sender; it is a comparison aid, not a security boundary. A per-install key gives the same fingerprint on every output from that install, so a recipient can check it against a fingerprint they already trust. A one-time key never repeats. The sealed report records which kind was used (signature.key_scope: install or one_time), and the certificate and HTML report print "Sealed with a one-time key: its fingerprint identifies no other file." That label is the sealer's own statement: it helps an honest recipient, and a key holder can write whatever they like. See also ยง4.2 item 10. |
| Repudiation | A seal is later presented as proof of when a document was sealed, or a classical-era seal is challenged after a future break of ECDSA. | The seal makes no time claim. Time evidence is the opt-in RFC 3161 anchor (B4) over the complete delivered file, tail included. Tension, stated plainly: a seal minted before the cutover rests on P-384, which today carries the guarantee. Lasting evidence that it predates a future break of P-384 needs an anchor, and anchoring is off by default (#1060), so most seals will not have one. The ML-DSA-87 leg does not depend on the anchor, but how epoch 2 treats pre-cutover seals is not yet decided (ยง1.5). |
| Information disclosure / Tampering | Parser differential: Check, or the redactor's own verification, skips the omitly-seal.env attachment as "Omitly's own", and an attacker uses that to hide content. |
The seal is verified at byte level from the tail, so no PDF-parser difference can change a seal verdict. The scanners treat the envelope as opaque only after all of these hold for that file: the seal v2 verdict is valid with no verifier notes and every leg checked; omitly-seal.env has exactly one holder (one name-tree entry, one stream behind /F and /UF, no /RF, and no other /AF entry or filespec anywhere naming it); and that stream's raw bytes are identical to the authenticated region. Otherwise it is treated as any other attachment and fails closed. If the PDF library's object set may differ from what another reader sees (a malformed object dropped and replaced from an object stream, for example), every hidden-content recheck that reads the object graph is reported as not proven, never as clean (omitly#1566). |
| Information disclosure | The PAdES exclusion is aimed at document content instead of the signature value, letting that content change under a valid seal. | The excluded range must be the hex value of the signature field itself: delimited by < and >, lowercase hex only, one DER SEQUENCE followed only by zero padding, and matched to the /ByteRange that precedes it. Otherwise the seal is invalid. The CMS signature value is not covered by the seal, and is checked by PAdES verification, which Verify reports separately. Since 4.1.0, PAdES verification finds a file's earlier revisions by its cross-reference chain (a %%EOF inside content is not a revision) and sees a superseded signature in linearized and hybrid-reference files too (omitly#1605, omitly#1614). |
| Denial of service / Integrity | The seal key cannot be stored or read, for example a Windows keystore that reports success but keeps nothing (the #616/#619 class) โ and a half-written or silently rotated key would change an install's seal identity. | One 32-byte seed holds both legs, so there is never a half-written key pair. It is written once under an OS file lock, and is accepted only after a fresh read-back returns it; a stored seed that cannot be read or decoded is refused and left unchanged, never replaced. On Windows, after the write, Omitly re-persists the credential as machine-local so that it does not roam with a roaming user profile (#619); this step is best-effort, and a credential it could not change stays readable. There is no flat-file fallback for the v2 seed. If the key is unavailable, that output is sealed with a one-time key and says so; PAdES signing with an unavailable key signs without a seal and says so. The expanded ML-DSA secret key is never stored. Not defended: the seed is readable by code running as the user, like any keystore item without hardware backing (ยง4.3). Operational note: on a Mac with both the stable and beta channels installed, macOS can ask for keychain access when sealing; a likely cause is that both channels share one keychain item, and this is tracked as #616. |
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). Since 4.1.0, a verified output delivered this session cannot be overwritten by a later save without a fresh dialog pick (omitly#1613). Read targets (read_file_bytes) are enforced the same way (omitly#467): a path must have been approved by a native open dialog Rust itself showed this session (pick_open_path โ ApprovedReadPaths), be that file's .anchor.json sidecar, or be a path this session already approved for writing; .. components and non-regular files are refused. |
| 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." | Release builds are code-signed on both platforms by the release pipeline. macOS builds are Developer ID-signed and notarized by Apple, with the notarization ticket stapled. Windows builds are Authenticode-signed with a key held in a cloud HSM (Azure Key Vault, via AzureSignTool) and checked with signtool verify /pa before publishing. Residual: the Windows OV code-signing certificate expires on 2027-07-17 and must be renewed before then (ยง4.2 item 2). |
| Tampering | Compromised dependency in the build. | Pure-Rust core with a small dependency set. An SBOM and a cryptography bill of materials (CBOM) are generated in CI, and CBOM drift fails the build; the CBOM is public on the security portal's Cryptography page and the full SBOM is available to customers. They are not yet attached to each release (ยง4.2 item 5). |
| 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. The engine's suite (over 1,100 Rust tests in
redaction-core) asserts 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 plaincargo 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 seal is tested against the spec's attack cases and external vectors, not only round-trips. Seal v2's tests cover the spec's conformance cases (among them a stripped tail, appended bytes, a line-ending conversion, non-canonical encodings, a removed leg, a high-S signature, a wrong ML-DSA context and each PAdES-hole rule), the FIPS 204 ACVP ML-DSA-87 and Wycheproof ECDSA P-384 vector files, and a key-derivation known-answer test whose cryptographic steps were computed with OpenSSL rather than the crates under test. The engine CLI's seal-verification output is pinned by a shared contract case (
contract/cases/verify_seal_v2.json) that the npm package andomitly-mcpalso test against. - 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 sidecars โ not implemented for redactions. The redaction
.audit.jsonand.audit.htmlsidecars are unsigned files (Check's saved report is sealed since 4.1.0, omitly#1570), and the report'screated_atis the local clock. The copy of the report embedded in the delivered PDF is covered by the whole-file seal (seal v2 binds its exact bytes) and, when anchoring is on, by the RFC 3161 anchor โ see the Audit log table. Omitly does not claim the sidecars are signed; verify the PDF. - Code signing / notarization โ in place on both platforms (see Application / supply chain). The live residual is the Windows OV code-signing certificate, which expires on 2027-07-17; renewal into the HSM-backed key store is tracked as omitly#372 and must happen before that date, or Windows release builds cannot be signed.
- Sandbox parity off macOS โ extend the qpdf sandboxing approach to Windows.
- MCP server boundary โ not yet documented here (omitly#1558).
omitly-mcpis published and functional (ยง1.2). It widens the trust boundary from "a human selecting files through an OS dialog" to "a local AI agent choosing file paths", and a document's content can steer what that agent asks for next. Access controls exist: every path is confined to one directory (OMITLY_ALLOWED_DIR, otherwise the directory the server was started in โ so a server started from the home directory is confined only to the home directory), symlinks are resolved before the check, outputs never overwrite an existing file, and licensing is enforced inside the engine binary. These are guardrails against confused-deputy mistakes, not a sandbox against a hostile local user. The full section is still to be written: it will map each tool to read or write, and confirm the MCP network surface โ includingcreate_pdf, which renders HTML in a headless browser through a separate binary not distributed outside the source repository. A known open issue:verify_redactionfollows a symlinked.audit.jsonsidecar outside the allowed directory and can echo reason codes from it (omitly#1563, low severity; it needs such a symlink to exist already). - SBOM / CBOM โ partly done. Both are generated and drift-checked in CI. The CBOM is public on the security portal's Cryptography page; the full SBOM is available to customers through the portal's customer access. Not yet done: attaching the SBOM and CBOM to each release.
- 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 tamper-evidence seal, ยง1.5). (a) is surfaced in every
verify_signatureresult's warnings; (b) is emitted by the backend only when the certificate carries a common name, so the Verify panel's "Digital signature" section additionally renders it as a fixed UI line on every signed document (omitly#923) โ the boundary must not disappear in exactly the case where the signer's identity is least legible. Both reach the user in the UI, 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 the object each standard/Metadatakey points at, wherever that key appears. An object that has to stay because something else still references it is emptied completely โ its whole dictionary and content โ so nothing it pointed to stays reachable through it. Before 4.0.1 an XMP stream held by a nonstandard key kept its other keys, and an object hanging off it survived into the output while the check reported clean (omitly#1586, fixed in 4.0.1 and pinned end to end through the realqpdffinalize). 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, so it is a known limit, by decision (owner, 2026-10-15; omitly#592): the scrub is not widened, and any claim about metadata removal โ marketing, questionnaires, certificate wording โ must say "declared metadata", never "all metadata" or "no XML remains". - Seal v2 has stated limits, not silent gaps: (a) No origin. A valid seal shows only that the file is unchanged since someone holding those keys sealed it; there is no Omitly signing identity, so anyone can seal anything (ยง1.5, "Tamper-evidence seal"). (b) Time needs the anchor, and the anchor is off by default. A seal minted before the post-quantum cutover rests on P-384. Lasting evidence that it predates a future break needs an RFC 3161 anchor, which most outputs will not have (#1060). How the cutover release treats such seals is not yet decided. (c) Unknown algorithms. A leg whose algorithm a verifier does not know is reported as "not checked", not as valid. Its signature bytes can be replaced without that verifier noticing; a verifier that knows the algorithm detects the change. No such leg is minted today. (d) Seals can be removed. A file rewritten without its seal envelope and tail reads as "not sealed", unless it still carries Omitly's sealed report: then, since 4.0.1, Verify says the seal was removed (omitly#1591). The seal cannot be moved onto changed content, but a recipient who expects a sealed file must treat "not sealed" as a failure. (e) v1 files. Legacy v1 (Ed25519-only) seals keep verifying as before and gain no post-quantum leg. A separate "witness" for pre-4.0 files is planned, but its format is not yet specified.
- Check: Omitly's own attachment names (sev:high, fixed in 4.0.1). Before 4.0.1, Check skipped an attachment named
omitly-audit.jsonoromitly-license.jsonas Omitly's own whenever it was held in Omitly's single-holder shape, and nothing tied that attachment's content to a genuine Omitly report or licence. A file carrying an arbitrary payload under one of those names could read Safe to Send on every Check surface โ desktop, the web checker, MCP and the CLI โ with no seal or key needed (omitly#1579). Since 4.0.1, Check skips the report only when it is bound to the file's verified seal, and never skips the licence name; anything else is flagged like any other attachment. Since 4.0.1 the skipped attachment must also verify as a genuine audit report, through the same checks Verify runs; before that, any JSON naming its own sealing key was skipped (omitly#1582). The bound is still integrity, not origin: seal keys are per install or one-time, so anyone can build a schema-valid report naming their own key, put text in its ordinary fields, seal the file, and Check will skip it (omitly#1595, open). Do not treat Check's "no embedded files" line as covering an attachment namedomitly-audit.jsonin a file whose sender you do not trust. - v4.1 "Trust the verdict": fixed after 4.1.0, and what was deferred. These fixes landed after 4.1.0 shipped and reach users in the next release: the Type3 refusal (omitly#568), unreferenced-object reporting (omitly#1610), the v2 report for Replace and pseudonymised outputs (omitly#1571), the
.notdefglyph check (omitly#1519), and stale-evidence removal on Save, Extract and Sign (omitly#1103). Until then, 4.1.0 behaves as the earlier text of this document described. Deferred, and not claimed: (a) the drawn-glyph check does not yet cover CID fonts, codes with no single-character/ToUnicodemapping, TrueType remappings that can only be compared by glyph name when the font has none, or a font whose outline for one character draws as another (omitly#1620); (b) the stale-evidence rule does not yet cover the print path, the CLI andomitly-mcppublish paths, certificate export, encrypted export oromitly-compare -o(omitly#1621); (c) the Check report-binding limit in item 10 (omitly#1595) is open.
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.
Changes in 2.2 (2026-10-19). Updated for the v4.1 release, "Trust the verdict" (omitly#1559): the annotation and form-field removal and the Type3 refusal (omitly#568); a new row for false Safe to Send verdicts, covering the survivor-check fixes (omitly#1528, #1529, #1533, #1607, #1459, #1518, #1532, #1601) and, after 4.1.0, #1610 and #1519; the sealed Check report (omitly#1570) and the encrypted export without a report (omitly#1497); the PAdES revision walk (omitly#1605, #1614); the write rule for delivered outputs (omitly#1613); and ยง4.2 item 11, which separates what shipped in 4.1.0 from what ships next and lists what was deferred with its issue numbers.
Changes in 2.0 (2026-10-13). Added the seal v2 description (ยง1.5) and its threat model ("Tamper-evidence seal" under ยง2 & 3, ยง4.2 item 9), and the open Check finding on Omitly's own attachment names (ยง4.2 item 10). Corrected statements that had fallen behind the product (omitly#1556): the status, date and platforms; the MCP server, described as both functional and a stub, is now described once and correctly, with its boundary documentation recorded as the gap (ยง4.2 item 4); code signing and notarization, which are in place, and the certificate-renewal residual; SBOM/CBOM status; the read-path allowlist; both unsigned sidecars; the anchor's coverage of the embedded report; the size of the engine's test suite; and the engine's environment inputs. The earlier revision was dated 2026-07-01 and carried no version number.