Security
Status: incomplete. A full threat model is being written and is not finished. This file records what is already decided and enforced in code, and is explicit about what is not yet covered. It will be wrong to treat the gaps below as anything other than gaps.
Reporting a vulnerability
Use GitHub's private vulnerability reporting: the Security tab of this repository → Report a vulnerability. It is private between you and the maintainers until we publish an advisory, it needs no mailbox on our side, and it costs nothing. We aim to acknowledge within 5 working days.
Please do not post a proof of concept before we have had a chance to respond.
This replaces an earlier instruction to email
security@arhas.in. That mailbox did not exist, and a security contact that nobody reads is worse than none — it converts a private report into a public one when the reporter gives up and files an issue instead. GitHub's own channel was available the whole time and required nothing we did not already have.
Please do not open a public issue for a vulnerability. There is no bug bounty.
The threat model in one paragraph
Every file this application opens is attacker-controlled: a PDF arrives from a government portal, an employer, or a stranger. There is no server, no account and no network service, so the classic web attack surface does not exist. What replaces it is a large C++ parser reachable from untrusted input, a writer that can silently retain content the user believes they removed, and an operating system that copies documents around behind our back.
What is already enforced in code
| Control | Where | Why |
|---|---|---|
| Non-V8, non-XFA PDFium build | PdfiumLibrary, NOTICE | No JavaScript engine exists in the process, so embedded PDF JavaScript is structurally unreachable rather than merely disabled. |
| No user font paths | PdfiumLibrary.EnsureInitialised | m_pUserFontPaths is null: PDFium never loads fonts from arbitrary filesystem locations. |
| AGG renderer, not Skia | PdfiumLibrary.EnsureInitialised | Smaller attack surface, and matches the build we vendor. |
| Input bounds before parsing | SafetyLimits, PdfDocument.Open | File size, page count, page dimensions and per-page object counts are checked. PDFium imposes no meaningful limits of its own; refusing absurd input is our job. |
| Single-revision save, always | SavePolicy, SaveAudit | See below. This is the most important control in the codebase. Countersigning is the single, checked exception, and it is described there too. |
| Pinned native dependency | Creasepoint.Core.csproj, NOTICE | PDFium is pinned to 154.0.8021 (upstream chromium/8021). Bumping it is a security decision with a changelog review, not a routine update. |
| Lock file + audit on restore | Directory.Build.props | RestorePackagesWithLockFile, NuGetAudit at low, NuGetAuditMode=all. |
Why incremental save does not exist here
PDF permits incremental updates (ISO 32000-1 §7.5.6): a writer may append a new cross-reference section and leave every original byte in place, chained through /Prev. If a user edits a salary figure, covers a line, or deletes a page and we append rather than rewrite, the original content is still in the delivered file and any text extractor recovers it.
So FPDF_INCREMENTAL is never passed, the flag is not exposed through any public API, and SaveAudit re-reads our own output and refuses to return bytes that contain more than one revision. SavePolicyTests asserts this. If that test is ever deleted or weakened, treat it as a security regression, not a test-maintenance decision.
There is exactly one exception, and it is stated here rather than found later. Adding a second signature to an already-signed document appends a revision — PdfSigner.CounterSign, and nothing else in the product can reach that code. It is not a general incremental save: there is no option, no flag and no second caller, and it never runs an editing operation. The hazard the rule above exists to prevent belongs to operations that remove content, and this one removes nothing — the earlier revision is the document the signer was given, which they already have a copy of.
What makes that a fact rather than an assurance is that it is checked on every call, on the finished bytes, before they are returned (CountersignChecks):
- The output begins with the input, byte for byte. A redaction cannot hide inside an operation with that property, because a redaction changes bytes that are already there.
- Every signature the document already carried still points where it pointed, and every one that verified before still verifies after.
- The new signature's byte range spans the whole new file.
- Exactly one revision was added —
startxrefand/Preveach rise by one, no more. - No object was replaced except the plumbing a signature field has to join: the catalog, the AcroForm, and the one page whose annotation list gained the field. Each of those is checked to be its old bytes with nothing changed but that plumbing, so an append that re-emits a page's content stream — a content change wearing an append's clothing — is refused.
Any of the five failing is a refusal, not a warning. If these checks are ever removed or made advisory, the exception above stops being justified and the feature has to go with them.
A countersigned file therefore holds more than one revision on purpose, and the tools say so instead of printing the single-revision line that is true of everything else.
Findings from the threat-model pass of 2026-08-31
Five domains, each researched and then adversarially re-verified. Severity is the researchers'. Anything marked CONFIRM has not been independently verified and must not be relied on.
Critical — these change decisions already made
The prebuilt PDFium we depend on ships with PartitionAlloc disabled. bblanchon/pdfium-binaries emits pdf_use_partition_alloc = false unconditionally in steps/05-configure.sh, while PDFium's own default for a clang build is true. Use-after-free is the dominant PDFium bug class of 2026 — the pass counted 18 PDFium CVEs between January and August 2026, 14 at CVSS 8.8, 10 of them UAF — and PartitionAlloc is the allocator hardening that makes those bugs hard to weaponise. We are currently shipping Chrome's PDF parser with Chrome's main UAF defence removed, by someone else's build choice. We must build PDFium ourselves. See DECISIONS #014, and #019/#020/#021/#024 for how far that has got. As of 2026-08-31 we build a single self-contained pdfium.dll with PartitionAlloc compiled in, for x64 and arm64, at the current upstream revision chromium/8021 against its own pinned Windows SDK with no deviation. Verified by tools/Creasepoint.VerifyPdfium; all 27 tests pass against it, including the hardening assertion the prebuilt fails.
The switchover has landed (corrected 2026-09-02; this section said the opposite for a week after it stopped being true). The shipped engine is the self-built, PartitionAlloc-hardened one. Verified rather than asserted: the pdfium.dll inside the signed MSIX hashes to 4e73431a0998df3c7fee821063ce689da664032dcf536a65fff9ba340709f48f, which is the x64 line in eng/engine.sha256 at chromium/8021, and packaging/build-msix.ps1 recomputes that hash on every pack and refuses to continue if it does not match a listed engine. Two design rules for that work, from the research pass of 2026-08-31: the gate must verify the engine's hash, not its presence, because an Exists() check passes on a zero-byte file and identity is what the claim rests on; and an MSBuild target is a developer convenience rather than an enforcement boundary, because dotnet publish --no-build bypasses it entirely, so CI needs its own post-publish check.
Independently confirmed on 2026-08-31 by tools/Creasepoint.VerifyPdfium against the exact binary we consume (bblanchon.PDFium.Win32 154.0.8021, win-x64, sha256 2a9031fa88f412147c3bc7115054550048c724db6ea70298b6c6b0d13e513882): XFA absent, V8 absent, renderer AGG, no PartitionAlloc markers. EngineProfileTests keeps that finding executable, and is written to start failing the day our own engine lands.
PDFium's save flags are a bitfield and were renumbered upstream. FPDF_REMOVE_SECURITY moved to 1<<2; the legacy value 3 is bit-for-bit FPDF_INCREMENTAL | FPDF_NO_INCREMENTAL. Assigning rather than OR-ing can silently flip a save into incremental mode. This was a live bug in this repository — SavePolicy replaced the no-incremental flag when removing a password, so "unlock this PDF" could have appended and shipped the encrypted original inside the unlocked file. Fixed, with a regression test, and the flag combination is now asserted before every save.
Whiteout must not exist as a drawing tool. A white rectangle over text leaves the text extractable while looking, to the user, exactly like redaction. In a product branded on privacy that is not a rough edge, it is a trap we built. It is removed from the design; an "erase" tool may only ship if it genuinely removes the underlying page objects.
Editor plus signature validation in one binary is the NDSS 2021 shadow-attack toolchain. Shadow attacks hide and replace content in signed PDFs using exactly incremental updates; 16 of 29 tested viewers failed. If we also show a "signature valid" badge we become the last line of defence for an attack class our own editor makes easy. Signature validation must implement the CCS 2019 ByteRange algorithm, not naive PKCS#7 verification, which is broken three separate ways (USF, ISA, SWA).
Signature-gated guards fail open. PDFium's CollectSignatures() reads only the top level of /AcroForm/Fields — no /Kids recursion, no /FT inheritance. A signature field nested one level down reports zero signatures, which would unlock password removal and editing on a signed document. Any guard conditioned on FPDF_GetSignatureCount() needs its own traversal.
Do not LoadLibrary a vendor PKCS#11 middleware DLL. That loads proprietary third-party code into the process holding the user's documents and PIN. Use the Windows certificate store and CNG instead, which keeps the PIN out of our address space entirely. Indian DSC tokens register CSP/KSP providers, so this is a viable path — to be confirmed against real ePass2003 and ProxKey hardware.
On Android, an in-process ad SDK inherits our UID, our app-private storage, and every persisted SAF URI grant. Google's own remedy, the Privacy Sandbox SDK Runtime, was retired on 2025-10-17 and every Android Privacy Sandbox feature is now marked for phaseout. There is no mechanism, present or announced, that fixes this. The ad-supported build cannot carry the same privacy sentence as the ad-free one — permanently. See DECISIONS #010.
High — telemetry channels we do not control
Windows Error Reporting. A PDFium heap-corruption crash calls __fastfail, which Microsoft documents as bypassing every exception handler and invoking WER regardless. A crash dump of the process parsing a user's document can contain that document's plaintext. The commonly cited WerSetFlags/unhandled-exception-filter opt-outs do not survive a fast-fail. The only control that actually works is having the parse happen in a separate, minimal child process.
Microsoft Store Partner Center Health. Publishing through the Store enrols the publisher in crash reporting: counts and stack traces from users' machines are delivered to ARHAS, with no code from us and no opt-in. Reworded and closed (#016, applied 2026-09-02). Invariant 1 no longer says "no telemetry" flatly: it says we collect none of our own, that Windows reports crashes to Microsoft regardless, and that a Store listing shows the publisher anonymous crash counts — neither of which is ours to switch off. PRIVACY.md §0 says the same thing in the second item, and adds the part that matters more: a dump taken from the process that was reading a document can contain that document.
Store re-signing breaks package-level verification. The Store re-signs our MSIX and is documented to mutate the manifest version field, so no user can hash-compare what they installed against anything we published. The strongest verifiable statement about a Store binary is per-file, not per-package, and the claim ladder must say so.
The IME sees every keystroke. An editor means the user types. On Android the keyboard is a third-party, network-capable application; there is no boundary available, only IME_FLAG_NO_PERSONALIZED_LEARNING as a request. This is unavoidable and must be disclosed rather than papered over.
Corrections the verifiers made to the research itself
Recorded because they are the kind of plausible-sounding mitigation that would have shipped:
- The Rust/Skia decoder recipe does not work as written:
checkout_rustdefaults false, Skia is gated oncheckout_skia, andpdf_use_skiadoes not remove AGG —pdf_use_aggis a separate arg defaulting true. - win32k lockdown breaks PDFium's Windows font mapper, so non-embedded Devanagari would stop rendering. Do not enable it blindly on the render process.
FPDF_LoadXFAandFPDF_GetXFAPacket*are exported unconditionally, so a CI gate asserting their absence would fail every correct build. The real discriminators areFPDF_BStr_*(XFA),FPDF_GetRecommendedV8Flags(V8) andFPDF_RenderPageSkia(Skia).android:allowBackup="false"alone does not stop device-to-device transfer; the<device-transfer>exclusions indata_extraction_rules.xmlare required as well. Both are in place.
Still open — no mitigation designed
- ~~No process isolation for parsing on Windows.~~ Built — see DECISIONS #017. PDFium now runs only in
Creasepoint.Worker, confined by a job object and self-applied mitigation policies, andSandboxTestsmeasures the confinement rather than assuming it. Still missing: ACG, which needs the worker published with NativeAOT, and reducing what a WER dump of the worker can contain. - ~~GitHub Actions are referenced by tag, not commit SHA.~~ Done. Every
uses:in both workflows is pinned to a full commit SHA with the tag recorded in a trailing comment, and.github/dependabot.ymlopens a pull request when a pin moves, so the pins cannot rot into a different problem. - No fuzzing. PDFium has in-tree targets under
testing/fuzzers/; we have none. - Redaction stays unimplemented until it passes an adversarial corpus. Removing glyphs is necessary and not sufficient (PETS 2023).
- Metadata is not stripped on save.
/Info, XMP,/ID, attachments and/OpenActionsurvive. Note that PDFsharp re-stamps/Producerat save time and republishes the source document's original producer, which no later pass can undo. - No temp-file policy.
- The shell context-menu handler, the Tesseract/Leptonica pipeline, and the Android JNI layer were all outside the scope of this pass and have had no security review at all.
- ~~The CLI parses in-process.~~ Built — see DECISIONS #026. Every document command routes through
SandboxedDocumentEngine; PDFium is not mapped in the front end at all, measured by sampling both processes' loaded modules across a 4,000-page split (424 samples, present in the worker on 402, in the front end on none). An adversarial review of the new boundary raised 34 findings, of which 5 were real and are fixed: an unreachableIsFaultedbranch that let framing violations escape asAggregateExceptionpast theWorkerExceptioncontract, an unboundedReadString, unfiltered worker text reaching the terminal, two request paths missing the oversize guard, and a quadraticSplitToPagesthat exceeded the worker's call timeout on large documents. Still open here: the worker replies are trusted for their content — a compromised worker can return a document that is not the one it was asked to build, and the host cannot tell without parsing, which is the thing it is refusing to do.
- The shipped package contains the .NET networking assemblies, even though our code never calls them. A self-contained publish carries the whole runtime, so
System.Net.Http.dlland its siblings are inside the MSIX.NoNetworkingTestsproves our assemblies reference none of them, which is what Line 1b actually claims — but anyone who unzips the package will find HTTP libraries, and "our code does not call them" is a weaker answer than "they are not there." Trimming would remove them and shrink the package, but Avalonia'sDesignerSupportand the built-in COM interop the shell needs are not trim-compatible (IL2104, IL2026), and forcing it past those warnings risks a UI that fails by reflection at run time. Left as-is deliberately. Do not describe the package as containing no networking code — say what is true: no code we wrote makes a network request.
What we do not claim
We do not claim this application cannot reach the network. Windows does not let an ordinary desktop application permanently surrender network access, and saying otherwise would violate the second invariant of the project. See docs/MASTER_PLAN.md §2 for the wording that is actually defensible.
We do not claim reproducible builds. What we can claim, once CI is wired, is build provenance: that a given binary was produced by a specific public workflow from a specific commit, verifiable with gh attestation verify.