From 2f6b5cd78a13abbe755391fcffad9c3b3834dc1e Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Fri, 9 Oct 2026 09:26:36 -0400 Subject: [PATCH 1/3] fix: require the face to match the chip photo before issuing 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 --- README.md | 9 ++ configs/config.yaml | 4 + internal/apiv1/chip.go | 29 +++++ internal/apiv1/chip_face_test.go | 145 +++++++++++++++++++++++ internal/apiv1/chip_test.go | 3 +- internal/apiv1/client.go | 22 ++++ internal/apiv1/liveness_expiry_test.go | 5 +- internal/apiv1/process_request_test.go | 5 + internal/config/config.go | 12 +- internal/config/config_test.go | 19 +++ internal/facetec/process_request.go | 32 +++-- internal/facetec/process_request_test.go | 49 ++++++++ internal/facetec/types.go | 5 + internal/idverrors/errors.go | 4 + 14 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 internal/apiv1/chip_face_test.go 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/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. From 07b7e68a07d3092adc0debb0c715242a32cc20ca Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Fri, 9 Oct 2026 09:59:36 -0400 Subject: [PATCH 2/3] build(deps): bump golang.org/x/net to v0.60.0 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. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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= From f52aa9cbb90153a2ccf03c306c09cc6290f153a3 Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Tue, 20 Oct 2026 08:13:38 -0400 Subject: [PATCH 3/3] Address review: chip face match range, strict type, privacy inventory - 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 --- PRIVACY.md | 6 ++++-- internal/apiv1/chip.go | 10 +++++++++- internal/apiv1/chip_face_test.go | 3 +++ internal/apiv1/liveness_expiry_test.go | 1 - internal/facetec/process_request.go | 6 +++--- internal/facetec/process_request_test.go | 17 ++++++++++------- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index d5049e8..b3bbb84 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -31,6 +31,7 @@ technical and organisational measures (TOMs), and considerations relevant to GDP | **FaceMap** (server-computed biometric template) | Biometric data | In process memory only — never persisted | | **Liveness score** (float, 0.0–1.0) | Derived biometric indicator | In process memory only; logged at `Info` level without the score value | | **Face-match level** (integer, 0–10) | Derived biometric indicator | In process memory only; logged at `Debug` level only on policy rejection | +| **Chip face-match level** (integer, 0–10, FaceTec `matchLevelNFCToFaceMap`) | Derived biometric indicator | In process memory; recorded as `chip_face_match_level` in the `credential_issued` audit event (`-1` when not reported). A refusal logs only whether a level was reported, not the level | | **ID scan image** (base64, from mobile SDK) | Biometric / documentary | In transit: HTTPS to facetec-api → FaceTec Server | | **ID front/back images** (base64) | Biometric / documentary | In transit: HTTPS to facetec-api → FaceTec Server | @@ -123,6 +124,7 @@ zeroing guarantees: restrict access and retention, and delete them after debuggi - FaceMap (biometric template) — only forwarded to FaceTec Server, never to issuer or client - Liveness score — internal only - Face-match level — internal only; logged at `Debug` level on rejection +- Chip face-match level — never sent to the issuer or client; recorded in the `credential_issued` audit event only - MRZ lines — stripped in code (`issueCredential()`) before any forwarding --- @@ -137,7 +139,7 @@ zeroing guarantees: restrict access and retention, and delete them after debuggi | Signed credential | Process RAM (`session.Manager.offers`) | `session.offer_ttl` (default **5 min**) | Deleted atomically on first `GET /v1/offer/:txid`; TTL eviction | | Request logs | Log sink (operator-configured) | Operator's log retention policy | No personal data in log fields; see §2.3 | | Opt-in document debug logs (development only) | Log sink | Operator-controlled; delete after debugging | May contain document PII only when both Debug logging and `logging.include_pii` are enabled; see §2.4 | -| Audit log event (`credential_issued`) | Log sink | Operator's log retention policy | Contains: tenant ID, transaction ID, doc type, format, scope, chip trust outcome and reason code, FaceTec `chip_auth_status`, DSC/CSCA certificate fingerprints — **no name, MRZ or biometric data** (the SOD signing time is not logged) | +| Audit log event (`credential_issued`) | Log sink | Operator's log retention policy | Contains: tenant ID, transaction ID, doc type, format, scope, chip trust outcome and reason code, FaceTec `chip_auth_status`, the chip face-match level (`chip_face_match_level`, an integer 0–10), DSC/CSCA certificate fingerprints — **no name, MRZ, image or biometric template** (the SOD signing time is not logged). The chip face-match level is a derived biometric indicator, kept as evidence that the gate passed; it identifies no one on its own | > **Note:** facetec-api has no database and no persistent storage of any kind. All data described > above is held exclusively in process heap memory. A process restart or graceful shutdown @@ -260,7 +262,7 @@ The following technical controls are implemented in code: | **Storage limitation** | Biometric data is never written to disk. RAM retention is bounded by configurable short TTLs with fallback eviction. | | **Explicit zeroing** | `clear(faceMap)` is called on the `[]byte` FaceMap immediately after use (deferred in `SubmitIDScan`), and again by `Manager.Close()` on every in-memory entry at shutdown. | | **No body logging** | Gin middleware never logs request or response bodies. The panic-recovery handler is bound to `nil` to prevent biometric data appearing in crash dumps. | -| **Structured logs scrubbed** | Log fields are explicitly enumerated: method, path, status, latency, IP, user-agent. Biometric scores are absent from Info-level logs. | +| **Structured logs scrubbed** | Log fields are explicitly enumerated: method, path, status, latency, IP, user-agent. Biometric scores are absent from Info-level logs, except the chip face-match level in the `credential_issued` audit event (§2.1), which records the evidence for the chip photo gate. | | **Sensitive debug opt-in** | Document identity fields are excluded by default even at Debug level. `logging.include_pii` enables the development-only exception with a startup warning; portraits and raw biometric data remain excluded (§2.4). | | **One-time-use sessions** | Liveness entries and credential offers are deleted on first access (`TakeLiveness`, `TakeOffer`). An attacker cannot replay a session ID. | | **Rate limiting** | Biometric endpoints are rate-limited per source IP to slow automated bulk enumeration or scraping. | diff --git a/internal/apiv1/chip.go b/internal/apiv1/chip.go index 63b0ed4..ec7d807 100644 --- a/internal/apiv1/chip.go +++ b/internal/apiv1/chip.go @@ -125,13 +125,18 @@ func bindPortraitToChip(id *facetec.IDScanResult, verifiedDGs []int) { // config.Validate refuses anything outside 0–10. const defaultMinChipFaceMatchLevel = 6 +// maxChipFaceMatchLevel is the top of FaceTec's match level scale. +const maxChipFaceMatchLevel = 10 + // 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). +// for which FaceTec reported no chip match, or a level outside FaceTec's 0–10 +// scale, is refused too (fail closed): a malformed score is not read as an +// exceptionally good match. func chipFaceRejection(id facetec.IDScanResult, minLevel int) (code idverrors.Code, msg string, rejected bool) { if minLevel < 1 { minLevel = defaultMinChipFaceMatchLevel @@ -139,6 +144,9 @@ func chipFaceRejection(id facetec.IDScanResult, minLevel int) (code idverrors.Co if id.ChipFaceMatchLevel == nil { return idverrors.CodeChipPhotoMismatch, "the face could not be compared with the photo on the document's chip", true } + if level := *id.ChipFaceMatchLevel; level < 0 || level > maxChipFaceMatchLevel { + 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 } diff --git a/internal/apiv1/chip_face_test.go b/internal/apiv1/chip_face_test.go index fa79a1a..7be1919 100644 --- a/internal/apiv1/chip_face_test.go +++ b/internal/apiv1/chip_face_test.go @@ -30,6 +30,9 @@ func TestChipFaceRejection(t *testing.T) { {"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}, + {"top of the scale", intPtr(10), 6, false}, + {"above the 0–10 scale", intPtr(11), 6, true}, + {"negative", intPtr(-1), 0, true}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/apiv1/liveness_expiry_test.go b/internal/apiv1/liveness_expiry_test.go index 2638651..913da09 100644 --- a/internal/apiv1/liveness_expiry_test.go +++ b/internal/apiv1/liveness_expiry_test.go @@ -164,7 +164,6 @@ func TestProcessRequest_ExpiryGateAfterChipGate(t *testing.T) { payload := `{ "idScanResultsSoFar": { "photoIDNextStepEnumInt": 4, "matchLevel": 7, "matchLevelNFCToFaceMap": 7, - "matchLevelNFCToFaceMap": 7, "nfcStatusEnumInt": 2, "nfcAuthenticationStatusEnumInt": 0, "mrzStatusEnumInt": 2, "barcodeStatusEnumInt": 3, "documentData": {"documentType": "passport", "dateOfExpiry": "2020-01-31"} diff --git a/internal/facetec/process_request.go b/internal/facetec/process_request.go index 9d35d8e..f14f4e1 100644 --- a/internal/facetec/process_request.go +++ b/internal/facetec/process_request.go @@ -92,10 +92,10 @@ func ExtractScanResult(payload map[string]any) (*ScanResult, bool, error) { // 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). + // is kept apart from 0; a present value of the wrong type, including a + // numeric string, is an error (fail closed), as in the legacy decoder. var chipFaceMatchLevel *int - if level, present, err := lookupInt(results["matchLevelNFCToFaceMap"]); err != nil { + if level, present, err := lookupEnumInt(results["matchLevelNFCToFaceMap"]); err != nil { return nil, false, fmt.Errorf("facetec: matchLevelNFCToFaceMap: %w", err) } else if present { chipFaceMatchLevel = &level diff --git a/internal/facetec/process_request_test.go b/internal/facetec/process_request_test.go index 713e037..a49f677 100644 --- a/internal/facetec/process_request_test.go +++ b/internal/facetec/process_request_test.go @@ -1,6 +1,7 @@ package facetec import ( + "fmt" "testing" ) @@ -576,11 +577,13 @@ func TestExtractScanResult_ChipFaceMatchLevel(t *testing.T) { } }) - 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") - } - }) + for _, v := range []any{"seven", "7", 7.5, true} { + t.Run(fmt.Sprintf("wrong type %T %v fails closed", v, v), func(t *testing.T) { + p := realPayload() + p["idScanResultsSoFar"].(map[string]any)["matchLevelNFCToFaceMap"] = v + if _, _, err := ExtractScanResult(p); err == nil { + t.Errorf("want an error for matchLevelNFCToFaceMap %#v", v) + } + }) + } }