Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions configs/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
29 changes: 29 additions & 0 deletions internal/apiv1/chip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
}
145 changes: 145 additions & 0 deletions internal/apiv1/chip_face_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
3 changes: 2 additions & 1 deletion internal/apiv1/chip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions internal/apiv1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion internal/apiv1/liveness_expiry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func expiryPayload(expiry string) string {
"idScanResultsSoFar": {
"photoIDNextStepEnumInt": 4,
"matchLevel": 7,
"matchLevelNFCToFaceMap": 7,
"nfcStatusEnumInt": 4,
"nfcAuthenticationStatusEnumInt": 4,
"mrzStatusEnumInt": 2,
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions internal/apiv1/process_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const nfcSkippedPayload = `{
"idScanResultsSoFar": {
"photoIDNextStepEnumInt": 4,
"matchLevel": 7,
"matchLevelNFCToFaceMap": 7,
"nfcStatusEnumInt": 2,
"nfcAuthenticationStatusEnumInt": 0,
"mrzStatusEnumInt": 2,
Expand All @@ -62,6 +63,7 @@ const nfcCompletedPayload = `{
"idScanResultsSoFar": {
"photoIDNextStepEnumInt": 4,
"matchLevel": 7,
"matchLevelNFCToFaceMap": 7,
"nfcStatusEnumInt": 4,
"nfcAuthenticationStatusEnumInt": 4,
"mrzStatusEnumInt": 2,
Expand All @@ -81,6 +83,7 @@ func nfcPayload(nfcStatus, nfcAuthStatus int) string {
"idScanResultsSoFar": {
"photoIDNextStepEnumInt": 4,
"matchLevel": 7,
"matchLevelNFCToFaceMap": 7,
"nfcStatusEnumInt": %d,
"nfcAuthenticationStatusEnumInt": %d,
"mrzStatusEnumInt": 2,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading