EVO Wallet Verifier

Ghid tehnic și implementare
Arhitectură, protocol, verificări, instalare
Versiune 1.0 | Februarie 2026 | CONFIDENȚIAL
Cuprins

1. Prezentare generală

1.1 Arhitectura sistemului

EVO Wallet Verifier este o aplicație PHP server-side care implementează protocolul OpenID for Verifiable Presentations (OID4VP) 1.0 în modul cross-device cu response_mode=direct_post.jwt. Comunicația cu portofelul EVO are loc exclusiv prin HTTP/HTTPS, fără canal Bluetooth sau NFC.

Sistemul interacționează cu următoarele entități externe:

EntitateProtocolScop
Portofelul EVO OID4VP 1.0 / HTTPS Solicită credențiale, primește răspuns criptat
AGE (emitent) Certificate X.509 preinstalate Ancoră de încredere (root CA)
Status List provider HTTPS (JWT sau CWT) Verificare revocare credențiale (RFC 9596)
OCSP responder HTTP POST (RFC 6960) Verificare revocare certificate emitent
CRL distribution point HTTPS (DER/PEM) Fallback revocare certificate
TSA HTTP POST (RFC 3161) Mărci temporale pe evidence

1.2 Endpoint-uri HTTP

Toate rutele sunt relative la BASE_URL din configurație (ex: https://server.example.com/evo).

MetodăRutăDescriere
GET / Pagină selecție ghișeu (listă toate tellerele)
GET /ghiseu/{tellerId} Interfață operator - afișează QR static, coada de prezentări
POST /wallet/teller/{tellerId}/request Wallet trimite wallet_metadata + wallet_nonce, primește JWS
POST /wallet/response/{transactionId} Wallet trimite response (JWE compact) + state
GET /queue/{tellerId} Polling UI - returnează JSON cu prezentări recente
POST /api/tellers/{tellerId}/settings Configurări teller: auto_approve, retention
GET /evidence/{id} Descarcă evidence JSON (Content-Disposition: attachment)
GET /evidence/{id}/jws Descarcă evidence JWS (Content-Type: application/jose)
GET /verify Pagină re-verificare evidence (UI upload)
POST /verify Acceptă evidence JSON/JWS, re-execută toate verificările

2. Generarea codului QR

Fiecare ghișeu afișează un cod QR static - nu se schimbă la fiecare tranzacție. QR-ul codifică un URI de engagement conform OID4VP 1.0, care instruiește portofelul EVO să contacteze server-ul verificatorului pentru a obține cererea de prezentare.

2.1 Formatul URI de engagement

eudi-openid4vp://?client_id=x509_hash:<base64url_SHA256_DER>
                 &request_uri=https://server.example.com/evo/wallet/teller/ghiseu-1/request
                 &request_uri_method=post
ParametruValoareDescriere
client_id x509_hash:<hash> Identificator verificator bazat pe certificatul X.509 (secțiunea 2.2)
request_uri {BASE_URL}/wallet/teller/{tellerId}/request URL unde wallet-ul trimite POST pentru a obține Authorization Request
request_uri_method post Wallet-ul trimite metadata și nonce prin POST (nu GET)

Schema URI (eudi-openid4vp://) este configurabilă prin variabila WALLET_URI_SCHEME. QR-ul se generează cu biblioteca chillerlan/php-qrcode, ECC level M, scale 10.

2.2 Calculul client_id (x509_hash)

Formatul x509_hash identifică verificatorul prin hash-ul certificatului său X.509, conform OID4VP 1.0 § 5.3.3:

// 1. Citeste certificatul PEM al verificatorului
$pem = file_get_contents('certs/verifier.pem');

// 2. Normalizeaza prin re-export OpenSSL
openssl_x509_export(openssl_x509_read($pem), $normalized);

// 3. Extrage bytes DER (strip PEM headers, base64 decode)
$der = base64_decode(preg_replace('/-----.+-----|\s/', '', $normalized));

// 4. SHA-256 peste DER bytes
$hash = hash('sha256', $der, true);  // 32 bytes raw

// 5. Base64url encode (fara padding)
$b64 = rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');

// Rezultat: "x509_hash:abCdEf12..."
$clientId = 'x509_hash:' . $b64;
De ce x509_hash? Wallet-ul poate verifica identitatea verificatorului comparând hash-ul din client_id cu certificatul din headerul x5c al JWS-ului primit. Nu necesită înregistrare prealabilă a verificatorului la un autoritate centrală.

3. Authorization Request (JAR)

După scanarea QR-ului, portofelul EVO inițiază protocolul OID4VP prin trimiterea unei cereri POST către request_uri. Server-ul răspunde cu un JWT Authorization Request semnat (JWS), conform RFC 9101 (JWT-Secured Authorization Request).

3.1 Inițierea cererii de către wallet

WALLET POST /wallet/teller/{tellerId}/request VERIFIER

Request body (application/x-www-form-urlencoded):

ParametruTipDescriere
wallet_metadata JSON string Capabilitățile wallet-ului (formate suportate, algoritmi)
wallet_nonce String Nonce generat de wallet pentru legarea sesiunii

Server-side processing:

  1. Validează că tellerId există în configurația teller-elor
  2. Creează tranzacție nouă: $id = bin2hex(random_bytes(16)) - 32 caractere hex
  3. Generează nonce tranzacție: $nonce = bin2hex(random_bytes(16))
  4. Generează cheie efemeră ECDH-ES (P-256) + UUID kid
  5. Construiește payload JWT (secțiunea 3.2)
  6. Semnează cu ES256 folosind cheia privată a verificatorului
  7. Salvează tranzacția cu status request_sent
VERIFIER HTTP 200 • Content-Type: application/oauth-authz-req+jwt WALLET

Response body: JWS compact serialization (header.payload.signature)

3.2 Structura payload-ului JWT

{
  "response_uri":    "{BASE_URL}/wallet/response/{transactionId}",
  "aud":             "https://wallet.staging.egov.md",
  "wallet_nonce":    "{wallet_nonce din POST}",
  "response_type":   "vp_token",
  "state":           "{transactionId}",
  "nonce":           "{transaction_nonce, 32 hex}",
  "iat":             1708430400,
  "client_id":       "x509_hash:...",
  "response_mode":   "direct_post.jwt",
  "dcql_query":      { /* vezi sectiunea 3.5 */ },
  "client_metadata": {
    "jwks": {
      "keys": [{
        "kty": "EC",  "crv": "P-256",
        "x": "...",    "y": "...",
        "kid": "{uuid4}",
        "use": "enc",  "alg": "ECDH-ES"
      }]
    },
    "vp_formats_supported": {
      "mso_mdoc": {
        "issuerauth_alg_values": [-7, -35, -36],
        "deviceauth_alg_values": [-7, -35, -36]
      }
    },
    "encrypted_response_enc_values_supported": ["A256GCM"]
  }
}
Observații:

3.3 Generarea cheii efemere ECDH-ES

// Genereaza pereche EC P-256
$key = openssl_pkey_new([
    'curve_name'      => 'prime256v1',
    'private_key_type' => OPENSSL_KEYTYPE_EC,
]);

// Extrage coordonate x, y, d (base64url encoded)
$details = openssl_pkey_get_details($key);
$public  = ['kty'=>'EC', 'crv'=>'P-256', 'x'=>$x, 'y'=>$y];
$private = ['kty'=>'EC', 'crv'=>'P-256', 'x'=>$x, 'y'=>$y, 'd'=>$d];

// kid = UUID v4 (16 random bytes, variant/version bits set per RFC 4122)
$kid = generateUuid4();

Cheia privată (d) este salvată în sesiunea tranzacției și folosită la decriptarea JWE. După decriptare, cheia nu mai este necesară. Fiecare tranzacție folosește o cheie unică.

3.4 Semnarea JWS (ES256)

JWS protected header:

{
  "alg": "ES256",
  "typ": "oauth-authz-req+jwt",
  "x5c": [
    "MIIBxTCCAW...certificat_leaf_base64...",
    "MIICATCCAa...certificat_CA_base64..."
  ]
}
ElementDetalii
alg ECDSA cu SHA-256 pe curba P-256 (NIST)
typ Media type conform RFC 9101 (JWT-Secured Authorization Request)
x5c Lanț de certificate X.509 (standard base64, NU base64url) - leaf primul, root ultimul

Semnarea se face cu cheia privată EC din certs/verifier.key prin biblioteca web-token/jwt-signature. Wallet-ul verifică semnătura folosind certificatul din x5c și validează că hash-ul său corespunde cu client_id.

3.5 Interogarea DCQL

Cererea de prezentare folosește formatul DCQL (Digital Credentials Query Language):

{
  "credentials": [{
    "id":     "pid_query",
    "format": "mso_mdoc",
    "meta":   { "doctype_value": "md.gov.wallet.pid.1" }
  }]
}

Interogarea solicită o credențială de tip md.gov.wallet.pid.1 (PID moldovenesc) în format mso_mdoc (ISO 18013-5). Wallet-ul returnează toate elementele disponibile din namespace-ul md.gov.wallet.

Elementele așteptate (20 câmpuri):

GrupElemente
Identitate family_name, given_name, idnp, birth_date, sex, nationality
Biometrice portrait, signature / signature_usual_mark
Adresă resident_address, resident_country_name, resident_region, resident_city, resident_street, resident_house_number
Document document_type, document_series, document_number, issue_date, expiry_date, issuing_authority

4. Wallet Response

După ce utilizatorul confirmă prezentarea pe dispozitiv, wallet-ul criptează răspunsul folosind cheia efemeră din cerere și îl trimite ca JWE compact la response_uri.

4.1 Recepția răspunsului criptat

WALLET POST /wallet/response/{transactionId} VERIFIER

Request body (application/x-www-form-urlencoded):

ParametruTipDescriere
response JWE compact Răspuns criptat: 5 părți separate prin .
state String Trebuie să corespundă cu transactionId

Dacă wallet-ul raportează eroare, trimite error + error_description în loc de response. Server-ul setează tranzacția pe status=failed.

4.2 Decriptarea JWE

JWE compact serialization: header.encrypted_key.iv.ciphertext.tag

Validarea headerului JWE:

CâmpValoare așteptatăVerificare
alg ECDH-ES Algoritmul de agreare a cheii
enc A256GCM Criptare simetrică (sau A128GCM)
apv {transaction_nonce} Se compară cu nonce-ul din tranzacție (plain sau base64url)
kid {ephemeral_key_kid} UUID-ul cheii efemere generate la pasul 3

Decriptarea folosește biblioteca web-token/jwt-encryption cu algoritm de cheie ECDHES, criptare conținut A256GCM, și compresie Deflate. Cheia destinatar este cheia privată efemeră din sesiunea tranzacției.

Payload decriptat (JSON):

{
  "vp_token": "<base64url DeviceResponse CBOR>",
  "state":    "{transactionId}"
}

vp_token poate fi un string (singur DeviceResponse) sau un obiect {"pid_query": "<base64url>"} (indexat pe query ID din DCQL).

4.3 Parsarea DeviceResponse (CBOR)

vp_token este base64url-decoded în bytes CBOR, apoi parsat cu spomky-labs/cbor-php v2. Structura conform ISO 18013-5:

DeviceResponse = {
  "version":   "1.0",                          // obligatoriu
  "status":    0,                               // 0 = OK
  "documents": [Document, ...],
  "documentErrors": [...]                      // optional, trebuie absent
}

Document = {
  "docType": "md.gov.wallet.pid.1",
  "issuerSigned": {
    "nameSpaces": {
      "md.gov.wallet": [                     // array de IssuerSignedItemBytes
        #6.24(bstr .cbor IssuerSignedItem),
        ...
      ]
    },
    "issuerAuth": COSE_Sign1                 // [protected, unprotected, payload, sig]
  },
  "deviceSigned": {
    "nameSpaces": #6.24(bstr .cbor {}),     // de obicei gol
    "deviceAuth": {
      "deviceSignature": COSE_Sign1            // [protected, {}, null, signature]
    }
  }
}

IssuerSignedItem = {
  "digestID":           int,                // index in MSO valueDigests
  "random":             bstr,               // random salt (16+ bytes)
  "elementIdentifier":  tstr,               // ex: "family_name"
  "elementValue":       any                 // valoarea efectiva
}
Atenție CBOR: Tag 24 (embedded CBOR) are comportament fragil în cbor-php v2. IssuerSignedItemBytes pot sosi ca: raw bytes, Tag24-framed bytes, sau obiecte CBOR deja decodate. Parser-ul implementează 5 strategii de unwrap pentru a acoperi toate variantele.

5. Verificări criptografice

După parsarea DeviceResponse, server-ul execută 7 verificări criptografice independente. Fiecare verificare produce un rezultat pass / fail / warning care este inclus în evidence.

5.A Semnătura emitentului (Issuer Signature)

issuerAuth este un COSE_Sign1 (RFC 9052) care semnează Mobile Security Object (MSO). Structura:

COSE_Sign1 = [
  protected:   bstr,       // CBOR-encoded header {1: alg, 33: x5chain}
  unprotected: {},          // de obicei gol
  payload:     bstr,       // MSO encoded CBOR (Tag 24 wrapped)
  signature:   bstr        // ECDSA r||s concatenation
]

Extracția certificatului emitentului

Protected header, label 33 (x5chain): conține certificatul X.509 DER al emitentului. Poate fi un singur bstr (leaf) sau un array [leaf, intermediate, ...].

Construcția Sig_structure

Sig_structure = [
  "Signature1",      // CBOR text string (major type 3, 0x6a prefix)
  protectedBytes,    // CBOR byte string (raw protected header bytes)
  b"",               // external_aad = empty byte string (0x40)
  msoPayload         // CBOR byte string (MSO bytes)
]
CRITIC: Context-ul "Signature1" TREBUIE codificat ca CBOR text string (major type 3, prefix 0x6a pentru 10 caractere). Multe implementări îl codifică greșit ca byte string (major type 2, prefix 0x4a), ceea ce invalidează semnătura.

Verificarea semnăturii ECDSA

COSE algAlgoritmOpenSSLCurbăr/s bytes
-7ES256OPENSSL_ALGO_SHA256P-25632
-35ES384OPENSSL_ALGO_SHA384P-38448
-36ES512OPENSSL_ALGO_SHA512P-52166

Semnătura COSE este în format IEEE P1363 (r||s concatenat). Se convertește în DER (ASN.1 SEQUENCE { INTEGER r, INTEGER s }) pentru OpenSSL:

// r si s se extrag din semnatura (32/48/66 bytes fiecare)
$r = substr($signature, 0, $keySize);
$s = substr($signature, $keySize, $keySize);

// Conversie la DER: strip leading zeros, prepend 0x00 daca high bit set
// DER: 0x30 <len> 0x02 <len_r> <r> 0x02 <len_s> <s>

openssl_verify($sigStructureBytes, $derSignature, $issuerPublicKey, $algo);

5.B Validarea MSO (Mobile Security Object)

Payload-ul COSE_Sign1 conține MSO-ul, structură CBOR definită în ISO 18013-5 §9.1.2:

MSO = {
  "version":        "1.0",
  "digestAlgorithm": "SHA-256",
  "valueDigests":   {
    "md.gov.wallet": {
      0: bstr,    // SHA-256 hash al IssuerSignedItem cu digestID=0
      1: bstr,    // SHA-256 hash al IssuerSignedItem cu digestID=1
      ...
    }
  },
  "deviceKeyInfo":  {
    "deviceKey": COSE_Key,    // cheia publica a dispozitivului
    "keyAuthorizations": { ... },
    "statusList": {           // optional (ISO 18013-7)
      "uri": "https://...",
      "idx": 42
    }
  },
  "validityInfo":   {
    "signed":    tdate,     // momentul semnarii
    "validFrom": tdate,     // inceputul validitatii
    "validUntil": tdate     // sfarsitul validitatii
  },
  "status":         {          // optional (EUDI ARF)
    "status_list": { "uri": "...", "idx": 42 }
  }
}

Verificări:

  1. version = "1.0"
  2. docType corespunde cu cel așteptat (md.gov.wallet.pid.1)
  3. validFromnowvalidUntil
  4. digestAlgorithm este suportat (SHA-256 / SHA-384 / SHA-512)

5.C Validarea lanțului de certificate

Certificatul emitentului (din x5chain, label 33) este validat conform ISO 18013-5 §9.5:

#VerificareCriteriu
1Validitate temporală notBeforenownotAfter
2Durata maximă (notAfter - notBefore) / 86400 ≤ 457 zile
3Key Usage Trebuie să conțină Digital Signature
4Extended Key Usage Trebuie să conțină 1.0.18013.5.1.2 (mdlDS)
5Extensii interzise NU: Name Constraints (2.5.29.30), Policy Mappings (2.5.29.33), Policy Constraints (2.5.29.36), Freshest CRL (2.5.29.46), Inhibit Any Policy (2.5.29.54)
6Algoritm semnătură ECDSA obligatoriu: ecdsa-with-SHA256 / SHA384 / SHA512
7Lanț de încredere openssl verify -CAfile <trusted> -untrusted <intermediates> <leaf>
8AKI/SKI Leaf authorityKeyIdentifier = CA subjectKeyIdentifier
9Jurisdicție Leaf și CA: C și ST trebuie să coincidă
10DN match Leaf issuer CN, O, C = CA subject CN, O, C
11MSO signed time mso.validityInfo.signed în fereastra de validitate a certificatului
12Revocare OCSP (preferabil) și/sau CRL (secțiunea 5.G)

Certificatele root de încredere sunt preinstalate în directorul certs/trusted-issuers/. Fiecare fișier PEM poate conține unul sau mai multe certificate (bundle).

5.D Integritatea elementelor (Digest Verification)

Fiecare IssuerSignedItem are un digestID corespunzător în MSO.valueDigests. Verificarea:

// Pentru fiecare IssuerSignedItem din nameSpaces["md.gov.wallet"]:

// 1. Obtine bytes-ii CBOR originali ai item-ului (Tag 24 wrapped)
$itemBytes = getOriginalCborBytes(item);

// 2. Calculeaza hash
$computed = hash('sha256', $itemBytes, true);

// 3. Compara cu valoarea din MSO
$expected = $mso['valueDigests']['md.gov.wallet'][item['digestID']];

assert($computed === $expected);  // byte-exact match

Această verificare demonstrează că fiecare element de date (nume, dată naștere, etc.) corespunde exact cu ceea ce a semnat emitentul. Modificarea oricărui octet invalidează hash-ul.

5.E Semnătura dispozitivului (Device Signature)

Semnătura dispozitivului dovedește că prezentarea a fost generată de telefonul pe care sunt stocate credențialele, nu de un atacator care a interceptat datele. Este un COSE_Sign1 detached - payload-ul este null, datele semnate se reconstruiesc din context.

Construcția SessionTranscript (OID4VP 1.0)

// Pas 1: OpenID4VPHandoverInfo
HandoverInfo = [
  clientId,        // tstr: "x509_hash:..."
  nonce,           // tstr: nonce-ul tranzactiei
  jwkThumbprint,   // bstr (32 bytes SHA-256) sau null
  responseUri      // tstr: "{BASE_URL}/wallet/response/{id}"
]

// Pas 2: Hash peste CBOR-encoded HandoverInfo
infoHash = SHA-256(CBOR(HandoverInfo))   // 32 bytes raw

// Pas 3: OpenID4VPHandover
Handover = [
  "OpenID4VPHandover",   // CBOR text string
  infoHash               // CBOR byte string (32 bytes)
]

// Pas 4: SessionTranscript
SessionTranscript = [null, null, Handover]

// Pas 5: DeviceAuthentication
DeviceAuthentication = [
  "DeviceAuthentication",   // CBOR text string
  SessionTranscript,
  docType,                  // tstr: "md.gov.wallet.pid.1"
  DeviceNameSpacesBytes     // Tag 24 wrapped
]

// Pas 6: Wrap in Tag 24
DeviceAuthenticationBytes = #6.24(bstr .cbor DeviceAuthentication)
// = 0xD8 0x18 <bstr-encoded CBOR(DeviceAuthentication)>

Calculul JWK Thumbprint (RFC 7638)

// Membrii EC in ordine lexicografica (RFC 7638 §3.2)
$input = json_encode([
  "crv" => $jwk['crv'],   // "P-256"
  "kty" => $jwk['kty'],   // "EC"
  "x"   => $jwk['x'],     // base64url X coordinate
  "y"   => $jwk['y']      // base64url Y coordinate
]);
$thumbprint = hash('sha256', $input, true);  // 32 bytes raw

Sig_structure pentru Device Signature

Sig_structure = [
  "Signature1",                // CBOR text string (0x6a prefix)
  protectedHeader,              // CBOR byte string
  b"",                          // external_aad (0x40)
  DeviceAuthenticationBytes    // CBOR byte string (Tag 24 encoded)
]

Conversia COSE_Key în cheie publică OpenSSL

// COSE_Key labels:
//   1 = kty (2 = EC2)
//  -1 = crv (1 = P-256, 2 = P-384, 3 = P-521)
//  -2 = x coordinate (bstr)
//  -3 = y coordinate (bstr)

// Uncompressed EC point:
$point = "\x04" . $x . $y;

// SubjectPublicKeyInfo DER:
// SEQUENCE {
//   SEQUENCE { OID ecPublicKey (1.2.840.10045.2.1), OID curve }
//   BIT STRING { 0x00 || point }
// }
//
// OID P-256: 06 08 2A 86 48 CE 3D 03 01 07
// OID P-384: 06 05 2B 81 04 00 22
// OID P-521: 06 05 2B 81 04 00 23

Verificarea se face cu openssl_verify() pe Sig_structure encoded CBOR.

5.F Starea de revocare - Status List (RFC 9596)

MSO-ul conține o referință la status list: un URI și un index (poziția în bitstring). Referința se caută în două locații posibile:

Locație în MSOStandard
deviceKeyInfo.statusList.uri + .idx ISO 18013-7
status.status_list.uri + .idx EUDI ARF / RFC 9596

Auto-detecția formatului

Status list-ul descărcat poate fi în format JWT (text, puncte separatoare) sau CWT (binar, COSE_Sign1 tag 0xD2 sau array CBOR cu 4 elemente 0x84).

Parsare JWT

  1. Split pe ., decode base64url header și payload
  2. Verificare semnătură: certificat din x5c header, sau brute-force pe certificatele trusted
  3. Extragere status_list din payload: {"bits": N, "lst": "<base64url zlib>"}
  4. Extragere exp claim pentru cache TTL

Parsare CWT (COSE_Sign1)

  1. Decode CBOR: Tag(18) [protected, unprotected, payload, signature]
  2. Protected header: label 1 = algorithm, label 33 = x5chain
  3. Verificare semnătură COSE_Sign1 (aceeași Sig_structure ca la issuer)
  4. Payload = CBOR map: claim key 65533 = status_list
  5. Status list: {"bits": int, "lst": bstr} - lst = raw zlib bytes

Decompresie și extracție status

// Decompresie bitstring (3 strategii fallback)
$raw = base64url_decode($lst);
$bits = gzuncompress($raw)     // zlib
     ?: gzinflate($raw)        // raw DEFLATE
     ?: gzdecode($raw);        // gzip

// Extragere valoare la pozitia idx
$byteIndex = ($idx * $bitsPerEntry) / 8;
$bitPos    = ($idx * $bitsPerEntry) % 8;
$mask      = (1 << $bitsPerEntry) - 1;
$status    = (ord($bits[$byteIndex]) >> $bitPos) & $mask;
ValoareStatus
0x00VALID
0x01REVOCAT (INVALID)
0x02SUSPENDAT

5.G Revocarea certificatelor (OCSP / CRL)

Pe lângă verificarea revocării credențialelor (Status List), se verifică și starea certificatelor X.509 din lanțul emitentului.

OCSP (Online Certificate Status Protocol) - preferat

  1. Se extrage OCSP URL din extensia authorityInfoAccess a certificatului leaf
  2. Se generează cerere OCSP: openssl ocsp -issuer <ca> -cert <leaf> -reqout <file> -no_nonce
  3. Se trimite prin HTTP POST: curl -X POST -H "Content-Type: application/ocsp-request" --data-binary @<req> <url>
  4. Se parsează răspunsul: openssl ocsp -respin <resp>
  5. Rezultat: ": good" = valid, ": revoked" = revocat

Certificatul issuer pentru OCSP: din x5chain[1], sau matching din certs/trusted-issuers/ după DN.

CRL (Certificate Revocation List) - fallback

  1. Se extrage CRL URL din extensia crlDistributionPoints
  2. Se descărcă CRL-ul (cu cache): curl -o <file> <url>
  3. Se detectează formatul: PEM sau DER
  4. Se parsează: openssl crl -inform <PEM|DER> -text -noout
  5. Se caută numărul serial al certificatului leaf în lista de seriale revocate

Toate cererile HTTP folosesc curl cu suport opțional de proxy (HTTP_PROXY, HTTP_PROXY_AUTH din .env). Rezultatele sunt cache-uite cu TTL configurabil (REVOCATION_CACHE_TTL, implicit 3600s).

6. Generarea evidence-ului

După toate verificările criptografice, sistemul generează un evidence - un fișier JSON auto-verificabil care constituie proba de audit. Evidence-ul este protejat prin trei straturi: hash de integritate, marcă temporală TSA, și semnătură digitală JWS.

6.1 Structura JSON

{
  "evidence_version":     "2.0",
  "regulation":           "Regulament intern ...",
  "transaction_id":       "a1b2c3d4e5f6...",
  "verification_timestamp": "2026-02-20T14:30:00+02:00",
  "verifier": {
    "name":     "Numele Institutiei",
    "system":   "EVO Wallet Verifier v2.0",
    "base_url": "https://..."
  },
  "presenter": {
    "ip_address":   "...",
    "user_agent":   "...",
    "protocol":     "OpenID4VP 1.0 (cross-device)",
    "response_mode": "direct_post.jwt"
  },
  "encryption": {
    "algorithm":         "ECDH-ES",
    "encryption":         "A256GCM",
    "ephemeral_key_kid": "{uuid4}"
  },
  "oid4vp_session": {
    "client_id":      "x509_hash:...",
    "response_uri":   "...",
    "nonce":          "...",
    "jwk_thumbprint": "..."
  },
  "documents": [{
    "docType":     "md.gov.wallet.pid.1",
    "query_id":    "pid_query",
    "elements":    { "family_name": "...", ... },
    "elements_by_namespace": { "md.gov.wallet": { ... } },
    "cryptographic_verification": {
      /* rezultatele fiecarei verificari A-G */
    },
    "cryptographic_evidence": {
      /* certificate DER base64, MSO raw, etc. */
    }
  }],
  "vp_token_sha256": "...",
  "cryptographic_evidence": {
    "vp_token_raw": { "pid_query": ["<base64url CBOR>"] },
    "verification_instructions": { /* 42 pasi */ }
  },

  // Straturile de protectie (adaugate in ordine)
  "tsa_token":      "<base64 DER TimeStampResp>",
  "integrity_hash": "sha256:a1b2c3d4...",
  "jws_header":     "<base64url JWS header>",
  "jws_signature":  "<base64url JWS signature>"
}

6.2 Hash-ul de integritate (SHA-256)

// 1. Construieste JSON-ul evidence FARA campurile de protectie
$json = json_encode($evidence, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

// 2. Calculeaza SHA-256
$hash = hash('sha256', $json);  // hex string

// 3. Apoi adauga tsa_token (daca disponibil)
// 4. Adauga integrity_hash
$evidence['integrity_hash'] = 'sha256:' . $hash;
Ordinea contează: Hash-ul se calculează pe JSON-ul FĂRĂ tsa_token, integrity_hash, jws_header, jws_signature. La re-verificare, aceste câmpuri trebuie eliminate (via regex cu lookahead (?=\n\})) înainte de recalculare.

6.3 Marca temporală (RFC 3161)

Dacă TSA_URL este configurat, se solicită o marcă temporală de la un Time Stamping Authority:

// 1. Genereaza cerere TSA
openssl ts -query -digest $sha256Hex -sha256 -cert -no_nonce -out $reqFile

// 2. Trimite cererea
curl -s -S --max-time 15 \
     -H "Content-Type: application/timestamp-query" \
     --data-binary @$reqFile \
     -o $respFile $tsaUrl

// 3. Verifica raspunsul
openssl ts -reply -in $respFile -text
// Trebuie sa contina "Granted"

// 4. Salveaza DER bytes ca base64 in evidence
$evidence['tsa_token'] = base64_encode(file_get_contents($respFile));

Marca temporală demonstrează că hash-ul exista la un moment precis în timp, independent de ceasul instituției. La re-verificare, se validează lanțul TSA până la certificatele din certs/trusted-tsa/.

6.4 Semnătura JWS a verificatorului

// JWS protected header
{
  "alg": "ES256",
  "typ": "evidence+jwt",
  "x5c": ["<verifier cert base64>", ...]
}

// Payload = JSON complet CU tsa_token si integrity_hash
// FARA jws_header si jws_signature

// Se semneaza cu cheia privata a verificatorului (ES256)
$jws = sign($header, $payload);  // header.payload.signature

// Se embeds header si signature inapoi in JSON
$evidence['jws_header']    = explode('.', $jws)[0];
$evidence['jws_signature'] = explode('.', $jws)[2];

Output fișiere:

FișierConținut
data/evidence/{id}.json Evidence JSON complet (cu toate cele 4 câmpuri de protecție)
data/evidence/{id}.jws JWS compact serialization (header.payload.signature)

7. Re-verificarea evidence-ului

Endpoint-ul POST /verify acceptă un evidence JSON sau JWS și re-execută integral 10 verificări independente (A–J):

#VerificareCe se verifică
A Integrity Hash Se recalculează SHA-256 pe JSON fără integrity_hash, tsa_token, jws_header, jws_signature. Trebuie să corespundă cu valoarea stocată. Se folosesc 3 metode fallback pentru matching byte-exact.
B TSA Timestamp Se decodează tsa_token din base64 DER. Se parsează cu openssl ts -reply -text. Se verifică lanțul certificatelor TSA contra certs/trusted-tsa/ cu verificare EKU (timeStamping = 1.3.6.1.5.5.7.3.8). Se validează că hash-ul din token corespunde hash-ului evidence.
C JWS Signature Se reconstruiește JWS compact din jws_header + payload + jws_signature. Se verifică semnătura ES256 cu certificatul din x5c header. Se validează certificate pinning: certificatul DER din JWS trebuie să fie identic byte-cu-byte cu certs/verifier.pem.
D DeviceResponse Decode Se re-decodează vp_token_raw din base64url CBOR. Se verifică version="1.0", status=0, prezența documentelor.
E Issuer Signature Re-verificare COSE_Sign1 pe MSO cu certificatul din x5chain.
F MSO + Certificate Chain Validitate MSO, lanț certificate, EKU mdlDS, durata ≤ 457 zile.
G Device Signature Re-construcția SessionTranscript cu parametrii din oid4vp_session, re-verificare COSE_Sign1 detached.
H Revocation Status Status List + OCSP/CRL (dacă online).
I Element Cross-Check Se extrag elementele din CBOR raw și se compară cu cele din documents[].elements. Orice discrepanță indică manipulare a evidence-ului.
J Element Completeness Se verifică prezența câmpurilor obligatorii: family_name, given_name, birth_date, idnp/personal_identifier.

8. Ciclul de viață al tranzacției

Fiecare prezentare creează o tranzacție în data/sessions/{id}.json. ID-ul este bin2hex(random_bytes(16)) = 32 caractere hex alfanumerice.

Diagrama stărilor tranzacției
pending request_sent completed
Sau: pending expired | request_sent failed
StatusCondițieDate stocate
pending Creat (QR scanat, wallet a contactat request_uri) id, teller_id, nonce, created_at
request_sent JWS trimis către wallet + wallet_nonce, ephemeral_key_private, ephemeral_key_public, ephemeral_key_kid
completed Răspuns primit și verificat + vp_token, presentation_data, verification_meta
failed Wallet a raportat eroare sau verificarea a eșuat + error
expired Auto-detectat: pending și (time() - created_at) > TRANSACTION_EXPIRY Implicit 300 secunde (5 minute)

Sesiunile sunt fișiere JSON individuale (LOCK_EX la scriere). ID-ul este validat contra regex /^[a-f0-9]{32}$/ pentru prevenirea path traversal.

9. Standarde și referințe

StandardUtilizare în sistem
ISO/IEC 18013-5:2021
mDL / mDoc
Format credențiale (DeviceResponse, IssuerSigned, MSO), reguli validare certificate (457 zile, EKU mdlDS, extensii interzise), format IssuerSignedItem
ISO/IEC 18013-7
mDL online
Status list reference în MSO (deviceKeyInfo.statusList)
OID4VP 1.0
OpenID for VP
Protocol verificare: engagement URI, authorization request JWS, response JWE, SessionTranscript / OID4VP handover, client_id format x509_hash
RFC 9101
JAR
JWT-Secured Authorization Request - formatul JWS al cererii de prezentare
RFC 9052
COSE
COSE_Sign1 pentru semnături emitent și dispozitiv, Sig_structure, COSE_Key encoding
RFC 8949
CBOR
Serializare binară pentru DeviceResponse, MSO, IssuerSignedItem, Tag 24
RFC 9596
Token Status List
Verificare revocare credențiale: JWT și CWT status list, bitstring comprimat
RFC 6960
OCSP
Verificare revocare certificate emitent (online, real-time)
RFC 5280
X.509 PKI
Validare lanț certificate, CRL, extensii (AKI/SKI, EKU, etc.)
RFC 3161
TSP
Mărci temporale externe pe evidence-uri
RFC 7638
JWK Thumbprint
Thumbprint cheie efemeră pentru SessionTranscript
RFC 7516
JWE
Criptarea răspunsului wallet (ECDH-ES + A256GCM)
RFC 7515
JWS
Semnătura authorization request și evidence (ES256)

10. Instalare și configurare

Această secțiune acoperă deploy-ul efectiv al pachetului de distribuție: structura fișierelor, cerințele de sistem, dependențele, și pașii de instalare.

10.1 Structura pachetului de distribuție

Pachetul se livrează ca arhivă evo-wallet-verifier.tar.gz (~85 KB). După dezarhivare, structura este:

Fișier / DirectorRol
public/ Apache DocumentRoot. Conține entry point, .htaccess, și assets.
  index.php Entry point unic + router. Toate cererile HTTP trec prin el.
  .htaccess Reguli mod_rewrite: dirijează toate URL-urile către index.php.
  assets/style.css Stilizare interfață (responsive, print-friendly).
src/ Codul sursă PHP (11 fișiere, ~240 KB). Namespace: EVO\WalletVerifier (PSR-4).
  Config.php Singleton: citește .env, expune toate setările.
  AuthorizationRequest.php Generează cererea JWS (ES256) trimisă wallet-ului.
  ResponseHandler.php Decriptează JWE, orchestrează verificările, generează evidence.
  CborParser.php Decodează CBOR (DeviceResponse, IssuerSignedItem).
  CoseVerifier.php Verifică semnături COSE_Sign1 (emitent + dispozitiv).
  CertificateValidator.php Validează lanț certificate X.509, OCSP, CRL.
  StatusListVerifier.php Verificare revocare credențiale (RFC 9596, JWT/CWT).
  DcqlBuilder.php Construiește interogarea DCQL.
  QrCodeGenerator.php Generează QR static per ghișeu (PNG data URI).
  SessionManager.php Ciclul de viață al tranzacțiilor (JSON files).
  TellerManager.php Administrare ghișee, coadă prezentări, retenție.
templates/ 3 template-uri PHP (HTML + CSS + JS inline).
  teller_selection.php Pagină selecție ghișeu.
  ghiseu.php Interfața ghișeului: QR, listă prezentări, polling 3s.
  verify.php Re-verificare evidence (upload + 10 verificări).
certs/ Certificate și instrucțiuni.
  trusted-issuers/ Root/intermediate CA AGE (preinstalate). Înlocuiți cu cert prod la lansare.
  trusted-tsa/ CA pentru TSA (gol - populați cu cert TSA propriu).
data/ Date runtime (create automat).
  sessions/ Tranzacții active (expiră automat la 5 min).
  evidence/ Evidence JSON + JWS generate.
  tellers.json Config ghișee (implicit 2 ghișee generice).
.env.example Șablon configurare → copiat în .env.
composer.json Dependențe PHP.
README.md Instrucțiuni rapide instalare + configurare.
Nu se includ în pachet: fișierul .env (secrete), certs/verifier.key + verifier.pem (chei proprii), vendor/ (se regenerează cu composer install), date sesiune/evidence. Fiecare instituție își generează propriile chei.

10.2 Cerințe de sistem

ComponentăCerință
Sistem de operare Linux (Ubuntu 20.04+, RHEL 8+, Debian 10+) sau Windows Server 2019+
Server web Apache 2.4+ cu mod_rewrite activ (sau Nginx cu reguli echivalente)
PHP ≥ 7.4 (recomandat 8.1+). Extensii: openssl, json, mbstring, gd, curl
Composer Manager dependențe PHP (necesar o dată, la instalare)
OpenSSL CLI Disponibil în PATH (pentru OCSP, CRL, TSA)
HTTPS Certificat SSL valid (wallet comunică exclusiv HTTPS)
Conectivitate Acces internet (direct sau proxy) pentru: OCSP/CRL, status list, TSA
Spațiu disc ~50 MB (aplicație + vendor), plus ~5 KB/evidence

Nu sunt necesare servicii suplimentare (Redis, Memcached, bază de date, message queue).

10.3 Dependențe PHP

Instalate automat prin composer install:

BibliotecăScop
spomky-labs/cbor-php ^2.0 Decodare/codare CBOR (formatul binar ISO 18013-5)
web-token/jwt-signature ^2.2 Semnături JWS (ES256) - Authorization Request + evidence
web-token/jwt-encryption ^2.2 Decriptare JWE (ECDH-ES + A256GCM) - răspuns wallet
web-token/jwt-key-mgmt ^2.2 Managementul cheilor JWK
chillerlan/php-qrcode ^4.3 Generare coduri QR (PNG data URI)

10.4 Instalare pas cu pas

# 1. Dezarhivare
tar xzf evo-wallet-verifier.tar.gz
cd evo-wallet-verifier

# 2. Instalare dependente PHP
composer install

# 3. Configurare
cp .env.example .env
nano .env   # completeaza cu datele institutiei (vezi 10.5)

# 4. Generare cheie privata + CSR (Certificate Signing Request)
openssl ecparam -name prime256v1 -genkey -noout -out certs/verifier.key
openssl req -new -key certs/verifier.key -out certs/verifier.csr \
    -subj "/CN=EVO Verifier/O=Numele Institutiei/C=MD"

# 5. Expediere CSR catre AGE
#    Transmiteti fisierul certs/verifier.csr catre AGE.
#    AGE va returna:
#      - certificatul semnat (verifier.pem)
#      - lantul CA (root + intermediate)

# 6. Plasare certificate primite de la AGE
#    certs/verifier.pem     — certificatul verificatorului (primit de la AGE)
#    certs/trusted-issuers/ — root/intermediate CA AGE (primite de la AGE)
#    certs/trusted-tsa/     — root CA pentru TSA (optional)

# 7. Configurare Apache
#    DocumentRoot → public/
#    Asigura AllowOverride All pe directorul public/

# 8. Setare permisiuni
chmod 750 data/sessions data/evidence
chmod 640 .env certs/verifier.key

# 9. Verificare — acceseaza BASE_URL in browser
Mediu de test: Nu e necesar server dedicat. Funcționează pe orice mașină cu PHP + Apache (XAMPP, Docker, VM). Certificatele AGE staging sunt deja incluse.

10.5 Configurare .env

Toate variabilele disponibile:

VariabilăDefaultDescriere
APP_NAME EVO Wallet Verifier Numele afișat în <title> al paginilor web
ORGANIZATION_NAME Numele Institutiei Numele instituției din subtitlu UI și evidence
EVIDENCE_REGULATION Regulament intern... Referința reglementară inclusă în fiecare evidence
BASE_URL https://localhost URL-ul public al serverului (folosit în QR, response_uri, evidence)
VERIFIER_CERT_PATH certs/verifier.pem Certificatul X.509 al verificatorului
VERIFIER_KEY_PATH certs/verifier.key Cheia privată EC (P-256) a verificatorului
TRUSTED_ISSUER_CERTS_PATH certs/trusted-issuers Director cu certificate root/intermediate CA AGE
TRUSTED_TSA_CERTS_PATH certs/trusted-tsa Director cu certificate CA pentru TSA
WALLET_ISSUER https://wallet.staging.egov.md URL emitent wallet (pentru claim aud în JWS)
WALLET_URI_SCHEME eudi-openid4vp:// Schema URI pentru QR
DOC_TYPE md.gov.wallet.pid.1 Tipul de document solicitat
NAMESPACE md.gov.wallet Namespace-ul elementelor de identitate
SESSION_DIR data/sessions Director stocare tranzacții
TRANSACTION_EXPIRY 300 TTL tranzacție în secunde (5 min)
HTTP_PROXY (gol) Proxy HTTP: host:port
HTTP_PROXY_AUTH (gol) Autentificare proxy: user:password
REVOCATION_CACHE_TTL 3600 Cache TTL pentru CRL/OCSP/status list (secunde)
TSA_URL (gol) URL serviciu TSA RFC 3161 (gol = fără timestamp)
TSA_REQUEST_TIMEOUT 15 Timeout curl pentru cereri TSA (secunde)

10.6 Personalizare white-label

Pachetul este neutru - nu conține identitatea niciunei instituții. Personalizarea se face exclusiv prin .env, fără modificarea codului sursă:

VariabilăUnde apareExemplu
APP_NAME <title> al tuturor paginilor web Verificator Identitate MoldovaBank
ORGANIZATION_NAME Subtitlu UI + câmpul verifier.name din evidence MoldovaBank S.A.
EVIDENCE_REGULATION Câmpul regulation din fiecare evidence generat Regulament intern nr. 45/2026

Actualizări viitoare: se înlocuiește pachetul (src/, public/, templates/), se păstrează .env, certs/, și data/. Codul sursă este identic pentru toate instituțiile.

Anexă: Fluxul complet al protocolului

Rezumatul tuturor request-urilor HTTP între componentele sistemului:

Faza 1 - Inițiere

OPERATOR GET /ghiseu/{tellerId} - deschide interfața cu QR VERIFIER
CETAȚEAN Scanează QR - eudi-openid4vp://... WALLET

Faza 2 - Authorization Request

WALLET POST /wallet/teller/{id}/request • wallet_metadata + wallet_nonce VERIFIER
VERIFIER HTTP 200 • JWS (ES256, x5c, DCQL query, ephemeral key) WALLET

Faza 3 - Consimțământ și răspuns

CETAȚEAN Confirmă prezentarea pe ecranul telefonului WALLET
WALLET POST /wallet/response/{txId} • JWE (ECDH-ES + A256GCM) VERIFIER
VERIFIER HTTP 200 • {} (empty JSON) WALLET

Faza 4 - Verificare și evidence

VERIFIER Decriptare JWE → Parse CBOR → 7 verificări criptografice INTERN
VERIFIER GET Status List URI (HTTPS) - verificare revocare AGE / SL
VERIFIER POST OCSP responder - revocare certificat OCSP
VERIFIER POST TSA URL - marcă temporală RFC 3161 TSA
VERIFIER Salvează evidence JSON + JWS în data/evidence/ DISK

Faza 5 - Afișare la ghișeu

BROWSER GET /queue/{tellerId} (polling la 3 secunde) VERIFIER
VERIFIER JSON: { teller, presentations: [...] } BROWSER