Skip to content

fix: take the identity claims from the chip, not from MRZ OCR - #84

Merged
jessevanmuijden merged 4 commits into
mainfrom
fix/chip-sourced-claims
Oct 19, 2026
Merged

jessevanmuijden merged 4 commits into
mainfrom
fix/chip-sourced-claims

Conversation

@jessevanmuijden

@jessevanmuijden jessevanmuijden commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #83

A tester's Photo ID had an extra letter in her name. The identity claims came from FaceTec's OCR of the MRZ in the camera image (mrzValues overriding scannedValues). An OCR misread, typically an MRZ < filler read as a letter, went straight into the credential. This PR takes those fields from the document's chip instead.

Before

The chip was read and verified (#64), but only used as a check:

  • What feat: validate ePassport DSC against our CSCA list via a go-trust PDP #64 does: it parses DG1, the signed MRZ on the chip, and compares the OCR'd fields with it.
  • What a mismatch did: it only made the chip "not trusted".
  • Passports with trust.required: a misread name meant a refusal (chip_untrusted).
  • ID cards, or deployments without trust enforcement: the misread was issued.

After

Once local passive authentication has verified the SOD signature and the data-group hashes, emrtd.Verify returns the holder's identity as signed on the chip (Result.Identity):

  • From DG1: family and given name, document number, date of birth, date of expiry, nationality, issuing state, sex and document code. Dates use the same century rules as before; a < sex is "unspecified".
  • From DG11, when the chip presents it: the full, untruncated name and the date of birth with century. Every presented data group is already hash-checked against the SOD. A DG11 date of birth that contradicts DG1 is ignored.

assessChip overwrites the OCR'd DocumentData with it. Everything downstream then uses the chip values: the expiry gate, the policy and the credential.

  • Trust: this happens whether or not the PDP trusts the signer. Data the chip signed is never worse than OCR of the page printed next to it.
  • Empty chip fields stay empty: the chip is authoritative for every identity field, including those it leaves empty. A single-name holder gets no given name, an MRZ date of birth with unknown parts (85<<<<) gives no birth_date or age claims, and an unspecified sex is ISO/IEC 5218 0. None of these is filled in from OCR.
  • Portrait: it follows the identity. Whenever the chip's identity is applied, with or without PDP trust, the portrait is the SOD-verified DG2 image, and FaceTec's photoIDFaceCrop (cropped from the camera image of the document) is replaced. If DG2 was not verified, no portrait is issued. Before, this binding ran only for a PDP-trusted chip, so an ID card could get the chip's name next to a camera crop.
  • Country codes: they are normalised to alpha-3, so ICAO's D (Germany) now becomes DE in the credential. It used to stay D.
  • Without an ICAO chip (e.g. an ISO 18013 driving licence) the OCR values and FaceTec's portrait stay (Only issue credentials based on verified chip data #85).
  • Audit: the issuance audit record gains chip_identity_applied.

The OCR→DG1 comparison is now a diagnostic:

  • differing fields are returned as names only (Result.Mismatches) and logged without values;
  • only the document type must still agree with the signed document code, because it selects the policy rule;
  • the SOD signing-time plausibility check uses the chip's expiry date instead of the OCR'd one;
  • ReasonMRZMismatch is removed, since nothing produces it any more.

Scope

Not in this PR:

  • Display-quality names: casing and diacritics. DG1 names are the upper-case, transliterated MRZ form, as today's OCR'd names already are. Using the visual zone's name when it matches the chip would be a follow-up.
  • New claims from DG11: e.g. place of birth into the schema's birthplace. That's a data-minimisation decision.
  • Whether FaceTec actually reads DG11: I haven't checked on a real scan. Without it, DG1 is used.

Merge note

#80 also adds an audit field. Whichever merges second needs TestAssessChip_FillsScanResult's field count (assert.Len(t, fields, 6)) bumped to 7.

Tests

  • emrtd package:
    • OCR mismatches of every field (including the tester's case, a filler read as K) now verify, are reported by field name, and the identity is the chip's;
    • identity from DG1;
    • unspecified sex;
    • DG11 name and date of birth, and a contradicting DG11 date of birth (ignored);
    • mrzDateToISO century cases.
    • The test chip builder (emrtdtest) can now emit a DG11, set the MRZ sex and date of birth, and encode a single-name holder.
  • apiv1 package:
    • an OCR mismatch issues from the chip;
    • the identity is applied without PDP trust (ID card);
    • the portrait is bound to DG2 without PDP trust, and dropped if DG2 is not available; without chip data FaceTec's portrait stays;
    • without chip data the OCR values stay;
    • a passport with a misread name under trust.required is no longer refused as chip_untrusted;
    • Germany's D maps to DE;
    • empty chip fields (single name, partly unknown date of birth, unspecified sex) are not filled from OCR.
  • Results: go vet is clean, go test -race ./... passes, and govulncheck with Go 1.27.2 finds no vulnerabilities.

The credential's identity fields came from FaceTec's OCR of the MRZ in the
camera image (mrzValues overriding scannedValues). An OCR misread, typically a
'<' filler read as a letter, put an extra letter in a tester's name. The chip
was read and verified, but only used to cross-check the OCR; a mismatch made the
chip untrusted, which refused a passport under trust.required but let an ID
card (or any document without trust enforcement) through with the misread.

Once local passive authentication has verified the SOD signature and the
data-group hashes, emrtd.Verify now returns the holder's identity as signed on
the chip: names, document number, dates of birth and expiry, nationality,
issuing state and sex from DG1, refined by DG11 (full, untruncated name; date
of birth with century) when the chip presents it. assessChip overwrites the
OCR'd fields with it, whether or not the PDP trusts the signer: signed chip
data is never worse than OCR of the page printed next to it. Country codes are
normalised to alpha-3, so ICAO's "D" (Germany) now maps to DE in the credential.

The OCR->DG1 comparison becomes a diagnostic: differing fields are reported by
name (never values) and logged. Only the document type must still agree with
the signed document code, since it selects the policy rule. The SOD signing
time is bounded by the chip's expiry date instead of the OCR'd one. Documents
without an ICAO chip keep the OCR values.

Fixes #83
@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.38710% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/emrtd/emrtdtest/emrtdtest.go 93.54% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…ptions

Codecov reported 75% patch coverage: the new emrtdtest options (DG11, MRZ sex)
were only exercised from other packages, which per-package coverage does not
count, and a few branches had no test. emrtdtest now has tests of its own that
round-trip DG1 and DG11 through gmrtd, and there are tests for the M/F/X sex
mapping, an impossible DG1 expiry date (fails closed, dg_malformed) and an
ICAO country code with no ISO 3166 equivalent (kept as signed). emrtdtest gains
a DG1ExpiryYYMMDD option for the expiry case.
@s-jairl

s-jairl commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Haven't had time to look through the PR yet, but a question arose based on the PR description:

Without an ICAO chip (e.g. an ISO 18013 driving licence) the OCR values stay.

and

without chip data the OCR values stay;

Is there any scenario where we'd feel okay issuing a credential just based (trivially falsifiable) OCR values?
I understand that not all types of ID have a chip containing signed data - the question is about if we should accept them.

Apologies if I've misunderstood anything or read to much into these text snippets.

@jessevanmuijden

Copy link
Copy Markdown
Contributor Author

Haven't had time to look through the PR yet, but a question arose based on the PR description:

Without an ICAO chip (e.g. an ISO 18013 driving licence) the OCR values stay.

and

without chip data the OCR values stay;

Is there any scenario where we'd feel okay issuing a credential just based (trivially falsifiable) OCR values? I understand that not all types of ID have a chip containing signed data - the question is about if we should accept them.

Apologies if I've misunderstood anything or read to much into these text snippets.

in practice, we are not issuing any credentials for documents without a verified chip.

@s-jairl

s-jairl commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Haven't had time to look through the PR yet, but a question arose based on the PR description:

Without an ICAO chip (e.g. an ISO 18013 driving licence) the OCR values stay.

and

without chip data the OCR values stay;

Is there any scenario where we'd feel okay issuing a credential just based (trivially falsifiable) OCR values? I understand that not all types of ID have a chip containing signed data - the question is about if we should accept them.
Apologies if I've misunderstood anything or read to much into these text snippets.

in practice, we are not issuing any credentials for documents without a verified chip.

Good! What is the reason for doing anything with the OCR data in that case?

@jessevanmuijden

Copy link
Copy Markdown
Contributor Author

Haven't had time to look through the PR yet, but a question arose based on the PR description:

Without an ICAO chip (e.g. an ISO 18013 driving licence) the OCR values stay.

and

without chip data the OCR values stay;

Is there any scenario where we'd feel okay issuing a credential just based (trivially falsifiable) OCR values? I understand that not all types of ID have a chip containing signed data - the question is about if we should accept them.
Apologies if I've misunderstood anything or read to much into these text snippets.

in practice, we are not issuing any credentials for documents without a verified chip.

Good! What is the reason for doing anything with the OCR data in that case?

Legacy. So we can try to see what we can prune, if you wish.

@jessevanmuijden

Copy link
Copy Markdown
Contributor Author

@s-jairl
Correction to my earlier reply: FaceTec has to report an authenticated chip for every issuance, but we can still issue OCR values in three cases:

  • a chip FaceTec authenticates but our own passive authentication can’t verify (only enforced as a refusal for passports with trust.required);
  • chips we don’t parse, e.g. ISO 18013 driving licences;
  • the legacy /v1/id-scan path.

Requiring chip-signed identity for every credential would close these, but would also exclude driving licences until we support ISO 18013 chips. That’s a team decision, so I’ll keep it out of this PR. This PR only makes passport and ID-card claims come from the chip.

@s-jairl

s-jairl commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

@s-jairl Correction to my earlier reply: FaceTec has to report an authenticated chip for every issuance, but we can still issue OCR values in three cases:

  • a chip FaceTec authenticates but our own passive authentication can’t verify (only enforced as a refusal for passports with trust.required);
  • chips we don’t parse, e.g. ISO 18013 driving licences;
  • the legacy /v1/id-scan path.

Requiring chip-signed identity for every credential would close these, but would also exclude driving licences until we support ISO 18013 chips. That’s a team decision, so I’ll keep it out of this PR. This PR only makes passport and ID-card claims come from the chip.

I agree that it would be a bigger change and probably best kept out of scope for this PR - I'll create a separate issue :-)

Comment thread internal/apiv1/chip.go Outdated
applyChipIdentity no longer keeps an OCR'd value where the chip leaves a
field empty: a single-name holder has no given name, an MRZ date of birth
with unknown parts gives no birth_date or age claims, and an unspecified
sex is ISO/IEC 5218 0. None of these is filled in from OCR.

Refs #83
@s-jairl

s-jairl commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Not sure if this is strictly within scope for the PR, but it looks like we fall back to a cropped portrait from a photo of the identity document if it's missing from the chip:

// documentData.Portrait is normally already populated by
// parseFaceTecGroupedFields (extracted from the NFC chip's DG2 face
// image -- see extractPortraitFromDG2). Some FaceTec configurations may
// additionally return a separate, pre-cropped face photo directly under
// "photoIDFaceCrop"; prefer that when present, since it's already
// cropped/normalized, but don't clobber the NFC-derived portrait with an
// empty string when it's absent (confirmed absent in this deployment's
// FaceTec Server responses as of 2026-07-29).
chipPortrait := extractPortraitFromDG2(documentDataMap(results["documentData"]))
if portrait, ok := lookupString(results["photoIDFaceCrop"]); ok {
documentData.Portrait = portrait
} else if portrait, ok := lookupString(payload["photoIDFaceCrop"]); ok {
documentData.Portrait = portrait
}

Probably wise to confirm before merging or at least creating a separate issue/PR if this is the case.

bindPortraitToChip only ran for a PDP-trusted chip, so a chip that verified
locally without PDP trust (e.g. an ID card) got the chip's identity next to
FaceTec's photoIDFaceCrop, cropped from the camera image of the document.
The portrait now follows the identity: the SOD-verified DG2 image, or none.

Refs #83
@jessevanmuijden

Copy link
Copy Markdown
Contributor Author

Thanks, confirmed, and fixed in 7155f65.

photoIDFaceCrop (cropped from the camera image of the document) did reach the credential in one case. bindPortraitToChip already replaced it with the SOD-verified DG2 image, or dropped it, but only for a PDP-trusted chip. Since this PR applies the chip's identity whenever the SOD signature and hashes verify locally, an ID card without PDP trust would have got the chip's name next to a camera crop.

The portrait now follows the identity. Whenever the chip's identity is applied, the portrait is the DG2 image, or none if DG2 was not verified. The new TestAssessChip_PortraitBoundToDG2WithoutPDPTrust covers it. The comment at the code you linked and the README now describe it.

Without a verified chip at all, FaceTec's portrait still stays, like the OCR'd fields. That case is part of #85.

@sonarqubecloud

Copy link
Copy Markdown

@s-jairl s-jairl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good AFAICT!

@jessevanmuijden
jessevanmuijden merged commit 6d49ba4 into main Oct 19, 2026
16 checks passed
jessevanmuijden added a commit that referenced this pull request Oct 19, 2026
Resolves the conflict with #84 in internal/apiv1/chip.go (both sides kept)
and counts #84's chip_identity_applied audit field next to
chip_face_match_level.
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.

Identity claims come from MRZ OCR instead of the document's chip

2 participants