Skip to content

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

Closed
jessevanmuijden wants to merge 2 commits into
sirosfoundation:mainfrom
jessevanmuijden:fix/chip-photo-match
Closed

jessevanmuijden wants to merge 2 commits into
sirosfoundation:mainfrom
jessevanmuijden: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 sirosfoundation#77
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

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

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

📢 Thoughts on this report? Let us know!

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 (sirosfoundation#76) does not cover it.
@jessevanmuijden

Copy link
Copy Markdown
Contributor Author

Superseded by #80: the same commits, opened from a branch in this repo so the SonarCloud scan gets its token (fork PRs never do). Nothing else changed.

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)

1 participant