Skip to content

Add e-Aadhaar PDF signature verification - #760

Closed
deekshithr97 wants to merge 2 commits into
storytold:mainfrom
deekshithr97:feat/eaadhaar-signature-verification
Closed

deekshithr97 wants to merge 2 commits into
storytold:mainfrom
deekshithr97:feat/eaadhaar-signature-verification

Conversation

@deekshithr97

Copy link
Copy Markdown

Summary

Adds native e-Aadhaar PDF digital signature verification to PdfCraft using its existing Rust verification architecture.

Changes

  • Adds Indian CCA root certificates for certificate-chain validation.
  • Adds signature verification reporting and trust status.
  • Integrates verification into the engine, automation interface, and signature UI.
  • Adds tests for verification behavior and document integrity.

Testing

The implementation report indicates that formatting, linting, relevant tests, and WASM checks passed locally. These checks should be rerun against the current upstream branch before merging.

Notes

Verification is performed locally. Revocation status may remain unknown when no applicable revocation information is available.

@deekshithr97

Copy link
Copy Markdown
Author

Hi guys, I’ve implemented e-Aadhaar digital signature verification in PdfCraft and submitted the changes through a pull request. I’ve also resolved the merge conflict with the upstream branch. The implementation uses PdfCraft’s native Rust verification engine and adds CCA root certificate support for validating e-Aadhaar signatures.

Thanks for the project and the opportunity to contribute! I’d appreciate any feedback or suggestions for further improvements.

@debasisnishank debasisnishank left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this, @deekshithr97. e-Aadhaar verification is a real gap (#718), and a lot of care has gone into this PR. The split into separate verdicts is a good idea, the roots are pinned, and the spoofed-name test is the right instinct. I rebased the branch onto current main (8309c84, which includes #749 and #620), built it and ran the tests. The notes below are ordered by how much they matter. Line numbers refer to the rebased tree.

How I tested

Rebase. The PR is one commit (74d735d) plus a merge of main. Rebasing replays only 74d735d onto 8309c84. There was one conflict:

File Conflict Resolution
parity/acrobat-features.toml (form.field-list, about line 4306) The PR had fixed a duplicated evidence/notes pair that existed on its old base. main has since fixed the same duplication with different wording. Took main's line. The PR's own addition (sign.eaadhaar-verify) merged cleanly after main's updated sign.validate entry.

Side note on the existing merge commit ce4665a: it resolved that same conflict by replacing the notes = … line with an empty line. On the PR branch as it stands, form.field-list has no notes at all. The rebase removes that problem, but a fresh rebase would be better than another merge.

Every other file merged automatically, including sign_ui.rs, canvas.rs, panels.rs, engine/src/lib.rs and automation.

Build and tests (macOS arm64, stable toolchain)

  • cargo build --release -p pdfcraft -p pdfcraft-cli: OK, no warnings.
  • cargo test -p pdfcraft-sign: all pass (unit tests 24, algorithms 6, ber 3, chain 10, crypto 17, pdf 37, store_keys 3, verify 11).
  • pdfcraft-engine cca_roots_verify_with_separated_verdicts and the sign filter (17): pass.
  • pdfcraft-automation --test automation sign (9, including signature_verification_with_cca_through_tools): pass.
  • pdfcraft-ui-egui: sign_ui (11, including the kittest pixel test), stamp (5) and tests/signing.rs (7): pass.
  • cargo clippy -p pdfcraft-sign --all-targets: clean.

Extra probes (not committed). I wrote a scratch test that builds a synthetic chain shaped like the real e-Aadhaar one:

  • root
  • licensed CA: pathLen 1, KU critical keyCertSign+cRLSign
  • Sub-CA: basicConstraints not critical, pathLen 0
  • signer: KU critical digitalSignature+nonRepudiation; EKU emailProtection, 1.3.6.1.4.1.311.10.3.12 and 1.2.840.113583.1.1.5; policy 2.16.356.100.2.2

With the root trusted, main's validator (#620 and #749 included) reports Valid. So the existing validator can already give the real chain a green result once the root is trusted. Most of the remaining question is trust policy and presentation, not missing crypto. The probes also found the first issue below.


Blocking

1. verify.rs can report "valid and trusted" when the core validator refused the signer (bypasses #749)

report_for works out trust and integrity again, independently, from SignatureInfo (crates/sign/src/verify.rs:156-158, 172-178, 193-209):

  • trust = "any chain certificate is byte-equal to a trust-store certificate"
  • Status::Unknown with no "can't check" wording maps to Integrity::Intact (verify.rs:207)

pdf.rs sets Status::Unknown for reasons other than missing trust: the #749 key-usage/EKU/critical-extension rule (crates/sign/src/pdf.rs:678), or a certificate that wasn't valid at signing time. When the signer's chain is trusted but #749 rejects the certificate, the report comes out as:

status=Unknown  integrity=Intact  trust=Trusted{anchor:"Test Root"}  cert=Valid
summary="Signature is valid and the signer is trusted."  overall="valid"
details=[…, "The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose).", …]

That is from a signer with EKU serverAuth under a trusted root. It's the same fixture shape as chain.rs::a_trusted_signer_whose_certificate_may_not_sign_is_not_valid. sign_verify would return "overall": "valid" while sign_list returns unknown. The Signatures panel's new "Verification" rows would also say Intact / Trusted / Valid under a yellow status.

summarize (verify.rs:359-396) has the same gap: Status::Unknown counts toward neither valid nor untrusted, so the result falls through to "valid".

Suggestion: make pdf.rs the single source of truth. Either:

  • add a structured reason to SignatureInfo (for example trusted: bool, signer_problem: Option<…>, revoked: bool), or
  • at least never report Trusted/valid unless info.status == Status::Valid.

Please add a regression test that reuses the #749 fixture.

2. The CCA roots are trusted by default, for every user, for every signer under them

Session::new() now puts both CCA roots into the session trust store (crates/engine/src/lib.rs:2095). Any signature chaining to CCA India 2022 / 2022 SPL then shows green on open. In practice that covers every DSC issued by every Indian licensed CA, not only UIDAI's e-Aadhaar signer. That is a trust-policy decision for the maintainer. It also goes against the direction of #622, which keeps every built-in set off by default.

It also behaves inconsistently in practice:

  • persist() writes the whole trust list, CCA roots included, as the user's own PEMs (crates/ui-egui/src/lib.rs:1331). They become indistinguishable from certificates the user chose, and a later root rotation can't remove them.
  • restore() replaces the store (crates/ui-egui/src/lib.rs:1480-1483). Existing users with a saved (even empty) trusted list never get the default. Only fresh profiles do.
  • sign_trust clear and set_trusted_certificates silently drop them (crates/automation/src/tools.rs:642).
  • The CCA roots also act as timestamp-authority anchors (pdf.rs trusted_tsa uses the same store). #622 deliberately limits built-in sets to TSAs with id-kp-timeStamping.

Suggestion: keep the CCA roots out of the user store and off by default. Make them a named, opt-in trust set (see "Overlap with #622" below). If the maintainer wants e-Aadhaar to work out of the box, that can be a separate, explicit decision.

3. The canvas paints an opaque plate over every signed visible widget

widget_plates/paint_widget_plate (crates/ui-egui/src/sign_ui.rs:936-1060, called from crates/ui-egui/src/canvas.rs:2386) cover the widget's own appearance with a white rectangle on every signed PDF, not only e-Aadhaar. That includes untrusted and invalid ones. The appearance stream is document content: the signer's drawn signature, image, name, reason. Hiding it changes what the user sees for all signed documents. It goes well beyond #79, which is only about the legacy layered n1/n3 "validity unknown" layers.

I'd suggest taking this out of the PR, or at least limiting it to hiding only the legacy /n1//n3 layers (the #79 approach). Please discuss the design in #79 first.


Should fix

4. Integrity, revocation and "unsupported" are recovered by matching English detail strings

contains_any(&info.details, …) looks for phrases such as "can't check", "has been revoked" and "does not permit" (verify.rs:193-225). This is fragile: any wording change in pdf.rs silently changes the verdicts. It already misclassifies some cases:

  • A revoked certificate is reported as Integrity::Altered. pdf.rs sets Status::Invalid for revocation, "has been revoked" isn't in altered_words, so it falls through to Status::Invalid => Altered (verify.rs:205). The summary then says "the document changed or the signature does not verify", which is wrong. The document is intact; the certificate is revoked.
  • "Signer's certificate is not in the signature" also becomes Altered.
  • first_detail() can pick the wrong line. It is used as the reason, but details begins with CMS quirk notes (pdf.rs sd.quirks), so the "reason" shown can be an unrelated note.

Structured fields on SignatureInfo (see 1) would fix all of these.

5. A missing signing time is described as an expired certificate

When there is no /M, no CMS signingTime and no timestamp, cert_validity is NoSigningTime. The summary match then falls into (Intact, Trusted, _) and reads "the certificate was not valid at the signing time" (verify.rs:277-279). summarize still says "valid". My probe:

cert=NoSigningTime summary="…but the certificate was not valid at the signing time." overall="valid"

Real e-Aadhaar files have no signingTime attribute and no timestamp, so they depend on /M. Please add an explicit NoSigningTime arm.

6. trust: "session" vs "both"

Because of item 2, session and both are identical by default. cca is the only mode that differs (crates/engine/src/lib.rs:3084-3100).

verify_signatures also re-runs a full list() (hashing everything again) instead of reusing the cached doc.signatures and DigestCache.

7. Work done on every frame in the Signatures panel

panels.rs:584-587 clones the trust store and builds a SignatureReport for every signature on every frame. cca::is_cca_root and cca_roots_trusted re-parse both PEMs on each call (crates/sign/src/cca.rs:99-113). Please compute the reports when the document is validated, and parse the roots once (OnceLock).

8. "Verify with CCA India roots" permanently changes trust

The button (sign_ui.rs:1165) reads like a one-off check, but it adds the roots to the persistent trust store. Either rename it ("Trust CCA India roots…" with a confirmation), or run a one-off VerifyTrust::Cca check without changing the store.

9. Stamp text is hard-coded English and imitates Acrobat's stamp

stamp.rs:28, 67-80 paints "Signature valid", "Digitally signed by …", "Date: …" and "Unknown signer" without tl!. The PR adds about 30 new UI strings and no catalog entries. Untranslated strings falling back to English is acceptable, but the stamp bypasses the catalog entirely.

The stamp also copies the third-party verifier's stamp-render.js layout and Acrobat's green-tick geometry (stamp.rs:1-50). The README says only the certificates were reused from that project (crates/sign/README.md:121). If code or geometry was ported, it needs an ATTRIBUTION.toml entry. Separately, we should ask whether we want to imitate Acrobat's trade dress at all.


Roots and pinning

  • CCA India 2022. I checked the bundled PEM independently with OpenSSL: subject C=IN, O=India PKI, CN=CCA India 2022, valid 2022-02-02 to 2042-02-02. SHA-256 9A:3F:D3:17:67:98:E8:42:DD:CB:12:C2:62:F1:1C:FA:CC:A7:0A:8B:84:C6:EA:6F:DA:30:84:2A:95:A9:4C:D8 matches the value published on cca.gov.in.
  • CCA India 2022 SPL. The bundled certificate hashes to B7:24:68:9B:…:D8:C9:1A, as the PR states. Could you say what e-Aadhaar needs it for? The UIDAI chain I've seen goes CCA India 2022 → (n)Code Solutions CA 2022 → Sub-CA for DSC 2022 → UIDAI. The SPL root anchors a separate special-purpose hierarchy, so trusting it widens the trust surface. If it isn't needed, please drop it.
  • The pin is self-referential. fingerprints_match_the_published_roots (cca.rs:133-142) hashes the bundled bytes and compares them with a constant in the same file, so it only guards against accidental edits. #622 has a manifest (builtin-roots.toml with source records) and an xtask that re-downloads each root and compares it. The CCA roots would get that for free as a #622 set.
  • Older roots. Older e-Aadhaars may chain to CCA India 2014 or 2015. Neither is included, which is fine if documented.
  • Small fixes:
    • The SPL PEM has a stray blank line before -----END CERTIFICATE----- (cca.rs:94).
    • Doc comments lost their backticks: "A [] of the bundled CCA roots" and "Whether is one…" (cca.rs:105, 110).
    • Certificate::fingerprint() already exists, so the test can reuse it.

Trust by chain, not by name

This part is right. Trust comes from pdf.rs's built chain, where each link is signature-checked and checked for CA, pathLen and validity, and anchors are compared by DER bytes. A certificate's name alone gains nothing. The spoof test (crates/sign/tests/verify.rs:239) only covers a self-signed look-alike, though, and build_chain already stops at self-signed certificates. A more telling test would be a leaf whose issuer name is "CCA India 2022" but which is signed by a different key, embedded alongside a fake intermediate.

Overlap with #622 (opt-in trust sets)

#622 already provides most of what cca.rs does, in a more general form:

  • built-in roots pinned by SHA-256 with a source record and an xtask re-check
  • sets that are off by default and kept separate from the user's own certificates
  • the vouching set named in the details
  • a rule that limits which TSAs a set may vouch for

The CCA roots fit well as a #622 trust set (for example india_cca), which would make cca.rs, Session::new()'s default trust and VerifyTrust unnecessary. #622 also adds fields to TrustStore, so the TrustStore { certs } struct literals added here (verify.rs:338, cca.rs:107, engine/src/lib.rs:3090, panels.rs:584) would need ..Default::default() once both land.

Proposed split, if you and @thorstenhirsch agree:

  1. Into #622 (or a follow-up on it): the CCA India roots as a pinned set, plus an ATTRIBUTION entry and the xtask source.
  2. This PR keeps: the separate verification rows in the Signatures panel (integrity / trust / certificate / revocation / time source / changes), fed by structured data from pdf.rs rather than string matching. Also sign_verify, minus the CCA-specific modes; a trust_set argument could replace them. Also a "Trust CCA India roots" toggle that switches the #622 set on.
  3. Separate issue or PR: the canvas plate and stamp (see 3 and 9), discussed under #79.

Smaller notes

  • catalog.rs:376 adds "Verify digital signature", which runs the same sign.validate command as "Validate all signatures" on the line above it. That gives two menu items for one action.
  • parity/acrobat-features.toml:6105 marks sign.eaadhaar-verify as shipped with notes describing the stamp. That should follow whatever is decided above.
  • README.md:132 says "genuine e-Aadhaar PDFs validate green on open". That depends on item 2, and on the real files having /M (item 5).
  • Tests changed to expect the bundled roots (ui-egui/tests/signing.rs:161, automation/tests/automation.rs:2781) would go back to their original counts if the default is dropped.
  • No test exercises via_cca: true. That can be done without real Aadhaar data by building a SignatureInfo whose chain contains a bundled root, or by making the anchor set injectable.
  • Revocation::Unknown for files without /DSS (verify.rs:211-225) is shown as information, not as a failure. I agree: it matches Acrobat offline.

Thanks again. Most of this is about where the pieces should live and how the verdict is derived, not about the idea itself. I'm happy to help with the #622 trust set or the structured SignatureInfo fields if that's useful.

debasisnishank added a commit to debasisnishank/pdfcraft that referenced this pull request Oct 11, 2026
The roots of India's PKI (Controller of Certifying Authorities), which
UIDAI's e-Aadhaar signatures and India's other licensed CAs chain to,
as a third set next to the commercial roots and the loaded lists: off
by default, kept apart from the user's certificates and from
builtin_roots, and named in the details when it vouches.

- trust: cca_india_roots() from data/cca-india-roots.der (3.5 KB):
  CCA India 2022, CCA India 2022 SPL and the expired CCA India 2014
  (a chain is judged at the signing time, so it vouches only for what
  was signed before 05.03.2024). Each is pinned by SHA-256 in
  cca-india-roots.toml, downloaded from cca.gov.in and compared byte
  for byte with Microsoft's root store (the 2022 pins also match
  PR storytold#760). CCA India 2015 SPL is left out: its own key does not verify
  its self-signature. 2011 and older expired before 2016.
- TrustStore::cca_india and TrustSource::CcaIndia; the set goes through
  the same chain rules, storytold#749's signer check and the time-stamp
  authority purpose rule as the other sets.
- sign_trust cca_india: true|false, applied with the other arguments
  (all or none) and reported back; Session::cca_india/set_cca_india.
- cargo xtask trust-roots rebuilds both embedded sets (or the one
  named) and reads CCA's bare-base64 .cer files.

Tests: the bundle matches its pins in order (self-signed CAs, the
published fingerprints), the set anchors only when switched on and is
apart from builtin_roots, and sign_trust/Session toggles.
debasisnishank added a commit to debasisnishank/pdfcraft that referenced this pull request Oct 11, 2026
The roots of India's PKI (Controller of Certifying Authorities), which
UIDAI's e-Aadhaar signatures and India's other licensed CAs chain to,
as a third set next to the commercial roots and the loaded lists: off
by default, kept apart from the user's certificates and from
builtin_roots, and named in the details when it vouches.

- trust: cca_india_roots() from data/cca-india-roots.der (3.5 KB):
  CCA India 2022, CCA India 2022 SPL and the expired CCA India 2014
  (a chain is judged at the signing time, so it vouches only for what
  was signed before 05.03.2024). Each is pinned by SHA-256 in
  cca-india-roots.toml, downloaded from cca.gov.in and compared byte
  for byte with Microsoft's root store (the 2022 pins also match
  PR storytold#760). CCA India 2015 SPL is left out: its own key does not verify
  its self-signature. 2011 and older expired before 2016.
- TrustStore::cca_india and TrustSource::CcaIndia; the set goes through
  the same chain rules, storytold#749's signer check and the time-stamp
  authority purpose rule as the other sets.
- sign_trust cca_india: true|false, applied with the other arguments
  (all or none) and reported back; Session::cca_india/set_cca_india.
- cargo xtask trust-roots rebuilds both embedded sets (or the one
  named) and reads CCA's bare-base64 .cer files.

Tests: the bundle matches its pins in order (self-signed CAs, the
published fingerprints), the set anchors only when switched on and is
apart from builtin_roots, and sign_trust/Session toggles.
@deekshithr97

deekshithr97 commented Oct 11, 2026 •

Copy link
Copy Markdown
Author

Thanks for the detailed review. I agree with the proposed direction. I'll prioritize fixing the verification verdict issue, keep CCA India roots opt-in through #622, and separate the canvas/stamp changes.

@DWAA1660

Copy link
Copy Markdown
Contributor

@deekshithr97 @debasisnishank I prepared a correction against your unchanged ce4665a head: deekshithr97#1. It preserves the existing commits/authorship and includes main 8ee3673 so the #749 certificate-purpose regressions and current quality gates can run. The PR explicitly documents that main integration.

The correction prevents a core Unknown/Invalid result from being promoted to Trusted or overall valid, gives missing signing time its own result, removes default CCA trust (including default agent verification), makes persistent CCA trust require confirmation, and preserves original signature-widget appearances by removing the canvas stamp/plates. It also fixes the duplicate menu action, localizes the verification labels in all 20 catalogs, and keeps existing signer-trust buttons reachable.

Validation: the two new report regressions failed before the fix; the final unfiltered workspace run passes 2,497 tests with seven ignored. Fmt, strict Clippy, all 31 WASM crates, layers/assets/deny/parity pass. The confirmation UI was rendered and inspected, and cancellation/confirmation plus existing signer-trust UI tests pass. Details and the diff are in the correction PR; the comparison against its integrated main baseline is DWAA1660/printcraft@8ee3673...fix/760-verification-verdict.

The broader structured refusal/revocation mapping and caching feedback remain tracked. Named opt-in trust sets/root-source pinning remain coordinated with #622 rather than duplicated here; the original canvas-design discussion is #79. This is a correction to fold into the existing #760, not a replacement feature PR.

DWAA1660 added a commit to DWAA1660/printcraft that referenced this pull request Oct 12, 2026
Reuse session validation snapshots in the panel and agent verification, retain digest caching for one-off trust modes, and parse immutable CCA roots once. Cover cache invalidation after signing and trust changes without persisting one-off trust.

Generated with Codex.
@DWAA1660

Copy link
Copy Markdown
Contributor

Follow-up on review items 6–7: 5b07eb6 is now in the existing correction PR. The panel/session checks reuse reports keyed to validation and trust snapshots; one-off CCA/Both checks reuse the document’s DigestCache; CCA roots are parsed once. Regression coverage prevents stale reports after signing, adding/removing trust or replacing signature records, and verifies one-off trust leaves session reports unchanged.

Full workspace validation passes: 2,498 tests, zero failures, seven ignored; fmt, strict all-target Clippy, 31 WASM crates and layers/assets/deny/parity pass. Your source head remains unchanged. Structured refusal/revocation mapping (item 4) and the #622 named trust-set/root-source work remain pending.

DWAA1660 added a commit to DWAA1660/printcraft that referenced this pull request Oct 12, 2026
Remove English-detail classification from verification reports. Keep verified signed bytes intact when a certificate is revoked, record unchecked integrity for missing certificates and unsupported checks, and retain the actual validation reason. Cover verified and forged CRLs, changed prose and preceding encoding notes without altering core acceptance status.

Generated with Codex.
@DWAA1660

Copy link
Copy Markdown
Contributor

Follow-up for review item 4: 2259cca is in the existing correction PR, whose description now records the consolidated scope and validation.

The core validator records integrity and embedded-revocation outcomes directly. Reports no longer classify English details or pick the first CMS note as a failure reason. Revoked certificates retain intact verified bytes and an invalid signature; missing certificates report unchecked integrity with the actual reason. Only verified evidence produces Good/Revoked; forged or absent evidence remains Unknown. Revoked results recommend replacement rather than adding a root. Existing core acceptance status is preserved.

The revoked/missing-certificate regressions failed before this follow-up. Focused signing validation: 71 passed, two existing certificate-store ignores. Full unfiltered workspace: 2,521 passed, zero failures, seven ignored, with reduced debug symbols and incremental caching disabled. Formatting, strict Clippy, all 31 WASM crates and layers/assets/deny/parity pass. Tests also cover misleading/changed detail wording and forged CRLs.

Your source head is still ce4665a. Named opt-in trust sets and root-source/SPL/older-root scope remain coordinated with #622.

@deekshithr97

Copy link
Copy Markdown
Author

Honestly, it feels like a complete waste of time and effort to suggest, develop, and implement the e-Aadhaar PDF signature verification feature for PdfCraft when there seems to be no real interest in getting it merged or making the feature available to users. I put significant effort into building what I believe is a genuinely useful feature, but it feels like nobody has cared enough to take it forward.

It's disappointing to see good contributions go unnoticed. At this point, I'd rather close this pull request and move on. Thank you to everyone who took the time to look at it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants