Omitly — Public Threat Model
Version: 3.0 · Status: Published · Last updated: 2026-10-28 · Applies to: Omitly 4.1.x, plus the v4.3 "Certificate honesty" changes as built and not yet in a release (the Audit log rows and §4.2 item 13 marked v4.3) — the desktop app (macOS and Windows), the engine CLI, and the
omitly-mcpserver — and Legal Production Mode (folder redaction) as built for 4.2, which is not yet in a release (§1.2, §1.4)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 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 |
| Legal Production Mode (v4.2, not yet released) | TypeScript orchestrator in the webview (src/batch/) + Rust commands |
Runs the single-file verified-removal pipeline over every PDF directly in one folder, one file at a time, and records the run in one sealed production manifest. New IPC commands: pick_source_dir, list_pdfs_under, write_batch_manifest, read_batch_manifest, verify_batch_manifest; commit_delivery gains a no-replace mode. No new engine code and no network path. |
The webview and Rust core it drives; the manifest sealer and verifier are trusted core logic in omitly-seal |
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"). What the certificate says about time (v4.3, omitly#1560): nothing. The Omitly Release Check Certificate and the.audit.htmlreport print no time claim beyond aGeneratedtime taken from the verification result, and no anchor line; the RFC 3161 anchor, when present, is a separate.anchor.jsonsidecar that Verify checks on its own. No anchor is the default state (above), so a certificate without one is the normal case. OpenTimestamps, C2PA and PAdES B-LTA/LTA are planned, not attested: no surface prints them and none is claimed here. - 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.
- Legal Production Mode (v4.2) adds no boundary and no network path. It drives B1 and B2 exactly as a single-file redaction does, once per file. It never anchors: the orchestrator passes no timestamp authority for any file (
anchorTsaUrl: nullinsrc/batch/orchestrator.ts), whatever the single-file "Timestamp proof" setting is, so B4 is never reached from a batch. Everything it writes goes into the output folder the user picked in a folder dialog (§1.4).
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 |
| Legal Production Mode (v4.2): redact a folder — pre-flight, per-file outputs and sidecars, the production manifest, resume | No | — | always. No file in a batch is anchored, whatever the "Timestamp proof" setting, so B4 is never reached from a batch |
Verify a production manifest (verify_batch_manifest) |
No | — | always |
Production log (production-log.csv, omitly#985; in progress) |
No | — | always — a local projection of the run record |
| Proposals from an MCP client (Settings → Connect your AI; omitly#1642–#1645) | No | — | always. The omitly-mcp server and the app exchange small JSON files under ~/.omitly (desktop.json, proposals/, clients/); no socket is opened by either side. What a connected AI client does with text it asks the server for (e.g. extract_pdf_text) is that client's own egress, not Omitly's — see Boundary B5. |
| 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.
Legal Production Mode (v4.2, not yet released) adds the artefacts below. All of them are written only inside the output folder the user picked in a folder dialog, and none of them leaves the device through Omitly (§1.3). Design: docs/BATCH-PRODUCTION-DESIGN-833.md (invariants B1–B8).
| Artefact | What it carries | What it never carries | Integrity |
|---|---|---|---|
production-manifest.json |
One record of the run: run id (a random UUID), start and finish times (local clock, in UTC), status, app version and engine commit (stamped by the Rust core from its own binary; the webview's values are discarded), one_time_key (set by the sealer from the keys it actually used), files planned, and the id of any run it resumes. The source and output folder names — the last path component only. The term count and the search options (match case, whole word, number of substitutions), and terms.mac: a MAC of the term list under a key only this install holds (SEAL-SPEC-v2 §20.9, omitly#1661), so a later run here can prove it searches the same terms. The naming template and the pinned {date}. Per input file: its name, and its SHA-256, size and page count once it was read; its disposition; a fixed reason code with a count. Per output: its name and SHA-256, whether it is sealed and passed, its audit schema and sidecar name, its seal public key, and counts of warnings, regions removed and labels drawn. |
A search term, an unkeyed hash of the terms (nothing a recipient can test a guessed term against), the operator messages shown on screen, page lists, any path. | Seal v2 detached JSON envelope, core subject 2 (SEAL-SPEC-v2 §20, owner-approved 2026-10-22), under the install's seal keys, or one-time keys when those cannot be loaded. Closed schema; the verifier recomputes status and totals. Rewritten after every file through the staged, reread, verified publish. |
production-manifest.<run id>.json |
The previous run's verified manifest, kept byte for byte when a new run resumes it (B5). | As above. | Its own seal, unchanged. Written once with the no-replace writer; never replaced. |
production-log.csv (omitly#985; in progress) |
One row per input file, 24 fixed columns projected from the manifest's fields: names, hashes, size and pages, disposition, reason code and count, output facts (including the audit/v2 overall status, omitly#1695), seal public key, run id and status, pinned date, start and finish times. UTF-8 with a BOM, RFC 4180. | Terms, operator messages, paths, folder names (the input carries the folders for the caller; they are not columns). | None: a plain, unsigned projection of the manifest; check it against the manifest. In this build no screen writes it yet: its export waits on the completion screen (omitly#984) and a no-replace text write. |
.omitly-<16 hex>.tmp (hidden) |
Every publish — each output, sidecar and manifest — first writes the bytes to a randomly named file in the same folder, rereads and verifies them, then publishes. A crash or power loss between staging and publish can leave one behind, holding a complete copy of what was being published: a redacted output that had already passed the webview's survivor gate, a sidecar, or a manifest. | The unredacted source. | Not published and not to be relied on. The pre-flight does not yet list leftovers (§4.2 item 12): delete any before handing a production folder over. Single-file saves stage the same way; a production folder is new in being meant to be handed over whole. |
Each output and its .audit.json / .audit.html sidecars |
As for a single-file redaction (§1.3, "Audit log"). A batch never writes .anchor.json. |
— | As for a single-file redaction, except that each is published without replacing anything: a sidecar that already exists refuses the whole delivery. |
| The term list | Typed into the set-up dialog and held in the webview's memory for the run. | It is not stored by the app and is not written to the manifest, the production log, or the per-file reports, which record redaction boxes, reason codes and any substitute labels drawn (redaction-core/src/model.rs, omitly-seal/src/audit_v2_build.rs). |
Operator messages that quote surviving terms are shown on screen only; abort_delivery drops its reason unlogged. |
What a production discloses to its recipient, by design. A manifest or production log sent with a production states: the source and output folder names; the name, size, page count and SHA-256 of every input file, including those that were not produced (no occurrences, not searchable, unresolved, skipped, failed); why each one was held back, as a fixed code with a count (for example terms_in_metadata, with the number of matching strings); how many terms were searched and with which options; the naming template; start and finish times; app version and engine commit; the run id and any run it resumed; whether one-time keys were used; and each output's seal public key, which for a per-install key is the same on every output from that install. A SHA-256 of a withheld source lets anyone who already holds a candidate file confirm it was in the folder. Treat the manifest as listing everything in the source folder.
Matter profiles (Omitly 5.2, omitly#982) add the artefacts below. A matter profile is a named, saved set of search settings for one matter, reused across documents and folder runs. Design: docs/MATTER-PROFILES-DESIGN-982.md.
| Artefact | What it carries | What it never carries | Integrity |
|---|---|---|---|
<app data>/profiles/<id>.json, one file per profile |
The profile's name, a random UUID, a random 32-byte salt (64 hex characters), and the search terms themselves: each term's text, its aliases, its case, whole-word and regex options, its label and exemption code, the profile's exclusion strings, and the default code. Creation and update times. In practice that is the names of the matter's people and organisations, and any identifier or pattern the user typed. The folder is the app's local data folder (the same one the licence uses, not the roaming one; OMITLY_DATA_DIR overrides it) (src-tauri/src/matter_profiles.rs, license::app_data_file). |
A document, any text read from a document, or a path. | None beyond the operating system. The file is plain JSON. It is not encrypted, not signed and not kept in the keychain, and the code sets no file permissions of its own. Anyone who can read the user's files on this device can read the names in it. Every read goes through one strict parser (unknown fields refused, length and count limits, a UUID-only id that names the file), so a hand-edited or corrupt file is refused and listed, never half-loaded. Writes are staged, reread and renamed into place. Full-disk encryption and an unshared OS account are the mitigations. |
<name>.omitly-profile.json (an export) |
The profile as above, including the salt, and the id and label of each exemption code it uses. Written only to a path the user picks in a save dialog. | As above. | Plain, unsigned JSON, and it holds client names. The export dialog says so. An import is parsed under the same limits (1 MiB cap), refuses the whole file on any failure, and recomputes the fingerprint from the content and salt; a file carrying a fingerprint field is refused. |
The matter_profile record in a sealed report and in the production manifest |
Two keyed fingerprints (HMAC-SHA-256, 64 hex characters each), a flag saying whether the run edited the profile, and (in the report) a count of occurrences left visible inside an exclusion. | The profile's name, its terms, its exclusions, its salt. The name is deliberately not recorded (owner decision D2): it can itself identify a client, and the report travels with the redacted file. | Inside the sealed report (§1.5) and the sealed manifest. |
Profiles are read and written only by the Rust core. The webview reaches them through a fixed set of commands (list, get, save, delete, duplicate, effective, export, import) and never touches the folder. No profile command makes a network call (§1.3). Nothing in §1.3 sends a profile anywhere, and the support form sends only what the user types. (Unverified: a test that fails if a profile name or term reaches a support submission. The design note promises one; we did not find it in the code.)
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, and embedded Type0/CID fonts (omitly#1620), 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 unless it is drawn in text render mode 3 (invisible), 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 and never time. The v2 certificate says so on its face: "Tamper-evidence seal: valid - document unchanged since sealing (integrity, not proof of origin)", one fixed seal-family line ("Seal: seal v2 - hybrid ML-DSA-87 + ECDSA P-384 signatures", or "Seal: Ed25519 (classical-era seal v1)" for a v1 seal; chosen from the seal found in the delivered bytes, never from report text), and the reliance text, which says it does not attest that the document contains no other sensitive information or the identity of the person who redacted it (omitly#275). 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). |
| Spoofing / Tampering | The certificate or .audit.html reads as affirmative when the result is not Safe to Send, or a code path renders a clean verdict without a verification behind it. |
Closed by type (v4.3, omitly#272, CLAUDE.md invariant #14). A certificate is issued for every v2 report that passes every verify_v2 gate, and the verdict is printed prominently; only Safe to Send reads as clean. Needs Review and Cannot Verify also get a certificate, with their own label and the second reliance sentence — that is a deliberate decision, so a certificate existing does not mean the result is Safe to Send. Every renderer takes an omitly_seal::audit_v2::Verdict with private fields; the only constructor that yields Safe to Send takes a TrustedAuditV2, which only verify_v2 makes (a compile-fail doctest pins this), and a renderer refuses a result whose state disagrees with its verdict. A failed verification returns no TrustedAuditV2, so success is never inferred from an absent error. Since 4.3 a redaction the detector can still re-find is Needs Review, never Safe to Send (omitly#1690). |
| Tampering | An output stands at the user's path before its report and seal exist, or with a report that does not verify over its bytes. | Closed in the Rust core (v4.3, omitly#272, invariant #15). An output is final only when the delivered bytes carry a sealed v2 report that verifies over them and carries its session record. omitly_seal::delivery::verify_final_v2_candidate is the test; the desktop and the engine CLI run it inside the publish step, over the bytes reread from staging, before the atomic rename. Sidecars follow and are informational. Print, CLI publish, omitly-compare -o and certificate export follow the same stale-evidence rule as Save: no path publishes a report or seal that no longer describes the bytes (omitly#1621). sign_pdf refuses an encrypted input at the signing boundary (omitly#1635). |
| Repudiation / Tampering | An operator override is used to wave a finding through, to hide incomplete coverage, or is later denied. | Bounded and attributable (v4.3, omitly#274, omitly#1698). Each operator decision in the sealed report records the actor (human-desktop, mcp-agent or cli), decided_at, the overridden status, the check and evidence_count; the verifier view carries those and never the operator's reason or note. A decision may acknowledge only a detector_residual_identifier finding. A check that carries detector_scan_incomplete, evidence_truncated or any engine_* finding cannot be acknowledged, and a report that does so is rejected (before omitly#1698 the validator accepted it and the verdict read Safe to Send). A mandatory review, such as an effectively image-only page, is never lifted by a decision. The producer refuses unless the accepted findings equal the check's evidence exactly. Limits: actor and decided_at are the producer's own statements, like provenance.decision_channel; mcp-agent is an allowed actor, so a decision recorded as an agent's is a claim about the channel, not about a person; a report with decisions reads as pass_with_operator_decisions, which surfaces as Needs Review, never Safe to Send (omitly#1701, owner decision 2026-10-27); and no desktop or MCP flow supplies a decision yet, so released outputs carry none. |
| Repudiation | The detector configuration behind "verified against the stated profile" is changed under the same name. | Pinned by content hash (v4.3, omitly#273). A report that records a detection profile also records its SHA-256 (release_check.entity_profile_sha256) over the effective configuration: each compiled pattern, its region, regex source and checksum validator, and the numeric visibility parameters. A verifier names the profile only by looking that hash up in its own append-only table of released revisions, never from the report's entity_profile_id or revision text; the certificate prints the recognised revision or "not a revision this version of Omitly released", then the hash. The hash is recorded only for the built-in profile, never for one built from user data. Limit: it pins configuration, not the matching algorithm, which is identified by the recorded engine version and build SHA, and a report from before 4.3 has no hash and says so. |
| Repudiation | What was done to the input, in what order, and what the output was finalised against, is not in the evidence. | Session record inside the sealed report (v4.3, omitly#271). The v2 report carries the operations that ran (redact, verify_regions, inspect, detector_rescan when a profile is recorded, build_report, seal), a finalisation statement derived from the two removal checks, and the pinned profile hash. It is signed by construction: it is part of the report that the whole-file seal binds (SHA-384 under seal v2's ML-DSA-87 and ECDSA P-384 legs), and there is no JCS manifest and no second signature. Limits: step times come from the producer's clock and a verifier checks their shape and order only; the webview-side delivery gates run after the seal and are not in the record; a verifier does not re-run the checks. |
| Elevation of privilege | A compromised webview reports delivery gates as passed, or reaches affirmative wording or a published output by IPC. | Split, and stated as such (omitly#272, spec section 6.5). No IPC argument, webview message or configuration reaches affirmative wording or a published output without the native gates: the webview never sends a verdict (CheckResult is not deserialisable) and can only commit or abort a delivery Rust prepared. Known limit: the scanned-page re-OCR, born-digital and text survivor checks and the label tally are enforced by the webview after the seal, over the pending bytes. A compromised webview can report them passed; Rust enforces only that every required gate is reported, on these exact bytes, with a non-zero target count. What such a webview can publish is still a sealed, verified output whose certificate says what the native checks found. Moving those gates native is open (§4.2 item 13). |
| 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. Since v4.3 the session record (below) adds per-step times to the sealed report; they come from the same producer clock and a verifier checks only their shape and order, so they are a record of process, not time evidence (omitly#271). |
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. |
Legal Production Mode (folder redaction, v4.2)
Not yet in a release. What it writes is in §1.4; the design's invariants are B1–B8 of docs/BATCH-PRODUCTION-DESIGN-833.md, and the manifest's seal is SEAL-SPEC-v2 §20, approved by the owner on 2026-10-22.
| STRIDE | Threat | Mitigation |
|---|---|---|
| Information disclosure | A term in a source or output file name, or in a folder name, travels into the output folder, the manifest and the production log, which record names. | Closed at pre-flight: the run refuses to start while any source name, templated output name, or the source or output folder's name contains a term — a substring match, case-insensitive whatever the run's match-case setting (namesContainingTerms and the folder check, src/batch/plan.ts). Limit: the test is a lowercase substring match without Unicode normalisation, so a name that spells a term with differently composed characters is not caught. |
| Information disclosure | Operator text that quotes a term — the on-screen gate messages name surviving values — reaches the manifest or the production log. | Closed structurally. The manifest's schema has no field for free text, a term list or a term hash, and every object rejects unknown fields (deny_unknown_fields), so a producer bug that adds one fails to seal instead of leaking. The webview projection copies codes, counts, hashes and names only (src/batch/manifest.ts), and the production log's input type names the only fields it reads. Tests seal a manifest whose run used a marker term and search the sealed bytes for it, and check that a manifest carrying a term list, an operator message or a term hash is refused without the refusal quoting it (terms_are_never_serialised, src/batch/manifest.test.ts). |
| Information disclosure | A term survives in a produced file outside the page text — an annotation, a form field, document metadata, an outline entry — or in an attachment. | Closed for batch (omitly#1638): before anything is produced, every string pdf.js exposes on annotations (hidden ones included), form fields, metadata (Info and XMP) and outline entries is matched with the run's rules. Any hit makes the file unresolved and nothing is written for it, even when the page text matched too. Any embedded file makes the file not_searchable. Every output's declared metadata is also scrubbed, as for a single file. Limit: text drawn only in an annotation's appearance stream (a stamp's artwork) is not read (§4.2 item 12). |
| Information disclosure | A term in an image on a page that also has text — a scan with a Bates stamp — is invisible to a text search, so the file reads "no occurrences", or is produced with the image intact. | Closed above a threshold (omitly#1637): a page with text whose images cover at least 10 % of the page area — measured conservatively, overlaps summed — makes the file not_searchable, and a page whose drawing operations cannot be read fails closed the same way. A page with no text was already not_searchable. Disclosed limit: images under 10 % of a page, images inside tiling patterns, and text inside any image are not searched; a batch runs no OCR. The run screen says so. |
| Tampering / Spoofing | A run that left a file failed, unresolved or unsearched reads as a complete production, or a count ("213 of 214") stands in for the list. | Closed: a run is complete only when every input is produced, already produced, or has no occurrences in fully searched text (B2, B2a); anything else makes it incomplete and is listed first. The verifier does not trust the manifest's headline: it recomputes the status and every total from the rows, requires a finished run to have exactly as many rows as files planned, and reports a manifest that disagrees as invalid however well it is signed (SEAL-SPEC-v2 §20.6). The producer applies the same rules and refuses to sign such a manifest. |
| Spoofing | A production manifest is presented as a Check report, or a Check report as a manifest. | Closed: the kind is in the signed core (subject 1 is a Check report, subject 2 a manifest) and each verifier accepts only its own. The other kind reads unsupported, never valid, and changing the subject breaks every signature. Conformance vectors cover both directions and the PDF verifier (SEAL-SPEC-v2 §20.4, §20.7). |
| Tampering | A later run, or a file that appears between pre-flight and publish, replaces an output, a sidecar or the manifest. | Closed in the Rust core, not by the pre-flight. Outputs are published with a hard link, which fails if anything is at the destination; on a volume without hard links, a check immediately before the rename, and an error that says so. A sidecar that already exists refuses the whole delivery, and sidecars use the same no-replace writer. The manifest is replaced only by the same run while it is in progress, by a version that keeps every earlier row; or by a new run that declares it resumes that verified manifest, which is first kept as production-manifest.<run id>.json and never replaced. A manifest that does not verify is never replaced, and the run will not start. |
| Tampering | The manifest is edited after sealing — a row changed, a failed file removed, the status rewritten. | Any changed byte makes the seal invalid. The production log is not sealed: check it against the manifest. |
| Tampering | A source, or an existing output that the pre-flight verified for resume, is swapped before that file's turn in the run. | Closed: at its turn the source and the output are hashed again, and different bytes make the file failed (destination_exists). The existing output is not replaced, and nothing is recorded under the hashes the pre-flight checked. A file processed fresh is hashed from the same bytes that are redacted. |
| Tampering / Elevation of privilege | A file name such as =HYPERLINK(…) becomes a live formula when the production log is opened in a spreadsheet. |
Closed in the production log (omitly#985, in progress): any field that starts with =, +, -, @, a tab or a carriage return is prefixed with ' before RFC 4180 quoting. The manifest is JSON shown by Omitly and has no formula surface. |
| Information disclosure | A resumed run carries forward outputs that were redacted for a different term list. | Closed (omitly#1648). Resume checks that each existing output verifies against its unchanged source — its seal, its report's pass verdict, and the report's input hash — and, because the manifest records only the term count and options (never the terms), it also refuses to resume at all under a different term count, match-case or whole-word setting, and runs the delivery gate's own oracle for the current terms over the output's bytes: any survivor is a pre-flight blocker, never already_produced. The oracle reads page text, so the batch's stated limits (#1637/#1638) apply to it as to any output. |
| Spoofing | Someone who can write to the output folder plants an output and a manifest sealed with their own keys, and a resumed run adopts them. | Closed (omitly#1649). A run resumes only from a manifest sealed with this install's seal-v2 key — never a one-time-key manifest, never another install's — and only carries forward outputs whose own report names that same key; anything else is a pre-flight blocker and the folder is never written into. The planted material still verifies (any valid seal does, and Verify says whose key it is), it just cannot become this install's word. A shared or synced output folder remains writable by everyone who can write to it; the manifest's per-row hashes are what a recipient checks. |
| Information disclosure | After a resume, the kept earlier manifest and the current one both name the same outputs, perhaps with different statuses (the earlier run incomplete, the later complete). |
Informational, not a contradiction: each manifest is valid for its own run. The later one's resumes names the earlier run's id, and an output carried forward appears as already_produced with the hash the earlier run recorded. Read the newest run and follow resumes back. |
| Repudiation | Who sealed the manifest, and when. | ⚠ Partial, as for every seal. The manifest records whether one-time keys were used (run.one_time_key, set by the sealer, never by the caller), and Verify shows its key fingerprint, which for a per-install key repeats across that install's productions. Start and finish times are the local clock, and a batch is never anchored, so a manifest carries no independent time evidence. A seal identifies keys, not a person (§1.5). |
| Elevation of privilege | The new folder commands widen what the webview can read or write. | A source folder must have been chosen in a native folder dialog this session (pick_source_dir). That approval lets the webview list the folder, and listing approves for reading exactly the regular .pdf files directly in it — not recursive; symlinks are skipped, never followed — so the webview can read only what it was shown. A source folder is never approved for writing. Writes go to the output folder through the existing pick_save_dir approval (direct children only) and cannot replace an output delivered this session (omitly#1613). |
| Information disclosure | Verifying a manifest makes Omitly read files the user did not pick. | Bounded: for each produced row, verify_batch_manifest hashes the file of that name in the manifest's own folder (a recorded name with a path separator makes the manifest invalid), skips anything that is not a regular file, and returns only "present" and "hash matches", never bytes. A crafted manifest, which anyone can seal, can make Verify report whether a named file in that folder has a given SHA-256; the answer is shown only to the person verifying, and nothing leaves the device. |
Matter profiles (Omitly 5.2, omitly#982)
What a profile holds and where it lives is in §1.4. Design: docs/MATTER-PROFILES-DESIGN-982.md. A profile feeds the term list of a name-list redaction or a folder run; it does not change the engine or the seal. Its exclusions do change what the survivor check treats as a survivor (below).
| STRIDE | Threat | Mitigation |
|---|---|---|
| Information disclosure | Someone with access to the device reads client or matter names from the profile files or from an exported profile. | ⚠ Not mitigated by Omitly. Profiles are plain JSON in the app's local data folder, under the OS user account, like the rest of the app's data (§1.4). There is no encryption and no keychain key. We accept this risk and rely on full-disk encryption and the OS account boundary. An export carries the names and the salt, and goes wherever the user sends it. |
| Information disclosure | A recipient of a redacted file or a production guesses the profile's contents and confirms the guess from the sealed report. | The report records keyed fingerprints only: HMAC-SHA-256 under the profile's own random salt, which is never in the report. A plain hash of a small profile could be confirmed by guessing it. These cannot, without the salt (crates/omitly-seal/src/matter_profile.rs; MatterProfileV2 in audit_v2.rs). The profile's name is not recorded. Limit: whoever holds the profile file (and so the salt) can compute the fingerprint and show which profile a run used. That is intended: the sender can prove it. |
| Tampering | A profile is edited, in the app or on disk, so that it quietly drops a term, adds an exclusion, or changes a label, and a run searches for less than the matter requires. | Detected as evidence, not prevented. (1) Each run seals two fingerprints (matter_profile, SAFE-TO-SEND §6.6): fingerprint, of the profile as stored when the run's binding was made, and effective_fingerprint, of the terms and exclusions the run used. Rust recomputes both from the stored file and the terms sent for the run; the webview does not supply them (matter_profiles::effective_in, binding_in). Every field that changes behaviour is in the fingerprint (terms, aliases, options, labels, codes, exclusions, defaults); the name, id and times are not. So any change to the effective search settings changes the fingerprint. (2) edited is derived (fingerprint != effective_fingerprint); a report whose flag contradicts its fingerprints is invalid (validate_matter_profile). So a run that departed from the stored profile cannot call itself unedited. (3) Exclusions are a disclosure: the count of occurrences left visible is sealed (exclusions_applied), it does not by itself change the verdict, and the survivor check still covers every non-excluded occurrence (owner decision D3). (4) A folder run snapshots the profile at Start. Editing the profile during the run cannot change what that run records: sealing is refused if Rust's recomputation differs from the manifest's fingerprints (check_expected). Limits: the report says which profile content was used, not what it was; a verifier cannot tell a weaker profile from a stronger one, and has nothing to compare to unless it holds the profile file. A profile edited before a run is not "edited": the run records the new content as its starting fingerprint, and edited is false. The record does not show that the file changed since an earlier run. The fingerprint is computed over the terms the webview hands to the sealing step; the Rust core does not re-derive the terms from the redactions drawn (unverified for the case of a compromised webview; see the trust boundary under the Audit log). |
| Tampering | A folder run is resumed with a different profile, or a profile edited since, and carries forward outputs made under other settings. | Refused. Resume requires the same profile binding as the kept manifest (sameProfileBinding, src/batch/plan.ts), and rechecks that each existing output's sealed report carries this run's fingerprints and exclusion count (sealedProfileBindingMatches). A mismatch refuses the file with "use a new output folder"; a batch never replaces a file. The manifest's profile record cannot change while a run is in progress (may_replace, src-tauri/src/batch_manifest.rs), and the verifier requires edited to match the two fingerprints. |
| Tampering | A hand-edited or imported profile file smuggles in an invalid value, an oversized list, or a pattern that hangs the matcher. | Refused whole. One parser with deny_unknown_fields, a 1 MiB import cap, term-count, length and control-character limits, and unique terms (validate_structure). A regex term must compile under the Rust regex crate (linear time, no backreferences or lookaround) with a length and size cap, or the whole profile is refused (redaction_core::term_regex). An id that is not a UUID v4 never reaches a path. |
| Denial of service | An older verifier meets a 5.2 report. | A verifier from before 5.2 rejects a report that carries matter_profile, because every v2 struct refuses unknown fields. This is the same trade 4.3 made for session, and it fails closed. |
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. |
Boundary B5 — MCP proposal channel (bring your own AI, 2026-10)
A customer may connect their own MCP client (Claude Desktop, Cursor, Windsurf, a firm's agent) to the omitly-mcp server. While the desktop app runs with Accept proposals in Omitly on (default), a redaction the client requests is not executed: it is written as a proposal file that the app shows, and a person accepts or rejects it in the app. Acceptance runs the ordinary verified-removal core and the sealed record says human-desktop. The channel is files under the state directory, never a listener.
| STRIDE | Threat | Mitigation |
|---|---|---|
| Elevation of privilege | A hostile or compromised MCP client on the same machine tries to make Omitly write a redaction without a person. | A proposal is data, not an instruction: nothing is written until a person accepts it in the app, and the output path still goes through the save dialog (ApprovedWritePaths is untouched by the channel). With the switch off or no desktop running, the server's direct write path is unchanged from today and its record says mcp-agent (not human-reviewed). |
| Tampering | Another local user, or a process running as the user, edits a proposal file (regions, paths) before acceptance. | Directory and files are owner-only (0700/0600); proposal ids are random and validated before any path is formed (no traversal). Show draws the proposed regions on the page as draft boxes the person can inspect, move or delete, and Accept applies them through the manual Apply path; the engine's own per-region verification and the survivor gates run exactly as for a manual redaction. The client's free-text reason is never composed into the sealed reason_code (provenance AI_PROPOSAL only). A same-user process can already do anything the user can — this is not a new boundary, and the model does not claim otherwise. |
| Information disclosure | Document text reaches the AI client's model (which may be hosted). | Omitly's own egress is unchanged (zero automatic). Text a client asks the server for is the client's egress, under the customer's chosen model; the Connect panel and the website say so in plain words and make no "nothing leaves" claim for this path. Client identity (clientInfo) is kept in the proposal file and a local run log only, never in the sealed report (cert v2 threat T7). |
| Spoofing | A document's text contains instructions that steer the customer's agent ("prompt injection") into proposing the wrong regions. | Out of Omitly's control and not claimed: the mitigation is the human gate — every proposal is reviewed on the page before anything is written. |
| DoS | A client floods the inbox. | Proposals are small files, listed per poll and swept once resolved; the free-tier rate limit on diagnose tools is unchanged. A flooded queue is visible, not silent. |
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. The certificate and.audit.htmlare renderings of the verified embedded report, titled "Omitly Release Check Certificate" for a v2 report (never "Proof-of-Destruction"; it records what was removed from this file and the checks that ran on the delivered bytes, and says nothing about originals or other copies). A legacy v1 certificate still prints "VERIFIED" for an old sealed v1 report; v1 verdicts are labelled "classical-era seal (v1)" on every surface (omitly#1588), but the v1 wording itself has not been changed. - 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 PAdES signature is a different artefact from the Release Check Certificate, which is not a signature and carries no signer identity). (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 not scan it as an attachment. Since omitly#1595 Check no longer does that silently: every Check surface adds an Information finding that the file carries an Omitly report, naming the sealing key's fingerprint and the verdict the report records (fixed text and a closed verdict set only, never a string from the report). It does not change the verdict, because Check cannot tell whose key it is. The free text inside such a report's ordinary fields is not bounded or scanned (still 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: compare the named key with one the sender published. - 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 compare a CID font's glyph names with its text (its.notdefrule is in, omitly#1620), nor cover 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) closed in v4.3 (omitly#1621): the stale-evidence rule now covers the print path, CLI publish (which theomitly-mcptools call), certificate export andomitly-compare -o; encrypted export already went through the checked publish, and that is now pinned by a test; (c) the Check report-binding limit in item 10 (omitly#1595) is open. - Legal Production Mode (v4.2, not yet released): stated limits and open gaps. (a) The manifest's seal format (SEAL-SPEC-v2 §20) was approved by the owner on 2026-10-22. (b) Both resume gaps found by this pass are closed: omitly#1648 (a changed term count or options cannot resume, and each carried output is re-analysed for the current terms — page text, annotations, form fields, document properties, bookmarks, attachments, image-beside-text pages) and omitly#1649 (resume only from this install's own sealed manifest and outputs). Resume is deliberately stricter than Verify; a one-time-key manifest never resumes. (c) Not searched in a batch: images under 10 % of a page, images inside tiling patterns, text inside any image, and text drawn only in an annotation's appearance stream. (d) A crash can leave a hidden
.omitly-<hex>.tmpstaging file in the output folder, and the pre-flight does not yet list it. (e) The production log is unsigned, and no screen writes it yet (omitly#984, omitly#985). - Release Check Certificate (v4.3 "Certificate honesty"): stated limits, not silent gaps (omitly#1560). (a) Integrity, not identity, not time. A certificate shows the result of the checks that ran on the delivered bytes and that the file is unchanged since sealing; it does not identify who sealed it, does not prove when, and (Safe to Send is a defined term, CLAIMS-POLICY §1a) does not mean the document holds no other sensitive information. Needs Review and Cannot Verify also get a certificate; only Safe to Send reads as affirmative. (b) The webview-side delivery gates are not native (re-OCR of scanned pages, survivor checks, label tally; Audit log table, "Elevation of privilege"). They are not in the session record. (c)
pass_with_operator_decisionsreads as Needs Review, never Safe to Send (omitly#1701, owner decision 2026-10-27).mcp-agentis still an allowed override actor, although the owner decided it should not be (tracked separately); no flow writes a decision yet. (d) A legacy v1 certificate still prints "VERIFIED" for old sealed v1 reports. (e) Free text in a sealed report is not yet bounded beyond what the verifier shows (omitly#1595 residual); the certificate prints no producer string for that reason. (f) The session record's times and theactorfield are the producer's own statements.
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 3.0 (2026-10-28). Added matter profiles (Omitly 5.2, omitly#982; omitly#1562): where they are stored and what they contain, including that they hold client and matter names in plain, unencrypted files (§1.4), and a "Matter profiles" threat table covering disclosure, the keyed fingerprints in the sealed report, tampering with a profile, resume with a different profile, and the limits of what the report can show.
Changes in 2.9 (2026-10-26). v4.3 "Certificate honesty" (omitly#1560): the v2 certificate is titled "Omitly Release Check Certificate" and prints one fixed seal-family line, and the Audit log table, §1.3 B4 and §4.2 items 1, 6 and 13 say so (omitly#275); added the type-level issuance gate (omitly#272, invariant #14), the final-delivery ordering gate (omitly#272, invariant #15) and the trust boundary with its webview limit; added attributable operator decisions bounded to detector_residual_identifier findings (omitly#274, omitly#1698); the detection profile pinned by content hash (omitly#273); and the session record signed by construction inside the sealed report (omitly#271). Added the stale-evidence rule for print, CLI publish, omitly-compare -o and certificate export (omitly#1621), the encrypted-input refusal in sign_pdf (omitly#1635) and the redaction-the-detector-can-still-find rule (omitly#1690). B4 now states what the certificate says about time: nothing, with OpenTimestamps, C2PA and B-LTA planned and unattested. No earlier claim in this document is withdrawn: it never described the certificate as proof of destruction, and the new text states the integrity/identity/time scope the certificate already prints. The open limits are item 13.
Changes in 2.8 (2026-10-26). The .notdef drawn-glyph check now covers embedded Type0/CID fonts, and no longer flags glyphs drawn in text render mode 3, which paints nothing (mode 7, clip only, stays in scope: a later fill can show its outline) (omitly#1620): the false Safe to Send row and §4.2 item 11(a).
Changes in 2.7 (2026-10-26). Check now reports an omitly-audit.json bound to the file's own seal as Information, naming the sealing key and the report's recorded verdict, instead of skipping it silently (§4.2 item 10, omitly#1595). Verify, the certificate, the engine CLI and the npm/MCP output now label a v1 seal's verdict "classical-era seal (v1)" as SEAL-SPEC-v2 §11.1 requires (omitly#1588).
Changes in 2.6 (2026-10-22). The production manifest records a keyed term-list MAC (SEAL-SPEC-v2 §20.9, omitly#1661), and resume requires it to match, so a later run with a different term list of the same length no longer carries earlier outputs forward (§1.4).
Changes in 2.5 (2026-10-22). Added Boundary B5, the MCP proposal channel (bring your own AI, omitly#1642–#1646): its row in the network-surface table and its STRIDE table.
Changes in 2.4 (2026-10-22). The manifest's seal format, SEAL-SPEC-v2 §20, is now owner-approved (§1.4, the Legal Production Mode section and §4.2 item 12(a) said "proposed").
Changes in 2.3 (2026-10-21). Added Legal Production Mode, the v4.2 folder-redaction workflow, as built and before it is released (omitly#1561): its component (§1.2); that it adds no network path and never anchors (§1.3 and the network-surface table); every file it writes, and what a production discloses to its recipient (§1.4); a STRIDE section of its own, including two open gaps in resume; and its stated limits (§4.2 item 12).
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.