Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,27 @@ constrains `chip-trusted` must list the fields before it (`(* set true false)` m
`nfc-verified` is FaceTec's own chip authentication (status 4: clone detection plus signature
verification against the DSC embedded in the same chip); it proves the chip is genuine hardware
holding intact data, not that the data was signed by a state we trust. `chip-trusted` is `true`
only when facetec-api itself verified the SOD signature, every data-group hash and the DG1/MRZ
cross-check, **and** the go-trust PDP trusts the DSC for the issuing state
only when facetec-api itself verified the SOD signature and every data-group hash, the signed
document code matches the reported document type, **and** the go-trust PDP trusts the DSC for the issuing state
(see [ADR-002](docs/adr/002-emrtd-document-signer-trust.md)). Passports need both. Passports whose
chips lack Active/Chip Authentication (status 1) are therefore refused by design; this is a known
coverage trade-off. With `trust.required` (passports only), a scan that passes the NFC gate (status 4) but whose raw
chip data is missing or untrusted is refused with `chip_untrusted`.

**Identity from the chip.** Once the SOD signature and the data-group hashes are verified, the
credential's identity fields come from the chip, not from FaceTec's OCR of the MRZ in the photo:
names, document number, dates of birth and expiry, nationality, issuing state and sex from DG1
(the signed MRZ), refined by DG11 (full name, date of birth with century) when the chip carries
it. This holds whether or not the PDP trusts the signer, since signed chip data is never worse
than OCR. The chip is authoritative for every identity field, including the ones it leaves empty:
a holder with a single name gets no given name, an MRZ date of birth with unknown parts (e.g.
`85<<<<`) gives no `birth_date` or age claims, and an unspecified sex is issued as ISO/IEC 5218
`0` (not known); none of these is filled in from OCR. The portrait follows the same rule: it is
the SOD-verified DG2 face image, never FaceTec's `photoIDFaceCrop` from the camera image, and is
left out when DG2 was not verified. Where the OCR differed, the fields are
logged by name (never values). Documents without an ICAO chip (e.g. an ISO 18013 driving
licence) keep the OCR values and FaceTec's portrait for now (#85).

## ETSI 119 461 §4.5 Compliance

This release implements the traceability and quality controls required by
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/002-emrtd-document-signer-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Split the work across three roles (shared contract for the emrtd effort):

| Role | Component | Responsibility |
|------|-----------|----------------|
| PEP | facetec-api (`internal/emrtd`) | Parse the SOD from `documentData.nfcValues.rawData`, verify the CMS signature with the embedded DSC, verify every presented data group against the SOD, cross-check DG1 against the scanned MRZ fields and issuing state, normalise the issuing state to ISO alpha-3, then ask the PDP. Never uses FaceTec's `signingCertificate` / `wasSignedDataValidated`. gmrtd's embedded master lists are not used as trust. |
| PEP | facetec-api (`internal/emrtd`) | Parse the SOD from `documentData.nfcValues.rawData`, verify the CMS signature with the embedded DSC, verify every presented data group against the SOD, check the signed document code against the reported document type, take the holder's identity from DG1 (and DG11 when present) instead of the scanned MRZ (#83; OCR differences are logged by field name only), normalise the issuing state to ISO alpha-3, then ask the PDP. Never uses FaceTec's `signingCertificate` / `wasSignedDataValidated`. gmrtd's embedded master lists are not used as trust. |
| PDP | go-trust `emrtd` registry | Decide whether the DSC chains to an anchor in the reviewed CSCA list for the claimed state, evaluated at the SOD signing time. Does not see the SOD. |
| Anchor list | emrtd-trust-anchors repo | The reviewed CSCA list and its maintenance tooling. |

Expand Down
58 changes: 51 additions & 7 deletions internal/apiv1/chip.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ func (c *Client) assessChip(ctx context.Context, scan *facetec.ScanResult) *idve
Sex: dd.Sex,
DocumentType: dd.DocumentType,
})
// Once the SOD signature and the data-group hashes are verified, the
// identity the chip signed replaces FaceTec's OCR of the MRZ (#83): an OCR
// misread, e.g. a '<' filler read as a letter, must not reach the
// credential. This does not depend on the PDP's trust decision: data the
// chip signed is never worse than OCR of the page printed next to it.
if local := out.Local; local != nil && local.Identity != nil {
applyChipIdentity(&id.DocumentData, local.Identity)
bindPortraitToChip(id, local.DataGroups)
id.ChipIdentityApplied = true
if len(local.Mismatches) > 0 && c.log != nil {
c.log.Info("chip identity differs from OCR; issuing from the chip",
zap.Strings("fields", local.Mismatches),
zap.String("doc_type", dd.DocumentType),
)
}
}
id.ChipTrusted = out.Trusted
id.ChipTrustReason = out.Reason
id.ChipDSCSHA256 = out.Local.DSCFingerprint()
Expand All @@ -55,9 +71,6 @@ func (c *Client) assessChip(ctx context.Context, scan *facetec.ScanResult) *idve
// Any sign of a chip read counts as presented, so an incomplete payload
// (status reported but SOD missing, stray raw fields) cannot dodge the gate.
presented := out.ChipPresented || id.ChipAuthStatus != 0 || len(id.ChipRaw) > 0 || id.NFCVerified
if out.Trusted {
bindPortraitToChip(id, out.Local.DataGroups)
}
// trust.required is enforced for passports (and for scans that reported no
// document type at all, which fail closed) only: the PDP's eMRTD registry
// covers ICAO 9303 travel documents, and other documents' chips (e.g. an
Expand All @@ -82,19 +95,50 @@ func chipAuditFields(id facetec.IDScanResult) []zap.Field {
zap.Bool("chip_trusted", id.ChipTrusted),
zap.String("chip_trust_reason", id.ChipTrustReason),
zap.Int("chip_auth_status", id.ChipAuthStatus),
zap.Bool("chip_identity_applied", id.ChipIdentityApplied),
zap.String("chip_dsc_sha256", id.ChipDSCSHA256),
zap.String("chip_csca_sha256", id.ChipCSCASHA256),
}
}

// bindPortraitToChip makes sure a chip-trusted scan only carries a portrait
// the SOD vouches for. When DG2's hash was verified, the DG2 face image
// replaces whatever FaceTec supplied (e.g. photoIDFaceCrop); when it was not,
// the portrait is dropped rather than issued under a chip-trusted credential.
// bindPortraitToChip makes sure a scan issued with the chip's identity only
// carries a portrait the SOD vouches for, whether or not the PDP trusts the
// signer: the portrait follows the same source as the name and dates. When
// DG2's hash was verified, the DG2 face image replaces whatever FaceTec
// supplied (e.g. photoIDFaceCrop, cropped from the camera image of the
// document); when it was not, the portrait is dropped rather than issued
// next to the chip's identity.
func bindPortraitToChip(id *facetec.IDScanResult, verifiedDGs []int) {
if slices.Contains(verifiedDGs, 2) && id.ChipPortrait != "" {
id.DocumentData.Portrait = id.ChipPortrait
return
}
id.DocumentData.Portrait = ""
}

// applyChipIdentity replaces the OCR'd identity fields with the chip's values.
// The chip is authoritative for every one of them, including the ones it
// leaves empty: a holder with a single name has no given name, an MRZ date of
// birth with unknown parts (e.g. "85<<<<") yields none, and an unspecified sex
// ('<') is unknown. An empty field is left out of the credential rather than
// filled from OCR, which is trivially falsifiable (#83, #85).
// Country codes are normalised to ISO 3166-1 alpha-3, so ICAO's "D" (Germany)
// becomes "DEU" and maps to an alpha-2 code in the credential; a code with no
// ISO 3166 equivalent stays as signed.
func applyChipIdentity(dd *facetec.DocumentData, chip *emrtd.Identity) {
dd.FamilyName = chip.FamilyName
dd.GivenName = chip.GivenName
dd.DocumentNumber = chip.DocumentNumber
dd.DateOfBirth = chip.DateOfBirth
dd.DateOfExpiry = chip.DateOfExpiry
dd.Sex = chip.Sex
dd.Nationality = normalizedCountry(chip.Nationality)
dd.IssuingCountry = normalizedCountry(chip.IssuingState)
}

func normalizedCountry(code string) string {
if a3, err := emrtd.NormalizeCountry(code); err == nil {
return a3
}
return code
}
149 changes: 144 additions & 5 deletions internal/apiv1/chip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ func (f *fakeEvaluator) EvaluateDSC(context.Context, emrtd.TrustRequest) (emrtd.

// chipPayload is a completed process-request response whose documentData
// carries real (synthetic) chip data and the matching MRZ-derived fields.
// chipPayloadFor is a completed, chip-authenticated passport scan whose OCR'd
// fields come from ocr and whose chip data comes from chip.
func chipPayloadFor(t *testing.T, chip, ocr *emrtdtest.Chip) string {
t.Helper()
ocrChip := *ocr
ocrChip.Raw = chip.Raw
return chipPayload(t, &ocrChip, 4, true)
}

func chipPayload(t *testing.T, chip *emrtdtest.Chip, authStatus int, withChip bool) string {
t.Helper()
field := func(k, v string) map[string]any { return map[string]any{"fieldKey": k, "value": v} }
Expand Down Expand Up @@ -166,21 +175,117 @@ func TestAssessChip_FillsScanResult(t *testing.T) {
fields := chipAuditFields(scan.IDScan)
enc := zap.NewExample()
enc.Info("x", fields...) // must not panic
assert.Len(t, fields, 5)
assert.Len(t, fields, 6)
}

func TestAssessChip_MRZMismatchUntrusted(t *testing.T) {
// An OCR'd field that differs from the chip no longer makes the chip
// untrusted: the identity is taken from the chip instead (#83).
func TestAssessChip_OCRMismatchIssuesFromChip(t *testing.T) {
chip := emrtdtest.New(emrtdtest.Options{})
c := &Client{chip: emrtd.NewChecker(&fakeEvaluator{dec: emrtd.TrustDecision{Trusted: true, CSCASHA256: "cafe"}}, false)}
scan := &facetec.ScanResult{IDScan: facetec.IDScanResult{
ChipRaw: chip.Raw,
DocumentData: facetec.DocumentData{
DocumentNumber: "SOMETHINGELSE", DateOfBirth: chip.DateOfBirth, DateOfExpiry: chip.DateOfExpiry,
FamilyName: chip.FamilyName + "K", GivenName: chip.GivenName, Nationality: "SWE",
},
}}
require.Nil(t, c.assessChip(t.Context(), scan), "not required: left to policy")
require.Nil(t, c.assessChip(t.Context(), scan))
assert.True(t, scan.IDScan.ChipTrusted)
assert.True(t, scan.IDScan.ChipIdentityApplied)
dd := scan.IDScan.DocumentData
assert.Equal(t, chip.DocumentNumber, dd.DocumentNumber)
assert.Equal(t, chip.FamilyName, dd.FamilyName)
assert.Equal(t, chip.GivenName, dd.GivenName)
assert.Equal(t, "SWE", dd.Nationality)
assert.Equal(t, "SWE", dd.IssuingCountry)
assert.Equal(t, "F", dd.Sex)
}

// The chip's identity is used even when the PDP does not trust the document
// signer (e.g. an ID card, where trust is not required): its signature and
// hashes were verified locally, so it is never worse than OCR.
func TestAssessChip_IdentityAppliedWithoutPDPTrust(t *testing.T) {
chip := emrtdtest.New(emrtdtest.Options{DocumentCode: "ID"})
c := &Client{chip: emrtd.NewChecker(&fakeEvaluator{dec: emrtd.TrustDecision{Trusted: false, Code: "unknown_csca"}}, true)}
scan := &facetec.ScanResult{IDScan: facetec.IDScanResult{
ChipRaw: chip.Raw,
DocumentData: facetec.DocumentData{DocumentType: "id_card", FamilyName: "WRONG"},
}}
require.Nil(t, c.assessChip(t.Context(), scan), "trust.required covers passports only")
assert.False(t, scan.IDScan.ChipTrusted)
assert.Equal(t, emrtd.ReasonMRZMismatch, scan.IDScan.ChipTrustReason)
assert.True(t, scan.IDScan.ChipIdentityApplied)
assert.Equal(t, chip.FamilyName, scan.IDScan.DocumentData.FamilyName)
}

// Without verified chip data the OCR values stay.
func TestAssessChip_NoChipDataKeepsOCR(t *testing.T) {
c := &Client{chip: emrtd.NewChecker(&fakeEvaluator{}, false)}
scan := &facetec.ScanResult{IDScan: facetec.IDScanResult{
DocumentData: facetec.DocumentData{DocumentType: "dl", FamilyName: "OCR NAME"},
}}
require.Nil(t, c.assessChip(t.Context(), scan))
assert.False(t, scan.IDScan.ChipIdentityApplied)
assert.Equal(t, "OCR NAME", scan.IDScan.DocumentData.FamilyName)
}

// A passport whose OCR misread the name used to be refused as chip_untrusted
// under trust.required; it now passes the chip gate (and reaches the policy).
func TestProcessRequest_PassportWithMisreadNameNotRefused(t *testing.T) {
chip := emrtdtest.New(emrtdtest.Options{})
misread := *chip
misread.FamilyName = chip.FamilyName + "K"
pdp := &fakeEvaluator{dec: emrtd.TrustDecision{Trusted: true, CSCASHA256: "aa"}}
c, ctx := chipClient(t, chipPayloadFor(t, chip, &misread), pdp, true)
resp := process(t, c, ctx)
assert.Equal(t, string(idverrors.CodePolicyRejected), resp.CredentialIssueErrCode)
}

func TestApplyChipIdentity_UnknownCountryCodeKeptAsSigned(t *testing.T) {
dd := facetec.DocumentData{Nationality: "SWE"}
applyChipIdentity(&dd, &emrtd.Identity{Nationality: "XXA", IssuingState: "UNO"})
assert.Equal(t, "XXA", dd.Nationality, "an ICAO code with no ISO 3166 equivalent stays as signed")
assert.Equal(t, "UNO", dd.IssuingCountry)
}

func TestApplyChipIdentity_GermanyD(t *testing.T) {
dd := facetec.DocumentData{FamilyName: "OCR", Nationality: "D", IssuingCountry: "Germany", DateOfBirth: "1990-01-01"}
applyChipIdentity(&dd, &emrtd.Identity{FamilyName: "MUSTERMANN", Nationality: "D", IssuingState: "D"})
assert.Equal(t, "MUSTERMANN", dd.FamilyName)
assert.Equal(t, "DEU", dd.Nationality)
assert.Equal(t, "DEU", dd.IssuingCountry)
assert.Empty(t, dd.DateOfBirth, "a field the chip leaves empty is not filled from OCR")
assert.Equal(t, "DE", toISO3166Alpha2(dd.Nationality))
}

// The chip is authoritative for the fields it leaves empty too: a holder with
// a single name, a date of birth with unknown parts and an unspecified sex are
// issued as such, never completed from the OCR'd values.
func TestAssessChip_EmptyChipFieldsNotFilledFromOCR(t *testing.T) {
chip := emrtdtest.New(emrtdtest.Options{NoGivenName: true, DG1BirthYYMMDD: "85<<<<", DG1Sex: "<"})
c := &Client{chip: emrtd.NewChecker(&fakeEvaluator{dec: emrtd.TrustDecision{Trusted: true, CSCASHA256: "cafe"}}, true)}
scan := &facetec.ScanResult{IDScan: facetec.IDScanResult{
ChipRaw: chip.Raw,
DocumentData: facetec.DocumentData{
DocumentType: "passport", FamilyName: chip.FamilyName, GivenName: "OCR GIVEN",
DocumentNumber: chip.DocumentNumber, DateOfBirth: "1985-01-01", DateOfExpiry: chip.DateOfExpiry,
Sex: "M", Nationality: "SWE",
},
}}
require.Nil(t, c.assessChip(t.Context(), scan))
require.True(t, scan.IDScan.ChipIdentityApplied)
dd := scan.IDScan.DocumentData
assert.Equal(t, chip.FamilyName, dd.FamilyName)
assert.Empty(t, dd.GivenName)
assert.Empty(t, dd.DateOfBirth)
assert.Empty(t, dd.Sex)

claims := MapPhotoIDClaims(dd, "ft-abc", PhotoIDIssuer{}, photoIDNow)
assert.Equal(t, "", claims["given_name_unicode"])
assert.Equal(t, 0, claims["sex"], "ISO/IEC 5218 not known")
for _, k := range []string{"birth_date", "age_over_18", "age_in_years", "age_birth_year"} {
assert.NotContains(t, claims, k)
}
}

func TestSubmitIDScan_LegacyPathNeverChipTrusted(t *testing.T) {
Expand Down Expand Up @@ -258,7 +363,41 @@ func TestAssessChip_TrustedPortraitBoundToDG2(t *testing.T) {
scan.IDScan.ChipPortrait = ""
scan.IDScan.DocumentData.Portrait = "swapped-crop"
require.Nil(t, c.assessChip(t.Context(), scan))
assert.Empty(t, scan.IDScan.DocumentData.Portrait, "a portrait the SOD does not cover is not issued under a trusted chip")
assert.Empty(t, scan.IDScan.DocumentData.Portrait, "a portrait the SOD does not cover is not issued with the chip's identity")
}

// The portrait follows the identity: a chip whose signature and hashes verify
// but whose signer the PDP does not trust (an ID card, where trust is not
// required) still replaces FaceTec's photoIDFaceCrop with the DG2 image, so
// the credential never pairs the chip's name with a crop from the camera.
func TestAssessChip_PortraitBoundToDG2WithoutPDPTrust(t *testing.T) {
chip := emrtdtest.New(emrtdtest.Options{DocumentCode: "ID"})
c := &Client{chip: emrtd.NewChecker(&fakeEvaluator{dec: emrtd.TrustDecision{Trusted: false, Code: "unknown_csca"}}, true)}
scan := &facetec.ScanResult{IDScan: facetec.IDScanResult{
ChipRaw: chip.Raw,
ChipPortrait: "dg2-portrait",
DocumentData: facetec.DocumentData{DocumentType: "id_card", Portrait: "camera-crop"},
}}
require.Nil(t, c.assessChip(t.Context(), scan))
require.False(t, scan.IDScan.ChipTrusted)
require.True(t, scan.IDScan.ChipIdentityApplied)
assert.Equal(t, "dg2-portrait", scan.IDScan.DocumentData.Portrait)

scan.IDScan.ChipPortrait = ""
scan.IDScan.DocumentData.Portrait = "camera-crop"
require.Nil(t, c.assessChip(t.Context(), scan))
assert.Empty(t, scan.IDScan.DocumentData.Portrait, "no crop next to the chip's identity")
}

// Without verified chip data the portrait FaceTec supplied stays, like the
// OCR'd fields (#85).
func TestAssessChip_NoChipDataKeepsPortrait(t *testing.T) {
c := &Client{chip: emrtd.NewChecker(&fakeEvaluator{}, false)}
scan := &facetec.ScanResult{IDScan: facetec.IDScanResult{
DocumentData: facetec.DocumentData{DocumentType: "dl", Portrait: "camera-crop"},
}}
require.Nil(t, c.assessChip(t.Context(), scan))
assert.Equal(t, "camera-crop", scan.IDScan.DocumentData.Portrait)
}

func TestAssessChip_RequiredRejectsMalformedRawData(t *testing.T) {
Expand Down
Loading
Loading