diff --git a/README.md b/README.md index a39713c..5f5d10b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/adr/002-emrtd-document-signer-trust.md b/docs/adr/002-emrtd-document-signer-trust.md index f6a3b9d..35b6185 100644 --- a/docs/adr/002-emrtd-document-signer-trust.md +++ b/docs/adr/002-emrtd-document-signer-trust.md @@ -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. | diff --git a/internal/apiv1/chip.go b/internal/apiv1/chip.go index 8566281..a93c329 100644 --- a/internal/apiv1/chip.go +++ b/internal/apiv1/chip.go @@ -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() @@ -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 @@ -82,15 +95,19 @@ 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 @@ -98,3 +115,30 @@ func bindPortraitToChip(id *facetec.IDScanResult, verifiedDGs []int) { } 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 +} diff --git a/internal/apiv1/chip_test.go b/internal/apiv1/chip_test.go index fa91c00..a72812a 100644 --- a/internal/apiv1/chip_test.go +++ b/internal/apiv1/chip_test.go @@ -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} } @@ -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) { @@ -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) { diff --git a/internal/emrtd/emrtdtest/emrtdtest.go b/internal/emrtd/emrtdtest/emrtdtest.go index ea9ce5f..2192df6 100644 --- a/internal/emrtd/emrtdtest/emrtdtest.go +++ b/internal/emrtd/emrtdtest/emrtdtest.go @@ -50,6 +50,23 @@ type Options struct { DocumentCode string // TwoSigners emits two SignerInfos. TwoSigners bool + // DG11Name, when set, adds a DG11 (additional personal details) carrying + // this full name in MRZ form, e.g. "ERIKSSON