Read a European Digital Credential: keep the file untouched, take what it states, and say plainly whether its seal was checked #670
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#670
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
From the list of 2026-10-02, under Documents: "Add diploma, supplement diploma, and certificates, including EDC." Assumption, to be confirmed: "EDC" here is the Europass European Digital Credential for Learning, the sealed digital certificate that universities and other issuers now award. If something else was meant, this issue is the wrong one. Nothing in Postulo reads one today, and nothing in Postulo checks a third party's signature.
A correction to the usual description. An EDC was a signed XML file until 2023. Since version 3 of the European Learning Model (May 2023) and the credential infrastructure's release 2.0 (June 2023), credentials are JSON-LD aligned with the W3C Verifiable Credential model, signed with JAdES; old XML credentials in Europass wallets were converted automatically (https://europass.europa.eu/en/news/updates-european-digital-credential-infrastructure, https://europass.europa.eu/en/information-developers). A reader written for signed XML would read the wrong format.
What exists today
europassplugin reads CVs and nothing else. It is an importer shipped inside the core (src/postulo/plugins/europass/__init__.py:30-85): the PDF europass.europa.eu gives you with its attached Candidate XML, and the older SkillsPassport XML and JSON (reader.py:1-60). A JSON file with none of those names is not recognised (__init__.py:55-80) and is refused as "not a Europass file" (reader.py:163-181).Recordof career entries and touches no database; the import only ever adds (resume/importing.py:25-57,reader.pydocstring).refuse_unreadablecaps a file at 5 MiB and refuses a document type declaration in XML (plugins/base.py:336-370).reader.pydocstring), a nesting cap on JSON (MAX_DEPTH = 40,:94), no assumption that a key exists or has the type it should, and a PDF's attachment is found by hand "without a PDF library", because the alternative is "a dependency it would have to trust with a stranger's file" (plugins/europass/pdf.py:1-27).Educationis qualification, institution, place, dates and grade (resume/models.py:101-117); an EQF level survives only as the text "EQF 6" insidegradefrom the Europass reader (plugins/europass/candidate.py:500-520), after #235 found the reader inventing levels it had not been given.plugins/catalogue.py:155-170), the rule being that trust is "decided by a key, never by a name" (plugins/provenance.py:16). It usescryptography, which is a dependency.pdf, doc, docx, odt, rtf, txt, png, jpg, jpeg(documents/models.py:585-589). Certificates have a kind (documents/kinds.py:61); diplomas and supplements arrive with #669.postulo.plugins.http), with size, time and address limits, and #366 is the inventory of them. A verifier's lookups (revocation, accreditation, trust lists) would be new entries in it.Checked against
mainatf09b6010b.Proposal
Three stages, each its own commit, in this order. The third is optional and is not part of the core.
typeis accepted for the kinds certificate, diploma and diploma supplement (jsonjoins the extension list atdocuments/models.py:585-589for these kinds only), stored byte for byte (a seal covers exact bytes: nothing re-encodes, pretty-prints or "tidies" it), checksummed and delivered as an attachment under the usual file policy.documents.integrity(#663) recognises it by content, not by extension. The Files page labels it "Seal not checked by Postulo" and never valid, verified or authentic.europassplugin reads an ELM v3 credential for the handful of facts a career entry needs: the title of the qualification or achievement, who awarded it, when, its EQF level and credits where they are stated, and the credential's own validity dates. Read as plain JSON: no@contextis dereferenced, no JSON-LD processor is run, an embedded file is never decoded or rendered in-process, every value is escaped, and the work is bounded as the other readers are (size, depth, count). Language-tagged values are kept under their BCP 47 tag. What was read is shown beside the file as "what this credential says", read only, and a button offers "Add an education entry from this" through the existing import path (resume.importing), which only adds. The EQF level lands in the field #684 adds, and is taken only where the credential states it.Decisions
europassplugin, so its issues are filed on core. Verification (C or D) is a separate plugin in its own repository, with its own catalogues, which core never translates; it is not created here. Recorded as a comment on #241.Recordand touches no database, and it has nowhere to say "keep this file as the proof of that entry". Recommended: the reader is attached to the upload, so the plugin surface does not change; the import path is used only for the button that adds an entry.Prior art
Every figure below was checked on 2026-10-02 (PyPI, OSV at
api.osv.dev, and the project pages), except where it says not verified.european-digital-credentials: Java, EUPL-1.2, modulesedci-issuer,edci-viewer,edci-wallet,edci-eseal-core,edci-data-model,edci-commons; archived on GitHub (last pushed to on 2024-02-02) and continued atcode.europa.eu/qualifications-courses-and-credentials/european-digital-credentials(created 2024-01-11). The verifier is a module of the viewer. Not adopted: a Java application cannot be a library of a Django one.ec.europa.eu, and sending a credential there discloses it to a third party. Not adopted in the core.cryptography(Apache-2.0 or BSD-3-Clause; locked at 50.0.1, latest 50.0.2; 0 advisories affecting either): already a dependency, and what verifies the catalogue's signature. Primitives and certificate handling for a verifier; not a JAdES implementation. Used as is, by a verifier plugin.PyJWT(MIT; 2.15.1 is locked and the latest; 45 advisories on record across versions, 0 affecting 2.15.1): already a dependency. It verifies a JWS given a key; support for JAdES's extra headers and detached payloads is not verified, and it has had an advisory about unknowncritextensions, which is the header JAdES leans on. Not relied on.jwcrypto(LGPL-3.0-or-later; 1.6.1, released 2026-09-15; 10 advisories on record, 0 affecting 1.6.1): a fuller JOSE implementation. Compatible with AGPL-3.0 as a library; a candidate for a verifier plugin; JAdES coverage not verified. Not a dependency.signxml(Apache-2.0; 5.1.0, released 2026-07-05; 4 advisories, fixed in 4.0.4; needslxml, BSD-3-Clause, a compiled dependency with 14 advisories on record and 0 affecting 6.1.3): XML signatures, relevant only to the old XAdES credentials. Not adopted, because the old format is not read.pyld(BSD-3-Clause; 3.3.0; 0 advisories): a JSON-LD processor that fetches@contextdocuments by address. Rejected for the reader: a credential's@contextwould make the server fetch whatever a stranger names. The reader reads keys as plain JSON.pyHanko(MIT; 0.37.0; 0 advisories): PDF signatures. Only if the Viewer's PDF export turns out to carry one; not verified.catalogue.pyverifies its own signature. The same reasoning covers stage 2 (JSON from a stranger, a handful of fields). It does not cover a seal check, where the standard (JAdES, eIDAS trust lists) is large and moves; the maintainer's rule to look for a library first points at DSS or a verifier plugin.Builds on
#244 and #235 (closed), #318 (the check against real Europass files), #669 (the kinds and the link to the entry), #663 (content recognition), #684 (the EQF field), #366 (outbound lookups).
Needed by
Nothing waits on it. A verifier plugin would build on stage 1.
Done when
tests/as fixtures (their licence checked), andtests/test_europass.pyor a newtests/test_credentials.pyreads each: the title, awarding body, dates, level and credits come out as stated; a file that is half right gives the half and says what was skipped; one with a hostile@context, a deeply nested value, a huge embedded attachment or a value with markup is bounded and escaped, and nothing is fetched (asserted with the network blocked).tests/test_documents.pyandtests/test_stores.py: a credential is stored byte for byte (its checksum is the file's), is delivered as an attachment, is copied to stores as any file is, and is labelled "Seal not checked by Postulo" on the Files page and in the API.tests/test_credentials.py).tests/test_importers.py), and never takes a level the credential does not state.core/export.pyFORMAT_VERSION, 29 onf09b6010b; one more than whatever it is when the commit lands), with a round trip; the candidate format (4) unchanged; the API (api/routers/documents.py) says whether a seal was checked; search unaffected; CV print choice not applicable; processing record (core/gdpr.py): a credential is the person's own document, and the plugin's verification lookups, if one is ever installed, are listed there as recipients;tests/test_wiki_surface.pydoes not apply (no new name onpostulo.plugins.api);tests/test_page_coverage.pyandtests/security/test_isolation_sweep.pyfor the summary view; the guarded-client inventory (#366) if anything fetches; strings in the working catalogues (en-GB, fr-FR, pt-PT as drafts), in theeuropassplugin's own catalogues, and Weblate (#349).From the list of improvements of 2026-10-02.