Skip to content
Merged
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
6 changes: 4 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

---
Expand All @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
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 @@ -315,6 +316,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
37 changes: 37 additions & 0 deletions internal/apiv1/chip.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,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),
Comment thread
s-jairl marked this conversation as resolved.
Resolved
zap.Bool("chip_trusted", id.ChipTrusted),
zap.String("chip_trust_reason", id.ChipTrustReason),
zap.Int("chip_auth_status", id.ChipAuthStatus),
Expand All @@ -116,6 +121,38 @@ 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

// 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, 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
}
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
}
return "", "", false
}

// applyChipIdentity replaces the OCR'd identity fields with the chip's values.
// The chip is authoritative for every one of them, including the ones it
// leaves empty: a holder with a single name has no given name, an MRZ date of
Expand Down
148 changes: 148 additions & 0 deletions internal/apiv1/chip_face_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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},
{"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) {
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 @@ -66,6 +66,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 @@ -175,7 +176,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, 6)
assert.Len(t, fields, 7)
}

// An OCR'd field that differs from the chip no longer makes the chip
Expand Down
22 changes: 22 additions & 0 deletions internal/apiv1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,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 @@ -326,6 +335,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
4 changes: 3 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,7 @@ func TestProcessRequest_DocumentExpiry(t *testing.T) {
func TestProcessRequest_ExpiryGateAfterChipGate(t *testing.T) {
payload := `{
"idScanResultsSoFar": {
"photoIDNextStepEnumInt": 4, "matchLevel": 7,
"photoIDNextStepEnumInt": 4, "matchLevel": 7, "matchLevelNFCToFaceMap": 7,
"nfcStatusEnumInt": 2, "nfcAuthenticationStatusEnumInt": 0,
"mrzStatusEnumInt": 2, "barcodeStatusEnumInt": 3,
"documentData": {"documentType": "passport", "dateOfExpiry": "2020-01-31"}
Expand Down Expand Up @@ -210,6 +211,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
Loading
Loading