The CNML bridge (sign + verify in the app)
An issued OIML certificate becomes a signed CNML document in the app, and anyone can verify it without leaving the app.
TODO.integration/09 · How an issued OIML certificate becomes a signed CNML document, and how anyone verifies it, without leaving the app.
1. What CNML is to this program
CNML (Certificat Numérique de Métrologie Légale) is the OIML SMART
program’s name for its machine-readable certificate format: an XML
document per Recommendation, signed with XMLDSig (ECDSA P-256) and
verifiable through a fixed check pipeline. It is implemented by the
sibling cnml repo (renamed from digital-certificates) (integration.yaml repo cnml),
which ships the format as TypeScript packages:
| Package | What it owns |
|---|---|
@oimlsmart/cnml-xml |
cert JSON → CNML XML and back (browser-native DOMParser/XMLSerializer) |
@oimlsmart/cnml-schemas |
the per-Recommendation JSON Schemas (R60.yaml, R129.yaml, …) + _core.yaml |
@oimlsmart/cnml-crypto |
key custody (WebCrypto), signCnmlXml, verifyCnmlXml, the check pipeline (runChecks) |
The bridge consumes these as dependencies, never copies. The app’s
Vite config aliases the package ids at the repo’s TS sources (the
packages ship src/ via their exports); when the CNML repo changes,
the bridge re-resolves, never re-vendors.
2. The flow, end to end
our Certificate (data/types.ts)
│ ① project (the per-rec field map, §3)
▼
CNML cert JSON ──② validate──► per-rec JSON Schema (@oimlsmart/cnml-schemas)
│ (a schema rejection never signs)
▼ ③ serialize (@oimlsmart/cnml-xml)
CNML XML document
│ ④ sign (@oimlsmart/cnml-crypto: signCnmlXml, the browser-held key, §4)
▼
signed CNML XML (XMLDSig enveloped)
│ ⑤ attest (the OTS relay: POST /api/cnml/timestamp — the pending
│ proof embeds inside the signature container; required, §4)
▼
signed + time-attested CNML XML
│ ⑥ verify (@oimlsmart/cnml-crypto: runChecks, §5) — anyone, any time
▼
the check panel (per-check pass/fail/warn/skip/pending with reasons)
Every step is honest: a failure at any step is surfaced with its reason, never silently skipped, never an invented success.
3. The per-rec field map (model-driven)
The projection from our Certificate to the CNML cert object is data
per rec, not code. The CNML per-rec schema IS the map’s target: the
bridge ships one projection module (browser/src/cnml/field-map.ts)
whose entries name, for each CNML top-level section, the source on our
certificate:
| CNML section | From our certificate |
|---|---|
certificate |
certificate_number, issue_date, expiry_date, status, scheme |
issuing_authority |
issuing_authority_id (the participant registry resolves the party) |
applicants / manufacturers |
applicant, manufacturer |
certified_type |
type_designation, type_description, model_family_id, classifications |
characteristics |
json_data.characteristics (the per-rec template rows, the R 60 e_max/d_min/…; already structured) |
recommendation |
the rec id + edition_year + scheme (per-rec const checked by the schema) |
test_reports |
test_reports[] (report_number, date) |
A new rec registers its CNML schema in the CNML repo and nothing in the bridge changes (OCP): the projection is section-shaped, the schema validates what it knows.
4. Signing and key custody
- The signing key is an ECDSA P-256 WebCrypto pair, generated and
held in the browser (
@oimlsmart/cnml-crypto’s key store, IndexedDB, the private key non-extractable, optionally passphrase-wrapped by theirencryptPrivateKey). This is the CNML pattern: the signer is a person at a browser, not a server. signCnmlXml(xml, privateKey, x509CertPem?)embeds an enveloped XMLDSig signature; the optional X.509 PEM rides for the PKI chain checks (scope/CRL).- The quorum path (TODO.ops/14):
signCnmlXmlExternal(xml, signer, groupChainPem)is the two-phase assembly for keys that never exist locally, the SignedInfo is built and canonicalized HERE (byte- identical to what xmldsigjs re-canonicalizes at verify, the namespace-injection trap is documented in the module), the signature comes from the CA’s/api/signthreshold endpoint (the group key never assembles). The emitted document is one shape with the locally signed path; the KeyInfo carries the GROUP certificate chain (never a self-issued placeholder), and the ceremony’s attestation (provider, N-of-M, session) persists on the signing record. - The evaluation-report binding (TODO.ops/15): at sign time the
bridge binds the certificate’s ER into the signed payload ,
evaluation_report: { id, date?, digest }, the digest a sha256 over the ER entity’s canonical JSON (canonicalJsonis exported so any verifier holding the same ER recomputes with THE SAME canonicalization). A certificate naming a missing ER signs nothing. - The issuance gate’s final step is “signed”. A certificate detail
page offers Sign as CNML: the projection validates against the
per-rec schema FIRST (a schema rejection lists every violation and
signs nothing), then serializes, then signs with the operator’s
unlocked key, then attests the signing time (required, never
optional): the signed document’s SHA-256 goes to the instance’s
timestamp relay (
POST /api/cnml/timestamp), the server submits the digest to the public OpenTimestamps calendars (a digest submission is never a custody event), and the pending proof embeds inside the signature container — ads:Objectchild ofds:Signature, the XAdES unsigned-property posture, so the embed and every later upgrade leave the signed bytes (and the signature) untouched. The proof commits to the document with the timestamp element stripped. A relay failure fails the signing honestly. The signed XML + the signing record (key id, at, by) persist on the certificate’sjson_data.cnml(evidence accrues, never rewrites, a re-sign appends), with the attestation’s provenance (ots: digest, calendars, submitted_at). - The attributed act (TODO.trust-registry/02): the officer may sign
with the ORGANIZATION’s published key instead of a browser-local
officer key. The org’s signing keys are the org account’s property on
the identity service (the OP — oimlsmart/identity, trust-registry/01);
the picker lists the org’s CURRENT keys from the OP’s public
key-resolution endpoint (
GET <issuer>/op/keys/<org-id>.json, anonymous, cacheable, CORS-open), and the officer supplies the picked key’s private half (the org’s custody — used for the signing, never persisted). The guards: the session’s org binding must BE the signing org; the key must be current on the published set; the private half must match the published coordinates. The artifact carries the reference in the frozen v1 format’s own carrier — the KeyInfo certificate’s subject DN (O=<org name>, OU=org:<org-id>, CN=<officer>, C=XX; the key id is the OP’s content-derived kid of exactly those coordinates, never a new field) — and the platform record (json_data.cnml.signer) + the audit chain carry who/org/kid/when with the authorization context. - Key custody failure modes are honest: no key unlocked ⇒ the panel says so; the wrong key ⇒ the signature check fails on verify, with the reason.
5. The verify embed
The check pipeline (runChecks) runs in order, each check named with
its reason; a hard failure short-circuits (the pipeline’s own rule):
- xml-well-formed, the document parses;
- schema-valid, it validates against its per-rec schema (the rec id is read from the document);
- signature, the XMLDSig signature verifies against the trusted keys (the verifier’s trust store);
- scope, the issuer’s certified scope covers the recommendation;
- crl, the signing key is not revoked;
- er-binding, the certificate binds its evaluation report (the digest inside the signed payload; pre-leg certificates skip honestly);
- timestamp, the required OpenTimestamps time attestation: an
attested proof passes (the Bitcoin block height + time + calendar
named); a pending proof reads
pending— the attestation is in flight, the calendar named, and the relay’s upgrade query (POST /api/cnml/timestamp/verify) can mature it at verify time (the app persists the matured proof — the lazy upgrade-on-read); a proof-less document FAILS — unless its record predates the mandate, which reads as the honest legacy mark; - transparency, an embedded transparency-log proof (when present).
The certificate detail page embeds the panel for its signed document;
the same panel verifies a dropped foreign CNML file (the verifier
needs no account). skip is a first-class status — the er-binding and
transparency legs skip honestly when the document carries no proof,
never fake a pass — and pending is first-class too (the timestamp
leg’s in-flight state, neither a pass nor a fail).
The signer-chain resolution (TODO.trust-registry/02) rides alongside
the pipeline on the register’s verify page and on the API
(GET /api/verify/attribution?number=…): the platform record’s
attribution (kid + org id + the resolution URL) is resolved against the
OP’s published org-key document — the key’s validity AT THE SIGNING TIME
(the OTS anchor), the org’s standing NOW, the rotation/revocation
stamps. The state lattice answers each question distinctly, never one
blended verdict: the artifact’s own signature (the pipeline’s verdict),
the key (current / since-rotated / revoked-after-signing — “valid at the
time, the key since revoked on DATE” — / revoked-before-signing /
created-after-signing / unknown-key), the org (the standing now vs the
recorded standing then), the anchor. The resolution is replayable: the
key-set fetch honors the endpoint’s own max-age in a verifier-local
cache (every answer says hit or freshly fetched, and the page shows the
endpoint URL, the fetch stamp, and the OP’s key row verbatim so an
auditor recomputes by hand); the panel’s re-resolve act bypasses the
cache. Both sides compute through the ONE lattice module
(src/services/signer-lattice.ts) — the client service and the server
route never diverge.
6. Failure semantics (the card catalogue)
| Failure | Surface |
|---|---|
| Projection has no value for a mandatory section | the projection says which section, signing never starts |
| Schema rejection | every ajv violation listed (path + message), nothing signed |
| No key / locked key | the panel states the custody state |
| Sign throws | the error is recorded on the signing record, the page shows it |
| Verify: any check fails | the check’s own reason, pipeline order preserved, short-circuit visible |
| Tamper after signing | the signature check fails (sig mismatch), proven by test |
7. What this leg deliberately is NOT
- Not the CA: the PKI CA server (the CNML repo’s
oiml-pki-server) issues real X.509 chains; this leg signs with browser-held keys (self-signed where no chain exists, the scope/CRL checks then skip honestly). The CA-issued chain is leg 2 (TODO.integration/20). - Not transparency-log onboarding: the tlog proof embeds when the log infrastructure answers; the transparency check warns honestly until then. (The OTS time attestation IS wired — §4 signs through the instance’s timestamp relay, §5 verdicts it.)