From 0c3c6ae57e4e5763263280f2f3b6cf6ebaf619b7 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 16 Jul 2026 17:00:18 +0200 Subject: [PATCH 1/4] =?UTF-8?q?docs(pkg):=20pin=20the=20v2=20HTTP=20contra?= =?UTF-8?q?ct=20=E2=80=94=20OpenAPI=20spec=20+=20condiscon=20JSON=20Schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three seams every PostGuard client depends on, as machine-checkable artifacts (EPIC #201, issues #209/#210): - pg-pkg/api-description.yaml: the full v2 OpenAPI (mirrors cryptify's api-description.yaml pattern) — endpoints, auth modes, request/response schemas incl. the ConItem condiscon shape, error envelope, rate limits. Lints clean (redocly). - pg-pkg/schemas/irma-auth-request.schema.json + canonical examples: the condiscon request pinned as JSON Schema, deliberately faithful to serde behavior (unknown fields ignored — documented). Examples cover the legacy flat shape, nested disjunctions, the optional empty-conjunction convention and its irmamobile#360 ordering workaround, plus the classic nesting mistakes as invalid vectors. - pg-pkg/tests/contract_examples.rs makes the examples EXECUTABLE: every valid example must parse as IrmaAuthRequest, every invalid one must be rejected — so the examples and the parser cannot drift apart silently. The e2e harness (postguard-e2e) will additionally validate live responses against the OpenAPI and the examples against the schema (issue #212). --- pg-pkg/api-description.yaml | 541 ++++++++++++++++++ .../examples/invalid/attr-missing-type.json | 4 + .../examples/invalid/con-not-array.json | 4 + .../discon-missing-conjunction-level.json | 6 + .../schemas/examples/invalid/missing-con.json | 4 + .../examples/invalid/type-not-string.json | 4 + .../examples/invalid/validity-not-number.json | 5 + .../valid/condiscon-name-disjunction.json | 18 + .../schemas/examples/valid/flat-legacy.json | 7 + .../examples/valid/optional-attribute.json | 7 + .../valid/optional-discon-empty-alt-last.json | 10 + .../examples/valid/value-constrained.json | 7 + pg-pkg/schemas/irma-auth-request.schema.json | 62 ++ pg-pkg/tests/contract_examples.rs | 62 ++ 14 files changed, 741 insertions(+) create mode 100644 pg-pkg/api-description.yaml create mode 100644 pg-pkg/schemas/examples/invalid/attr-missing-type.json create mode 100644 pg-pkg/schemas/examples/invalid/con-not-array.json create mode 100644 pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json create mode 100644 pg-pkg/schemas/examples/invalid/missing-con.json create mode 100644 pg-pkg/schemas/examples/invalid/type-not-string.json create mode 100644 pg-pkg/schemas/examples/invalid/validity-not-number.json create mode 100644 pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json create mode 100644 pg-pkg/schemas/examples/valid/flat-legacy.json create mode 100644 pg-pkg/schemas/examples/valid/optional-attribute.json create mode 100644 pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json create mode 100644 pg-pkg/schemas/examples/valid/value-constrained.json create mode 100644 pg-pkg/schemas/irma-auth-request.schema.json create mode 100644 pg-pkg/tests/contract_examples.rs diff --git a/pg-pkg/api-description.yaml b/pg-pkg/api-description.yaml new file mode 100644 index 0000000..4e5526a --- /dev/null +++ b/pg-pkg/api-description.yaml @@ -0,0 +1,541 @@ +openapi: "3.0.3" +info: + title: "PostGuard PKG API" + description: | + The Private Key Generator (PKG). Coordinates identity verification with a + Yivi (IRMA) server and derives identity-based keys: IBE user secret keys + for decryption and IBS signing keys for sender authenticity, plus the + public parameters clients need to seal/verify. + + This document is the **v2 HTTP contract** — one of the three seams every + PostGuard client depends on (wire format, this API, and the condiscon + request shape; the latter is additionally pinned by + `schemas/irma-auth-request.schema.json`). Treat response/request shape + changes here as breaking unless additive. + + ### Path prefix aliases + The identity endpoints are registered under `/v2/{irma|request}/…`; both + prefixes serve the same handlers. `…/request/…` is canonical; `…/irma/…` + is a legacy alias kept for deployed clients. This document lists the + canonical paths only. + + ### Authentication + - **Yivi JWT** (`Authorization: Bearer `): the session-result JWT + obtained from `/v2/request/jwt/{token}` after a completed disclosure, + signed by the IRMA server (RS256). Used for key issuance. + - **Business API key** (`Authorization: Bearer PG-…`): issued by the + business portal. Only available when the PKG is configured with the + business database; routes requiring it are absent otherwise. + + ### Rate limiting + All `/v2` routes are per-IP rate-limited; session-start and key-issuance + routes have a stricter budget. Over-limit requests receive **429**. + version: "2.0.0" +servers: + - url: http://localhost:8087 + description: Development server (e2e stack / docker compose) +tags: + - name: "Health" + description: "Health check" + - name: "Metrics" + description: "Prometheus scrape endpoint" + - name: "Parameters" + description: "Public parameters for sealing and signature verification" + - name: "Session" + description: "Yivi disclosure sessions (proxied to the IRMA server)" + - name: "Keys" + description: "Identity-based key issuance" + - name: "API keys" + description: "Business API key validation" +paths: + /health: + get: + tags: ["Health"] + summary: "Health check" + operationId: "health" + security: [] + responses: + "200": + description: "The PKG is up." + /metrics: + get: + tags: ["Metrics"] + summary: "Prometheus metrics" + operationId: "metrics" + security: [] + responses: + "200": + description: "Prometheus text exposition format." + content: + text/plain: + schema: + type: string + /v2/parameters: + get: + tags: ["Parameters"] + summary: "IBE master public key" + description: | + The public parameters clients use to **seal**. The `publicKey` value + is an opaque pg-core-serialized artifact: pass it to the client + library (`@e4a/pg-wasm` `seal`, pg-core `Sealer`) verbatim. + Cacheable; served with an `ETag`. + operationId: "parameters" + security: [] + responses: + "200": + description: "Successful operation." + content: + application/json: + schema: + $ref: "#/components/schemas/Parameters" + /v2/sign/parameters: + get: + tags: ["Parameters"] + summary: "IBS verifying key" + description: | + The public parameters used to **verify signatures** when unsealing. + Same envelope as `/v2/parameters`; the `publicKey` value is the + opaque IBS verifying key. Cacheable; served with an `ETag`. + operationId: "signParameters" + security: [] + responses: + "200": + description: "Successful operation." + content: + application/json: + schema: + $ref: "#/components/schemas/Parameters" + /v2/request/start: + post: + tags: ["Session"] + summary: "Start a Yivi disclosure session" + description: | + Builds a Yivi disclosure request from the posted condiscon (see + `IrmaAuthRequest` and `schemas/irma-auth-request.schema.json`) and + starts a session on the IRMA server. The response is the IRMA + server's session package: render `sessionPtr` as a QR / deeplink for + the Yivi app, then poll `/v2/request/jwt/{token}`. + operationId: "startSession" + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IrmaAuthRequest" + examples: + flat: + summary: "Legacy flat conjunction" + value: + con: + - { t: "pbdf.sidn-pbdf.email.email" } + - { t: "pbdf.gemeente.personalData.fullname", optional: true } + condiscon: + summary: "Conjunction with a nested disjunction (condiscon)" + value: + con: + - { t: "pbdf.sidn-pbdf.email.email" } + - - [ { t: "pbdf.gemeente.personalData.fullname" } ] + - - { t: "pbdf.pbdf.passport.firstName" } + - { t: "pbdf.pbdf.passport.lastName" } + validity: 300 + responses: + "200": + description: "Session started." + content: + application/json: + schema: + $ref: "#/components/schemas/SessionData" + "400": + description: "Malformed body, or `validity` above the maximum (86400 s)." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: "Rate limited." + "500": + description: "The IRMA server rejected the session or was unreachable." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v2/request/jwt/{token}: + get: + tags: ["Session"] + summary: "Fetch the session-result JWT" + description: | + Proxies the IRMA server's `result-jwt` for the session. Once the + disclosure completes, the response body is a signed JWT (RS256, the + IRMA server's key) whose claims carry the session status, proof + status and disclosed attributes. Use it as the Bearer token for the + key endpoints. + operationId: "sessionJwt" + security: [] + parameters: + - name: token + in: path + required: true + description: "The session token from `/v2/request/start`." + schema: + type: string + responses: + "200": + description: "The (possibly not-yet-final) session result JWT." + content: + text/plain: + schema: + type: string + description: "A compact JWS (three dot-separated segments)." + "502": + description: "Upstream IRMA server error (e.g. unknown session)." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v2/request/statusevents/{token}: + get: + tags: ["Session"] + summary: "Session status events (SSE)" + description: | + Proxies the IRMA server's server-sent-events stream for the session, + so web clients can react to status changes without polling. + operationId: "statusEvents" + security: [] + parameters: + - name: token + in: path + required: true + schema: + type: string + responses: + "200": + description: "An SSE stream of session status updates." + content: + text/event-stream: + schema: + type: string + "503": + description: "Upstream IRMA server unreachable." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v2/request/key/{timestamp}: + get: + tags: ["Keys"] + summary: "IBE user secret key for a timestamp" + description: | + Derives the caller's IBE user secret key for the decryption identity + proven by the Bearer JWT, bound to `timestamp` (the seal-time policy + timestamp — it must equal the `ts` in the ciphertext's policy). The + key is only present when the session status is `DONE` with a `VALID` + proof. + operationId: "keyWithTimestamp" + security: + - yiviJwt: [] + parameters: + - name: timestamp + in: path + required: true + description: "Unix seconds; the policy timestamp the key must match." + schema: + type: integer + format: int64 + responses: + "200": + description: "Successful operation (inspect `status`/`proofStatus`)." + content: + application/json: + schema: + $ref: "#/components/schemas/KeyResponse" + "401": + description: "Missing/invalid JWT." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: "Rate limited." + "503": + description: "IRMA verification key unavailable (IRMA server unreachable)." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v2/request/key: + get: + tags: ["Keys"] + summary: "IBE user secret key (no timestamp)" + description: "As `/v2/request/key/{timestamp}`, for identities without a policy timestamp." + operationId: "key" + security: + - yiviJwt: [] + responses: + "200": + description: "Successful operation (inspect `status`/`proofStatus`)." + content: + application/json: + schema: + $ref: "#/components/schemas/KeyResponse" + "401": + description: "Missing/invalid JWT." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v2/request/sign/key: + post: + tags: ["Keys"] + summary: "IBS signing keys" + description: | + Derives the caller's identity-based **signing** keys. + + - **Yivi JWT auth:** the signing identity is the subset of the JWT's + disclosed attributes selected by the request body (`pubSignId`, + optionally `privSignId`). + - **Business API key auth (`PG-…`):** the signing identity comes from + the business database configuration for that key; the request body + is ignored. The PKG stamps the policy timestamp in both cases. + operationId: "signingKey" + security: + - yiviJwt: [] + - businessApiKey: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SigningKeyRequest" + responses: + "200": + description: "Successful operation (inspect `status`/`proofStatus`)." + content: + application/json: + schema: + $ref: "#/components/schemas/SigningKeyResponse" + "401": + description: "Missing/invalid JWT or API key." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: "Rate limited." + /v2/api-key/validate: + get: + tags: ["API keys"] + summary: "Validate a business API key" + description: | + Confirms a `PG-…` key is valid (exists, not expired, not revoked) and + returns the tenant identity. Used by sibling services (cryptify) to + gate per-tenant behaviour. **Only registered when the PKG is + configured with the business database.** + operationId: "apiKeyValidate" + security: + - businessApiKey: [] + responses: + "200": + description: "The key is valid." + content: + application/json: + schema: + $ref: "#/components/schemas/ApiKeyValidateResponse" + "401": + description: "Unknown, expired or revoked key." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + securitySchemes: + yiviJwt: + type: http + scheme: bearer + bearerFormat: JWT + description: "Session-result JWT from `/v2/request/jwt/{token}`." + businessApiKey: + type: http + scheme: bearer + description: "Business API key; the token starts with `PG-`." + schemas: + Parameters: + type: object + required: [formatVersion, publicKey] + properties: + formatVersion: + type: integer + description: "Formatting version of the key material." + publicKey: + description: | + Opaque pg-core-serialized key artifact. Pass verbatim to the + client crypto library; never introspect. + additionalProperties: false + DisclosureAttribute: + type: object + required: [t] + properties: + t: + type: string + description: "Yivi attribute type, e.g. `pbdf.sidn-pbdf.email.email`." + v: + type: string + nullable: true + description: "When present, constrains disclosure to this exact value." + optional: + type: boolean + default: false + description: | + When true, the PKG wraps the attribute in a disjunction with an + empty option so the user may skip it in the Yivi app. + description: | + Unknown extra properties are ignored by the server (serde default) — + misspelled field names fail silently, so validate against + `schemas/irma-auth-request.schema.json` client-side. + ConItem: + oneOf: + - $ref: "#/components/schemas/DisclosureAttribute" + - type: array + description: | + A Yivi disjunction-of-conjunctions (OR of ANDs). An **empty inner + conjunction** marks the whole disjunction optional per Yivi + convention. Note irmamobile#360: deployed apps mis-render an + empty alternative listed first — clients currently place it last. + items: + type: array + items: + $ref: "#/components/schemas/DisclosureAttribute" + description: "One entry of `IrmaAuthRequest.con`." + IrmaAuthRequest: + type: object + required: [con] + properties: + con: + type: array + items: + $ref: "#/components/schemas/ConItem" + description: "Conjunction over the entries: every entry must be satisfied." + validity: + type: integer + format: int64 + minimum: 0 + maximum: 86400 + default: 300 + description: "Validity of the session-result JWT, in seconds." + description: | + The condiscon request accepted by `/v2/request/start`. Also pinned as + a standalone JSON Schema with canonical examples under + `pg-pkg/schemas/` — those examples are executable tests against the + server's parser. + SessionData: + type: object + required: [sessionPtr, token] + properties: + sessionPtr: + type: object + required: [u, irmaqr] + properties: + u: + type: string + description: "Session URL for the Yivi app (QR/deeplink payload)." + irmaqr: + type: string + description: "Session type, e.g. `disclosing`." + token: + type: string + description: "Requestor token; path parameter for the jwt/statusevents endpoints." + SessionStatus: + type: string + description: "Yivi session status." + enum: [INITIALIZED, PAIRING, CONNECTED, CANCELLED, DONE, TIMEOUT] + ProofStatus: + type: string + description: "Yivi proof status." + enum: [VALID, INVALID, INVALID_TIMESTAMP, UNMATCHED_REQUEST, MISSING_ATTRIBUTES, EXPIRED] + KeyResponse: + type: object + required: [status] + properties: + status: + $ref: "#/components/schemas/SessionStatus" + proofStatus: + $ref: "#/components/schemas/ProofStatus" + key: + description: | + Opaque pg-core-serialized IBE user secret key. Present only when + `status` is `DONE` and `proofStatus` is `VALID`. + Attribute: + type: object + required: [t] + properties: + t: + type: string + description: "Yivi attribute type." + v: + type: string + nullable: true + description: "Attribute value." + SigningKeyRequest: + type: object + required: [pubSignId] + properties: + pubSignId: + type: array + items: + $ref: "#/components/schemas/Attribute" + description: "Attributes to include in the public (always visible) signing identity." + privSignId: + type: array + items: + $ref: "#/components/schemas/Attribute" + description: "Attributes to include in the private (recipient-only) signing identity." + SigningKeyResponse: + type: object + required: [status] + properties: + status: + $ref: "#/components/schemas/SessionStatus" + proofStatus: + $ref: "#/components/schemas/ProofStatus" + pubSignKey: + $ref: "#/components/schemas/SigningKeyExt" + privSignKey: + $ref: "#/components/schemas/SigningKeyExt" + SigningKeyExt: + type: object + required: [key, policy] + properties: + key: + description: "Opaque pg-core-serialized IBS signing key." + policy: + type: object + required: [ts, con] + properties: + ts: + type: integer + format: int64 + description: "PKG-stamped policy timestamp (unix seconds)." + con: + type: array + items: + $ref: "#/components/schemas/Attribute" + ApiKeyValidateResponse: + type: object + required: [tenant_id] + properties: + tenant_id: + type: string + description: "Stable per-tenant identifier (`organizations.id`)." + organisation_name: + type: string + nullable: true + description: "Best-effort organisation display name." + Error: + type: object + required: [error, message] + properties: + error: + type: boolean + enum: [true] + message: + type: string diff --git a/pg-pkg/schemas/examples/invalid/attr-missing-type.json b/pg-pkg/schemas/examples/invalid/attr-missing-type.json new file mode 100644 index 0000000..328cd34 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/attr-missing-type.json @@ -0,0 +1,4 @@ +{ + "$comment": "INVALID: every attribute needs t.", + "con": [ { "v": "alice@example.com" } ] +} diff --git a/pg-pkg/schemas/examples/invalid/con-not-array.json b/pg-pkg/schemas/examples/invalid/con-not-array.json new file mode 100644 index 0000000..df3c895 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/con-not-array.json @@ -0,0 +1,4 @@ +{ + "$comment": "INVALID: con must be an array, not a single attribute object.", + "con": { "t": "pbdf.sidn-pbdf.email.email" } +} diff --git a/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json b/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json new file mode 100644 index 0000000..f7b6d35 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json @@ -0,0 +1,6 @@ +{ + "$comment": "INVALID: the classic nesting mistake. A disjunction entry is an array of CONJUNCTIONS (arrays), not of attributes — this one-level array is rejected. Wrap each alternative in its own array: [[{...}]].", + "con": [ + [ { "t": "pbdf.gemeente.personalData.fullname" } ] + ] +} diff --git a/pg-pkg/schemas/examples/invalid/missing-con.json b/pg-pkg/schemas/examples/invalid/missing-con.json new file mode 100644 index 0000000..dd85b43 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/missing-con.json @@ -0,0 +1,4 @@ +{ + "$comment": "INVALID: con is required.", + "validity": 300 +} diff --git a/pg-pkg/schemas/examples/invalid/type-not-string.json b/pg-pkg/schemas/examples/invalid/type-not-string.json new file mode 100644 index 0000000..c3b42b5 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/type-not-string.json @@ -0,0 +1,4 @@ +{ + "$comment": "INVALID: t must be a string.", + "con": [ { "t": 42 } ] +} diff --git a/pg-pkg/schemas/examples/invalid/validity-not-number.json b/pg-pkg/schemas/examples/invalid/validity-not-number.json new file mode 100644 index 0000000..753f866 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/validity-not-number.json @@ -0,0 +1,5 @@ +{ + "$comment": "INVALID: validity is a number of seconds, not a string.", + "con": [ { "t": "pbdf.sidn-pbdf.email.email" } ], + "validity": "300" +} diff --git a/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json b/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json new file mode 100644 index 0000000..16db69a --- /dev/null +++ b/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json @@ -0,0 +1,18 @@ +{ + "$comment": "The postguard-website sender-signing request: email plus a disjunction over name sources (condiscon).", + "con": [ + { "t": "pbdf.sidn-pbdf.email.email" }, + [ + [ { "t": "pbdf.gemeente.personalData.fullname" } ], + [ + { "t": "pbdf.pbdf.passport.firstName" }, + { "t": "pbdf.pbdf.passport.lastName" } + ], + [ + { "t": "pbdf.pbdf.idcard.firstName" }, + { "t": "pbdf.pbdf.idcard.lastName" } + ] + ] + ], + "validity": 300 +} diff --git a/pg-pkg/schemas/examples/valid/flat-legacy.json b/pg-pkg/schemas/examples/valid/flat-legacy.json new file mode 100644 index 0000000..1140f60 --- /dev/null +++ b/pg-pkg/schemas/examples/valid/flat-legacy.json @@ -0,0 +1,7 @@ +{ + "$comment": "The pre-condiscon shape every deployed client sends: a flat conjunction of attribute objects. Must keep parsing forever.", + "con": [ + { "t": "pbdf.sidn-pbdf.email.email" }, + { "t": "pbdf.gemeente.personalData.fullname", "optional": true } + ] +} diff --git a/pg-pkg/schemas/examples/valid/optional-attribute.json b/pg-pkg/schemas/examples/valid/optional-attribute.json new file mode 100644 index 0000000..1c41d5f --- /dev/null +++ b/pg-pkg/schemas/examples/valid/optional-attribute.json @@ -0,0 +1,7 @@ +{ + "$comment": "optional:true on a top-level attribute: the PKG expands it into a disjunction with an empty option.", + "con": [ + { "t": "pbdf.sidn-pbdf.email.email" }, + { "t": "pbdf.sidn-pbdf.mobilenumber.mobilenumber", "optional": true } + ] +} diff --git a/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json new file mode 100644 index 0000000..c1dfa76 --- /dev/null +++ b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json @@ -0,0 +1,10 @@ +{ + "$comment": "A skippable disjunction: the empty conjunction marks it optional (Yivi convention). It is listed LAST as a workaround for irmamobile#360 — deployed apps mis-render an empty alternative listed first. The server accepts either order.", + "con": [ + { "t": "pbdf.sidn-pbdf.email.email" }, + [ + [ { "t": "pbdf.gemeente.personalData.fullname" } ], + [] + ] + ] +} diff --git a/pg-pkg/schemas/examples/valid/value-constrained.json b/pg-pkg/schemas/examples/valid/value-constrained.json new file mode 100644 index 0000000..bd1cc09 --- /dev/null +++ b/pg-pkg/schemas/examples/valid/value-constrained.json @@ -0,0 +1,7 @@ +{ + "$comment": "v constrains disclosure to an exact value — used when the key must match a specific identity.", + "con": [ + { "t": "pbdf.sidn-pbdf.email.email", "v": "alice@example.com" } + ], + "validity": 120 +} diff --git a/pg-pkg/schemas/irma-auth-request.schema.json b/pg-pkg/schemas/irma-auth-request.schema.json new file mode 100644 index 0000000..96ff9d1 --- /dev/null +++ b/pg-pkg/schemas/irma-auth-request.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://postguard.eu/schemas/irma-auth-request.schema.json", + "title": "IrmaAuthRequest", + "description": "The condiscon disclosure request accepted by the PKG's POST /v2/{irma|request}/start. This schema is deliberately faithful to the server's parser (serde): unknown extra properties are IGNORED, not rejected — a misspelled field name fails silently, so validate client-side against this schema before sending. The canonical examples in examples/{valid,invalid}/ are executable tests against the server's actual parser (pg-pkg/tests/contract_examples.rs).", + "type": "object", + "required": ["con"], + "properties": { + "con": { + "description": "Conjunction over the entries: the user must satisfy every entry.", + "type": "array", + "items": { "$ref": "#/$defs/conItem" } + }, + "validity": { + "description": "Validity of the session-result JWT in seconds. The parser accepts any non-negative integer; the handler rejects values above 86400 (1 day) with 400, and defaults to 300 when absent.", + "type": "integer", + "minimum": 0 + } + }, + "$defs": { + "attribute": { + "title": "DisclosureAttribute", + "type": "object", + "required": ["t"], + "properties": { + "t": { + "description": "Yivi attribute type, e.g. pbdf.sidn-pbdf.email.email.", + "type": "string" + }, + "v": { + "description": "When present and non-null, constrains disclosure to this exact value.", + "type": ["string", "null"] + }, + "optional": { + "description": "When true, the PKG wraps this attribute in a disjunction with an empty option so the user may skip it in the Yivi app. Only meaningful on a top-level (Single) entry; ignored inside a nested disjunction.", + "type": "boolean", + "default": false + } + } + }, + "conjunction": { + "title": "Conjunction", + "description": "An AND of attributes. An EMPTY conjunction inside a disjunction marks the whole disjunction as skippable (Yivi's optional-disjunction convention). Ordering note (irmamobile#360): deployed Yivi apps mis-render a disjunction whose empty alternative comes FIRST, so clients currently place the empty alternative LAST; the server accepts either order.", + "type": "array", + "items": { "$ref": "#/$defs/attribute" } + }, + "disjunction": { + "title": "Disjunction", + "description": "An OR of conjunctions (Yivi 'discon'): the user picks ONE inner conjunction to satisfy. Note the nesting: a disjunction entry is an array of ARRAYS of attributes — a single-level array of attributes is malformed (see examples/invalid/discon-missing-conjunction-level.json).", + "type": "array", + "items": { "$ref": "#/$defs/conjunction" } + }, + "conItem": { + "title": "ConItem", + "description": "One entry of `con`: either a single attribute object, or a nested disjunction-of-conjunctions.", + "oneOf": [ + { "$ref": "#/$defs/attribute" }, + { "$ref": "#/$defs/disjunction" } + ] + } + } +} diff --git a/pg-pkg/tests/contract_examples.rs b/pg-pkg/tests/contract_examples.rs new file mode 100644 index 0000000..cee2c3f --- /dev/null +++ b/pg-pkg/tests/contract_examples.rs @@ -0,0 +1,62 @@ +//! Executable contract examples (EPIC issue #210). +//! +//! The canonical condiscon examples under `schemas/examples/` are not +//! documentation — they are test vectors against the server's actual parser: +//! every `valid/` example must deserialize into [`pg_core::api::IrmaAuthRequest`] +//! and every `invalid/` example must be rejected. If a change to the request +//! types breaks one of these, a deployed client's requests break the same way; +//! that must be a deliberate, versioned decision. +//! +//! The same examples are validated against `schemas/irma-auth-request.schema.json` +//! by the e2e harness (postguard-e2e), keeping schema and parser in agreement. + +use std::fs; +use std::path::PathBuf; + +use pg_core::api::IrmaAuthRequest; + +fn examples(kind: &str) -> Vec<(String, String)> { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("schemas/examples") + .join(kind); + let mut out: Vec<(String, String)> = fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) + .filter_map(|entry| { + let path = entry.ok()?.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let body = fs::read_to_string(&path).expect("read example"); + Some((name, body)) + } else { + None + } + }) + .collect(); + out.sort(); + assert!( + !out.is_empty(), + "no {kind} examples found in {} — an empty contract is a failing contract", + dir.display() + ); + out +} + +#[test] +fn valid_examples_parse() { + for (name, body) in examples("valid") { + serde_json::from_str::(&body).unwrap_or_else(|e| { + panic!("valid/{name} must parse as IrmaAuthRequest but was rejected: {e}") + }); + } +} + +#[test] +fn invalid_examples_are_rejected() { + for (name, body) in examples("invalid") { + assert!( + serde_json::from_str::(&body).is_err(), + "invalid/{name} parsed successfully — either the parser got laxer \ + (a silent contract widening) or the example is wrong" + ); + } +} From 5d5306cd1aaa1433c4b510a8831e0340962ce9a4 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 16 Jul 2026 17:23:40 +0200 Subject: [PATCH 2/4] feat(api): reject unknown fields in v2 request bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a key-issuance request, a silently ignored misspelled field (`vaule` for `v`, `optioanl` for `optional`) widens the disclosure the caller intended — intent-weakening must be a 400, not a reinterpretation. Deployment reality allows going strict directly: all clients are first-party. - deny_unknown_fields on IrmaAuthRequest, DisclosureAttribute and SigningKeyRequest. identity::Attribute stays lenient deliberately: it is shared with the bincode wire format, the wasm/FFI seal boundary and response policies, and its values are not load-bearing in request paths. - ConItem's untagged Deserialize replaced with a manual visitor: the JSON shape (object vs array) discriminates the variants, so the caller gets the precise inner error ("unknown field `vaule`, expected one of `t`, `v`, `optional`") instead of "did not match any variant". - Body-deserialization failures now use the documented {error, message} envelope (JsonConfig error_handler) instead of actix's plain-text default. - Schema/OpenAPI flipped to additionalProperties: false; examples stripped of $comment (which only ever parsed thanks to the old leniency — commentary moved to examples/README.md); new executable invalid vectors for the typo cases; tests assert the 400 names the offending field. --- pg-core/src/api.rs | 105 +++++++++++++++++- pg-pkg/api-description.yaml | 23 ++-- pg-pkg/schemas/examples/README.md | 32 ++++++ .../examples/invalid/attr-missing-type.json | 1 - .../examples/invalid/con-not-array.json | 1 - .../discon-missing-conjunction-level.json | 1 - .../schemas/examples/invalid/missing-con.json | 1 - .../examples/invalid/type-not-string.json | 1 - .../invalid/unknown-attribute-field.json | 3 + .../invalid/unknown-top-level-field.json | 4 + .../examples/invalid/validity-not-number.json | 1 - .../valid/condiscon-name-disjunction.json | 1 - .../schemas/examples/valid/flat-legacy.json | 1 - .../examples/valid/optional-attribute.json | 1 - .../valid/optional-discon-empty-alt-last.json | 1 - .../examples/valid/value-constrained.json | 1 - pg-pkg/schemas/irma-auth-request.schema.json | 4 +- pg-pkg/src/server.rs | 60 +++++++++- 18 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 pg-pkg/schemas/examples/README.md create mode 100644 pg-pkg/schemas/examples/invalid/unknown-attribute-field.json create mode 100644 pg-pkg/schemas/examples/invalid/unknown-top-level-field.json diff --git a/pg-core/src/api.rs b/pg-core/src/api.rs index 9315875..4a416a2 100644 --- a/pg-core/src/api.rs +++ b/pg-core/src/api.rs @@ -23,7 +23,13 @@ pub struct Parameters { /// first option, allowing the user to skip disclosing it in the Yivi app. /// /// This type is only used in API requests (JSON), not in the binary wire format. +/// +/// Unknown fields are rejected: in a key-issuance request a silently dropped +/// misspelled field (`vaule` for `v`, `optioanl` for `optional`) would *widen* +/// the disclosure the caller intended, so a typo must be a 400, not a +/// reinterpretation. #[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] pub struct DisclosureAttribute { /// Attribute type. #[serde(rename = "t")] @@ -45,6 +51,7 @@ pub struct DisclosureAttribute { /// `[{t,v?,optional?}, ...]` JSON shape still deserialises, because /// `ConItem` is `#[serde(untagged)]`. #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct IrmaAuthRequest { /// The conjunction of attributes (or disjunctions) for the disclosure request. pub con: Vec, @@ -71,7 +78,7 @@ pub struct KeyResponse { /// The request Signing key request body. #[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SigningKeyRequest { /// The public signing identity. pub pub_sign_id: Vec, @@ -89,7 +96,7 @@ pub struct SigningKeyRequest { /// disjunction-of-conjunctions (`OR` of `AND`), which deserializes into /// [`ConItem::Discon`]. An empty inner conjunction marks the discon as /// optional per Yivi convention. -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Clone)] #[serde(untagged)] pub enum ConItem { /// A single attribute, optionally marked `optional: true`. @@ -98,6 +105,52 @@ pub enum ConItem { Discon(Vec>), } +/// Manual [`Deserialize`] instead of `#[serde(untagged)]`: untagged enums +/// swallow the inner error ("data did not match any variant"), which is +/// useless to a client debugging a 400. The JSON shape already discriminates +/// the variants (object vs array), so dispatch on it and let the real error — +/// e.g. ``unknown field `vaule`, expected one of `t`, `v`, `optional``` — +/// propagate. +impl<'de> Deserialize<'de> for ConItem { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct ConItemVisitor; + + impl<'de> serde::de::Visitor<'de> for ConItemVisitor { + type Value = ConItem; + + fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str( + "an attribute object like {\"t\": …} or a disjunction \ + (array of conjunctions, i.e. array of arrays of attributes)", + ) + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + DisclosureAttribute::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(ConItem::Single) + } + + fn visit_seq(self, seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + Vec::>::deserialize( + serde::de::value::SeqAccessDeserializer::new(seq), + ) + .map(ConItem::Discon) + } + } + + deserializer.deserialize_any(ConItemVisitor) + } +} + /// The signing key response from the Private Key Generator (PKG). #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -161,6 +214,54 @@ mod tests { } } + /// Unknown fields in a request are rejected, not silently ignored: a + /// misspelled `v` would otherwise WIDEN the disclosure the caller + /// intended. The error must name the offending field so a client + /// developer can act on the 400. + #[test] + fn irma_auth_request_rejects_unknown_attribute_field() { + let body = + r#"{ "con": [ { "t": "pbdf.sidn-pbdf.email.email", "vaule": "alice@example.com" } ] }"#; + + let err = serde_json::from_str::(body) + .expect_err("a misspelled attribute field must be rejected"); + let msg = alloc::string::ToString::to_string(&err); + assert!( + msg.contains("vaule"), + "error must name the unknown field, got: {msg}" + ); + } + + /// Same at the top level: extra request fields are rejected. + #[test] + fn irma_auth_request_rejects_unknown_top_level_field() { + let body = r#"{ "con": [ { "t": "pbdf.sidn-pbdf.email.email" } ], "validty": 300 }"#; + + let err = serde_json::from_str::(body) + .expect_err("a misspelled top-level field must be rejected"); + let msg = alloc::string::ToString::to_string(&err); + assert!( + msg.contains("validty"), + "error must name the unknown field, got: {msg}" + ); + } + + /// The nesting mistake (a one-level array of attributes where a + /// disjunction needs arrays-of-arrays) yields the visitor's shape hint, + /// not an inscrutable untagged-enum error. + #[test] + fn con_item_nesting_mistake_gets_a_useful_error() { + let body = r#"{ "con": [ [ { "t": "pbdf.gemeente.personalData.fullname" } ] ] }"#; + + let err = serde_json::from_str::(body) + .expect_err("attributes directly inside a disjunction must be rejected"); + let msg = alloc::string::ToString::to_string(&err); + assert!( + !msg.contains("did not match any variant"), + "must not surface the untagged-enum catch-all, got: {msg}" + ); + } + /// Backwards-compat: an old-style flat `con` of `{t,v?,optional?}` objects /// must still parse into [`ConItem::Single`] variants. #[test] diff --git a/pg-pkg/api-description.yaml b/pg-pkg/api-description.yaml index 4e5526a..2e80ada 100644 --- a/pg-pkg/api-description.yaml +++ b/pg-pkg/api-description.yaml @@ -388,10 +388,11 @@ components: description: | When true, the PKG wraps the attribute in a disjunction with an empty option so the user may skip it in the Yivi app. + additionalProperties: false description: | - Unknown extra properties are ignored by the server (serde default) — - misspelled field names fail silently, so validate against - `schemas/irma-auth-request.schema.json` client-side. + Unknown fields are **rejected** (400, naming the offending field): + silently dropping a misspelled `v` or `optional` would widen the + disclosure the caller intended. ConItem: oneOf: - $ref: "#/components/schemas/DisclosureAttribute" @@ -422,11 +423,12 @@ components: maximum: 86400 default: 300 description: "Validity of the session-result JWT, in seconds." + additionalProperties: false description: | - The condiscon request accepted by `/v2/request/start`. Also pinned as - a standalone JSON Schema with canonical examples under - `pg-pkg/schemas/` — those examples are executable tests against the - server's parser. + The condiscon request accepted by `/v2/request/start`. Unknown fields + are rejected with a 400 naming the field. Also pinned as a standalone + JSON Schema with canonical examples under `pg-pkg/schemas/` — those + examples are executable tests against the server's parser. SessionData: type: object required: [sessionPtr, token] @@ -475,9 +477,16 @@ components: type: string nullable: true description: "Attribute value." + description: | + Deliberately lenient about extra fields (unlike `DisclosureAttribute`): + this type is shared with the binary wire format, the wasm/FFI seal + boundary and response policies, and in the signing-key request paths + its values are not semantically load-bearing (the identity comes from + the JWT or the business database). SigningKeyRequest: type: object required: [pubSignId] + additionalProperties: false properties: pubSignId: type: array diff --git a/pg-pkg/schemas/examples/README.md b/pg-pkg/schemas/examples/README.md new file mode 100644 index 0000000..80e5290 --- /dev/null +++ b/pg-pkg/schemas/examples/README.md @@ -0,0 +1,32 @@ +# Canonical condiscon examples + +Test vectors for `POST /v2/{irma|request}/start` request bodies, pinned by +`../irma-auth-request.schema.json` and **executable** against the server's +actual parser (`pg-pkg/tests/contract_examples.rs`): every `valid/` file must +parse as `pg_core::api::IrmaAuthRequest`, every `invalid/` file must be +rejected. JSON has no comments and the parser rejects unknown fields (so a +`$comment` key would itself make an example invalid) — the commentary lives +here instead. + +## valid/ + +| File | What it pins | +|---|---| +| `flat-legacy.json` | The pre-condiscon shape every deployed client sends: a flat conjunction of attribute objects. Must keep parsing forever. | +| `condiscon-name-disjunction.json` | The postguard-website sender-signing request: email plus a disjunction over name sources. | +| `optional-attribute.json` | `optional: true` on a top-level attribute; the PKG expands it into a disjunction with an empty option. | +| `optional-discon-empty-alt-last.json` | A skippable disjunction: the empty conjunction marks it optional (Yivi convention). Listed **last** as a workaround for irmamobile#360 — deployed apps mis-render an empty alternative listed first; the server accepts either order. | +| `value-constrained.json` | `v` constrains disclosure to an exact value. | + +## invalid/ + +| File | The mistake | +|---|---| +| `missing-con.json` | `con` is required. | +| `con-not-array.json` | `con` must be an array, not a bare attribute object. | +| `attr-missing-type.json` | Every attribute needs `t`. | +| `type-not-string.json` | `t` must be a string. | +| `validity-not-number.json` | `validity` is a number of seconds, not a string. | +| `discon-missing-conjunction-level.json` | The classic nesting mistake: a disjunction entry is an array of **conjunctions** (arrays), not of attributes. Wrap each alternative: `[[{...}]]`. | +| `unknown-attribute-field.json` | A misspelled field (`vaule` for `v`) is **rejected**, never silently ignored — silently dropping a value constraint would widen the disclosure the caller intended. The 400 names the field. | +| `unknown-top-level-field.json` | Same strictness at the request's top level (`validty` for `validity`). | diff --git a/pg-pkg/schemas/examples/invalid/attr-missing-type.json b/pg-pkg/schemas/examples/invalid/attr-missing-type.json index 328cd34..08bcfad 100644 --- a/pg-pkg/schemas/examples/invalid/attr-missing-type.json +++ b/pg-pkg/schemas/examples/invalid/attr-missing-type.json @@ -1,4 +1,3 @@ { - "$comment": "INVALID: every attribute needs t.", "con": [ { "v": "alice@example.com" } ] } diff --git a/pg-pkg/schemas/examples/invalid/con-not-array.json b/pg-pkg/schemas/examples/invalid/con-not-array.json index df3c895..f0f0fec 100644 --- a/pg-pkg/schemas/examples/invalid/con-not-array.json +++ b/pg-pkg/schemas/examples/invalid/con-not-array.json @@ -1,4 +1,3 @@ { - "$comment": "INVALID: con must be an array, not a single attribute object.", "con": { "t": "pbdf.sidn-pbdf.email.email" } } diff --git a/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json b/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json index f7b6d35..8331c52 100644 --- a/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json +++ b/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json @@ -1,5 +1,4 @@ { - "$comment": "INVALID: the classic nesting mistake. A disjunction entry is an array of CONJUNCTIONS (arrays), not of attributes — this one-level array is rejected. Wrap each alternative in its own array: [[{...}]].", "con": [ [ { "t": "pbdf.gemeente.personalData.fullname" } ] ] diff --git a/pg-pkg/schemas/examples/invalid/missing-con.json b/pg-pkg/schemas/examples/invalid/missing-con.json index dd85b43..9669d61 100644 --- a/pg-pkg/schemas/examples/invalid/missing-con.json +++ b/pg-pkg/schemas/examples/invalid/missing-con.json @@ -1,4 +1,3 @@ { - "$comment": "INVALID: con is required.", "validity": 300 } diff --git a/pg-pkg/schemas/examples/invalid/type-not-string.json b/pg-pkg/schemas/examples/invalid/type-not-string.json index c3b42b5..bf979c4 100644 --- a/pg-pkg/schemas/examples/invalid/type-not-string.json +++ b/pg-pkg/schemas/examples/invalid/type-not-string.json @@ -1,4 +1,3 @@ { - "$comment": "INVALID: t must be a string.", "con": [ { "t": 42 } ] } diff --git a/pg-pkg/schemas/examples/invalid/unknown-attribute-field.json b/pg-pkg/schemas/examples/invalid/unknown-attribute-field.json new file mode 100644 index 0000000..1090eeb --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/unknown-attribute-field.json @@ -0,0 +1,3 @@ +{ + "con": [ { "t": "pbdf.sidn-pbdf.email.email", "vaule": "alice@example.com" } ] +} diff --git a/pg-pkg/schemas/examples/invalid/unknown-top-level-field.json b/pg-pkg/schemas/examples/invalid/unknown-top-level-field.json new file mode 100644 index 0000000..786090f --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/unknown-top-level-field.json @@ -0,0 +1,4 @@ +{ + "con": [ { "t": "pbdf.sidn-pbdf.email.email" } ], + "validty": 300 +} diff --git a/pg-pkg/schemas/examples/invalid/validity-not-number.json b/pg-pkg/schemas/examples/invalid/validity-not-number.json index 753f866..cfe9ded 100644 --- a/pg-pkg/schemas/examples/invalid/validity-not-number.json +++ b/pg-pkg/schemas/examples/invalid/validity-not-number.json @@ -1,5 +1,4 @@ { - "$comment": "INVALID: validity is a number of seconds, not a string.", "con": [ { "t": "pbdf.sidn-pbdf.email.email" } ], "validity": "300" } diff --git a/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json b/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json index 16db69a..111af12 100644 --- a/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json +++ b/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json @@ -1,5 +1,4 @@ { - "$comment": "The postguard-website sender-signing request: email plus a disjunction over name sources (condiscon).", "con": [ { "t": "pbdf.sidn-pbdf.email.email" }, [ diff --git a/pg-pkg/schemas/examples/valid/flat-legacy.json b/pg-pkg/schemas/examples/valid/flat-legacy.json index 1140f60..9e6e9bc 100644 --- a/pg-pkg/schemas/examples/valid/flat-legacy.json +++ b/pg-pkg/schemas/examples/valid/flat-legacy.json @@ -1,5 +1,4 @@ { - "$comment": "The pre-condiscon shape every deployed client sends: a flat conjunction of attribute objects. Must keep parsing forever.", "con": [ { "t": "pbdf.sidn-pbdf.email.email" }, { "t": "pbdf.gemeente.personalData.fullname", "optional": true } diff --git a/pg-pkg/schemas/examples/valid/optional-attribute.json b/pg-pkg/schemas/examples/valid/optional-attribute.json index 1c41d5f..4bccb74 100644 --- a/pg-pkg/schemas/examples/valid/optional-attribute.json +++ b/pg-pkg/schemas/examples/valid/optional-attribute.json @@ -1,5 +1,4 @@ { - "$comment": "optional:true on a top-level attribute: the PKG expands it into a disjunction with an empty option.", "con": [ { "t": "pbdf.sidn-pbdf.email.email" }, { "t": "pbdf.sidn-pbdf.mobilenumber.mobilenumber", "optional": true } diff --git a/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json index c1dfa76..914b0bd 100644 --- a/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json +++ b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json @@ -1,5 +1,4 @@ { - "$comment": "A skippable disjunction: the empty conjunction marks it optional (Yivi convention). It is listed LAST as a workaround for irmamobile#360 — deployed apps mis-render an empty alternative listed first. The server accepts either order.", "con": [ { "t": "pbdf.sidn-pbdf.email.email" }, [ diff --git a/pg-pkg/schemas/examples/valid/value-constrained.json b/pg-pkg/schemas/examples/valid/value-constrained.json index bd1cc09..ceec322 100644 --- a/pg-pkg/schemas/examples/valid/value-constrained.json +++ b/pg-pkg/schemas/examples/valid/value-constrained.json @@ -1,5 +1,4 @@ { - "$comment": "v constrains disclosure to an exact value — used when the key must match a specific identity.", "con": [ { "t": "pbdf.sidn-pbdf.email.email", "v": "alice@example.com" } ], diff --git a/pg-pkg/schemas/irma-auth-request.schema.json b/pg-pkg/schemas/irma-auth-request.schema.json index 96ff9d1..4552ea1 100644 --- a/pg-pkg/schemas/irma-auth-request.schema.json +++ b/pg-pkg/schemas/irma-auth-request.schema.json @@ -2,9 +2,10 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://postguard.eu/schemas/irma-auth-request.schema.json", "title": "IrmaAuthRequest", - "description": "The condiscon disclosure request accepted by the PKG's POST /v2/{irma|request}/start. This schema is deliberately faithful to the server's parser (serde): unknown extra properties are IGNORED, not rejected — a misspelled field name fails silently, so validate client-side against this schema before sending. The canonical examples in examples/{valid,invalid}/ are executable tests against the server's actual parser (pg-pkg/tests/contract_examples.rs).", + "description": "The condiscon disclosure request accepted by the PKG's POST /v2/{irma|request}/start. Faithful to the server's parser (serde), which REJECTS unknown fields with a 400 naming the offending field: in a key-issuance request a silently dropped misspelled field would widen the disclosure the caller intended. The canonical examples in examples/{valid,invalid}/ are executable tests against the server's actual parser (pg-pkg/tests/contract_examples.rs).", "type": "object", "required": ["con"], + "additionalProperties": false, "properties": { "con": { "description": "Conjunction over the entries: the user must satisfy every entry.", @@ -22,6 +23,7 @@ "title": "DisclosureAttribute", "type": "object", "required": ["t"], + "additionalProperties": false, "properties": { "t": { "description": "Yivi attribute type, e.g. pbdf.sidn-pbdf.email.email.", diff --git a/pg-pkg/src/server.rs b/pg-pkg/src/server.rs index e98afcb..1195619 100644 --- a/pg-pkg/src/server.rs +++ b/pg-pkg/src/server.rs @@ -64,6 +64,27 @@ impl Guard for ApiKeyGuard { } } +/// JSON body handling for the `/v2` scope. Body-deserialization failures +/// (malformed JSON, unknown or missing fields — the request types are +/// `#[serde(deny_unknown_fields)]`) become a 400 in the documented +/// `{error, message}` envelope, carrying serde's client-actionable message +/// (e.g. ``unknown field `vaule`, expected one of `t`, `v`, `optional```). +fn json_config() -> web::JsonConfig { + web::JsonConfig::default() + .limit(64 * 1024) + .error_handler(|err, _req| { + let body = serde_json::json!({ + "error": true, + "message": err.to_string(), + }); + actix_web::error::InternalError::from_response( + err, + actix_web::HttpResponse::BadRequest().json(body), + ) + .into() + }) +} + /// Precomputed parameter data. #[derive(Debug, Clone)] pub struct ParametersData { @@ -246,7 +267,12 @@ pub async fn exec(server_opts: ServerOpts) -> Result<(), PKGError> { // layers in reverse registration order, so the Governor must be // wrapped after `collect_metrics` to sit in front of it. .wrap(Governor::new(&general_ratelimit)) - .app_data(Data::new(web::JsonConfig::default().limit(64 * 1024))) + // Body-deserialization failures (malformed JSON, unknown/missing + // fields — request types are #[serde(deny_unknown_fields)]) must + // use the documented {error, message} envelope, not actix's + // default plain-text body. The serde message is client-actionable + // ("unknown field `vaule`, expected one of `t`, `v`, `optional`"). + .app_data(Data::new(json_config())) .service( resource("/parameters") .app_data(Data::new(ibe_pd.clone())) @@ -1349,4 +1375,36 @@ pub(crate) mod tests { ); } } + + /// A request with a misspelled field must be a 400 in the documented + /// `{error, message}` envelope, with the message naming the field — not + /// silently accepted (deny_unknown_fields), and not actix's default + /// plain-text error body. + #[actix_web::test] + async fn test_unknown_request_field_yields_400_envelope() { + use pg_core::api::IrmaAuthRequest; + + let app = test::init_service(App::new().app_data(Data::new(json_config())).service( + resource("/echo").route(web::post().to(|_body: web::Json| async { + actix_web::HttpResponse::Ok().finish() + })), + )) + .await; + + let req = test::TestRequest::post() + .uri("/echo") + .insert_header(("Content-Type", "application/json")) + .set_payload(r#"{ "con": [ { "t": "pbdf.sidn-pbdf.email.email", "vaule": "x" } ] }"#) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 400); + + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["error"], serde_json::Value::Bool(true)); + let msg = body["message"].as_str().expect("message must be a string"); + assert!( + msg.contains("vaule"), + "the 400 must name the unknown field, got: {msg}" + ); + } } From cb38915048d1a1b8138d3619c976535f552df40f Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 16 Jul 2026 17:30:20 +0200 Subject: [PATCH 3/4] fix(pkg): emit the optional empty alternative last; correct jwt/statusevents response docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract docs surfaced a contradiction: clients place a discon's empty alternative LAST to dodge irmamobile#360 (apps mis-render empty-first), but the server's own optional:true expansion emitted it FIRST — tripping the very bug the workaround exists for. The expansion now appends it last; either order is still accepted on input, and the schema/OpenAPI notes say so. Also per review: /v2/request/jwt/{token} maps upstream error statuses to 503 (UpstreamError), not the previously documented 502, and both jwt and statusevents additionally return 400 (malformed session token) and 500 (upstream unreachable) — now documented. --- pg-core/src/api.rs | 3 +- pg-pkg/api-description.yaml | 33 +++++++++++++++++--- pg-pkg/schemas/irma-auth-request.schema.json | 2 +- pg-pkg/src/handlers/start.rs | 17 ++++++---- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/pg-core/src/api.rs b/pg-core/src/api.rs index 4a416a2..2ca0c63 100644 --- a/pg-core/src/api.rs +++ b/pg-core/src/api.rs @@ -20,7 +20,8 @@ pub struct Parameters { /// An attribute in a disclosure request, extending [`Attribute`] with an `optional` flag. /// /// When `optional` is true, the PKG wraps this attribute in a disjunction with an empty -/// first option, allowing the user to skip disclosing it in the Yivi app. +/// option (listed last — see irmamobile#360), allowing the user to skip disclosing it +/// in the Yivi app. /// /// This type is only used in API requests (JSON), not in the binary wire format. /// diff --git a/pg-pkg/api-description.yaml b/pg-pkg/api-description.yaml index 2e80ada..8342792 100644 --- a/pg-pkg/api-description.yaml +++ b/pg-pkg/api-description.yaml @@ -187,8 +187,20 @@ paths: schema: type: string description: "A compact JWS (three dot-separated segments)." - "502": - description: "Upstream IRMA server error (e.g. unknown session)." + "400": + description: "Malformed session token." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: "The IRMA server was unreachable." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + description: "Upstream IRMA server error status (e.g. unknown session)." content: application/json: schema: @@ -215,8 +227,20 @@ paths: text/event-stream: schema: type: string + "400": + description: "Malformed session token." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: "Unexpected upstream failure while opening the stream." + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "503": - description: "Upstream IRMA server unreachable." + description: "Upstream IRMA server unreachable or returned an error status." content: application/json: schema: @@ -401,7 +425,8 @@ components: A Yivi disjunction-of-conjunctions (OR of ANDs). An **empty inner conjunction** marks the whole disjunction optional per Yivi convention. Note irmamobile#360: deployed apps mis-render an - empty alternative listed first — clients currently place it last. + empty alternative listed first — place it last, as the server's + own `optional: true` expansion does. Either order is accepted. items: type: array items: diff --git a/pg-pkg/schemas/irma-auth-request.schema.json b/pg-pkg/schemas/irma-auth-request.schema.json index 4552ea1..8b2873f 100644 --- a/pg-pkg/schemas/irma-auth-request.schema.json +++ b/pg-pkg/schemas/irma-auth-request.schema.json @@ -42,7 +42,7 @@ }, "conjunction": { "title": "Conjunction", - "description": "An AND of attributes. An EMPTY conjunction inside a disjunction marks the whole disjunction as skippable (Yivi's optional-disjunction convention). Ordering note (irmamobile#360): deployed Yivi apps mis-render a disjunction whose empty alternative comes FIRST, so clients currently place the empty alternative LAST; the server accepts either order.", + "description": "An AND of attributes. An EMPTY conjunction inside a disjunction marks the whole disjunction as skippable (Yivi's optional-disjunction convention). Ordering note (irmamobile#360): deployed Yivi apps mis-render a disjunction whose empty alternative comes FIRST, so place the empty alternative LAST — the server's own optional:true expansion does the same. The server accepts either order on input.", "type": "array", "items": { "$ref": "#/$defs/attribute" } }, diff --git a/pg-pkg/src/handlers/start.rs b/pg-pkg/src/handlers/start.rs index 19b052e..aa14647 100644 --- a/pg-pkg/src/handlers/start.rs +++ b/pg-pkg/src/handlers/start.rs @@ -58,8 +58,11 @@ fn con_item_to_discon(item: &ConItem) -> Vec> { ConItem::Single(attr) => { let ar = attr_to_request(attr); if attr.optional { - // Empty first option lets the user skip this attribute. - vec![vec![], vec![ar]] + // An empty option lets the user skip this attribute. It goes + // LAST: deployed Yivi apps mis-render a disjunction whose + // empty alternative comes first (irmamobile#360) — the same + // workaround clients apply to their own discons. + vec![vec![ar], vec![]] } else { vec![vec![ar]] } @@ -121,14 +124,16 @@ mod tests { } #[test] - fn single_optional_prepends_empty_alternative() { + fn single_optional_appends_empty_alternative_last() { let mut a = attr("pbdf.sidn-pbdf.mobilenumber.mobilenumber"); a.optional = true; let item = ConItem::Single(a); let dis = con_item_to_discon(&item); - assert_eq!(dis.len(), 2, "optional: empty alt + actual"); - assert!(dis[0].is_empty(), "empty alternative first"); - assert_eq!(dis[1].len(), 1); + assert_eq!(dis.len(), 2, "optional: actual + empty alt"); + assert_eq!(dis[0].len(), 1, "actual attribute first"); + // LAST, not first: deployed Yivi apps mis-render an empty-first + // alternative (irmamobile#360). + assert!(dis[1].is_empty(), "empty alternative last"); } #[test] From d18b807a6434ac7866de0c42076b8c80ea29d276 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Thu, 16 Jul 2026 17:37:47 +0200 Subject: [PATCH 4/4] fix(pkg): drop the obsolete irmamobile#360 ordering workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #360 mis-render of empty-first alternatives is fixed and shipped (irmamobile PRs #557/#558, released in v8.1.0 on 2026-07-10; issue closed 2026-07-14), and postguard-website has already reverted to the conventional leading-empty order. Prescribing empty-last in the new contract docs — and flipping the server's optional expansion to match — was codifying a workaround for a dead bug. - start.rs: optional expansion back to the conventional empty-first - schema/OpenAPI/api.rs: ordering documented as presentational with either order accepted; #360 kept only as a one-line historical note - examples: condiscon-name-disjunction.json now mirrors the website's actual current request verbatim (leading empty alternative, four name sources, optional phone/dob); optional-discon example renamed and reordered --- pg-core/src/api.rs | 3 +-- pg-pkg/api-description.yaml | 7 ++++--- pg-pkg/schemas/examples/README.md | 4 ++-- .../valid/condiscon-name-disjunction.json | 9 ++++++++- ...st.json => optional-discon-empty-alt.json} | 4 ++-- pg-pkg/schemas/irma-auth-request.schema.json | 2 +- pg-pkg/src/handlers/start.rs | 20 +++++++++---------- 7 files changed, 27 insertions(+), 22 deletions(-) rename pg-pkg/schemas/examples/valid/{optional-discon-empty-alt-last.json => optional-discon-empty-alt.json} (52%) diff --git a/pg-core/src/api.rs b/pg-core/src/api.rs index 2ca0c63..4a416a2 100644 --- a/pg-core/src/api.rs +++ b/pg-core/src/api.rs @@ -20,8 +20,7 @@ pub struct Parameters { /// An attribute in a disclosure request, extending [`Attribute`] with an `optional` flag. /// /// When `optional` is true, the PKG wraps this attribute in a disjunction with an empty -/// option (listed last — see irmamobile#360), allowing the user to skip disclosing it -/// in the Yivi app. +/// first option, allowing the user to skip disclosing it in the Yivi app. /// /// This type is only used in API requests (JSON), not in the binary wire format. /// diff --git a/pg-pkg/api-description.yaml b/pg-pkg/api-description.yaml index 8342792..487a7bf 100644 --- a/pg-pkg/api-description.yaml +++ b/pg-pkg/api-description.yaml @@ -424,9 +424,10 @@ components: description: | A Yivi disjunction-of-conjunctions (OR of ANDs). An **empty inner conjunction** marks the whole disjunction optional per Yivi - convention. Note irmamobile#360: deployed apps mis-render an - empty alternative listed first — place it last, as the server's - own `optional: true` expansion does. Either order is accepted. + convention (conventionally listed first). Ordering is + presentational only; either order is accepted. (Historical: apps + before irmamobile v8.1.0 mis-rendered an empty-first alternative + — irmamobile#360, fixed.) items: type: array items: diff --git a/pg-pkg/schemas/examples/README.md b/pg-pkg/schemas/examples/README.md index 80e5290..218002f 100644 --- a/pg-pkg/schemas/examples/README.md +++ b/pg-pkg/schemas/examples/README.md @@ -13,9 +13,9 @@ here instead. | File | What it pins | |---|---| | `flat-legacy.json` | The pre-condiscon shape every deployed client sends: a flat conjunction of attribute objects. Must keep parsing forever. | -| `condiscon-name-disjunction.json` | The postguard-website sender-signing request: email plus a disjunction over name sources. | +| `condiscon-name-disjunction.json` | The postguard-website sender-signing request verbatim: email, a skippable disjunction over four name sources (leading empty alternative), optional phone and date of birth. | | `optional-attribute.json` | `optional: true` on a top-level attribute; the PKG expands it into a disjunction with an empty option. | -| `optional-discon-empty-alt-last.json` | A skippable disjunction: the empty conjunction marks it optional (Yivi convention). Listed **last** as a workaround for irmamobile#360 — deployed apps mis-render an empty alternative listed first; the server accepts either order. | +| `optional-discon-empty-alt.json` | A skippable disjunction: the empty conjunction marks it optional (Yivi convention, conventionally listed first). Ordering is presentational; either order parses. (Historical: apps before irmamobile v8.1.0 mis-rendered empty-first — irmamobile#360, fixed.) | | `value-constrained.json` | `v` constrains disclosure to an exact value. | ## invalid/ diff --git a/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json b/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json index 111af12..773f2b3 100644 --- a/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json +++ b/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json @@ -2,6 +2,7 @@ "con": [ { "t": "pbdf.sidn-pbdf.email.email" }, [ + [], [ { "t": "pbdf.gemeente.personalData.fullname" } ], [ { "t": "pbdf.pbdf.passport.firstName" }, @@ -10,8 +11,14 @@ [ { "t": "pbdf.pbdf.idcard.firstName" }, { "t": "pbdf.pbdf.idcard.lastName" } + ], + [ + { "t": "pbdf.pbdf.drivinglicence.firstName" }, + { "t": "pbdf.pbdf.drivinglicence.lastName" } ] - ] + ], + { "t": "pbdf.sidn-pbdf.mobilenumber.mobilenumber", "optional": true }, + { "t": "pbdf.gemeente.personalData.dateofbirth", "optional": true } ], "validity": 300 } diff --git a/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt.json similarity index 52% rename from pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json rename to pg-pkg/schemas/examples/valid/optional-discon-empty-alt.json index 914b0bd..a211563 100644 --- a/pg-pkg/schemas/examples/valid/optional-discon-empty-alt-last.json +++ b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt.json @@ -2,8 +2,8 @@ "con": [ { "t": "pbdf.sidn-pbdf.email.email" }, [ - [ { "t": "pbdf.gemeente.personalData.fullname" } ], - [] + [], + [ { "t": "pbdf.gemeente.personalData.fullname" } ] ] ] } diff --git a/pg-pkg/schemas/irma-auth-request.schema.json b/pg-pkg/schemas/irma-auth-request.schema.json index 8b2873f..87697fa 100644 --- a/pg-pkg/schemas/irma-auth-request.schema.json +++ b/pg-pkg/schemas/irma-auth-request.schema.json @@ -42,7 +42,7 @@ }, "conjunction": { "title": "Conjunction", - "description": "An AND of attributes. An EMPTY conjunction inside a disjunction marks the whole disjunction as skippable (Yivi's optional-disjunction convention). Ordering note (irmamobile#360): deployed Yivi apps mis-render a disjunction whose empty alternative comes FIRST, so place the empty alternative LAST — the server's own optional:true expansion does the same. The server accepts either order on input.", + "description": "An AND of attributes. An EMPTY conjunction inside a disjunction marks the whole disjunction as skippable (Yivi's optional-disjunction convention, conventionally listed first). Ordering is presentational only; the server accepts either order. (Historical: Yivi apps before irmamobile v8.1.0 mis-rendered an empty-first alternative — irmamobile#360, fixed.)", "type": "array", "items": { "$ref": "#/$defs/attribute" } }, diff --git a/pg-pkg/src/handlers/start.rs b/pg-pkg/src/handlers/start.rs index aa14647..072db0b 100644 --- a/pg-pkg/src/handlers/start.rs +++ b/pg-pkg/src/handlers/start.rs @@ -58,11 +58,11 @@ fn con_item_to_discon(item: &ConItem) -> Vec> { ConItem::Single(attr) => { let ar = attr_to_request(attr); if attr.optional { - // An empty option lets the user skip this attribute. It goes - // LAST: deployed Yivi apps mis-render a disjunction whose - // empty alternative comes first (irmamobile#360) — the same - // workaround clients apply to their own discons. - vec![vec![ar], vec![]] + // Empty first option lets the user skip this attribute + // (Yivi convention). Ordering is presentational only; apps + // before irmamobile v8.1.0 mis-rendered this order + // (irmamobile#360), fixed since. + vec![vec![], vec![ar]] } else { vec![vec![ar]] } @@ -124,16 +124,14 @@ mod tests { } #[test] - fn single_optional_appends_empty_alternative_last() { + fn single_optional_prepends_empty_alternative() { let mut a = attr("pbdf.sidn-pbdf.mobilenumber.mobilenumber"); a.optional = true; let item = ConItem::Single(a); let dis = con_item_to_discon(&item); - assert_eq!(dis.len(), 2, "optional: actual + empty alt"); - assert_eq!(dis[0].len(), 1, "actual attribute first"); - // LAST, not first: deployed Yivi apps mis-render an empty-first - // alternative (irmamobile#360). - assert!(dis[1].is_empty(), "empty alternative last"); + assert_eq!(dis.len(), 2, "optional: empty alt + actual"); + assert!(dis[0].is_empty(), "empty alternative first"); + assert_eq!(dis[1].len(), 1); } #[test]