Skip to content

fix: require the face to match the chip photo before issuing - #80

Merged
jessevanmuijden merged 4 commits into
mainfrom
fix/chip-photo-match
Oct 21, 2026
Merged

jessevanmuijden merged 4 commits into
mainfrom
fix/chip-photo-match

Conversation

@jessevanmuijden

@jessevanmuijden jessevanmuijden commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #77

A hard gate on issuance: the user's face must match the photo on the document's chip, not only the photo printed on it.

Why

FaceTec reports two face matches:

  • matchLevel compares the live FaceMap with the photo printed on the document. The SPOCP rules check it (face-match-level >= 06).
  • matchLevelNFCToFaceMap compares the live FaceMap with the photo on the chip (DG2). facetec-api ignored it.

The chip gates (#66, #64) prove the chip is genuine, and the printed-photo match proves the face matches the data page. Nothing tied the chip to the person in front of the camera, so a genuine document with a substituted printed photo passed every check. Its holder's name, birth date and chip portrait would be issued to someone else.

The gate

  • Parsing: ExtractScanResult reads matchLevelNFCToFaceMap into IDScanResult.ChipFaceMatchLevel. It's a pointer, so "not reported" stays distinct from 0. A value of the wrong type is an error (fail closed), like the other result fields.
  • The rule: a scan is refused with chip_photo_mismatch (new code) when the chip match is below facetec.min_chip_face_match_level, or when FaceTec reported none.
    • The setting is FACETEC_MIN_CHIP_FACE_MATCH_LEVEL, default 6, the same as the default face-match-level rule.
    • 0 selects the default, so the gate cannot be switched off; values outside 0–10 are refused at startup.
  • Order: on /process-request it runs after the NFC and chip gates and before the expiry gate. The order is now liveness → NFC → chip → chip photo → expiry → SPOCP.
  • Audit: the issuance record gains chip_face_match_level (-1 when not reported).

Legacy /v1/id-scan

The legacy path gets the same gate. It reads matchLevelNFCToFaceMap from FaceTec's legacy response if present. If that response does not carry it, every legacy issuance is now refused (fail closed). I think that's acceptable: the only legacy clients are the SDKs' FaceTec 9 delegates, which cannot run against FaceTec 10 (siros-sdk-kotlin#246). The apps use /process-request. Say so if the legacy path should be exempt instead.

Evidence

FaceTec Server 10.1.4 returns the field. In a recorded session on the dev stack it is 0 on the response before the chip read and 7 on the final response, after an authenticated read (nfcAuthenticationStatusEnumInt: 4).

Apps

chip_photo_mismatch is a new refusal code. Until the apps and SDKs below merge, clients show their generic "could not be verified" text.

The check itself stays server-side; the clients only explain the refusal.

Tests

  • Parsing of matchLevelNFCToFaceMap: reported, reported as 0, absent, and a non-numeric value (an error).
  • chipFaceRejection: not reported, below, at and above the minimum, a 0 level, the default minimum, and a stricter configured one.
  • The photo-substitution case: printed match 9 with chip match 2 is refused before the policy.
  • Gate behaviour: the default and a configured minimum, ordering after the NFC gate, and the legacy /v1/id-scan path.
  • Config: default 6, and Validate bounds.
  • Existing fixtures gained a passing chip match.
  • Results: go vet is clean, and go test -race ./... passes, including tests/integration.

FaceTec reports two face matches: matchLevel against the photo printed on the
document, which the SPOCP rules check, and matchLevelNFCToFaceMap against the
photo on the chip, which facetec-api ignored. The chip gates prove the chip is
genuine and the printed-photo match proves the face matches the data page, but
nothing tied the chip to the person in front of the camera: a genuine document
with a substituted printed photo passed every check, and its holder's identity
and chip portrait were issued to someone else.

A scan whose chip match is below facetec.min_chip_face_match_level (default 6,
the default face-match-level rule; 0 selects the default, so the gate cannot be
switched off), or for which FaceTec reported none, is now refused with
chip_photo_mismatch. The gate runs after the chip gates and before the expiry
gate, on /process-request and on the legacy /v1/id-scan. The level is recorded
as chip_face_match_level in the issuance audit log.

Fixes #77
govulncheck reports five advisories against golang.org/x/net v0.59.0
(GO-2026-6603, -6610, -6611, -6612, -6617), all fixed in v0.60.0, reached
through http2 (the audit-log webhook client and fmt). x/net is an indirect
dependency, so dependabot's go-deps group (#76) does not cover it.
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.95918% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/apiv1/chip.go 92.30% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copilot AI 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.

🟡 Changes recommended

The gate accepts malformed score inputs and introduces an undocumented Info-level biometric score.

4 open findings
What changed in this PR

Adds a hard issuance gate requiring the live face to match the authenticated chip portrait.

Changes:

  • Parses and validates chip-face match scores across both issuance paths.
  • Adds configuration, rejection codes, audit fields, documentation, and tests.
  • Updates the indirect golang.org/x/net dependency.
File Description
README.md Documents the gate and configuration.
configs/​config.yaml Adds the default threshold.
go.mod Updates x/net.
go.sum Updates dependency checksums.
internal/​apiv1/​chip.go Implements gate and audit field.
internal/​apiv1/​chip_face_test.go Tests gate behavior.
internal/​apiv1/​chip_test.go Updates chip fixtures.
internal/​apiv1/​client.go Applies gate to both issuance paths.
internal/​apiv1/​liveness_expiry_test.go Updates expiry fixtures.
internal/​apiv1/​process_request_test.go Updates NFC fixtures.
internal/​config/​config.go Adds threshold configuration.
internal/​config/​config_test.go Tests defaults and bounds.
internal/​facetec/​process_request.go Extracts the chip-match score.
internal/​facetec/​process_request_test.go Tests score extraction.
internal/​facetec/​types.go Adds the score field.
internal/​idverrors/​errors.go Adds the rejection code.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/apiv1/chip.go
Comment thread internal/apiv1/chip.go
Comment thread internal/facetec/process_request.go Outdated
Comment thread internal/apiv1/liveness_expiry_test.go Outdated
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.
- chipFaceRejection refuses a level outside FaceTec's 0-10 scale instead
  of reading e.g. 11 as an excellent match.
- matchLevelNFCToFaceMap is read with the strict numeric helper, so a
  numeric string fails closed as in the legacy decoder.
- PRIVACY.md lists chip_face_match_level in the credential_issued audit
  event as a derived biometric indicator.
- Drop the duplicate matchLevelNFCToFaceMap member from a test fixture.

Refs #77
@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 8beb27d into main Oct 21, 2026
16 checks passed
@jessevanmuijden
jessevanmuijden deleted the fix/chip-photo-match branch October 21, 2026 16:44
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.

Issuance does not compare the face with the chip photo (matchLevelNFCToFaceMap)

3 participants