Checks the digital product passport (DPP) data retrievable for a product identifier against the automated passport criteria of dpp-criteria (EN 18219, EN 18223 and related regulation).
Results state how many automated checks passed. They are not a certification and do not establish a presumption of conformity. dpplint is part of a research project and shares what we currently know; the checks make no claim to be complete and are not a legal assessment.
-
/is a start page: enter a product identifier (the URL encoded in the data carrier) and see the result per criterion./?productId=<identifier>runs the check directly and can be linked. The page runs one check at a time and waits for it at mostCHECK_TIMEOUTseconds (default:CHECK_DEADLINEplus 5, so 40). If the check API answers with an error, without a result or not in time, the page says what happened, links the API call and offers to try again. -
GET /api/v1/validate/<product identifier>retrieves the passport like a phone scanning a data carrier (plain HTTPS GET, no credentials) and checks it. -
Every check ends within
CHECK_DEADLINEseconds (default 35). The limits of single requests (name lookup 5 s, connection 10 s, answer 20 s for GET and 10 s for the links, per redirect; SOyA web-cli 5 s and 30 s; didlint, see below) are cut to the time left. When the time is up, no further request is sent: the criteria evaluated so far keep their results, the others are reported asskipped(not_evaluated) with the reason, andtimeLimitandnoticesin the result say that the limit was reached. -
POST /api/v1/validatechecks a passport sent as JSON, or as compact JWS withContent-Type: application/vc+jwt(alsoapplication/jwt,application/jose). -
Criteria with
check.type: shaclare validated with SOyA: the passport goes through the SOyA web-cli endpointsacquireandvalidatefor the structure named in the criterion. Results belong to a criterion by the criterion ID at the start of each message. -
Criteria with
check.type: resolverequest the product identifier themselves (with the Accept header the criterion names) and needGET /api/v1/validate/<product identifier>; withPOSTthey are skipped.expectchecks status and content type first (media type without parameters, case-insensitive; forapplication/jsonand+jsontypes the body must be a single JSON object) and the header fields (exists,equals,contains,matches; field names case-insensitive, several fields of one name combined with commas) only if both hold. A media type incontent_type_with_warningsatisfies the content type with a warning instead of a failure.matchesis an ECMA-262 regular expression without flags, searched anywhere in the value and case-sensitive (^and$anchor the whole value); a criterion with a pattern that is not valid ECMA-262, or that uses lookaround, named groups or backreferences, is skipped with the reason.further_requestsare sent afterwards with their own Accept header and evaluated independently. A criterion fails if a check with severity error fails; if only checks withseverity: warningfail, the result iswarning. -
applies_ifsupports the paths$.<member>and$.<member>[?search(@, '<regex>')]or[?match(@, '<regex>')]. The regular expression ofsearch()andmatch()is an I-Regexp (RFC 9485) as RFC 9535 requires:match()needs the entire value,search()a substring. The pattern is checked before the JSONPath is evaluated; an invalid I-Regexp or one with^or$outside a character class skips the criterion with the reason.matchesof the condition itself is ECMA-262 as above and holds only for JSON strings (numbers, booleans, null, arrays and objects never satisfy it). Any other condition dpplint cannot evaluate also skips the criterion with the reason. -
Criteria with
check.type: didsend the DIDs of the passport to didlint (DID Core, DID Resolution) and check linked verifiable presentations for the VC Data Model 2.0 context. The didlint instance is set withDIDLINT_URL(defaulthttps://didlint.ownyourdata.eu); it is the only service dpplint calls besides the passport and the resources it links. A check waits for didlint at mostDIDLINT_TIMEOUTseconds per request (default 10) andDIDLINT_BUDGETseconds in total (default 20). If didlint does not answer in time, or answers with something other than JSON, the criteria that need it are reported asskipped(not_evaluated) with the reason, and the rest of the check goes on. -
Criteria with
check.type: proofcheck integrity proofs of the passport. Withoutkey_from(DPP-SEC-002) every proof found is verified with the key it names:- W3C Data Integrity proofs in the passport (
DataIntegrityProof, cryptosuiteeddsa-jcs-2022, key fromverificationMethod); - the passport as compact JWS (VC-JOSE-COSE,
EdDSAorES256, key fromkid). WithGET, dpplint also requests the identifier withAccept: application/vc+jwt, application/jwt, application/jose; a JWS delivered that way has to carry the same passport as the JSON answer; - the passport DID (
digitalProductPassportId,did:oyd): its DID document, resolved by didlint in the current version, carries in the service of typeDigitalProductPassportapayloadHash(SHA-256 multihash, base58btc) of the passport bytes delivered by that service'sserviceEndpointin the full representation. dpplint requests theserviceEndpointwithrepresentation=full(EN 18222 8.1; without it an EN 18222 service answers in the compressed representation) and compares the hash with these bytes and with the bytes delivered for the product identifier (withPOST: with the content sent).
With
key_from(DPP-SEC-013) at least one verified proof has to be issued with a key of the DID at that path (the economic operator); otherwise the result is a warning. Keys are taken from adid:keyor from the DID document resolved by didlint (Ed25519 or P-256, aspublicKeyMultibaseorpublicKeyJwk). Passports without a proof, and other proof formats, are reported asskipped. - W3C Data Integrity proofs in the passport (
-
Criteria with
check.type: linkscheck everyRelatedResourceof the passport for the required attributes and send a HEAD request to its URL (at most 20 URLs per passport). A URL that does not answer gives a warning. -
Criteria with
check.type: html(DPP-SEC-014, DPP-SEC-021, DPP-DAT-020) request the product identifier withAccept: text/html. Without an HTML view the result isskipped(no_evidence).- With
external_resources: forbiddendpplint lists the URLs a browser loads without user action while it displays the HTML view (scripts, style sheets, fonts, images, frames, preloads, CSSurl()and@import, meta refresh); every such URL on another host than the passport's fails the criterion. Resources added by scripts at run time are not seen. - With
device_features: forbiddendpplint reads, without running any script, the inline scripts, event handler attributes andjavascript:URLs of the HTML view and the scripts it loads from its own host (at most 10, the first 2 MB of each) as JavaScript tokens and looks for uses of functions of the reader's device (location, camera, microphone, notifications, contacts, sensors, network information and others): calls of their browser interfaces, permission requests and listeners for device events; in the markup it looks for frames granted such permissions, file inputs withcaptureand permission elements. Every use fails the criterion. Names in comments and strings, testing whether an interface exists, and hooking into it without a call, as polyfills such as zone.js do, are not a use. A reference after which a use may follow that only running the script would show (an interface stored under another name, a device method read without a call) gives a warning if there is no use; so does a script that cannot be read as JavaScript but names a device interface. Code assembled at run time, obfuscated or loaded by a script, and scripts from other hosts, are not seen. - With
markup_errors: forbiddendpplint posts the HTML view, as received and with the media type and charset of the response, to the Nu Html Checker that it runs itself (NU_CHECKER_URL; see "Deployment"). It never sends the document to a public instance. Every message of typeerrorfails the criterion; warnings and otherinfomessages do not count. Without a checker, or if the checker does not answer or cannot check the document, the criterion isskipped(not_evaluated).
- With
-
Criteria with
check.type: element_ids(DPP-DAT-017, DPP-DAT-021) check that elementIds are unique. In the expanded full serialization dpplint compares the elementIds within every array of data elements (unique: level, a repeated elementId fails) or across the whole passport (unique: passport, an elementId at two places gives a warning; the items of a MultiValuedDataElement count as one place). In the compressed serializationlevellooks for a member name repeated within one JSON object of the passport as delivered (the response, or the posted body), andpassportisskipped. -
Criteria whose condition does not hold are reported as
skipped. -
Every
skippedcriterion carries areasonand areason_code(not_applicable,no_evidence,not_evaluated, … as defined under "Results" in CRITERIA-FORMAT.md of dpp-criteria); the summary counts them per code inskipped_by_reason. A passport without any integrity proof givesno_evidencefor DPP-SEC-002. -
dpplint only contacts public addresses: URLs whose host resolves to loopback, private or link-local ranges are not retrieved.
DPPLINT_ALLOW_PRIVATE_NETWORKS=1lifts this for local development. -
API documentation:
/api-docs. -
GET /versionnames the versions of dpplint, dpp-criteria and the SOyA web-cli.GET /readyanswers 200 when the instance can check passports (the SOyA web-cli and the SOyA repository with the structures of the image answer) and 503 with the reason when not; the result is held and renewed at most every 30 s, every second while not ready.
The image contains everything it needs at run time: the Rails API, the SOyA
web-cli (oydeu/soya-web-cli) and the criteria and SOyA structures of
dpp-criteria, built with soya init when the image is built. It does not
contact soya.ownyourdata.eu. The SOyA context the structures import is
fetched when the image is built; if it cannot be retrieved, the build uses
the cached copy of dpp-criteria. GET /version shows which (soya-context),
and results checked against such a structure say so in notices.
./build.sh
docker run --rm --platform linux/amd64 -p 3000:3000 oydeu/dpplint
curl -s http://localhost:3000/api/v1/validate/https://dpp.oydapp.eu/01/09520123456788/21/000001
./build.sh <ref> builds with a given commit or tag of dpp-criteria; the
default is main. Branch and tag names are resolved to their commit first, so
that the Docker build cache never reuses an older dpp-criteria. GET /version shows the commit in use. The image is built
for linux/amd64, like the SOyA web-cli it contains.
Without NU_CHECKER_URL the criteria that need the Nu Html Checker are
skipped. To run them locally, start the checker on a network of its own next
to dpplint:
docker network create dpplint
docker run --rm -d --name nu-html-checker --network dpplint ghcr.io/validator/validator@sha256:8f21030474e68e06905cfa801b37376e99c0e37867c653c5b6cd7e2dcaf3f503
docker run --rm --platform linux/amd64 --network dpplint -p 3000:3000 -e NU_CHECKER_URL=http://nu-html-checker:8888/ oydeu/dpplint
kubernetes/ holds the manifests for dpplint.ownyourdata.eu: deployment,
pod disruption budget, service, certificate (cert-manager, ClusterIssuer
letsencrypt-prod) and ingress (nginx).
The deployment runs two replicas with 16 threads each (RAILS_MAX_THREADS),
spread over different nodes where possible (topologySpreadConstraints,
ScheduleAnyway). The pod disruption budget keeps at least one replica
running while nodes are drained. Replicas keep no state of their own: every
check is answered by one replica from the criteria and structures in the
image. A replica whose threads are all busy, or whose SOyA web-cli does not
answer, fails its readiness probe (/ready) and is taken out of the ingress
until it answers again; the liveness probe only checks that the port is open,
so a busy replica is not restarted.
Each pod also runs the Nu Html Checker (ghcr.io/validator/validator, MIT
licence) as a second container, named by its digest like the dpplint image.
It listens on 127.0.0.1:8888 only, so it is reachable from dpplint in the same
pod and from nowhere else, and it runs with a heap of 192 MB within a memory
limit of 512 Mi. dpplint finds it through NU_CHECKER_URL. The pod's
readiness does not depend on it: if the checker is down or still starting,
only the criteria that need it are skipped. Its liveness probe restarts it if
it stops answering.
The deployment names the image by its digest (oydeu/dpplint@sha256:…), so
that all replicas run the same image and a restart never picks up a newer
one. The digest of a new image gets into the manifest when the image is
pushed: DOCKERHUB=1 ./build.sh builds and pushes it and writes its digest
into kubernetes/deployment.yaml. The changed manifest is committed and
applied like any other change. kubernetes/ is not part of the image, so
writing the digest does not change the image.
The Nu Html Checker in the manifest is version 26.10.12. Its digest is compared with the latest release once a month, and at once when a security notice concerns the checker or its image:
docker buildx imagetools inspect ghcr.io/validator/validator:latest
If the digest shown there differs from the one in kubernetes/deployment.yaml,
the checker is updated in one pull request:
- Write the new digest into
kubernetes/deployment.yamland intotest/fixtures/files/html/DPP-DAT-020/README.md, and the new version into both READMEs. - Record the answers of the new checker to the test vectors again, as that
README describes, and run the test suite. Every file in
valid/must still have no error and every file ininvalid/at least one; if a new rule of the checker moves a vector, the pull request says which and why. - Check the HTML views of the example passports with the new checker before the manifest is applied.
The dpplint image does not change for this; the manifest is applied like any other change.
OwnYourData advertises the DPP Linter with Google Ads. With the visitor's consent, the start page measures whether a visit that started with an ad leads to a check. Without configuration the feature is off: no banner, no script, no "Cookie settings" link and no request to Google.
| Variable | Value |
|---|---|
GOOGLE_ADS_ID |
Conversion ID of the Google Ads account, AW- followed by digits. Empty or malformed: off. |
GOOGLE_ADS_LABEL_LINTER_CHECK |
Conversion label of a completed check (the part of send_to after the slash). Without a valid label the banner still works, but nothing is measured. |
- With
GOOGLE_ADS_IDset, the start page embeds/consent.js(app/javascript/consent.js, adapted from the DPP Service) and shows "Cookie settings" in the footer. Without it/consent.jsis an empty script. - Before the visitor decides, nothing is loaded from Google. The banner offers
"Decline" and "Accept" in the same style; the choice is kept in
localStorage(oyd-consent) for twelve months, and "Cookie settings" opens the banner again. Declining after accepting deletes the_gcl_and_gac_cookies on the host and its parent domains and reloads the page without the tag. - After "Accept", Google Consent Mode v2 is set (everything denied by default,
then
ad_storageandad_user_datagranted;ad_personalizationandanalytics_storagestay denied), ad personalisation signals are switched off andgtag.jsis loaded for the ID. The tag is not configured for the ID (nogtag("config")): no page views, no Google Analytics, no remarketing, no enhanced conversions. - A completed check (the API answered with a result, whatever the result) sends one conversion. A check that cannot be run sends none.
- No user data goes to Google. The conversion reports the address of the start
page with its ad click identifier (
gclid,gbraid,wbraid) but without the product identifier, and only the origin of the referrer (app/javascript/click_ids.js); the result is not sent either. The start page keeps the click identifier in its address when it adds the product identifier, so that the conversion can be attributed to the ad without a configured tag and without help from the server. - The API documentation loads no web fonts from Google
(
app/views/rswag/ui/home/index.html.erb, the template of rswag-ui without them).
The privacy policy is that of the DPP Service: https://dpp-service.ownyourdata.eu/privacy.
test/integration/consent_test.rb checks the server side.
test/browser/consent_check.mjs checks a running instance in a browser
(Node.js and Playwright); requests to Google are blocked, so no conversion
reaches Google:
node test/browser/consent_check.mjs https://dpplint.ownyourdata.eu --no-id
node test/browser/consent_check.mjs http://localhost:3000 --screenshots tmp/consent
The first form is for an instance without GOOGLE_ADS_ID. With
--inspect-tag the tag itself is loaded from Google (everything else stays
blocked) and the check also reads the conversion request it would send.
docker run --rm --platform linux/amd64 oydeu/dpplint test
The suite includes the JavaScript tests of the start page
(test/javascript), which run with the node of the image.
These repositories and services of OwnYourData belong together. Each is a project of its own; the list shows how they depend on each other.
- dpp-criteria – the criteria
and SOyA structures that dpplint checks. The image contains them at a fixed
commit;
GET /versionnames it. - dpp-validator – runs the service criteria against the listed DPP services every day and calls the API of dpplint for their example passports; results at https://ownyourdata.github.io/dpp-validator/.
- DIDlint – validates DID documents, at https://didlint.ownyourdata.eu; dpplint calls it for the DIDs in a passport (see "How it works").
- SOyA (source) – dpplint validates passports with the SOyA web-cli contained in its image.
- OYDID
(source) – the DID method
did:oyd; DIDlint resolvesdid:oydthrough the OYDID resolver.
OwnYourData also operates one of the DPP services whose passports are checked in the daily runs, the OwnYourData DPP Service; its passports go through the same checks as every other passport.
The DPP Linter is part of PACE-DPP – Promoting and Accelerating a Digital Product Passport based Data-Service Ecosystem. This Lighthouse Project has been made possible by financial contributions from the Austrian Federal Ministry of Innovation, Mobility and Infrastructure (BMIMI, formerly BMK), supported by the Austrian Research Promotion Agency (FFG) under grant number 917177, as well as from the German Federal Ministry of Research, Technology and Space (BMFTR, formerly BMWK), supported by the German Research Promotion Agency (DLR-PT).
Apache License 2.0 – see LICENSE.