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:
| Entitate | Protocol | Scop |
|---|---|---|
| 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 |
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 |
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.
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
| Parametru | Valoare | Descriere |
|---|---|---|
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.
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;
client_id cu certificatul din headerul x5c al JWS-ului primit.
Nu necesită înregistrare prealabilă a verificatorului la un autoritate centrală.
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).
Request body (application/x-www-form-urlencoded):
| Parametru | Tip | Descriere |
|---|---|---|
wallet_metadata |
JSON string | Capabilitățile wallet-ului (formate suportate, algoritmi) |
wallet_nonce |
String | Nonce generat de wallet pentru legarea sesiunii |
Server-side processing:
tellerId există în configurația teller-elor$id = bin2hex(random_bytes(16)) - 32 caractere hex$nonce = bin2hex(random_bytes(16))request_sentResponse body: JWS compact serialization (header.payload.signature)
{
"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"]
}
}
jwks este folosită de wallet pentru a cripta răspunsul (ECDH-ES)-7 = ES256, -35 = ES384, -36 = ES512nonce este generat de verifier și trimis ca apv în headerul JWE al răspunsuluistate = transactionId - wallet-ul îl returnează nemodificat pentru corelare// 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ă.
JWS protected header:
{
"alg": "ES256",
"typ": "oauth-authz-req+jwt",
"x5c": [
"MIIBxTCCAW...certificat_leaf_base64...",
"MIICATCCAa...certificat_CA_base64..."
]
}
| Element | Detalii |
|---|---|
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.
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):
| Grup | Elemente |
|---|---|
| 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 |
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.
Request body (application/x-www-form-urlencoded):
| Parametru | Tip | Descriere |
|---|---|---|
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.
JWE compact serialization: header.encrypted_key.iv.ciphertext.tag
Validarea headerului JWE:
| Câmp | Valoare 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).
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 }
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.
După parsarea DeviceResponse, server-ul execută 7 verificări criptografice independente.
Fiecare verificare produce un rezultat pass / fail / warning care este
inclus în evidence.
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 ]
Protected header, label 33 (x5chain): conține certificatul X.509 DER al emitentului.
Poate fi un singur bstr (leaf) sau un array [leaf, intermediate, ...].
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) ]
"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.
| COSE alg | Algoritm | OpenSSL | Curbă | r/s bytes |
|---|---|---|---|---|
-7 | ES256 | OPENSSL_ALGO_SHA256 | P-256 | 32 |
-35 | ES384 | OPENSSL_ALGO_SHA384 | P-384 | 48 |
-36 | ES512 | OPENSSL_ALGO_SHA512 | P-521 | 66 |
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);
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:
version = "1.0"docType corespunde cu cel așteptat (md.gov.wallet.pid.1)validFrom ≤ now ≤ validUntildigestAlgorithm este suportat (SHA-256 / SHA-384 / SHA-512)
Certificatul emitentului (din x5chain, label 33) este validat conform ISO 18013-5 §9.5:
| # | Verificare | Criteriu |
|---|---|---|
| 1 | Validitate temporală | notBefore ≤ now ≤ notAfter |
| 2 | Durata maximă | (notAfter - notBefore) / 86400 ≤ 457 zile |
| 3 | Key Usage | Trebuie să conțină Digital Signature |
| 4 | Extended Key Usage | Trebuie să conțină 1.0.18013.5.1.2 (mdlDS) |
| 5 | Extensii 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) |
| 6 | Algoritm semnătură | ECDSA obligatoriu: ecdsa-with-SHA256 / SHA384 / SHA512 |
| 7 | Lanț de încredere | openssl verify -CAfile <trusted> -untrusted <intermediates> <leaf> |
| 8 | AKI/SKI | Leaf authorityKeyIdentifier = CA subjectKeyIdentifier |
| 9 | Jurisdicție | Leaf și CA: C și ST trebuie să coincidă |
| 10 | DN match | Leaf issuer CN, O, C = CA subject CN, O, C |
| 11 | MSO signed time | mso.validityInfo.signed în fereastra de validitate a certificatului |
| 12 | Revocare | 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).
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.
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.
// 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)>
// 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 = [ "Signature1", // CBOR text string (0x6a prefix) protectedHeader, // CBOR byte string b"", // external_aad (0x40) DeviceAuthenticationBytes // CBOR byte string (Tag 24 encoded) ]
// 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.
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 MSO | Standard |
|---|---|
deviceKeyInfo.statusList.uri + .idx |
ISO 18013-7 |
status.status_list.uri + .idx |
EUDI ARF / RFC 9596 |
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).
., decode base64url header și payloadx5c header, sau brute-force pe certificatele trustedstatus_list din payload: {"bits": N, "lst": "<base64url zlib>"}exp claim pentru cache TTLTag(18) [protected, unprotected, payload, signature]1 = algorithm, label 33 = x5chainSig_structure ca la issuer)65533 = status_list{"bits": int, "lst": bstr} - lst = raw zlib bytes// 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;
| Valoare | Status |
|---|---|
0x00 | VALID |
0x01 | REVOCAT (INVALID) |
0x02 | SUSPENDAT |
Pe lângă verificarea revocării credențialelor (Status List), se verifică și starea certificatelor X.509 din lanțul emitentului.
authorityInfoAccess a certificatului leafopenssl ocsp -issuer <ca> -cert <leaf> -reqout <file> -no_noncecurl -X POST -H "Content-Type: application/ocsp-request" --data-binary @<req> <url>openssl ocsp -respin <resp>": good" = valid, ": revoked" = revocatCertificatul issuer pentru OCSP: din x5chain[1], sau matching din certs/trusted-issuers/ după DN.
crlDistributionPointscurl -o <file> <url>openssl crl -inform <PEM|DER> -text -noout
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).
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.
{
"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>"
}
// 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;
tsa_token,
integrity_hash, jws_header, jws_signature. La re-verificare,
aceste câmpuri trebuie eliminate (via regex cu lookahead (?=\n\})) înainte de recalculare.
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/.
// 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șier | Conț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) |
Endpoint-ul POST /verify acceptă un evidence JSON sau JWS și re-execută
integral 10 verificări independente (A–J):
| # | Verificare | Ce 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. |
Fiecare prezentare creează o tranzacție în data/sessions/{id}.json.
ID-ul este bin2hex(random_bytes(16)) = 32 caractere hex alfanumerice.
| Status | Condiție | Date 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.
| Standard | Utilizare î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) |
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.
Pachetul se livrează ca arhivă evo-wallet-verifier.tar.gz (~85 KB).
După dezarhivare, structura este:
| Fișier / Director | Rol |
|---|---|
| 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. |
.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.
| 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).
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) |
# 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
Toate variabilele disponibile:
| Variabilă | Default | Descriere |
|---|---|---|
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) |
Pachetul este neutru - nu conține identitatea niciunei instituții.
Personalizarea se face exclusiv prin .env, fără modificarea codului sursă:
| Variabilă | Unde apare | Exemplu |
|---|---|---|
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.
Rezumatul tuturor request-urilor HTTP între componentele sistemului:
Faza 1 - Inițiere
Faza 2 - Authorization Request
Faza 3 - Consimțământ și răspuns
Faza 4 - Verificare și evidence
Faza 5 - Afișare la ghișeu