"DOC identity verification — 4 methods (WebAuthn / VNeID / SMS-OTP / email-link) with per-document method selection + audit"
§1 — Description (BCP-14 normative)
The DOC service MUST ship identity verification at services/doc/src/verification/ supporting 4 methods with eIDAS level mapping, immutable audit, 4 memory audit kinds.
- MUST support 4 methods per DEC-1740 — webauthn / vneid / sms_otp / email_link.
- MUST validate
verification_methodagainst closed enum per DEC-1741,verification_resultper DEC-1742.
- MUST dispatch per method:
webauthn_handler.rs::challenge()+verify(response)— FIDO2/passkeyvneid_handler.rs::redirect_to_vneid()+callback(token)— VN national ID via gov OAuthotp_handler.rs::send_otp(phone)+verify(code)— 6-digit SMS codeemail_link_handler.rs::send_link(email)+verify(token)— magic-link
- MUST map to eIDAS assurance level per DEC-1743:
- webauthn → 'high'
- vneid → 'substantial'
- sms_otp / email_link → 'low'
- MUST enforce minimum level per document per DEC-1743 — if document requires 'substantial', sms_otp rejected.
- MUST define audit table at migration
0005: ``sql CREATE TABLE doc_identity_verifications ( verification_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, document_id UUID NOT NULL, signer_id UUID NOT NULL, method TEXT NOT NULL CHECK (method IN ('webauthn','vneid','sms_otp','email_link')), result TEXT NOT NULL CHECK (result IN ('verified','failed_invalid','failed_expired','failed_no_match')), assurance_level TEXT NOT NULL CHECK (assurance_level IN ('low','substantial','high')), challenge_id TEXT, verified_at TIMESTAMPTZ, failure_reason TEXT, ip_address TEXT, -- hashed per TASK-MEMORY-111 user_agent TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX verifications_doc_signer_idx ON doc_identity_verifications(tenant_id, document_id, signer_id); ALTER TABLE doc_identity_verifications ENABLE ROW LEVEL SECURITY; CREATE POLICY verif_rls ON doc_identity_verifications USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON doc_identity_verifications FROM cyberos_app; -- Audit immutable per DEC-1744``
- MUST expose endpoints: ``
text POST /v1/doc/documents/{id}/verify/start body: {method, signer_id} POST /v1/doc/documents/{id}/verify/complete body: {challenge_id, response_data} GET /v1/doc/documents/{id}/verifications (signer-scoped list)``
- MUST emit 4 memory audit kinds per DEC-1745. PII per TASK-MEMORY-111: ip_address SHA-256 hashed; signer_id (uuid) ok.
- MUST thread trace_id from start → complete → audit.
- MUST enforce challenge expiry: webauthn 5min, otp 10min, email_link 24h.
- MUST NOT allow lower-than-required assurance level per DEC-1743.
- MUST NOT mutate verification audit per DEC-1744.
§2 — Why this design
Why 4 methods (DEC-1740)? Covers global (webauthn/email), VN (vneid), and fallback (sms_otp); each tenant picks per-document.
Why eIDAS levels (DEC-1743)? EU contracts need 'substantial' or 'high' for legal validity; mapping enables enforcement.
Why immutable audit (DEC-1744)? Court evidence: years later, must prove who verified, when, how.
Why per-method handler files (DEC-1740)? Each has distinct protocols (FIDO2 vs OAuth vs SMS vs magic-link); separation enables independent testing.
§3 — API contract
POST /v1/doc/documents/{id}/verify/start
POST /v1/doc/documents/{id}/verify/complete
GET /v1/doc/documents/{id}/verifications
Sample start:
{
"method": "webauthn",
"signer_id": "uuid"
}
Response:
{
"challenge_id": "uuid",
"challenge_data": "base64-challenge-bytes",
"expires_at": "2026-05-17T10:05:00Z"
}
§4 — Acceptance criteria
- 4 methods enum + cardinality test. 2. 4 results enum + cardinality test. 3. Assurance levels mapped correctly. 4. Min level enforced per document. 5. WebAuthn FIDO2 flow works. 6. VNeID OAuth callback works. 7. SMS OTP 6-digit format. 8. Email magic-link token. 9. Challenge expiry enforced (5/10/24min/h). 10. 4 memory audit kinds emitted. 11. PII scrubbed (IP SHA256). 12. RLS denies cross-tenant. 13. Audit immutable (no UPDATE/DELETE grant). 14. Trace_id preserved. 15. Multiple verifications per signer allowed (retry on fail). 16. Multiple signers per doc. 17. Verification status visible to AM. 18. Failed reasons categorized (invalid/expired/no_match). 19. Challenge_id one-time use. 20. eIDAS level downgrade attempt rejected.
§5 — Verification
#[tokio::test]
async fn webauthn_high_assurance() {
let ctx = TestContext::with_webauthn_credential().await;
let r = ctx.verify_webauthn(ctx.doc_id, ctx.signer_id).await;
assert_eq!(r.result, "verified");
assert_eq!(r.assurance_level, "high");
}
#[tokio::test]
async fn sms_otp_low_level() {
let ctx = TestContext::with_phone().await;
let start = ctx.start_verify(ctx.doc_id, "sms_otp").await;
let r = ctx.complete_verify(start.challenge_id, "123456").await;
assert_eq!(r.assurance_level, "low");
}
#[tokio::test]
async fn min_level_enforced() {
let ctx = TestContext::doc_requires_substantial().await;
let r = ctx.try_verify_method(ctx.doc_id, "sms_otp").await;
assert!(r.is_err()); // sms_otp is 'low', doc requires 'substantial'
}
#[tokio::test]
async fn challenge_expiry() {
let ctx = TestContext::otp_started().await;
ctx.advance_time(Duration::minutes(11)).await;
let r = ctx.complete_verify(ctx.challenge_id, "123456").await;
assert_eq!(r.result, "failed_expired");
}
// 5.5..5.10
§7 — Dependencies
Upstream: TASK-AUTH-105. Downstream: TASK-DOC-005 (multi-party signing uses this). Cross-module: TASK-MEMORY-111 (PII), TASK-DOC-001 (document RLS context).
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Challenge expired | timestamp check | failed_expired; new challenge | retry |
| OTP code wrong | compare | failed_invalid | retry within window |
| WebAuthn signature invalid | verify | failed_invalid | retry |
| VNeID OAuth denied | callback err | failed_no_match | retry |
| Email link reuse | one-time-use flag | failed_invalid | new link |
| SMS provider down | retry | sev-2; fall back to email | inherent |
| Phone number changed | challenge to current | inherent | data update |
| WebAuthn no credential | enroll first | sev-2; pick other method | enroll |
| eIDAS level downgrade attempt | reject | 403 | use higher method |
| Cross-tenant verification | RLS | 404 | inherent |
§11 — Implementation notes
- §11.1 WebAuthn via
webauthn-rscrate; RPID = tenant domain. - §11.2 VNeID: integrate via official VN gov OAuth (placeholder until contracted).
- §11.3 OTP: 6-digit; Twilio for global, VN provider (Esms.vn) for VN tenants.
- §11.4 Email-link: signed token; 24h expiry; one-time-use.
- §11.5 memory audit body: doc_id, signer_id, method, result, assurance_level; IP SHA256.
End of TASK-DOC-006 spec.