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 new file mode 100644 index 0000000..487a7bf --- /dev/null +++ b/pg-pkg/api-description.yaml @@ -0,0 +1,576 @@ +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)." + "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: + $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 + "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 or returned an error status." + 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. + additionalProperties: false + description: | + 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" + - type: array + description: | + A Yivi disjunction-of-conjunctions (OR of ANDs). An **empty inner + conjunction** marks the whole disjunction optional per Yivi + 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: + $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." + additionalProperties: false + description: | + 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] + 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." + 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 + 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/README.md b/pg-pkg/schemas/examples/README.md new file mode 100644 index 0000000..218002f --- /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 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.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/ + +| 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 new file mode 100644 index 0000000..08bcfad --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/attr-missing-type.json @@ -0,0 +1,3 @@ +{ + "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..f0f0fec --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/con-not-array.json @@ -0,0 +1,3 @@ +{ + "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..8331c52 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/discon-missing-conjunction-level.json @@ -0,0 +1,5 @@ +{ + "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..9669d61 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/missing-con.json @@ -0,0 +1,3 @@ +{ + "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..bf979c4 --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/type-not-string.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 0000000..cfe9ded --- /dev/null +++ b/pg-pkg/schemas/examples/invalid/validity-not-number.json @@ -0,0 +1,4 @@ +{ + "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..773f2b3 --- /dev/null +++ b/pg-pkg/schemas/examples/valid/condiscon-name-disjunction.json @@ -0,0 +1,24 @@ +{ + "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" } + ], + [ + { "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/flat-legacy.json b/pg-pkg/schemas/examples/valid/flat-legacy.json new file mode 100644 index 0000000..9e6e9bc --- /dev/null +++ b/pg-pkg/schemas/examples/valid/flat-legacy.json @@ -0,0 +1,6 @@ +{ + "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..4bccb74 --- /dev/null +++ b/pg-pkg/schemas/examples/valid/optional-attribute.json @@ -0,0 +1,6 @@ +{ + "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.json b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt.json new file mode 100644 index 0000000..a211563 --- /dev/null +++ b/pg-pkg/schemas/examples/valid/optional-discon-empty-alt.json @@ -0,0 +1,9 @@ +{ + "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..ceec322 --- /dev/null +++ b/pg-pkg/schemas/examples/valid/value-constrained.json @@ -0,0 +1,6 @@ +{ + "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..87697fa --- /dev/null +++ b/pg-pkg/schemas/irma-auth-request.schema.json @@ -0,0 +1,64 @@ +{ + "$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. 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.", + "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"], + "additionalProperties": false, + "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, 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" } + }, + "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/src/handlers/start.rs b/pg-pkg/src/handlers/start.rs index 19b052e..072db0b 100644 --- a/pg-pkg/src/handlers/start.rs +++ b/pg-pkg/src/handlers/start.rs @@ -58,7 +58,10 @@ 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. + // 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]] 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}" + ); + } } 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" + ); + } +}