OID4VP 1.0 verifier (relying party) for the EU Digital Identity Wallet and any wallet that speaks the same dialect. Every endpoint below is reachable on the live host www.aloaha.com and the response shapes are verified against the running build. Wallets and integrators can use this page as the canonical integration reference.
Lead with x509_hash — the wallet‑preferred Client Identifier Prefix.x509_hash (OID4VP 1.0 §5.9.3, base64url SHA‑256 of the DER‑encoded leaf certificate) is the form most real EUDI wallets bind to. The verifier also supports x509_san_dns (OpenID4VP compatibility mode; not HAIP 1.0 Final) as a compatibility option. The caller of vp‑start picks the prefix per session via ?client_id_prefix=x509_hash|x509_san_dns; wallet integrators should explicitly request x509_hash. The server default when the parameter is absent is now x509_hash — aligned with the wallet‑preferred form. Once chosen the prefix is locked onto the session and persisted so the JAR client_id claim and the ExpectedClientId validation stay consistent.
Why this matters beyond compliance. Document‑upload KYC and video‑ID flows have a 2026 trust problem: video‑ID checks are increasingly spoofable with generative AI (face‑swap and voice‑clone deepfakes on a webcam feed). The EUDI Wallet sidesteps the spoof attack surface entirely — the presentation is cryptographic, hardware‑bound and government‑rooted, not "did this video look like the passport photo". Integrators positioning the verifier should lead with that trust story, not just with the regulatory window. See telecoms.html for the long version.
Built by Aloaha Limited. Not a fork of any SDK. Every endpoint below is implemented in-house — SD-JWT VC parsing, JWS + KB-JWT verification, OpenID4VP request/response, DCQL, JWE ECDH-ES, x5c chain validation, OpenID Federation 1.0. No eudi-lib-*, no third-party EUDI SDK in the dependency tree. Three practical guarantees: (1) the code belongs to us — every EUDI-protocol line auditable, no closed EUDI libraries your CISO has to trust on faith (generic infrastructure libs are widely-used open-source); (2) our own release cadence — CVE patches and features ship on our schedule, not gated on a consortium’s next SDK version; (3) strict standards-based interoperability — wallet, verifier and issuer talk exclusively through published RFCs and EUDI protocols, every piece replaceable, no vendor lock-in even between our own components.
Health check and live verifier metrics. Public, no authentication. Use this from monitoring or to confirm a deployed build version.
Response
JSON body. Field-by-field:
ok — true if the OIDC handler is initialised.
build — build version string of the handler. Format YYYY-MM-DD-slug. Bumped on every meaningful change.
tenant — tenant the request resolved to (multi‑tenancy by domain).
now — current server time, seconds since epoch.
vp_started — counter, total vp‑start sessions created since service start.
vp_completed — counter, total vp‑response verifications that returned ok=true.
vp_abandoned — counter, sessions that started and were never followed by a vp‑response within the TTL (operators can spot the abandonment rate from this).
vp_pending_or_inflight — gauge, sessions currently waiting on a wallet response.
Verifier (relying party) descriptor — lists the cryptographic algorithms, response‑encryption modes, supported credential formats, and the endpoints a wallet should talk to. Mirrors the verifier‑metadata pattern used by EUDI reference deployments.
x509_hash_value — base64url SHA‑256 of the DER‑encoded leaf certificate. This is the primary identifier real EUDI wallets bind to; x509_hash:<hash> is the recommended Client Identifier Prefix form.
client_id_schemes_supported — the full set the verifier will honour per‑request (x509_hash recommended; x509_san_dns for OpenID4VP compatibility with legacy wallets (not HAIP Final)).
client_id — the prefixed identifier returned when no ?client_id_prefix= is supplied to vp‑start. Server default is now x509_hash (wallet‑preferred form).
vp_formats_supported — CodeB ships dc+sd-jwt only. mdoc / ISO mDL is not in scope.
encrypted_response_enc_values_supported — per HAIP 1.0 Final; declares the content-encryption algorithms this verifier accepts for direct_post.jwt JWE responses. Both A128GCM (baseline) and A256GCM are supported. Key agreement is ECDH-ES against the ephemeral JWK published in the JAR client_metadata.jwks.
Standard OpenID Connect discovery document. Lists OIDC endpoints, claim names, signing algorithms, and the amr / acr values the verifier emits for wallet‑backed sessions. Same handler as the rest of the OIDC IdP — see oidc_api.html for the full envelope; the EU Wallet‑relevant bits are below.
EU Wallet relevant fields
acr_values_supported includes eudi:pid:high, eudi:pid:substantial, urn:codeb:vc:member — the verifier tags the resulting OIDC session with acr=urn:codeb:acr:eudi-wallet when the bearer authenticated via the EU Wallet flow.
amr_values_supported includes the implicit vc — reported in the SSO assertion (see §SSO assertion).
claims_supported covers the standard PID claim shape returned by the EU PID issuer (given_name, family_name, birth_date, email_address, etc.).
Begin a verifiable presentation flow. The caller is typically a CodeB‑hosted login page (logineu.html) or an external integrator. Returns the session id, the URL the wallet will fetch to retrieve the signed request, and a wallet‑invocation deep link.
Request
No body. Query parameters:
client_id_prefix — optional, one of x509_hash (recommended for real EUDI wallets per OID4VP 1.0 §5.9.3) or x509_san_dns (OpenID4VP compatibility profile for legacy wallets; not HAIP Final). Locks the Client Identifier Prefix scheme onto the session. Server default when omitted: x509_hash.
The chosen prefix is encoded in the client_id value: x509_hash:<base64url‑sha256‑of‑DER‑leaf> (wallet‑preferred) or x509_san_dns:www.aloaha.com (OpenID4VP compatibility mode; not HAIP Final). Callers can split at the first : to recover the prefix. status starts at pending and advances as the wallet round‑trips.
Examples
# Wallet‑preferred (x509_hash per OID4VP 1.0 §5.9.3) — recommended for real EUDI wallets.
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&client_id_prefix=x509_hash'
# OpenID4VP compatibility profile (x509_san_dns; NOT HAIP Final -- HAIP mandates x509_hash).
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&client_id_prefix=x509_san_dns'
# Without the parameter, the server now defaults to x509_hash (wallet‑preferred).
curl https://phone.aloaha.com/oidc.ashx?action=vp-start
Session lifecycle
The session id is opaque, ~16 hex characters, scoped to the tenant. The session record is persisted to disk under App_Data/<tenant>/vp‑sessions/<id>.json so app‑pool recycles and multi‑worker IIS deployments do not lose state. Sessions expire after expires_in seconds; abandoned sessions are counted in the vp_abandoned metric returned by ping.
Wallet fetches this endpoint to obtain the signed authorization request (JAR — JWT‑Secured Authorization Request, RFC 9101). The body is a single ES256‑signed JWT with the verifier‑certificate x5c chain in the JWS header.
The client_id claim is prefixed with the scheme the caller picked at vp‑start (x509_hash: or x509_san_dns:).
For x509_hash, the prefix value is base64url(SHA‑256(DER leaf)), identical to the x509_hash_value exposed in verifier‑metadata.
The wallet validates the signature against the leaf in the x5c chain and matches the chosen prefix against the client_id claim.
DCQL trusted_authorities
Every DCQL credential query emitted by the verifier carries a trusted_authorities array so the wallet knows which credential-issuer authorities the verifier will accept for this transaction. Structure per OpenID4VP §6 and HAIP 1.0 Final §6:
type: "aki" means the values are Authority Key Identifiers. Each value is base64url(SubjectKeyIdentifier bytes) taken from the trust-anchor certificate’s X.509 SKI extension (OID 2.5.29.14). Anchors without an SKI extension fall back to RFC 5280 §4.2.1.2 method (1) — SHA-1 of the subject public key BIT STRING.
A credential satisfies the query if the authority key identifier on its issuance chain matches any listed value.
The verifier populates the list from the tenant’s trust anchor store under App_Data/<tenant>/trust/vc-issuers/ (preferred) or trust/mdoc-issuers/ (fallback). The list is soft-cached for 45 seconds with mtime invalidation, so operators can add anchors without an IIS restart.
Per-tenant appsettings key dcqlTrustedAuthoritiesMode controls emission: all (default — every anchor in the store), explicit (read a whitelist from App_Data/<tenant>/trust/dcql-trusted-akis.txt, one b64url AKI per line), or off (omit the field for wallets that reject it).
Wallet posts the verifiable presentation here. The verifier accepts both wire shapes defined by OID4VP 1.0 §8.4 — pick whichever the wallet emits, the handler auto‑detects.
HAIP Final vs OpenID4VP legacy. Encrypted direct_post.jwt is the HAIP 1.0 Final default and the recommended shape. Plain direct_post is accepted as an OpenID4VP legacy fallback for non-HAIP wallets. Both are documented below.
The <JWE> is encrypted with ECDH‑ES key agreement and A128GCM or A256GCM content encryption, against the ephemeral JWK the verifier shipped in the JAR client_metadata.jwks. The plaintext inside the JWE is the form‑encoded body shown in Shape B.
The vp_token is the SD‑JWT VC with selectively‑disclosed claims and the holder‑binding KB‑JWT appended. Format: <issuer‑signed JWT>~<disclosure1>~<disclosure2>~...~<KB‑JWT>.
redirect — where the calling page should send the browser next. Always /account.html on the default tenant; can be a deeper return-url if the wallet flow was started from one.
sso_assertion — RS256-signed assertion the browser tab plants into sessionStorage and then exchanges for an OIDC code at /oauth2/v1/authorize (see SSO assertion).
sso_max_age — assertion lifetime in seconds.
vct + issuer — credential type and issuer of the SD-JWT VC that was just verified. The PID claims themselves are written to the user record on disk; they are not echoed in the response body to keep it small. Use the resulting OIDC session (after assertion exchange) to query claims via /oauth2/v1/userinfo.
disclosures_verified — count of selectively-disclosed claims whose hash matched the SD-claim digest in the issuer-signed JWT.
Error response
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"ok": false,
"error": "invalid_vp_token",
"error_description": "kb-jwt signature did not verify against holder cnf"
}
JWE decrypt (ECDH‑ES + A128GCM/A256GCM) when applicable.
SD‑JWT VC envelope parse + structure validation.
Disclosure hash check against the SD claim digests.
KB‑JWT signature verification against the holder‑binding key in cnf.
User identifier binding (one of: userHint, personal_administrative_number, email_address, pid_name_dob_hash, anonymous).
SSO assertion mint (RS256, OIDC‑compatible).
Each gate emits a structured log event keyed by session id (vp‑shape‑detected, vp‑verify‑result, vp‑user‑resolved, vp‑sso‑minted, etc.) so a failing wallet's path can be traced from the OIDC log alone.
The vp‑response success payload includes sso_assertion — a short‑lived RS256‑signed JWT that any CodeB‑federated application can exchange for a standard OIDC session. This is the bridge from "wallet just presented credentials" to "user is now signed in".
Run a standard OIDC Authorization Code + PKCE flow against /oauth2/v1/authorize as documented in oidc_api.html. Add acr_values=urn:codeb:acr:eudi-wallet to require an EU Wallet‑backed login.
The CodeB IdP will redirect the user through logineu.html, which drives vp‑start, vp‑request, and vp‑response behind the scenes.
On success, the IdP completes the OIDC flow normally — your app receives an authorization code, exchanges it at /oauth2/v1/token, and receives an id_token and access_token as if the user had used password login.
The id_token carries amr: ["vc"] and acr: "urn:codeb:acr:eudi-wallet", plus the PID claims your app requested via standard OIDC scopes (profile, email).
User identifiers minted from the wallet flow are stable per‑subject (e.g. pin_de_3f8a1d7e for a PID with a personal administrative number, eu_3f8a1d7e for a name‑and‑DOB hash fallback). The role on first sign‑in is guest; admins can promote.
POST/oauth2/v1/token · JWT‑bearer grant (RFC 7523) #
Exchange an SSO assertion (the one returned by vp‑response) for a standard OIDC access_token + id_token + refresh_token. Useful for service‑to‑service flows and for integrators (or test scripts) that have just verified a wallet holder and want a bearer to call any OIDC‑bearer endpoint without bouncing the browser through /authorize.
Request
POST /oauth2/v1/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<sso_assertion> (the one returned by vp-response)
&client_id=<client_id> (must be a registered client)
&client_secret=<secret> (confidential clients only)
&scope=openid (optional; defaults to openid)
It lets a wallet integrator skip the full Authorization Code + PKCE choreography (which requires a registered redirect_uri + PKCE pair + a 302 hop) when the user has already proved identity via OID4VP. The wallet round‑trip is at least as strong a factor as a password login; turning that proof directly into a Bearer keeps automated tests + S2S integrations clean.
Type guard. The assertion typ claim must be "sso". An access_token or id_token presented in the assertion parameter will be rejected with invalid_grant — an anti‑confused‑deputy guard against token reuse.
Why HA1 (MD5) on the wire? HA1 (MD5 of user:realm:password) is the wire format required by RFC 2617 / RFC 7616 HTTP Digest Authentication and is used here only for SIP hardphone interoperability. It is not our identity-platform password hash. OIDC sign-in, EU Wallet sign-in, and passkey flows do not touch HA1. Deployments that do not need SIP-digest interop can disable the account=password path entirely.
Read the signed‑in user's account state. Used immediately before account=password so the caller knows the realm (needed to compute HA1) and whether the user already has a stored password.
Request
GET /signal.ashx?account=get
Authorization: Bearer <access_token>
Response (200)
{
"user": "eu_3f8a1d7e",
"role": "guest",
"disabled": false,
"profile": { ... OIDC claims the user has saved ... },
"has_password": false, // true if a HA1 is already stored
"realm": "www.aloaha.com" // for HA1 = MD5(user:realm:password)
}
Errors
401 invalid_token — bearer missing, expired, or wrong audience.
Why expose realm? The credentials file uses SIP‑style digest HA1 = MD5(user:realm:password). Returning realm on account=get means the caller can compute the HA1 client‑side without a separate config lookup; the plaintext password never crosses the wire.
POST/signal.ashx?account=password · first‑time set + wallet‑as‑recovery #
Set or rotate the signed‑in user's password. Three distinct paths based on stored state and bearer factor:
First‑time set (user has no HA1 yet) — current_ha1 NOT required. Wallet‑created guests use this to enable username + password sign‑in.
Wallet‑as‑recovery (user has a HA1, bearer carries acr=urn:codeb:acr:eudi-wallet) — current_ha1 NOT required. The wallet auth is the proof‑of‑identity; this is the "forgot password" path.
Regular change (user has a HA1, bearer is password‑authenticated) — current_ha1 MUST be supplied and verified constant‑time.
Request
POST /signal.ashx?account=password
Authorization: Bearer <access_token>
Content-Type: application/json
{
"ha1": "<32 lowercase hex = MD5(user:realm:password)>",
"current_ha1": "<optional; required only for regular change>"
}
Response (200)
{
"ok": true,
"first_set": true, // true if user had no password before
"wallet_recovery": true // true if wallet auth skipped current_ha1
}
Both flags can be true simultaneously on a brand‑new wallet‑driven set — the user had no password (first_set) AND the wallet auth was the proof (wallet_recovery).
Errors
400 missing_ha1 / invalid_ha1 — body missing or malformed.
400 missing_current_ha1 — regular change required current_ha1 but it wasn't supplied.
400 invalid_current_ha1 — supplied value isn't a valid HA1 shape.
400 new_equals_current — new password is identical to current.
401 current_password_incorrect — supplied current_ha1 doesn't match what's stored.
Audit trail
Three distinct event names in the connection log so an operator can tell the paths apart:
account-password-first-set — wallet‑only guest set first password.
account-password-wallet-reset — existing user reset via wallet, no current_ha1.
account-password-changed — existing user rotated via password.
The full integration story. Six request/response hops that let a brand‑new EUDI Wallet user (a) sign in via wallet, (b) enable username + password sign‑in via the same wallet‑authenticated session, and (c) verify the password works by logging in with it. Same shape as eu‑wallet‑mock.py --set-password, which is the shipping automated test for this exact flow.
Pre‑condition: the integrator already drove vp‑start → vp‑request → vp‑response and now holds:
An sso_assertion from the success body of POST /oidc.ashx?action=vp‑response (one‑time use, 30‑min TTL).
The username that came back in the same body (e.g. eu_3f8a1d7e).
Step 1 — Exchange SSO assertion for an OIDC access_token
Step 6 — (Manual UI sign‑in) the user can now visit /login.html and sign in
Username = eu_3f8a1d7e (or whatever vp‑response handed back), password = the plaintext from step 3. The browser will hash to HA1 locally before posting; the server matches against what step 4 stored. The user is now permanently enabled for both wallet and password sign‑in.
Forgot‑password path
The exact same six steps work when the user already has a password. Step 4 returns first_set: false, wallet_recovery: true — the wallet auth bypassed current_ha1. Step 5 confirms the new password works. The audit log records account-password-wallet-reset.
Security argument. Bypassing current_ha1 only happens when the bearer carries acr=urn:codeb:acr:eudi-wallet — that's set only when the OID4VP verifier completed a full SD‑JWT VC + KB‑JWT round‑trip with cryptographically‑verified holder binding. A successful wallet auth is at least as strong as any password, so requiring current_ha1 on top would be a UX bug, not a security feature.
TRYeu-wallet-mock-both.py · local mock‑wallet round trip #
The shipping test harness exercises both Client Identifier Prefixes back‑to‑back, end‑to‑end. If both calls return HTTP 200 with revealed PID claims and an SSO assertion, the integration is working.
Outline
# 1. Begin a session with x509_hash (wallet‑preferred)
GET https://phone.aloaha.com/oidc.ashx?action=vp-start&client_id_prefix=x509_hash
→ { id, request_uri, deep_link, client_id, status }
# 2. Fetch the signed request (as the wallet would)
GET {request_uri}
→ ES256-signed JWT, application/oauth-authz-req+jwt
# 3. Verify the JWS, decrypt nothing yet, build the VP response
# (SD-JWT VC + KB-JWT against the verifier's ephemeral ECDH-ES key)
# 4. POST the response
POST https://phone.aloaha.com/oidc.ashx?action=vp-response&id={id}
Content-Type: application/x-www-form-urlencoded
Body: response=<JWE>
→ { ok:true, vct, iss, disclosures_verified, claims, sso_assertion }
# 5. Repeat with client_id_prefix=x509_san_dns to verify the OpenID4VP legacy compatibility
# compatibility path. Same script, just flip the query parameter.
Verify the build matches
# Confirm you are testing the build you think you are testing.
curl https://phone.aloaha.com/oidc.ashx?action=ping
# build = current handler version. Bumped on every meaningful change.
Logs an operator can grep
Every gate listed in vp‑response emits a structured event keyed by session id, plus a small handful of failure‑path events. Greppable vocabulary (incomplete list):
CFGhaipStrictMode · strict-mode toggle for vp-response#
HAIP 1.0 Final mandates encrypted direct_post.jwt for VP responses. Plain direct_post (form‑encoded vp_token) is accepted as an OpenID4VP legacy fallback for non‑HAIP wallets. The strict‑mode toggle lets an operator refuse the legacy shape and force encrypted responses only.
Per‑tenant default
Set the JSON key haipStrictMode in App_Data/<tenant>/appsettings.json:
{
"haipStrictMode": true
}
Accepted values: true / false, "1" / "0", "yes" / "no", "on" / "off". When the key is absent, the built‑in default is true. Set false to accept both encrypted and plain responses (legacy‑wallet compatibility).
Per‑session override
Callers of vp-start can override the tenant default for a single session with ?strict_haip=1 or ?strict_haip=0. The resolved value is locked onto the session record at start time; the wallet cannot change it. This is useful for A/B tests, integrator diagnostics, and per‑flow relaxations without touching the tenant config file.
# Force strict for one session (regardless of tenant default)
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&strict_haip=1'
# Relax strict for one session (regardless of tenant default)
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&strict_haip=0'
Every decision leaves greppable trace lines: [HAIP-STRICT-DIAG] vp-start ... effective=<bool> source=<default|tenant-cfg|query-override> at start, [HAIP-STRICT-DIAG] vp-response-check ... at every wallet POST, and one of [HAIP-STRICT-DIAG] REJECT|ACCEPT-JWE|ACCEPT-PLAIN per decision. Reserved metric names emitted alongside: haip.vp.strict-reject, haip.vp.strict-accept-jwe, haip.vp.strict-accept-plain.
Strict-mode does not affect the JAR the wallet fetches from vp-request — the request object already advertises response_mode: direct_post.jwt. Strict-mode only changes what the verifier accepts back.
Sibling per‑session overrides — ?lotl_require_chain= and ?status_list_check=
Two additional per‑session knobs follow the same pattern as ?strict_haip=. All three are resolved at vp-start, locked onto the session record, and evaluated by vp-response. Wallets cannot change them mid‑flow.
# Skip LOTL chain enforcement for one session (smoke tests / synthetic issuers)
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&lotl_require_chain=0'
# Force LOTL chain enforcement for one session (regardless of tenant setting)
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&lotl_require_chain=1'
# Skip the OAuth Status List fetch for one session (credential has no status_list)
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&status_list_check=skip'
# Log-only status list check for one session (do not reject on revoked/suspended)
curl 'https://phone.aloaha.com/oidc.ashx?action=vp-start&status_list_check=warn'
lotl_require_chain accepts 1/0, true/false, yes/no, on/off. When unset, the session inherits the tenant setting Oidc:LotlRequireChain (default true).
status_list_check accepts enforce, warn, skip. When unset, the session inherits Oidc:StatusListCheckMode (default enforce).
Invalid values are rejected fail‑closed with HTTP 400 invalid_request (RASP; value length capped at 16 chars, allowlist enforced).
Production‑profile lock. When the tenant sets eudiProductionProfile=true or dcqlTrustedAuthoritiesMode=all, all three per‑session overrides are IGNORED and forced back to safe defaults (strict, true, enforce). Every ignored attempt fires a compliance audit event (lotl-chain-compliance-override or statuslist-check-compliance-override) plus a LOUD [LOTL-CHAIN-DIAG] COMPLIANCE-OVERRIDE / [STATUSLIST-DIAG] COMPLIANCE-OVERRIDE trace.
Per‑tenant RFC 9116 security.txt. Served by the same oidc.ashx handler that hosts discovery + JWKS + OpenID Federation; the canonical URL is per‑tenant (built from the request’s Host header), so multi‑tenant deployments do not need a shared file.
Configuration
Drop a security-txt.json file under App_Data/<tenant>/:
All keys are optional. If contact contains a scheme (mailto:, https:, tel:) it is emitted as‑is; a plain address is prefixed with mailto:. Values are sanitised (CR/LF stripped, capped at 512 chars) to prevent header‑injection through operator config.
Expires is computed as now + 1 year on every request. Update the operator config before the year rolls over to keep the file RFC‑valid.
Canonical uses the request host (respecting X-Forwarded-Proto). Each tenant domain therefore serves its own canonical URL.
Missing preferred_languages defaults to en; missing policy defaults to https://<host>/privacy.html.
Response — tenant without security-txt.json
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Cache-Control: no-store
# security.txt not configured for this tenant
The verifier deliberately does not fabricate a contact address when no config is present. Publishing a security contact you do not monitor is worse than publishing none.
RASP
Only GET is accepted; other methods return 405 Method Not Allowed with an Allow: GET header.
Requests with a body return 400.
User‑Agent headers longer than 1024 characters return 400.
Every branch emits a [SECTXT-DIAG] or [RASP-SECTXT-*] trace so operators can grep the audit log per session.
ARF 3.0 Technical Specification 7. Data subjects (or a data controller / DPA acting on their behalf) can lodge a deletion request against their European Digital Identity Wallet session records and audit trail held by this tenant. The request is queued for human operator review; nothing is auto‑deleted.
{
"request_id": "<opaque>",
"status": "queued",
"eta_days": 30,
"controller": "<DPO contact from tenant security.txt if configured>"
}
The queued record is persisted at App_Data/<tenant>/data-deletion-queue/<request_id>.json (atomic write + .backup sidecar). A per‑month meta‑audit line is appended to App_Data/<tenant>/audit/deletion-requests-YYYY-MM.log.jsonl.
Tenant opt‑out: setting Ts7:DisableDeletionRequests=true in appsettings.json makes the endpoint return 404. Default is enabled.
Operator listing
The queue is browsable via GET /oidc.ashx?action=data-deletion-list, restricted to Authorization: Bearer <admin JWT> or X-CodeB-Admin-Signature. Response includes request_id, subject_hash, requester_type, status and created_at per entry (capped 500 per call).
Discovery
The endpoint URL is advertised in the tenant's /.well-known/openid-configuration as the custom field data_deletion_request_endpoint.
Every request emits a [TS7-DELETION-DIAG] trace at entry, queued, and each RASP branch ([RASP-TS7-*]) so operators can grep the audit log per session id. Reserved metric names: ts7.deletion.requested, ts7.deletion.processed. This is a Data-Subject Rights interface, not a promise of instant erasure — every request enters an operator review workflow.
ARF 3.0 Technical Specification 8. Superuser‑only export of the processing records held about a single data subject on this tenant. Intended for response to Article 15 GDPR subject access requests and Article 58 GDPR Data Protection Authority investigations.
Request
GET /oidc.ashx?action=dpa-report&subject=<sub>
&from=YYYY-MM-DD (optional)
&to=YYYY-MM-DD (optional)
&format=json (default; pdf deferred)
&purpose=<free text> (optional, capped 256)
Authorization: Bearer <admin JWT>
OR
X-CodeB-Admin-Signature: <HMAC-SHA256 hex>
Default span is the last 12 months; explicit from/to is capped at 24 months. Anything wider is silently clamped and traced as [TS8-DPA-DIAG] span-capped.
App_Data/<tenant>/vp-sessions/*.json filtered by UserHint / ClientRef.
App_Data/<tenant>/vci-issuance/*.jsonl filtered by sub.
App_Data/<tenant>/audit/*.log.jsonl filtered by user. Own meta‑audit files (dpa-reports-*, deletion-requests-*) are skipped to avoid recursion.
Every report request is recorded in App_Data/<tenant>/audit/dpa-reports-YYYY-MM.log.jsonl with report id, subject hash, purpose, and requesting IP.
RASP
Any non‑GET → 405 with Allow: GET.
Missing / unauthorised auth → 401.
Response body cap 10 MB. On overflow the response emits truncated:true with a hint to narrow the span.
format=pdf returns 501 Not Implemented. PDF export is deferred.
Every branch emits a [TS8-DPA-DIAG] or [RASP-TS8-*] trace. Reserved metric name: ts8.dpa.report.generated. The endpoint is Data-Subject-Rights machinery, not general-purpose analytics; every hit is auditable.
Beyond the Wallet Instance Attestation (which proves the wallet software itself is trusted), a European Digital Identity Wallet may attach a separate Key Attestation proving that the specific key used to prove possession of a credential was generated inside a trusted secure area (StrongBox, Secure Enclave, or equivalent). This is ARF 3.0 Technical Specification 3 — the “Wallet Trust” profile.
Where the wallet places it
The wallet embeds the key attestation JWT in the OpenID4VCI proof JWT it sends to POST /vci.ashx?credential. Two carriage points are accepted:
JOSE header slot: "key_attestation": "<compact JWS>".
Payload array: "attestations": ["<compact JWS>", ...]. Only the first entry is used.
What we accept
Generic JWT-based key attestations (RFC 7515 compact JWS). Vendor-specific formats (Android android-key, Apple apple-anonymous, TPM) are not parsed — wrap them in a JWS whose payload lists the attested keys and format tag.
alg in ES256, ES384, ES512.
typ = "key-attestation+jwt" when present (soft check).
x5c header with a chain up to 5 certificates that roots at a per-tenant trust anchor.
Payload: iss, iat within the past 24 hours (±60 s skew), exp not passed, attested_keys array with up to 10 JWKs; one entry must match the proof-of-possession key by RFC 7638 thumbprint.
Trust anchors: drop the attestation-issuer certificates into App_Data/<tenant>/trust/key-attestation-issuers/ as *.pem (also *.cer or *.crt). Anchors are read on every credential request; no restart required.
The key_attestations_required boolean mirrors the tenant's Vci:KeyAttestationMode (true only when set to require).
JAdES Baseline B (ETSI TS 119 182-1)
Per the July 2026 European Digital Identity Wallet implementing rules, a Key Attestation MAY be a JWS whose protected header carries the JSON Advanced Electronic Signatures (JAdES) Baseline B header claims defined by ETSI TS 119 182-1. The verifier accepts both the plain typ = "key-attestation+jwt" shape and the JAdES-flavoured typ = "key-attestation+jades" shape on the same code path.
crit — array of extension-header claim names the verifier MUST understand. Every listed name is checked against the recognised set (alg, typ, kid, cty, x5c, x5t, x5t#S256, sigT, sigPl, sigX5ts, srCms, srAts, srCommitment, adoTst, sigPId, crit). Unknown → jades_crit_unrecognised. Length cap 20 entries (RASP).
sigT — RFC 3339 signing time. Warned when absent; rejected with jades_sigt_iat_skew when the drift against iat exceeds 5 minutes.
x5t#S256 — base64url SHA-256 of the leaf certificate in x5c. When present, the verifier hashes x5c[0].RawData and rejects with jades_x5t_s256_mismatch on any difference. This is a strong anchor-pin defence against chain-swap.
sigPl — signature policy identifier. Captured in the diag log but not gated (strict-policy mode is future work).
Successful validation emits [KEY-ATTESTATION-DIAG] jades-validated typ=… x5t-pinned=… crit-count=… sigT-present=… sealed=…. This is the primary evidence trail for the JAdES header claims.
Sealed-vs-signed certificate (opt-in)
The July 2026 implementing rules distinguish a Wallet-Provider signing certificate (eIDAS advanced signature) from a Wallet-Provider sealing certificate (eIDAS electronic seal, ETSI EN 319 412-1). A sealing certificate carries the X.509 Extended Key Usage OID 0.4.0.194112.1.4 (id-kp-EIDASSeal). Set "Vci:KeyAttestationRequireSealed": true in App_Data/<tenant>/appsettings.json to require this OID on the leaf certificate; the default is false, so both signing and sealing certificates are accepted. When enforced, a chain without the seal EKU is rejected with cert_not_sealed and logged as [KEY-ATTESTATION-DIAG] reject reason=jades-cert-not-sealed.
RASP
Attestation JWT length cap 32 KB.
alg allowlist strictly ES256/ES384/ES512.
iat window: within the past 24 hours + 60 s skew.
x5c chain length cap 5 certificates.
attested_keys array cap 10 entries.
crit array cap 20 entries; sigT vs iat drift cap 5 min; x5t#S256 mismatch fails closed.
Missing trust anchors on a tenant with a wallet-presented attestation → invalid_key_attestation / no_anchors_configured. Missing attestation on a require-mode tenant → key_attestation_required.
Every branch emits a [KEY-ATTESTATION-DIAG] or [RASP-KEY-ATTESTATION] trace. Reserved metric names: vci.key-attestation.required, vci.key-attestation.accepted, vci.key-attestation.rejected, vci.key-attestation.skipped.
POST/vci.ashx?nonce · OID4VCI 1.0 Final dedicated Nonce Endpoint #
OpenID for Verifiable Credential Issuance 1.0 Final (published after Draft 15) defines a dedicated Nonce Endpoint in section 7.2. Prior to Final, the issuer could only hand a c_nonce to the wallet inside a Token Response or a Credential Error Response. With the Nonce Endpoint, a European Digital Identity Wallet can request a fresh replay-defence nonce at any moment — typically to prepare the proof-of-possession JWT in parallel with the token request, or to recover after a credential retry.
Request
POST /vci.ashx?nonce — empty body. Public: no bearer token required. GET is also accepted for wallet convenience but POST is the HAIP-conformant verb.
The wallet uses this c_nonce as the nonce claim in its next proof-of-possession JWT presented to /vci.ashx?credential. The issuer stores the nonce in a per-tenant standalone store (5 minute TTL, 5000-entry hard cap) and consumes it on first match — single-use replay defence.
Method allowlist: POST and GET only; anything else → 405 method_not_allowed.
Per-IP sliding window rate limit (60 requests/minute, shared with the other public wallet paths).
Request body silently drained and discarded — the endpoint takes no input.
Response uses Cache-Control: no-store and Pragma: no-cache so intermediaries cannot replay nonces.
Every branch emits a [VCI-NONCE-DIAG] trace: entry, issued, proof-check. Reserved metric name: vci.nonce.issued. When a wallet's proof JWT presents a nonce that matches a standalone-store entry, HandleCredential's diag line reads src=standalone-consumed; the token-bound fallback path reads src=token-bound.
POST/vci.ashx?notification · OID4VCI 1.0 Final Notification Endpoint #
OpenID for Verifiable Credential Issuance 1.0 Final section 10 adds a wallet-to-issuer feedback channel. After the wallet has stored a fresh credential (or failed to), it POSTs a small notification so the issuer can update its state for the credential lifecycle. A European Digital Identity Wallet uses this to signal accepted, deleted, or failure outcomes.
Request
POST /vci.ashx?notification HTTP/1.1
Authorization: DPoP eyJhbGciOiJFUzI1NiI…
Content-Type: application/json
{
"notification_id": "9f83bcc0-4e1a-4c3f-8d2f-1b0a3d7c5e12",
"event": "credential_accepted",
"event_description": "Stored in secure enclave"
}
Response
HTTP/1.1 204 No Content on success. No response body.
Auth
The wallet MUST present a bearer or DPoP-scheme access token issued by an earlier call to /vci.ashx?token. The token must still be within its 5 minute TTL, must belong to the same tenant, and must appear in the issuer's live access-token index. Missing / unknown / expired / tenant-mismatched tokens → 401 invalid_token.
Persistence
Each accepted notification is appended to App_Data/<tenant>/vci-notifications/<YYYY-MM>.log.jsonl. One JSON object per line; monthly rollover keeps individual files small. The record carries a truncated 12-character token fingerprint (never the raw token), the notification id, the event, the sanitised event description, plus offer_id and client_id where available. This is auditable evidence of wallet-side outcomes for the credential lifecycle.
RASP
Method allowlist: POST only.
Body cap: 2 KB (both Content-Length header pre-check and post-read length check).
Once a European Digital Identity Wallet presentation lands and PID claims are extracted, the operator decides on a per-OIDC-client basis which of those claims are allowed to reach a downstream consumer through the id_token / userinfo channels. This is the GDPR Article 25 “data protection by design and by default” posture, enforced at the token-minting boundary rather than left to downstream trust.
Tenant appsettings Oidc:WalletClaimDefaultAllowAll=true → the caller passes every wallet-derived claim through (backward-compat escape hatch).
Default (setting absent, or false) → no wallet-derived claim reaches the client. An empty allow array is treated the same as a missing file: nothing passes.
Enforcement point
Filtering runs inside MergeVcClaims in oidc.ashx immediately after the two-channel merge (namespaced vc block plus top-level standard-claim lift) and before the userinfo response is serialised. Admin-curated profile fields that pre-existed in the claims dictionary are never filtered — only the wallet-contributed delta is.
RASP
Allowlist file capped at 8 KB; larger files → deny all wallet claims and log [RASP-WALLET-CLAIM-ALLOWLIST] reject reason=file-too-big.
allow array capped at 32 entries.
Each entry must match regex ^[a-z][a-z0-9_]{0,63}$ (standard OIDC claim-name shape). Rejected entries are logged and skipped.
Non-JSON body, IO failure, or any processing exception → fail-closed: every wallet-added key is stripped from the response.
LOUD trace on every filter pass: [WALLET-CLAIM-ALLOWLIST-DIAG] filter tenant=<t> client=<cid> src=<file-loaded|no-file-default-deny|no-file-default-allow-all> mode=<allow-all|explicit> requested=<csv> allowed=<csv> denied=<csv>. Reserved metric names: oidc.wallet-claim.filtered, oidc.wallet-claim.denied.
Verified Person Identification Data (PID) claims from a European Digital Identity Wallet presentation may be persisted on disk in the tenant’s user record so that subsequent authenticated sessions can surface them without a fresh wallet round-trip. GDPR Article 5(1)(c) data minimisation and Article 5(1)(e) storage limitation require that persistence to be bounded and auditable.
2592000 → PID claims are persisted for up to 30 days; the shipped default.
0 → PID claims are never persisted; the write path skips the disk step entirely and the wallet presentation is session-only.
-1 → indefinite persistence (opt-out). Requires eudiProductionProfile=false; in production profile the value is ignored and clamped back to 30 days, with a compliance audit event fired.
Storage schema
When PID claims are persisted the sibling field pid_claims_at (Unix seconds) is written alongside them in the same JSON document. On subsequent reads the retention helper compares now − pid_claims_at against the tenant TTL; expired PID slots are omitted from the returned dictionary. There is no active sweeper this round — expiry is lazy on read.
Production-profile lock
When eudiProductionProfile=true the value -1 is refused. The effective TTL clamps to 30 days, a [PID-RETENTION-DIAG] COMPLIANCE-OVERRIDE trace fires, and a pid-retention-compliance-override audit event is written for the operator. This preserves the honest default for tenants that ship EU regulated services.
Every cryptographic operation the Aloaha CSC v2 remote-signing surface performs — ensuring a per-tenant signing credential, signing a raw hash, minting and verifying the Signature Activation Data (SAD) JWT — is routed through a small ICryptoModule interface. The purpose is architectural: the CSC v2 API handlers know nothing about where the private key material lives, so a future hardware substitution (Azure Managed HSM, PKCS#11 on-prem HSM) can be dropped in without touching the handler surface.
The software module keys the on-disk PFX per user, not per tenant. On the first credentials/list call for a given OIDC subject, the module lazily provisions App_Data/<tenant>/csc-signer-certs/<sha256(user).first16>.pfx with subject DN CN=<user>, O=Aloaha CSC Signing MVP, OU=self-signed-per-user. User A never sees user B’s cert or credentialID; the SAD JWT carries the OIDC subject and signatures/signHash refuses to sign if the SAD subject does not match the Bearer’s subject. The per-user cert reuses the tier-3 parameters (ECDsa P-256, KU=digitalSignature+nonRepudiation, EKU=emailProtection, 1-year validity). Set Csc:PerUserCerts=false in appsettings to force the legacy tenant-wide path used before Phase 1b. Metric csc.cert.user-autogenerated increments on first user cert mint. This is a prerequisite for a future ETSI EN 319 411-2 CP-2 (QCP-l) aligned QSCD-backed connector; the software module on its own does not reach CP-2 (still no QSCD, no face-to-face enrolment, AdES only).
The CSC v2 credentials/authorize endpoint has two branches selected by Csc:DefaultScal (default 1) or per-request query ?scal=1|2.
SCAL1 (default): the SAD JWT is returned immediately in the response. Bearer token is sufficient authorisation.
SCAL2: credentials/authorize mints a pending record under App_Data/<tenant>/csc-pending-sad/<authorization_id>.json (TTL 300s). When the authenticated OIDC subject has one or more registered passkeys the response additionally carries webauthn_supported: true and a webauthn_options object (WebAuthn Level 2 PublicKeyCredentialRequestOptions, session-bound challenge) that the client passes straight to navigator.credentials.get(). A second call to credentials/authorize/confirm then releases the actual SAD after presenting a confirmation factor. Confirmation factors accepted: (a) pin field in the JSON body OR X-CSC-Sign-PIN header (4–12 hex/digit characters); (b) webauthn_assertion field carrying the raw WebAuthn assertion (id + response.{clientDataJSON, authenticatorData, signature, userHandle?}) — verified by CodeB.Passkeys.AuthenticationFacade as a full FIDO2 Level 2 relying-party check: clientDataJSON.type="webauthn.get", challenge match against the server-issued challenge, origin match against the tenant host, authenticatorData.rpIdHash == SHA-256(rpId), UP + UV flags asserted (D-4 always requires user verification), signature verification against the stored COSE public key (ES256/RS256/EdDSA), counter regression check (anti-clone), and one-shot session consumption (anti-replay). SAD payload confirm_factor becomes webauthn-hwk for hardware-bound credentials or webauthn-swk for synced credentials per RFC 8176. When Csc:DefaultScal=2 is set, credentials/info advertises SCAL: "2" and PIN: {presence:"true", format:"N", label:"Sign PIN"} per CSC v2 §11.5.
Pending records are single-use: successful confirm deletes the record, expired records are refused and swept, mismatched Bearer subjects delete the record and return 403. Metrics: csc.scal2.pending-minted, csc.scal2.confirm-success, csc.scal2.confirm-expired, csc.scal2.confirm-not-found, csc.scal2.confirm-user-mismatch, csc.scal2.confirm-factor-missing.
software — shipped default. Wraps the three-tier signing-cert lifecycle described in the CSC v2 MVP section (on-disk PFX → wacs-issued cert → auto-generated ECDsa P-256 self-signed leaf). Private key material sits in the Windows CNG key container tied to the LocalMachine key set; the PFX password on disk is wrapped with DPAPI at LocalMachine scope. No hardware root of trust, no tamper evidence, no protection-profile certification.
hsm-azurekv — reserved for the Azure Managed HSM connector. Not implemented in this build. Requesting this provider returns HTTP 501 with error=crypto_module_not_configured.
hsm-pkcs11 — reserved for the on-premises PKCS#11 connector. Not implemented in this build. Requesting this provider returns HTTP 501 with error=crypto_module_not_configured.
Honest disclosure via credentials/info
The CSC v2 credentials/info response has been extended with three fields inside the key object — per CSC v2 §11.5 the shape is extensible, and the baseline fields (status, algo, len, curve) are unchanged. The additions are honest disclosure of what backs the signing key:
An HSM-backed deployment would return e.g. protection="hsm-cloud:azurekv:<vault>", moduleType="hsm-cloud", moduleCertification="EN 419 221-5:2018 cert:<ref>". Wallets that need this fidelity to decide whether to trust a signing surface can consume these fields without needing any protocol change.
Standards alignment
ETSI TS 119 431-1 clause 5 describes the module boundary of a Trust Service Provider generating signatures in a server-side module. Our ICryptoModule reifies the four operations that the CSC v2 flow uses. What the software module explicitly does NOT do is: module certification (EN 419 221-5, FIPS 140-2/3), tamper evidence, physical-security controls, or QSCD binding for Qualified Electronic Signatures. Those are HSM concerns and belong to a later phase.
ETSI TS 119 432-1 (Signature Activation Protocol, SAP): Phase 1b ships the two-step SCAL2 flow with two confirmation-factor paths: (1) a PIN-based factor (hex/digit, 4–12 chars) and (2) WebAuthn / FIDO2 Level 2 assertions verified end-to-end by CodeB.Passkeys.AuthenticationFacade (challenge/origin/RP-ID binding, UP+UV enforcement, signature verification against the stored COSE public key, counter regression check, one-shot session consumption). The confirm endpoint deletes pending records single-use and refuses replayed SADs whose sub claim does not match the Bearer subject.
LOUD traces per branch: [CRYPTOMOD-DIAG] factory tenant=<t> provider=<software|hsm-azurekv|hsm-pkcs11>, [CRYPTOMOD-DIAG] EnsureCredential ok tenant=<t> userSlug=<16hex|*> module=<m> credentialId=<...>, [CSC-SCAL2-DIAG] pending-minted tenant=<t> authId=<...>, [CSC-SCAL2-DIAG] confirm-success tenant=<t> authId=<...> factor=<pin|webauthn-hwk|webauthn-swk>, [CRYPTOMOD-DIAG] SignHash tenant=<t> alg=<ES256|RS256> hashLen=32, [CRYPTOMOD-DIAG] SignSadJwt tenant=<t> alg=<...> kid=<...>, [CRYPTOMOD-DIAG] VerifySadJwt tenant=<t> alg=<...> result=<OK|reject:<reason>>. RASP: SignHash refuses non-32-byte hashes; EnsureCredential fails-closed if all three software-module tiers fail; HSM providers fail-closed with crypto_module_not_configured.
The Aloaha CSC v2 subset exposes three signature-producing endpoints. All three share the same per-user credential lifecycle described above and can be exercised from a browser (see the Web Wallet PDF signer) or from a server-side integration.
signatures/signHash (CSC v2 §11.9)
Hash-in / signature-out. Accepts a Base64-encoded 32-byte SHA-256 hash plus a SAD JWT and returns the raw ECDSA-P-256 signature. The browser flow uses this to keep the raw PDF on the device — only the ByteRange hash and the resulting signature bytes cross the wire.
Document-in / signed-document-out. Accepts a Base64-encoded PDF plus a SAD JWT and returns the fully-assembled PAdES-B-T signed PDF — useful for backend batch jobs, PMS integrations and notification pipelines where the caller does not want to assemble CMS on its own. Uses the same per-user certificate, the same RFC 3161 timestamp source and the same encrypted-PDF handling as the browser flow.
TSA-relay. Accepts a Base64-encoded RFC 3161 TimeStampReq and returns the Base64-encoded TimeStampResp fetched from the configured TSA. The TSA URL is read from the Windows registry key HKLM\SOFTWARE\CodeB\TSAURL and defaults to the Sectigo Qualified TSA when unset. The sign.html client augments its CMS to a PAdES-B-T signature by calling this endpoint immediately after signHash.
All three endpoints require a Bearer access token from auth/login. signHash and signDoc additionally require a SAD JWT whose sub matches the Bearer subject; SAD/Bearer subject mismatches return 403 and delete any pending SCAL2 record. Reserved metric names: csc.signhash.ok, csc.signdoc.ok, csc.timestamp.ok, csc.timestamp.tsa-error.
Caveats & defensible scope. CodeB's verifier is a relying party, not a notified national wallet, and not a qualified trust service provider. The protocol substrate is live and verifiable (proof of work), but high‑assurance identity proofing should wait for iter‑2 hardening (issuer signature chain against the EU List of Trusted Lists, OAuth Status List revocation, wallet‑attestation conformance).