diff --git a/README.md b/README.md index 879b0d5..8916fbe 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ by environment variables. The full annotated reference is [configs/config.yaml]( | `facetec.device_key` | `FACETEC_DEVICE_KEY` | *(optional)* | FaceTec device key; sent as `X-Device-Key`. Only required by the FaceTec Testing API — omit when using your own FaceTec Server (v10+) | | `facetec.device_key_path` | `FACETEC_DEVICE_KEY_PATH` | *(optional)* | File containing the FaceTec device key (takes precedence over `device_key`) | | `facetec.timeout` | `FACETEC_TIMEOUT` | `30s` | HTTP timeout for FaceTec Server requests | +| `facetec.min_chip_face_match_level` | `FACETEC_MIN_CHIP_FACE_MATCH_LEVEL` | `6` | Lowest match (1–10) between the live face and the photo on the document's chip that may be issued; a hard gate, independent of the SPOCP rules. `0` selects the default, so the gate cannot be switched off | | `facetec.tls.skip_verify` | `FACETEC_TLS_SKIP_VERIFY` | `false` | Disable cert verification — **never use in production** | | `facetec.tls.ca_file` | `FACETEC_TLS_CA_FILE` | *(optional)* | CA certificate for outbound FaceTec TLS | | `facetec.tls.cert_file` | `FACETEC_TLS_CERT_FILE` | *(optional)* | Client certificate for FaceTec mTLS | @@ -307,6 +308,14 @@ rules with `liveness-score` 100. or unreadable (`document_unreadable`), is refused. A document is valid through its expiry date, compared in UTC. The legacy `/v1/id-scan` endpoint applies the same check. +**Chip photo.** FaceTec reports two face matches: `matchLevel` against the photo printed on the +document (the rules' `face-match-level`), and `matchLevelNFCToFaceMap` against the photo on the +chip. Only the second ties the chip, and so the identity in the credential, to the person in front +of the camera; without it, a genuine document with a substituted printed photo passes every other +check. A scan whose chip match is below `facetec.min_chip_face_match_level` (default 6), or for +which FaceTec reported none, is refused with `chip_photo_mismatch`, on both `/process-request` and +`/v1/id-scan`. The level is recorded as `chip_face_match_level` in the issuance audit log. + **Chip.** A scan without an authenticated chip is refused: | Code | FaceTec `nfcStatusEnumInt` | Meaning | diff --git a/configs/config.yaml b/configs/config.yaml index 6b62608..a9d6890 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -35,6 +35,10 @@ facetec: # HTTP timeout for FaceTec Server requests. timeout: "30s" + # Lowest match (1-10) between the live face and the photo on the document's + # chip (FaceTec's matchLevelNFCToFaceMap) that may be issued. + min_chip_face_match_level: 6 + # TLS configuration for the outbound HTTPS connection to the FaceTec Server. tls: # skip_verify: false # Disable cert verification — NEVER use in production. diff --git a/go.mod b/go.mod index 4e9ee3f..fff6e10 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.31.0 // indirect golang.org/x/crypto v0.57.0 // indirect - golang.org/x/net v0.59.0 // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect google.golang.org/protobuf v1.36.12 // indirect diff --git a/go.sum b/go.sum index 93d582f..ac628fa 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk= golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= diff --git a/internal/apiv1/chip.go b/internal/apiv1/chip.go index 8566281..0e7805f 100644 --- a/internal/apiv1/chip.go +++ b/internal/apiv1/chip.go @@ -78,7 +78,12 @@ func (c *Client) assessChip(ctx context.Context, scan *facetec.ScanResult) *idve // chipAuditFields are the structured log fields recording how the chip was // judged, for the AUDIT credential_issued record. func chipAuditFields(id facetec.IDScanResult) []zap.Field { + chipFaceMatchLevel := -1 // not reported + if id.ChipFaceMatchLevel != nil { + chipFaceMatchLevel = *id.ChipFaceMatchLevel + } return []zap.Field{ + zap.Int("chip_face_match_level", chipFaceMatchLevel), zap.Bool("chip_trusted", id.ChipTrusted), zap.String("chip_trust_reason", id.ChipTrustReason), zap.Int("chip_auth_status", id.ChipAuthStatus), @@ -98,3 +103,27 @@ func bindPortraitToChip(id *facetec.IDScanResult, verifiedDGs []int) { } id.DocumentData.Portrait = "" } + +// defaultMinChipFaceMatchLevel applies when no minimum is configured (0); +// config.Validate refuses anything outside 0–10. +const defaultMinChipFaceMatchLevel = 6 + +// chipFaceRejection refuses a scan whose face does not match the photo on the +// document's chip (FaceTec's matchLevelNFCToFaceMap). The chip gates prove +// the chip is genuine and the printed-photo match (matchLevel) proves the face +// matches the data page; only this ties the chip, and so the identity in the +// credential, to the person in front of the camera. Without it, a genuine +// document with a substituted printed photo passes every other check. A scan +// for which FaceTec reported no chip match is refused too (fail closed). +func chipFaceRejection(id facetec.IDScanResult, minLevel int) (code idverrors.Code, msg string, rejected bool) { + if minLevel < 1 { + minLevel = defaultMinChipFaceMatchLevel + } + if id.ChipFaceMatchLevel == nil { + return idverrors.CodeChipPhotoMismatch, "the face could not be compared with the photo on the document's chip", true + } + if *id.ChipFaceMatchLevel < minLevel { + return idverrors.CodeChipPhotoMismatch, "the face does not match the photo on the document's chip", true + } + return "", "", false +} diff --git a/internal/apiv1/chip_face_test.go b/internal/apiv1/chip_face_test.go new file mode 100644 index 0000000..fa79a1a --- /dev/null +++ b/internal/apiv1/chip_face_test.go @@ -0,0 +1,145 @@ +package apiv1 + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sirosfoundation/facetec-api/internal/facetec" + "github.com/sirosfoundation/facetec-api/internal/idverrors" + "github.com/sirosfoundation/facetec-api/internal/tenant" +) + +func intPtr(v int) *int { return &v } + +func TestChipFaceRejection(t *testing.T) { + cases := []struct { + name string + level *int + min int + rejected bool + }{ + {"not reported", nil, 6, true}, + {"below the minimum", intPtr(5), 6, true}, + {"at the minimum", intPtr(6), 6, false}, + {"above the minimum", intPtr(9), 6, false}, + {"reported as 0", intPtr(0), 6, true}, + {"no minimum configured uses the default (6)", intPtr(5), 0, true}, + {"no minimum configured, at the default", intPtr(6), 0, false}, + {"stricter configured minimum", intPtr(7), 8, true}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + code, msg, rejected := chipFaceRejection(facetec.IDScanResult{ChipFaceMatchLevel: tt.level}, tt.min) + assert.Equal(t, tt.rejected, rejected) + if tt.rejected { + assert.Equal(t, idverrors.CodeChipPhotoMismatch, code) + assert.NotEmpty(t, msg) + } + }) + } +} + +// completedScan is a /process-request final response with an authenticated +// chip, an unexpired document and the given face matches. +func completedScan(printedMatch int, chipMatch string) string { + chipField := "" + if chipMatch != "" { + chipField = fmt.Sprintf(`"matchLevelNFCToFaceMap": %s,`, chipMatch) + } + return fmt.Sprintf(`{ + "idScanResultsSoFar": { + "photoIDNextStepEnumInt": 4, + "matchLevel": %d, + %s + "nfcStatusEnumInt": 4, + "nfcAuthenticationStatusEnumInt": 4, + "mrzStatusEnumInt": 2, + "barcodeStatusEnumInt": 3, + "documentData": {"givenName": "Alice", "familyName": "Test", "documentType": "passport", "dateOfExpiry": "2099-12-31"} + } +}`, printedMatch, chipField) +} + +// A genuine document whose printed photo was replaced by the user's own: the +// printed-photo match is excellent, the chip is authentic, but the face does +// not match the chip photo. Nothing may be issued. +func TestProcessRequest_PhotoSubstitution_Refused(t *testing.T) { + c := newTestClientForProcessRequest(t, completedScan(9, "2")) + ctx := tenant.WithStdContext(t.Context(), &tenant.Context{ID: "test-tenant", Policy: noRulesPolicy(t)}) + + resp, err := c.ProcessRequest(ctx, provenSession(t, c, ctx)) + require.NoError(t, err) + assert.Equal(t, string(idverrors.CodeChipPhotoMismatch), resp.CredentialIssueErrCode) + assert.Empty(t, resp.TransactionID) +} + +func TestProcessRequest_ChipFaceMatch(t *testing.T) { + cases := []struct { + name string + chipMatch string + minLevel int + want idverrors.Code + }{ + {"not reported", "", 0, idverrors.CodeChipPhotoMismatch}, + {"below the default minimum", "5", 0, idverrors.CodeChipPhotoMismatch}, + // Passing the gate reaches the (empty, rejecting) policy. + {"at the default minimum", "6", 0, idverrors.CodePolicyRejected}, + {"configured stricter minimum", "7", 8, idverrors.CodeChipPhotoMismatch}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + c := newTestClientForProcessRequest(t, completedScan(9, tt.chipMatch)) + c.cfg.FaceTec.MinChipFaceMatchLevel = tt.minLevel + ctx := tenant.WithStdContext(t.Context(), &tenant.Context{ID: "test-tenant", Policy: noRulesPolicy(t)}) + + resp, err := c.ProcessRequest(ctx, provenSession(t, c, ctx)) + require.NoError(t, err) + assert.Equal(t, string(tt.want), resp.CredentialIssueErrCode) + }) + } +} + +// The NFC gate runs first: a scan without a chip read is refused for that, +// not for the missing chip face match it implies. +func TestProcessRequest_ChipFaceGateAfterNFCGate(t *testing.T) { + c := newTestClientForProcessRequest(t, nfcSkippedPayload) + ctx := tenant.WithStdContext(t.Context(), &tenant.Context{ID: "test-tenant", Policy: noRulesPolicy(t)}) + + resp, err := c.ProcessRequest(ctx, provenSession(t, c, ctx)) + require.NoError(t, err) + assert.Equal(t, string(idverrors.CodeNFCSkipped), resp.CredentialIssueErrCode) +} + +func TestSubmitIDScan_ChipFaceMatch(t *testing.T) { + for _, tt := range []struct { + name string + chipMatch string + want idverrors.Code + }{ + {"not reported", "", idverrors.CodeChipPhotoMismatch}, + {"below the minimum", `"matchLevelNFCToFaceMap": 3,`, idverrors.CodeChipPhotoMismatch}, + {"passes, reaches the policy", `"matchLevelNFCToFaceMap": 8,`, idverrors.CodePolicyRejected}, + } { + t.Run(tt.name, func(t *testing.T) { + c, livenessID := newTestClientForIDScan(t, `{ + "success": true, + "faceMatchLevel": 9, + `+tt.chipMatch+` + "nfcVerified": true, + "mrzVerified": true, + "barcodeVerified": true, + "documentData": {"givenName": "Alice", "familyName": "Test", "documentType": "passport", "dateOfExpiry": "2099-12-31"} + }`) + ctx := tenant.WithStdContext(t.Context(), &tenant.Context{ID: "test-tenant", Policy: noRulesPolicy(t)}) + + _, _, err := c.SubmitIDScan(ctx, livenessID, &facetec.IDScanRequest{}) + var idvErr *idverrors.Error + require.True(t, errors.As(err, &idvErr), "got %v", err) + assert.Equal(t, tt.want, idvErr.Code) + }) + } +} diff --git a/internal/apiv1/chip_test.go b/internal/apiv1/chip_test.go index fa91c00..83e8af8 100644 --- a/internal/apiv1/chip_test.go +++ b/internal/apiv1/chip_test.go @@ -57,6 +57,7 @@ func chipPayload(t *testing.T, chip *emrtdtest.Chip, authStatus int, withChip bo body, err := json.Marshal(map[string]any{"idScanResultsSoFar": map[string]any{ "photoIDNextStepEnumInt": 4, "matchLevel": 8, + "matchLevelNFCToFaceMap": 8, "nfcStatusEnumInt": 4, "nfcAuthenticationStatusEnumInt": authStatus, "mrzStatusEnumInt": 2, @@ -166,7 +167,7 @@ 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) { diff --git a/internal/apiv1/client.go b/internal/apiv1/client.go index 89eda70..235eccf 100644 --- a/internal/apiv1/client.go +++ b/internal/apiv1/client.go @@ -197,6 +197,15 @@ func (c *Client) SubmitIDScan(ctx context.Context, livenessSessionID string, idS return "", "", rej } + if code, msg, rejected := chipFaceRejection(*idScanResult, c.cfg.FaceTec.MinChipFaceMatchLevel); rejected { + c.log.Info("id-scan scan rejected: chip photo match", + zap.String("tenant", tc.ID), + zap.String("doc_type", idScanResult.DocumentData.DocumentType), + zap.Bool("chip_face_match_reported", idScanResult.ChipFaceMatchLevel != nil), + ) + return "", "", idverrors.New(code, msg) + } + if code, msg, rejected := documentExpiryRejection(idScanResult.DocumentData, time.Now()); rejected { c.log.Info("id-scan scan rejected: document expiry", zap.String("tenant", tc.ID), @@ -322,6 +331,19 @@ func (c *Client) ProcessRequest(ctx context.Context, req *facetec.ProcessRequest return resp, nil } + // Hard gate: the face must match the photo on the chip, not only the one + // printed on the document (see chipFaceRejection). + if code, msg, rejected := chipFaceRejection(scanResult.IDScan, c.cfg.FaceTec.MinChipFaceMatchLevel); rejected { + c.log.Info("process-request scan rejected: chip photo match", + zap.String("tenant", tc.ID), + zap.String("doc_type", scanResult.IDScan.DocumentData.DocumentType), + zap.Bool("chip_face_match_reported", scanResult.IDScan.ChipFaceMatchLevel != nil), + ) + resp.CredentialIssueError = msg + resp.CredentialIssueErrCode = string(code) + return resp, nil + } + // Hard gate: an expired document, or one whose expiry date could not be // read, is not a basis for a credential. if code, msg, rejected := documentExpiryRejection(scanResult.IDScan.DocumentData, time.Now()); rejected { diff --git a/internal/apiv1/liveness_expiry_test.go b/internal/apiv1/liveness_expiry_test.go index 3f3c4db..2638651 100644 --- a/internal/apiv1/liveness_expiry_test.go +++ b/internal/apiv1/liveness_expiry_test.go @@ -124,6 +124,7 @@ func expiryPayload(expiry string) string { "idScanResultsSoFar": { "photoIDNextStepEnumInt": 4, "matchLevel": 7, + "matchLevelNFCToFaceMap": 7, "nfcStatusEnumInt": 4, "nfcAuthenticationStatusEnumInt": 4, "mrzStatusEnumInt": 2, @@ -162,7 +163,8 @@ func TestProcessRequest_DocumentExpiry(t *testing.T) { func TestProcessRequest_ExpiryGateAfterChipGate(t *testing.T) { payload := `{ "idScanResultsSoFar": { - "photoIDNextStepEnumInt": 4, "matchLevel": 7, + "photoIDNextStepEnumInt": 4, "matchLevel": 7, "matchLevelNFCToFaceMap": 7, + "matchLevelNFCToFaceMap": 7, "nfcStatusEnumInt": 2, "nfcAuthenticationStatusEnumInt": 0, "mrzStatusEnumInt": 2, "barcodeStatusEnumInt": 3, "documentData": {"documentType": "passport", "dateOfExpiry": "2020-01-31"} @@ -210,6 +212,7 @@ func TestSubmitIDScan_ExpiredDocument_Rejected(t *testing.T) { c, livenessID := newTestClientForIDScan(t, `{ "success": true, "faceMatchLevel": 7, + "matchLevelNFCToFaceMap": 7, "nfcVerified": true, "mrzVerified": true, "barcodeVerified": true, diff --git a/internal/apiv1/process_request_test.go b/internal/apiv1/process_request_test.go index 2bc6443..a0a829e 100644 --- a/internal/apiv1/process_request_test.go +++ b/internal/apiv1/process_request_test.go @@ -43,6 +43,7 @@ const nfcSkippedPayload = `{ "idScanResultsSoFar": { "photoIDNextStepEnumInt": 4, "matchLevel": 7, + "matchLevelNFCToFaceMap": 7, "nfcStatusEnumInt": 2, "nfcAuthenticationStatusEnumInt": 0, "mrzStatusEnumInt": 2, @@ -62,6 +63,7 @@ const nfcCompletedPayload = `{ "idScanResultsSoFar": { "photoIDNextStepEnumInt": 4, "matchLevel": 7, + "matchLevelNFCToFaceMap": 7, "nfcStatusEnumInt": 4, "nfcAuthenticationStatusEnumInt": 4, "mrzStatusEnumInt": 2, @@ -81,6 +83,7 @@ func nfcPayload(nfcStatus, nfcAuthStatus int) string { "idScanResultsSoFar": { "photoIDNextStepEnumInt": 4, "matchLevel": 7, + "matchLevelNFCToFaceMap": 7, "nfcStatusEnumInt": %d, "nfcAuthenticationStatusEnumInt": %d, "mrzStatusEnumInt": 2, @@ -241,6 +244,7 @@ func TestSubmitIDScan_NFCNotVerified_RejectsWithoutIssuing(t *testing.T) { c, livenessID := newTestClientForIDScan(t, `{ "success": true, "faceMatchLevel": 7, + "matchLevelNFCToFaceMap": 7, "nfcVerified": false, "mrzVerified": true, "barcodeVerified": true, @@ -268,6 +272,7 @@ func TestSubmitIDScan_NFCVerified_DoesNotTriggerSkipGate(t *testing.T) { c, livenessID := newTestClientForIDScan(t, `{ "success": true, "faceMatchLevel": 7, + "matchLevelNFCToFaceMap": 7, "nfcVerified": true, "mrzVerified": true, "barcodeVerified": true, diff --git a/internal/config/config.go b/internal/config/config.go index ef3b374..3ba642c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,11 @@ type FaceTecConfig struct { // DeviceKeyPath loads DeviceKey from a file (takes precedence over DeviceKey if set). DeviceKeyPath string `yaml:"device_key_path" envconfig:"FACETEC_DEVICE_KEY_PATH"` Timeout time.Duration `yaml:"timeout" envconfig:"FACETEC_TIMEOUT"` + // MinChipFaceMatchLevel is the lowest matchLevelNFCToFaceMap (0–10, the + // match between the live face and the photo on the document's chip) that + // may be issued. A hard gate, independent of the SPOCP rules; 0 selects the + // default (6), so it cannot be switched off. + MinChipFaceMatchLevel int `yaml:"min_chip_face_match_level" envconfig:"FACETEC_MIN_CHIP_FACE_MATCH_LEVEL"` // TLS configures the HTTPS connection to the FaceTec Server. TLS FaceTecTLSConfig `yaml:"tls"` } @@ -282,6 +287,10 @@ func (c *Config) Validate() error { if c.FaceTec.ServerURL == "" { return fmt.Errorf("config: facetec.server_url is required") } + // 0 selects the default (6); the gate cannot be switched off. + if c.FaceTec.MinChipFaceMatchLevel < 0 || c.FaceTec.MinChipFaceMatchLevel > 10 { + return fmt.Errorf("config: facetec.min_chip_face_match_level must be between 1 and 10 (0 selects the default, 6), got %d", c.FaceTec.MinChipFaceMatchLevel) + } if c.Issuer.Addr == "" { return fmt.Errorf("config: issuer.addr is required") } @@ -392,7 +401,8 @@ func defaultConfig() *Config { MaxConcurrentBiometric: 10, }, FaceTec: FaceTecConfig{ - Timeout: 30 * time.Second, + Timeout: 30 * time.Second, + MinChipFaceMatchLevel: 6, }, Issuer: IssuerConfig{ Format: "sdjwt", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3a24464..e5d0df5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,6 +24,9 @@ func TestLoad_Defaults(t *testing.T) { if cfg.FaceTec.Timeout != 30*time.Second { t.Errorf("FaceTec.Timeout: got %v, want 30s", cfg.FaceTec.Timeout) } + if cfg.FaceTec.MinChipFaceMatchLevel != 6 { + t.Errorf("FaceTec.MinChipFaceMatchLevel: got %d, want 6", cfg.FaceTec.MinChipFaceMatchLevel) + } if cfg.Issuer.Format != "sdjwt" { t.Errorf("Issuer.Format: got %q, want sdjwt", cfg.Issuer.Format) } @@ -402,3 +405,19 @@ func TestValidate_TrustPDPURLAtMostOneTrailingSlash(t *testing.T) { t.Error("multiple trailing slashes must be rejected") } } + +func TestValidate_MinChipFaceMatchLevel(t *testing.T) { + for _, tc := range []struct { + level int + ok bool + }{{0, true}, {1, true}, {6, true}, {10, true}, {-1, false}, {11, false}} { + cfg := &config.Config{ + FaceTec: config.FaceTecConfig{ServerURL: "https://x", MinChipFaceMatchLevel: tc.level}, + Issuer: config.IssuerConfig{Addr: "x", Scope: "s"}, + JWT: config.JWTConfig{Secret: "shared-secret"}, + } + if err := cfg.Validate(); (err == nil) != tc.ok { + t.Errorf("min_chip_face_match_level %d: Validate() = %v, want ok=%v", tc.level, err, tc.ok) + } + } +} diff --git a/internal/facetec/process_request.go b/internal/facetec/process_request.go index 5064845..7092c72 100644 --- a/internal/facetec/process_request.go +++ b/internal/facetec/process_request.go @@ -90,6 +90,17 @@ func ExtractScanResult(payload map[string]any) (*ScanResult, bool, error) { return nil, false, nil } + // matchLevelNFCToFaceMap compares the live FaceMap with the photo on the + // document's chip (matchLevel compares it with the printed photo). Absent + // is kept apart from 0; a present value of the wrong type is an error + // (fail closed). + var chipFaceMatchLevel *int + if level, present, err := lookupInt(results["matchLevelNFCToFaceMap"]); err != nil { + return nil, false, fmt.Errorf("facetec: matchLevelNFCToFaceMap: %w", err) + } else if present { + chipFaceMatchLevel = &level + } + // documentData lives inside idScanResultsSoFar. documentData, ok, err := extractDocumentData(results["documentData"]) if err != nil { @@ -160,16 +171,17 @@ func ExtractScanResult(payload map[string]any) (*ScanResult, bool, error) { // fills Liveness in from that verdict. return &ScanResult{ IDScan: IDScanResult{ - Success: true, - FaceMatchLevel: matchLevel, - DocumentData: documentData, - MRZVerified: mrzStatus == 2, - NFCVerified: nfcAuthStatus == 4, - NFCStatus: nfcStatus, - BarcodeVerified: barcodeStatus == 3, - ChipAuthStatus: nfcAuthStatus, - ChipRaw: extractNFCRawData(results["documentData"]), - ChipPortrait: chipPortrait, + Success: true, + FaceMatchLevel: matchLevel, + DocumentData: documentData, + MRZVerified: mrzStatus == 2, + ChipFaceMatchLevel: chipFaceMatchLevel, + NFCVerified: nfcAuthStatus == 4, + NFCStatus: nfcStatus, + BarcodeVerified: barcodeStatus == 3, + ChipAuthStatus: nfcAuthStatus, + ChipRaw: extractNFCRawData(results["documentData"]), + ChipPortrait: chipPortrait, }, }, true, nil } diff --git a/internal/facetec/process_request_test.go b/internal/facetec/process_request_test.go index e836f63..713e037 100644 --- a/internal/facetec/process_request_test.go +++ b/internal/facetec/process_request_test.go @@ -535,3 +535,52 @@ func TestLivenessProven(t *testing.T) { }) } } + +// TestExtractScanResult_ChipFaceMatchLevel reads matchLevelNFCToFaceMap, the +// match between the live FaceMap and the chip's photo. A live FaceTec Server +// 10.1.4 response reported 7 after an authenticated chip read. +func TestExtractScanResult_ChipFaceMatchLevel(t *testing.T) { + t.Run("reported", func(t *testing.T) { + p := realPayload() + p["idScanResultsSoFar"].(map[string]any)["matchLevelNFCToFaceMap"] = float64(7) + result, ok, err := ExtractScanResult(p) + if err != nil || !ok { + t.Fatalf("err=%v ok=%v", err, ok) + } + if result.IDScan.ChipFaceMatchLevel == nil || *result.IDScan.ChipFaceMatchLevel != 7 { + t.Errorf("ChipFaceMatchLevel = %v, want 7", result.IDScan.ChipFaceMatchLevel) + } + }) + + t.Run("reported as 0 is kept apart from absent", func(t *testing.T) { + p := realPayload() + p["idScanResultsSoFar"].(map[string]any)["matchLevelNFCToFaceMap"] = float64(0) + result, _, err := ExtractScanResult(p) + if err != nil { + t.Fatal(err) + } + if result.IDScan.ChipFaceMatchLevel == nil || *result.IDScan.ChipFaceMatchLevel != 0 { + t.Errorf("ChipFaceMatchLevel = %v, want 0", result.IDScan.ChipFaceMatchLevel) + } + }) + + t.Run("absent", func(t *testing.T) { + p := realPayload() + delete(p["idScanResultsSoFar"].(map[string]any), "matchLevelNFCToFaceMap") + result, _, err := ExtractScanResult(p) + if err != nil { + t.Fatal(err) + } + if result.IDScan.ChipFaceMatchLevel != nil { + t.Errorf("ChipFaceMatchLevel = %v, want nil", *result.IDScan.ChipFaceMatchLevel) + } + }) + + t.Run("wrong type fails closed", func(t *testing.T) { + p := realPayload() + p["idScanResultsSoFar"].(map[string]any)["matchLevelNFCToFaceMap"] = "seven" + if _, _, err := ExtractScanResult(p); err == nil { + t.Error("want an error for a non-numeric matchLevelNFCToFaceMap") + } + }) +} diff --git a/internal/facetec/types.go b/internal/facetec/types.go index 9cd038f..1560dd2 100644 --- a/internal/facetec/types.go +++ b/internal/facetec/types.go @@ -64,6 +64,11 @@ type IDScanResult struct { // (NFCVerified) is accepted; 3 and 5 are refused as nfc_not_authenticated. // Only set by ExtractScanResult. ChipAuthStatus int `json:"chipAuthStatus"` + // ChipFaceMatchLevel is FaceTec's matchLevelNFCToFaceMap (0–10): how well + // the live 3D FaceMap matches the photo on the document's chip (DG2). + // FaceMatchLevel, by contrast, compares it with the photo printed on the + // document. nil when FaceTec did not report it (e.g. before a chip read). + ChipFaceMatchLevel *int `json:"matchLevelNFCToFaceMap,omitempty"` // ChipTrusted is true only when facetec-api itself verified the SOD and // data-group hashes AND the go-trust PDP trusts the DSC for the issuing // state. FaceTec's own checks never contribute to it. Set by the caller diff --git a/internal/idverrors/errors.go b/internal/idverrors/errors.go index b160b12..17371dd 100644 --- a/internal/idverrors/errors.go +++ b/internal/idverrors/errors.go @@ -44,6 +44,10 @@ const ( // (nfcAuthenticationStatusEnumInt != AUTHENTICATED), including a chip that // was read but failed authentication. CodeNFCNotAuthenticated Code = "nfc_not_authenticated" + // CodeChipPhotoMismatch indicates the face did not match the photo on the + // document's chip (FaceTec's matchLevelNFCToFaceMap), or could not be + // compared with it. + CodeChipPhotoMismatch Code = "chip_photo_mismatch" // CodeDocumentExpired indicates the document's expiry date has passed. CodeDocumentExpired Code = "document_expired" // CodeIssuanceFailed indicates credential issuance failed after successful verification.