From ab4e65163c9b15728c3f048700e9abf612735bb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 6 Oct 2026 17:33:49 +0900 Subject: [PATCH 1/2] feat(identity): publish subject-assertion trust contract v1 (#155) Add an offline ES256/RS256 OIDC ID-token verifier with a deployment-pinned profile, closed rejection reasons, and an explicit durable versus session-only correlation decision. Publish its JSON Schema receipt and a signed conformance fixture catalog under docs/contracts, plus ADR-0020 and the standards doctoring record. Independent adversarial review findings (lone-surrogate crash, NaN clock, mutable key set, non-UTF-8 JSON, invisible subject characters, non-canonical base64url, unbounded NumericDates, weak key and profile inputs, schema length) were reproduced as failing tests before repair. --- ARCHITECTURE.md | 7 + CHANGELOG.md | 6 + docs/TRACEABILITY.md | 1 + .../0020-subject-assertion-trust-contract.md | 104 +++ docs/adr/README.md | 4 +- docs/contracts/README.md | 50 ++ .../subject-assertion-v1.fixtures.json | 207 +++++ .../subject-assertion-v1.schema.json | 83 ++ .../subject-assertion-trust-contract.md | 181 ++++ .../app/subject_assertion.py | 569 ++++++++++++ .../tests/test_subject_assertion.py | 823 ++++++++++++++++++ .../generate_subject_assertion_fixtures.py | 134 +++ 12 files changed, 2168 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0020-subject-assertion-trust-contract.md create mode 100644 docs/contracts/README.md create mode 100644 docs/contracts/subject-assertion-v1.fixtures.json create mode 100644 docs/contracts/subject-assertion-v1.schema.json create mode 100644 docs/doctoring/subject-assertion-trust-contract.md create mode 100644 services/account_unification/app/subject_assertion.py create mode 100644 services/account_unification/tests/test_subject_assertion.py create mode 100644 services/account_unification/tools/generate_subject_assertion_fixtures.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f68965d0..cad0d2c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -186,6 +186,13 @@ and then apply bounded role/scope/group RBAC. A registered client or accepted mapper receipt never grants authorization by itself; see ADR-0008 for the non-fork application matrix and remediation gates. +An RP that stores a Keyverse subject as a durable link uses the +`keyverse.subject-assertion/v1` contract (ADR-0020, +[`docs/contracts/`](docs/contracts/README.md)). Its verifier is pure and +offline: the deployment pins issuer, audience, algorithms, public keys, and +subject type. Only `public` and `pairwise` profiles return a `durable` +correlation key; `ephemeral` profiles authenticate the session only. + ## Account and provisioning invariants 1. Matching precedence is exact `(identity_provider, subject)`, then verified diff --git a/CHANGELOG.md b/CHANGELOG.md index 4639f1aa..c6d453c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ Keep a Changelog, and releases use semantic versioning. ### Added +- `keyverse.subject-assertion/v1` (ADR-0020, #155): an offline ES256/RS256 ID + token verifier with a deployment-pinned profile, closed rejection reasons, + and an explicit `durable` versus `session_only` correlation decision; a + JSON Schema 2020-12 receipt contract and a signed conformance fixture + catalog under `docs/contracts/`. No immutable release is claimed yet. + - ADR-0008 and the non-fork RP authorization matrix, requiring explicit Keyverse token validation, tenant/resource ABAC, bounded RBAC, and cross-tenant acceptance evidence per application. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 61c5e185..764eefb7 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -26,6 +26,7 @@ | pg-erd-cloud Keyverse organization boundary | ADR-0008; verified tenant binding before project authorization | pg-erd-cloud PR #855 `e4b4771` exact `org`/audience/`iat` checks, single-tenant profile, API-key bypass denial; shared multi-tenant persistence remains unimplemented | active-PR | | contextual-orchestrator Keyverse identity and tenant boundary | ADR-0008; deployment-owned OIDC validation plus downstream scope RBAC and org/workspace ABAC | PR #109 `32ba3a9` adds the injected verifier seam; stacked PR #110 `8607eba` requires `VerifiedIdentity`, rejects boolean-only authorization, binds workflow/evaluation/batch resources to secret-free tenant context, and denies cross-tenant or ownerless reads; main remains unchanged until both protected PRs merge | active-PR | | sidecar anonymous-access boundary | ADR-0008; private service-boundary and least-privilege policy | newsdom-api protected `develop` `3d0426b` (PR #595) fail-closed token gate, startup credential registry, explicit anonymous opt-in, review-fixed authenticated examples/healthcheck/401 contract, and pypdf Trivy remediation; Keyverse-aware gateway evidence remains required for exposure | implemented-main | +| subject-assertion trust and durable RP correlation (#155) | ADR-0020; OIDC Core §2, §3.1.3.7, §8; RFC 7515, RFC 7518, RFC 7519, RFC 8725 | `app/subject_assertion.py`, `docs/contracts/subject-assertion-v1.*`, `tests/test_subject_assertion.py`; real Keycloak token acceptance and immutable release remain unclaimed | active-PR | | 100% production statement/branch/docstring | CWL quality contract | CI/pytest/interrogate | implemented-main | | product and technical gap baseline | PRD/TRD/ADR/operability and live exact-head evidence | [`docs/product-technical-gap-baseline.md`](product-technical-gap-baseline.md) plus [`docs/doctoring/product-technical-gap-baseline.md`](doctoring/product-technical-gap-baseline.md) | active-PR | diff --git a/docs/adr/0020-subject-assertion-trust-contract.md b/docs/adr/0020-subject-assertion-trust-contract.md new file mode 100644 index 00000000..5fcb7205 --- /dev/null +++ b/docs/adr/0020-subject-assertion-trust-contract.md @@ -0,0 +1,104 @@ +# ADR-0020: Publish a versioned subject-assertion trust contract for durable RP binding + +**Status:** Proposed +**Date:** 2026-10-06 +**Issue:** [#155](https://github.com/ContextualWisdomLab/keyverse/issues/155) + +## Context + +Orgmetra (#297), ConceptWeave (#44), and CalendarWeave (#2) need to store a +Keyverse `(issuer, subject)` pair as a long-lived link to their own records. +Protected `main` gives them no owner-published rule for two questions: + +1. Was this ID token verified under the exact Keyverse profile (issuer, + signature and algorithm, audience, time claims, subject, tenant)? +2. May this subject be stored for durable correlation, or is it valid only for + the current session? + +OpenID Connect Core 1.0 §2 and §8 define `sub` as locally unique and never +reassigned for the `public` and `pairwise` subject types. The OpenID Connect +Ephemeral Subject Identifier 1.0 specification intentionally changes `sub` +across visits. A consumer that persists every syntactically valid `sub` would +create broken or misleading links under an ephemeral profile. Each consumer +currently refuses to invent a local `authenticated=true` receipt, so they fail +closed. + +## Decision + +1. Keyverse owns a closed, versioned contract named + `keyverse.subject-assertion/v1`. Its parts are: + - the pure offline verifier `services/account_unification/app/subject_assertion.py`; + - the receipt JSON Schema `docs/contracts/subject-assertion-v1.schema.json`; + - the conformance catalog `docs/contracts/subject-assertion-v1.fixtures.json`, + produced by `tools/generate_subject_assertion_fixtures.py` with a + throwaway key. +2. The deployment profile pins the exact HTTPS issuer, one audience, an + allowed subset of `ES256`/`RS256`, a public JWK Set, the subject type, an + optional canonical `org` tenant claim, and a clock leeway of 0–300 seconds. + An unsafe profile fails at construction, before any token is read. +3. The verifier performs no discovery, JWKS fetch, DNS, socket, storage, or + Keycloak call. Header members other than `alg`, `typ`, and `kid` are + rejected, so `crit`, `jku`, `x5u`, `jwk`, and `x5c` never select a key. + `typ` must be absent or the `application/jwt` media type, compared + case-insensitively (RFC 7515 §4.1.9); the Keycloak payload `typ`, when + present, must be `ID`. Access tokens (`at+jwt` or `Bearer`) are not subject assertions. +4. The audience rule follows OIDC Core §3.1.3.7: `aud` contains this RP, any + other audience must be listed in the profile's + `trusted_additional_audiences`, a multi-valued `aud` requires `azp`, and a + present `azp` must equal this RP. +5. Correlation eligibility is a profile property, not a token claim. + `public` and `pairwise` return `correlation: durable` plus a SHA-256 + `correlation_key` over `[contract_version, issuer, audience, tenant, + subject]`. `ephemeral` returns `correlation: session_only` and no key. +6. A rejected receipt carries exactly one closed reason and no identity field. + The receipt never contains raw token bytes. +7. Key rotation is a profile change: an RP keeps the old public key in the + profile until tokens signed by it expire, then builds a new profile without + it. A removed key yields `unknown_key`. The profile keeps a read-only deep + copy of its key set, so editing the caller's dict cannot add or revoke trust. +8. Input hygiene is strict so that every conforming verifier makes the same + decision. Segments must be canonical base64url. JSON must be strict UTF-8 + with no duplicate members and no lone surrogates. `sub` and `org` must not + contain control, format, surrogate, or line/paragraph separator characters + or edge whitespace. NumericDates are integers in 0..2^53-1, and `exp` must + not be before `iat`. The caller's `now` must be an integer; a NaN or float + clock raises `TypeError` instead of silently disabling the time checks. +9. Trusted RSA keys have 2048 to 8192 bits and a public exponent of at least + 65537. EC coordinates are exactly 32 bytes. `key_ops`, when present, is + exactly `["verify"]`. The correlation key also binds the subject type, so + changing a client between `public` and `pairwise` cannot reuse a key. + +## Evidence boundary + +| Claim | Status | +|---|---| +| Offline verifier, schema, and fixtures with 100% statement and branch coverage | `active-PR` | +| Real Keycloak 26 ID token accepted by this verifier | `gap-not-claimed` | +| Keycloak support for an ephemeral subject mode | `gap-not-claimed`; no supported Keyverse profile uses it today | +| Immutable release artifact, SBOM, provenance, rollback | `gap-not-claimed`; required by #155 and #158 before consumers pin it | +| Downstream ACL, tenant membership, and resource authorization | consumer-owned; ADR-0008 still applies | + +## Consequences + +- Consumers have one owner-defined rule for persistence eligibility and do not + need a parallel receipt schema. #158 must reuse this contract rather than + publish a second subject receipt. +- The contract does not change the closed RP mapper profile. It adds no claim, + audience, or mapper. +- A verified receipt proves authentication evidence, not authorization. #160 + remains a separate runtime resource-decision contract. +- The release envelope (version, digest, SBOM, provenance) stays open. A + merged PR is not a consumable release. + +## Alternatives rejected + +- **Online JWKS discovery in the verifier.** Violates the no-network preflight + pattern and makes results depend on mutable remote state. +- **A token claim that says "durable".** The issuer would need a new mapper, + which the closed mapper profile forbids, and a claim cannot describe how the + issuer assigns subjects. +- **Persist `sub` without the tenant and audience.** A pairwise or + multi-tenant deployment could then merge distinct identities. + +See `docs/doctoring/subject-assertion-trust-contract.md` for the standards +interpretation and APA 7th references. diff --git a/docs/adr/README.md b/docs/adr/README.md index aad6a6a4..53b25d05 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,13 +18,15 @@ authorization boundary and is not rewritten by that expansion. | [0007](0007-automation-authority.md) | Autonomous development remains separate from review/merge/release authority | Accepted | | [0008](0008-keyverse-rp-authorization-boundary.md) | Every non-fork RP explicitly validates Keyverse identity and manages ABAC/RBAC at its own boundary | Accepted | | [0013](0013-mcp-oauth-client-authorization.md) | Use Keycloak-backed authorization code plus PKCE and exact resource binding for MCP clients | Proposed | +| [0020](0020-subject-assertion-trust-contract.md) | Publish a versioned offline subject-assertion verifier, receipt schema, and fixtures that classify durable versus session-only RP correlation | Proposed | ADR numbering note: protected `main` currently ends at ADR-0008. ADR-0009 is proposed in the open LineageWeave claim-profile PR, and ADR-0010 through ADR-0012 are proposed in the open authorization-plane PR. ADR-0013 preserves the next intended number without renumbering parallel work; it must be reconciled after those PRs land, and none of the absent records is accepted -architecture on protected `main` yet. +architecture on protected `main` yet. ADR-0014 through ADR-0019 are proposed in +other open PRs, so the subject-assertion contract uses ADR-0020. ## ADR triggers diff --git a/docs/contracts/README.md b/docs/contracts/README.md new file mode 100644 index 00000000..b06d3322 --- /dev/null +++ b/docs/contracts/README.md @@ -0,0 +1,50 @@ +# Keyverse consumer contracts + +This directory holds versioned contracts that relying parties may consume +without copying Keyverse source. + +## `keyverse.subject-assertion/v1` + +Use this contract when your application wants to store a Keyverse user's +`(issuer, subject)` as a long-lived link to one of your records. + +| File | Purpose | +|---|---| +| `subject-assertion-v1.schema.json` | JSON Schema 2020-12 for the verifier receipt. | +| `subject-assertion-v1.fixtures.json` | Signed conformance cases with expected receipts. The signing key is throwaway test material. | + +The reference verifier is +`services/account_unification/app/subject_assertion.py`. Decision record: +[ADR-0020](../adr/0020-subject-assertion-trust-contract.md). + +### How to consume + +1. Configure a profile with the exact issuer, your client ID as audience, the + allowed algorithms, the realm's public JWK Set, the subject type, and + `org` when your application is tenant-scoped. List any other audience you + accept in `trusted_additional_audiences`; every unlisted extra audience is + rejected (OIDC Core §3.1.3.7). Load the JWK Set from your + deployment configuration. The verifier does not fetch it. +2. Verify each ID token after the authorization-code exchange. Pass the + `nonce` you sent in the authorization request as `expected_nonce`. +3. Persist a binding only when `outcome == "verified"` and + `correlation == "durable"`. Use `correlation_key` as the lookup key. +4. When `correlation == "session_only"`, authenticate the session but do not + store the subject. +5. Treat every `rejected` receipt as no authentication. Use `reason` for + metrics and support, not for authorization. +6. Apply your own tenant membership, resource, and role checks after + verification (ADR-0008). A verified receipt is not authorization. + +### Conformance in another language + +Load `subject-assertion-v1.fixtures.json`, build the profile from its +`profile` member, verify each `token` at time `now`, and compare your receipt +with `expected_receipt`. Every case must match exactly. + +### Current limits + +- `v1` is published from repository source only. No immutable release, + digest, SBOM, or provenance exists yet (#155, #158). +- The fixtures prove decision equivalence. They do not prove that a live + Keycloak deployment issues tokens with this exact shape. diff --git a/docs/contracts/subject-assertion-v1.fixtures.json b/docs/contracts/subject-assertion-v1.fixtures.json new file mode 100644 index 00000000..2604f050 --- /dev/null +++ b/docs/contracts/subject-assertion-v1.fixtures.json @@ -0,0 +1,207 @@ +{ + "contract_version": "keyverse.subject-assertion/v1", + "description": "Synthetic conformance cases. The signing key is throwaway test material; it is not a Keyverse deployment key.", + "now": 1790000000, + "profile": { + "issuer": "https://id.contextualwisdom.example/realms/cwl", + "audience": "orgmetra-web", + "allowed_algorithms": [ + "ES256" + ], + "trusted_keys": { + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "kid": "fixture-es256-v1", + "use": "sig", + "alg": "ES256", + "x": "zKt9bEnP4_5l525CKpJOZ7rqHQI_z23oJkI762oNg7s", + "y": "WZdGn3m0bwRgRB5XaaAFVaJPKPvZGKJKZb12QwelU30" + } + ] + }, + "subject_type": "public", + "required_tenant_claim": "org", + "leeway_seconds": 60, + "trusted_additional_audiences": [ + "account" + ] + }, + "cases": [ + { + "name": "valid_public_subject", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.Et9x-pNl_S2XHGE15pbQe10C9cK0xxzfr3Y06lBsPnFcGR8z679xSlanBehhzUAgnGIGaAzMpra4lKgw6hwLWA", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "verified", + "issuer": "https://id.contextualwisdom.example/realms/cwl", + "subject": "f3b0c9a2-6d1e-4c55-9b7a-2f0e8d1c4b6a", + "audience": "orgmetra-web", + "tenant": "tenant-acme", + "subject_type": "public", + "correlation": "durable", + "correlation_key": "95983108b87e38d813d61691272534514b92b804ee87f1387aec682406b64730", + "key_id": "fixture-es256-v1", + "algorithm": "ES256", + "issued_at": 1789999990, + "expires_at": 1790000300 + } + }, + { + "name": "wrong_issuer", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsLXN0YWdpbmciLCJzdWIiOiJmM2IwYzlhMi02ZDFlLTRjNTUtOWI3YS0yZjBlOGQxYzRiNmEiLCJhdWQiOiJvcmdtZXRyYS13ZWIiLCJhenAiOiJvcmdtZXRyYS13ZWIiLCJleHAiOjE3OTAwMDAzMDAsImlhdCI6MTc4OTk5OTk5MCwib3JnIjoidGVuYW50LWFjbWUifQ.VXCbso4BeC42GUxhDrgtET4MODNIamlh8D7I9Iaw5ypW6vgHyoAP0l3aNfHP7f0sqWIn9UmuszT57imH3LxmMg", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "issuer_mismatch" + } + }, + { + "name": "wrong_audience", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoibmFydW9uLXdlYiIsImF6cCI6Im5hcnVvbi13ZWIiLCJleHAiOjE3OTAwMDAzMDAsImlhdCI6MTc4OTk5OTk5MCwib3JnIjoidGVuYW50LWFjbWUifQ.YONyWwbdZiLyG9AUhDI_Mxjlo1uV6uV28VNxXWocPiKHknRdpF6OKI0P2Z2FVm_9MDj0fVWJ_cgkOdisKQ65Ow", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "audience_mismatch" + } + }, + { + "name": "expired", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzg5OTk5OTM5LCJpYXQiOjE3ODk5OTk2MDAsIm9yZyI6InRlbmFudC1hY21lIn0.VCo7HPtnBrADsGJRSzF1NF_AEpTwc0bI9zGyVEffuiFPEliTVW4Q5FZqx5W2QHxDFZWc-3f_ZujMgVy5Ic4fMA", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "expired" + } + }, + { + "name": "not_yet_valid", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIiwibmJmIjoxNzkwMDAwMDYxfQ.5Ji9f6dVpKQYLut33hCDvDkWT9qxsVIciJuaZCef0kvOiuU33cVhawDymLWgGMlpFzBUoPiFo_gRV2TBSukUjg", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "not_yet_valid" + } + }, + { + "name": "forged_signature_same_kid", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.m-vQL1KZwGJ2_Sgkvq-Dxj5OHZ-DoTP0FQNjxu3TvXIWDQ8HgUJSaTt7kpo8V8vNLXbU5K4FnBHVa2NkR49m-A", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "invalid_signature" + } + }, + { + "name": "unknown_kid", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdGF0ZWQtYXdheSJ9.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.QED4kNYKbo8S3pJF06qLFpgdrPDcgveyyQNFEyJjSY7kAspwb7XQr6m9XezMeLJ7zp1NOYgzRMydzHT2MNnypA", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "unknown_key" + } + }, + { + "name": "access_token_type", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.QRkOCGI9X9BrpMFh3PCm-UTSjl89y3cri3BTb6LCgxSlxMRB2NEKUWjNwHy7H2Nz7f3eCwpZmBo9CKVF6iinmg", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "token_type_mismatch" + } + }, + { + "name": "critical_header", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEiLCJjcml0IjpbImV4cCJdfQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.05HTthvsstif8vsdDIra6aCHHi-eJbrlLwZxZSYd6eoE3B5lkYJcAyI0r-OnrfHouiWOgobGt2PjVJZFi47NNQ", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "unsupported_header" + } + }, + { + "name": "alg_none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoiZml4dHVyZS1lczI1Ni12MSJ9.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "algorithm_not_allowed" + } + }, + { + "name": "cross_tenant_missing_org", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTB9.BYzl1BY9ZlygMW5Jhj7vZyjVGfaT18qT2-uELaRMXuoNTn3NaKvB7p3jvFTMaViqkf402TyCi1iZ3FPEntr86g", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "tenant_missing" + } + }, + { + "name": "empty_subject", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.RV6KQnu4o6QQdqLlVS9aAsCN4HvqAQbldBW9GGQvh8WoWqJkzvaGgvcslV7kKkj2bPnADqszRZYZLm3KMkHG8g", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "invalid_claim" + } + }, + { + "name": "untrusted_extra_audience", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjpbIm9yZ21ldHJhLXdlYiIsIm5hcnVvbi13ZWIiXSwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.ofjCtEI1m63hgqUYyuNLgRtYOcDJKOzwS5deW6h4nC23IpwtzAmCy2fqabdV-EmeOE_Fr-jktyU_TSFGfZuZTQ", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "audience_mismatch" + } + }, + { + "name": "trusted_extra_audience", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjpbImFjY291bnQiLCJvcmdtZXRyYS13ZWIiXSwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.UkAwEPaDRpcFUOJMH4ejLOdqHR0hVPNkgo1wjvjos8HUX-iq6lW70PiINAes7GfKhVPxyipPJlSTV0HmIpE6ew", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "verified", + "issuer": "https://id.contextualwisdom.example/realms/cwl", + "subject": "f3b0c9a2-6d1e-4c55-9b7a-2f0e8d1c4b6a", + "audience": "orgmetra-web", + "tenant": "tenant-acme", + "subject_type": "public", + "correlation": "durable", + "correlation_key": "95983108b87e38d813d61691272534514b92b804ee87f1387aec682406b64730", + "key_id": "fixture-es256-v1", + "algorithm": "ES256", + "issued_at": 1789999990, + "expires_at": 1790000300 + } + }, + { + "name": "lowercase_typ", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6Imp3dCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzkwMDAwMzAwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.Es4kprm0P_M67MyreXbM3gaFUWakbC5xhYwXnSWbdcY6fLxDL4n08FdfeZCS47dBtMmEHtTwBYDOmRQFKiGkZQ", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "verified", + "issuer": "https://id.contextualwisdom.example/realms/cwl", + "subject": "f3b0c9a2-6d1e-4c55-9b7a-2f0e8d1c4b6a", + "audience": "orgmetra-web", + "tenant": "tenant-acme", + "subject_type": "public", + "correlation": "durable", + "correlation_key": "95983108b87e38d813d61691272534514b92b804ee87f1387aec682406b64730", + "key_id": "fixture-es256-v1", + "algorithm": "ES256", + "issued_at": 1789999990, + "expires_at": 1790000300 + } + }, + { + "name": "expiry_before_issue", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZpeHR1cmUtZXMyNTYtdjEifQ.eyJpc3MiOiJodHRwczovL2lkLmNvbnRleHR1YWx3aXNkb20uZXhhbXBsZS9yZWFsbXMvY3dsIiwic3ViIjoiZjNiMGM5YTItNmQxZS00YzU1LTliN2EtMmYwZThkMWM0YjZhIiwiYXVkIjoib3JnbWV0cmEtd2ViIiwiYXpwIjoib3JnbWV0cmEtd2ViIiwiZXhwIjoxNzg5OTk5OTgwLCJpYXQiOjE3ODk5OTk5OTAsIm9yZyI6InRlbmFudC1hY21lIn0.tXLWQJ_uLCXpMIzRiwaPAPrO92BEFI9DYe-478jVYaDmKqukPUNaQ0NCKIWCSDnISTitMXoSeePY_sBAysrd8w", + "expected_receipt": { + "contract_version": "keyverse.subject-assertion/v1", + "outcome": "rejected", + "reason": "invalid_claim" + } + } + ] +} diff --git a/docs/contracts/subject-assertion-v1.schema.json b/docs/contracts/subject-assertion-v1.schema.json new file mode 100644 index 00000000..e707a8c7 --- /dev/null +++ b/docs/contracts/subject-assertion-v1.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://keyverse.contextualwisdom.dev/contracts/subject-assertion-v1", + "title": "Keyverse subject-assertion receipt v1", + "description": "Receipt returned by the Keyverse subject-assertion verifier. A relying party may persist (issuer, subject) as a durable binding only when outcome is 'verified' and correlation is 'durable'. The receipt never contains raw token bytes.", + "type": "object", + "additionalProperties": false, + "properties": { + "contract_version": {"const": "keyverse.subject-assertion/v1"}, + "outcome": {"enum": ["verified", "rejected"]}, + "reason": { + "enum": [ + "malformed_token", + "algorithm_not_allowed", + "unsupported_header", + "token_type_mismatch", + "unknown_key", + "invalid_signature", + "issuer_mismatch", + "audience_mismatch", + "invalid_claim", + "expired", + "not_yet_valid", + "nonce_mismatch", + "tenant_missing" + ] + }, + "issuer": {"type": "string", "format": "uri"}, + "subject": {"type": "string", "minLength": 1, "maxLength": 255}, + "audience": {"type": "string", "minLength": 1, "maxLength": 255}, + "tenant": {"type": "string", "minLength": 1, "maxLength": 128}, + "subject_type": {"enum": ["public", "pairwise", "ephemeral"]}, + "correlation": {"enum": ["durable", "session_only"]}, + "correlation_key": {"type": "string", "minLength": 64, "maxLength": 64, "pattern": "^[0-9a-f]{64}$"}, + "key_id": {"type": "string", "minLength": 1}, + "algorithm": {"enum": ["ES256", "RS256"]}, + "issued_at": {"type": "integer"}, + "expires_at": {"type": "integer"} + }, + "required": ["contract_version", "outcome"], + "oneOf": [ + { + "title": "verified durable subject", + "properties": { + "outcome": {"const": "verified"}, + "subject_type": {"enum": ["public", "pairwise"]}, + "correlation": {"const": "durable"} + }, + "required": [ + "contract_version", "outcome", "issuer", "subject", "audience", "subject_type", + "correlation", "correlation_key", "key_id", "algorithm", "issued_at", "expires_at" + ], + "not": {"anyOf": [{"required": ["reason"]}]} + }, + { + "title": "verified session-only subject", + "properties": { + "outcome": {"const": "verified"}, + "subject_type": {"const": "ephemeral"}, + "correlation": {"const": "session_only"} + }, + "required": [ + "contract_version", "outcome", "issuer", "subject", "audience", "subject_type", + "correlation", "key_id", "algorithm", "issued_at", "expires_at" + ], + "not": {"anyOf": [{"required": ["reason"]}, {"required": ["correlation_key"]}]} + }, + { + "title": "rejected assertion", + "properties": {"outcome": {"const": "rejected"}}, + "required": ["contract_version", "outcome", "reason"], + "not": { + "anyOf": [ + {"required": ["issuer"]}, {"required": ["subject"]}, {"required": ["audience"]}, + {"required": ["tenant"]}, {"required": ["subject_type"]}, + {"required": ["correlation"]}, {"required": ["correlation_key"]}, + {"required": ["key_id"]}, {"required": ["algorithm"]}, + {"required": ["issued_at"]}, {"required": ["expires_at"]} + ] + } + } + ] +} diff --git a/docs/doctoring/subject-assertion-trust-contract.md b/docs/doctoring/subject-assertion-trust-contract.md new file mode 100644 index 00000000..ba820f11 --- /dev/null +++ b/docs/doctoring/subject-assertion-trust-contract.md @@ -0,0 +1,181 @@ +# Subject-assertion trust contract — doctoring record + +## Scope + +This record supports ADR-0020 and issue #155. It explains how the +`keyverse.subject-assertion/v1` verifier maps primary standards and Keycloak +behavior to rules. It covers an offline verifier for one signed OpenID Connect +ID token. It does not claim OpenID Certification, live Keycloak token +acceptance, or a released artifact. + +## Evidence categories + +- **Standard**: normative text in a primary standard. +- **Vendor**: documented or source-code behavior of Keycloak. +- **Policy**: a Keyverse choice that a standard permits but does not require. +- **Inference**: a conclusion derived from the sources above. +- **Unverified**: a claim that the retrieval could not confirm. + +## Rule map + +| Verifier rule | Class | Basis | +|---|---|---| +| Exactly three base64url segments, strict JSON objects, no duplicate member names | Standard | RFC 7515 §5.2 step 4 | +| `alg` is in the pinned subset of `ES256`/`RS256`; each key is bound to one algorithm; `none` and HMAC are refused | Standard + Policy | RFC 8725 §3.1, §3.2; RFC 7518 §3.6 | +| Only `alg`, `typ`, and `kid` header members are accepted; `crit`, `jku`, `x5u`, `jwk`, and `x5c` reject | Standard + Policy | RFC 7515 §4.1.11 and App. E; OIDC Core §2 ("SHOULD NOT use"); RFC 8725 §3.10 | +| `typ` is absent or the `application/jwt` media type, compared case-insensitively with an implied `application/` prefix | Standard + Inference | RFC 7515 §4.1.9; RFC 8725 §3.11–§3.12; RFC 9068 §2.1, §4 | +| Keycloak payload `typ`, when present, must be `ID` | Vendor | Keycloak `TokenUtil.TOKEN_TYPE_ID = "ID"` | +| ES256 signature is exactly 64 octets (R‖S) | Standard | RFC 7518 §3.4 | +| `iss` equals the pinned issuer exactly | Standard | OIDC Core §3.1.3.7 | +| `aud` contains the RP; every other audience must be explicitly trusted by the profile; several audiences require `azp`; `azp`, when present, equals the RP | Standard + Policy | OIDC Core §2, §3.1.3.7; RFC 8725 §3.9 | +| `exp` and `iat` required as integer NumericDates; optional `nbf`; one leeway of 0–300 s (default 60 s) | Standard + Policy | RFC 7519 §4.1.4–§4.1.6; OIDC Core §2 | +| `sub` is a non-empty string of at most 255 characters with no control characters | Standard + Policy | OIDC Core §2 | +| Durable identity key covers `(iss, sub)` plus audience and tenant | Standard + Inference | OIDC Core §5.7, §8 | +| `public`/`pairwise` → `durable`; `ephemeral` → `session_only` | Standard + Inference | OIDC Core §8; OIDC Ephemeral Subject Identifier 1.0 Draft 03 §1, §4, §7 | + +## Interpretation notes + +### Audience + +OIDC Core §3.1.3.7 says the ID token "MUST be rejected if the ID Token does +not list the Client as a valid audience, or if it contains additional +audiences not trusted by the Client." The first candidate accepted any extra +audience when `azp` named the RP. That was a false admission. The verifier now +accepts an extra audience only when the deployment lists it in +`trusted_additional_audiences`. Strict `azp` equality is a policy choice: +Core says the client "SHOULD verify" it. + +### Token type + +RFC 7515 §4.1.9 states that media type values are case-insensitive and that a +recipient "MUST treat it as if "application/" were prepended to any "typ" value +not containing a '/'". The verifier therefore accepts `JWT`, `jwt`, and +`application/jwt`. Keycloak 26 source (`DefaultTokenManager`) writes `JWT` for +ID tokens and, by default, for access tokens too; `at+jwt` appears only when the +26.2.0 client flag is enabled. So `typ` cannot separate the token kinds alone +(RFC 8725 §3.11 makes the same point). The audience pin and the Keycloak +payload `typ` value `ID` are the stronger separators. + +### Subject stability and correlation + +OIDC Core §5.7 says `iss` and `sub` "used together, are the only Claims that an +RP can rely upon as a stable identifier". §8 defines `public` and `pairwise` +subjects as locally unique and never reassigned; a pairwise `sub` is stable +only for one client, so the correlation key includes the audience. The +Ephemeral Subject Identifier draft (§4) defines `ephemeral` as a `sub` that +"is different for every authentication request" and warns (§7) that a +collision can cause "account mix-up". The verifier therefore never returns a +durable key for an ephemeral profile. The subject type is declared by the +deployment profile, because the verifier does not read discovery. + +### Status of the Ephemeral Subject Identifier specification + +The retrieved specification is Draft 03 (10 July 2026). The OpenID Foundation +public review ran from 17 July to 15 September 2026 and the final vote from 16 +to 30 September 2026. **Unverified:** no retrieved primary source announced the +vote result. Cite Draft 03 until an approval notice is retrieved. The verifier +rule does not depend on the result. + +### Keycloak algorithm support + +Keycloak 26.8.0 source sets `DEFAULT_SIGNATURE_ALGORITHM = RS256`, and +`GeneratedEcdsaKeyProviderFactory` maps P-256 to `ES256`. **Unverified:** no +prose documentation sentence states the ID-token `typ` value; the evidence is +source code at tag 26.8.0. + +### Independent adversarial review + +An independent reviewer ran hostile probes against the candidate. Each +confirmed finding was reproduced first as a failing repository test and then +repaired: + +| Finding | Class | Repair | +|---|---|---| +| A signed lone-surrogate `nonce` raised `UnicodeEncodeError` | Inference | Decoded JSON is re-encoded as strict UTF-8; failure is `malformed_token` | +| A NaN `now` turned off every time check | Inference | `now` must be a non-boolean integer, else `TypeError` | +| Editing `trusted_keys` after construction did not revoke a key | Policy | The profile stores a read-only deep copy; rotation builds a new profile | +| UTF-16 and BOM payloads were accepted | Standard + Policy (RFC 8259 §8.1: JSON exchanged between systems "MUST be encoded using UTF-8"; a parser "MAY ignore" a byte order mark, so rejecting it is policy) | Strict `bytes.decode("utf-8")` before parsing | +| Invisible or padded `sub`/`org` characters were accepted | Policy | Unicode categories Cc, Cf, Cs, Zl, and Zp and edge whitespace are refused | +| Several base64url spellings verified as one token | Standard (RFC 4648 §3.5: "decoders MAY chose to reject an encoding if the pad bits have not been set to zero") | Canonical re-encoding is required | +| Unbounded or inverted NumericDates were accepted | Policy | 0..2^53-1 and `exp >= iat` | +| Profile gaps: list algorithm, issuer whitespace or bad port, unchecked extra audiences, RSA e=3 or very large n, padded EC coordinates, non-verify `key_ops` | Policy (the 2048–8192-bit and e≥65537 bounds are Keyverse choices; RFC 7518 §3.3 sets only the 2048-bit minimum) | Construction raises `ValueError` | +| Schema accepted a 64-hex key with a trailing newline | Inference | `minLength`/`maxLength` 64 | +| Fixtures lacked the audience and `typ` rules | Inference | New catalog cases and the `trusted_additional_audiences` profile member | + +The reviewer also confirmed correct behavior for deep JSON nesting, very large +integers, malformed ECDSA/RSA signatures, off-curve EC keys, invalid RSA +exponents, unhashable header values, and the audience and `typ` rules. The +reviewer's probes are evidence for this candidate only; they are not a +substitute for a hosted security scan. + +## Residual assumptions + +- A deployment supplies the realm public JWK Set out of band and rotates it by + profile change. +- The verifier is evaluated against synthetic tokens. A controlled Keycloak + login that produces a real ID token for this verifier is still required. + +## Retrieval ledger + +| Source | URL | Retrieved | SHA-256 prefix | +|---|---|---|---| +| OIDC Core 1.0 (errata set 2) | https://openid.net/specs/openid-connect-core-1_0.html | 2026-10-06 | 4d016752a645e8a3 | +| OIDC ESID 1.0 Draft 03 (HTML) | https://openid.net/specs/openid-connect-ephemeral-subject-identifier-1_0.html | 2026-10-06 | 918ed24e90e661c3 | +| OIDC ESID 1.0 Draft 03 (MD) | https://openid.net/specs/openid-connect-ephemeral-subject-identifier-1_0.md | 2026-10-06 | bba8881b5fa77d5e | +| OIDF public review notice | https://openid.net/public-review-period-for-proposed-openid-connect-ephemeral-subject-identifier-1-0-final-specification/ | 2026-10-06 | 61740a02e5f894e6 | +| OIDF notice of vote | https://openid.net/notice-of-vote-for-proposed-openid-connect-ephemeral-subject-identifier-1-0-final-specification/ | 2026-10-06 | 8c58791f4e1b161c | +| OIDF news list | https://openid.net/news/ | 2026-10-06 | 091e52a52abf7f9f | +| OIDF spec directory | https://openid.net/specs/ | 2026-10-06 | bcf3535c410bdb41 | +| OIDF tag / category pages | https://openid.net/tag/spec-announcement/ ; https://openid.net/category/final-specification/ | 2026-10-06 | — | +| RFC 7515 | https://www.rfc-editor.org/rfc/rfc7515.txt | 2026-10-06 | ecbf84674118e625 | +| RFC 7517 | https://www.rfc-editor.org/rfc/rfc7517.txt | 2026-10-06 | 681c19b92a850687 | +| RFC 7518 | https://www.rfc-editor.org/rfc/rfc7518.txt | 2026-10-06 | 9a9ae524b09ea700 | +| RFC 7519 | https://www.rfc-editor.org/rfc/rfc7519.txt | 2026-10-06 | fecd930e9ccf2276 | +| RFC 7638 | https://www.rfc-editor.org/rfc/rfc7638.txt | 2026-10-06 | 98b29ab9652070e6 | +| RFC 8725 | https://www.rfc-editor.org/rfc/rfc8725.txt | 2026-10-06 | 737b7013a2495dcf | +| RFC 9068 | https://www.rfc-editor.org/rfc/rfc9068.txt | 2026-10-06 | 68d3ff3910c0f234 | +| RFC 8259 | https://www.rfc-editor.org/rfc/rfc8259.txt | 2026-10-06 | 61a5378f4255c720 | +| RFC 4648 | https://www.rfc-editor.org/rfc/rfc4648.txt | 2026-10-06 | 84e14418f795d503 | +| Keycloak Server Admin Guide | https://www.keycloak.org/docs/latest/server_admin/index.html | 2026-10-06 | 4c751f93f7fb9e6d | +| Keycloak Release Notes | https://www.keycloak.org/docs/latest/release_notes/index.html | 2026-10-06 | 8500c2e4f82b9c77 | +| Keycloak DefaultTokenManager @26.8.0 | https://raw.githubusercontent.com/keycloak/keycloak/26.8.0/services/src/main/java/org/keycloak/jose/jws/DefaultTokenManager.java | 2026-10-06 | 12c92e247041db96 | +| Keycloak GeneratedEcdsaKeyProviderFactory @26.8.0 | https://raw.githubusercontent.com/keycloak/keycloak/26.8.0/services/src/main/java/org/keycloak/keys/GeneratedEcdsaKeyProviderFactory.java | 2026-10-06 | 67c8aff25c6a9cc6 | +| Keycloak TokenUtil @main | https://raw.githubusercontent.com/keycloak/keycloak/main/core/src/main/java/org/keycloak/util/TokenUtil.java | 2026-10-06 | 8d7cfc6d5892a8db | +| Keycloak Constants @26.8.0 | https://raw.githubusercontent.com/keycloak/keycloak/26.8.0/server-spi-private/src/main/java/org/keycloak/models/Constants.java | 2026-10-06 | (grep only) | + + +## References + +Bertocci, V. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* (RFC 9068). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9068 + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259; STD 90). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +Josefsson, S. (2006). *The Base16, Base32, and Base64 data encodings* (RFC 4648). Internet Engineering Task Force. https://doi.org/10.17487/RFC4648 + +Jones, M. (2015a). *JSON Web Algorithms (JWA)* (RFC 7518). Internet Engineering Task Force. https://doi.org/10.17487/RFC7518 + +Jones, M. (2015b). *JSON Web Key (JWK)* (RFC 7517). Internet Engineering Task Force. https://doi.org/10.17487/RFC7517 + +Jones, M., Bradley, J., & Sakimura, N. (2015a). *JSON Web Signature (JWS)* (RFC 7515). Internet Engineering Task Force. https://doi.org/10.17487/RFC7515 + +Jones, M., Bradley, J., & Sakimura, N. (2015b). *JSON Web Token (JWT)* (RFC 7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +Jones, M., & Sakimura, N. (2015). *JSON Web Key (JWK) thumbprint* (RFC 7638). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc7638 + +Keycloak. (n.d.-a). *Release notes* [Keycloak 26.2.0 section: "New client configuration for access token header type"]. Retrieved October 6, 2026, from https://www.keycloak.org/docs/latest/release_notes/index.html + +Keycloak. (n.d.-b). *Server administration guide* (Version 26.8.0). Retrieved October 6, 2026, from https://www.keycloak.org/docs/latest/server_admin/index.html + +Keycloak. (2026). *DefaultTokenManager.java* (Version 26.8.0) [Source code]. GitHub. https://github.com/keycloak/keycloak/blob/26.8.0/services/src/main/java/org/keycloak/jose/jws/DefaultTokenManager.java + +Keycloak. (2026). *GeneratedEcdsaKeyProviderFactory.java* (Version 26.8.0) [Source code]. GitHub. https://github.com/keycloak/keycloak/blob/26.8.0/services/src/main/java/org/keycloak/keys/GeneratedEcdsaKeyProviderFactory.java + +OpenID Foundation. (2026a, July 17). *Public review period for proposed OpenID Connect Ephemeral Subject Identifier 1.0 Final Specification*. https://openid.net/public-review-period-for-proposed-openid-connect-ephemeral-subject-identifier-1-0-final-specification/ + +OpenID Foundation. (2026b, September 2). *Notice of vote for proposed OpenID Connect Ephemeral Subject Identifier 1.0 Final Specification*. https://openid.net/notice-of-vote-for-proposed-openid-connect-ephemeral-subject-identifier-1-0-final-specification/ + +Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., & Mortimore, C. (2023). *OpenID Connect Core 1.0 incorporating errata set 2*. OpenID Foundation. https://openid.net/specs/openid-connect-core-1_0.html + +Sakimura, N., & Jay, E. (2026). *OpenID Connect Ephemeral Subject Identifier 1.0 – Draft 03*. OpenID Foundation. https://openid.net/specs/openid-connect-ephemeral-subject-identifier-1_0.html + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current practices* (RFC 8725; BCP 225). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725 diff --git a/services/account_unification/app/subject_assertion.py b/services/account_unification/app/subject_assertion.py new file mode 100644 index 00000000..72396283 --- /dev/null +++ b/services/account_unification/app/subject_assertion.py @@ -0,0 +1,569 @@ +"""Verify Keyverse subject assertions and classify durable RP correlation. + +A relying party (RP) receives an OpenID Connect ID token from Keyverse. Before +the RP stores ``(issuer, subject)`` as a long-lived link to one of its own +records, it must know two things: the token is genuine, and the subject is +stable enough to persist. This module answers both questions offline. + +The verifier uses only a deployment-pinned profile: the exact issuer, the exact +audience, allowed algorithms, and the trusted public keys. It never fetches +discovery documents or JWKS, never follows header-supplied key locations, and +never returns raw token material. A subject is ``durable`` only when the +profile declares a public or pairwise subject type; an ephemeral profile still +authenticates the session but returns ``session_only``. + +Every rejection uses one closed reason code from ``REJECTION_REASONS`` so that +an RP can distinguish a bad credential from an incompatible profile without +parsing free text. Hostile input never raises; an unsafe profile raises +``ValueError`` at construction time, before any token is read. +""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import re +import unicodedata +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any +from urllib.parse import urlsplit + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature + +CONTRACT_VERSION = "keyverse.subject-assertion/v1" +SUPPORTED_ALGORITHMS = frozenset({"ES256", "RS256"}) +SUBJECT_TYPES = frozenset({"public", "pairwise", "ephemeral"}) +TENANT_CLAIMS = frozenset({"org"}) +REJECTION_REASONS = frozenset( + { + "malformed_token", + "algorithm_not_allowed", + "unsupported_header", + "token_type_mismatch", + "unknown_key", + "invalid_signature", + "issuer_mismatch", + "audience_mismatch", + "invalid_claim", + "expired", + "not_yet_valid", + "nonce_mismatch", + "tenant_missing", + } +) + +_DURABLE_SUBJECT_TYPES = frozenset({"public", "pairwise"}) +_KEY_ALGORITHM_BY_TYPE = {"EC": "ES256", "RSA": "RS256"} +_ALLOWED_HEADER_MEMBERS = frozenset({"alg", "typ", "kid"}) +_PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"}) +_MAX_TOKEN_LENGTH = 16_384 +_MAX_SUBJECT_LENGTH = 255 +_MAX_TENANT_LENGTH = 128 +_MAX_AUDIENCE_LENGTH = 255 +_MAX_LEEWAY_SECONDS = 300 +_MIN_RSA_BITS = 2_048 +_MAX_RSA_BITS = 8_192 +_MIN_RSA_EXPONENT = 65_537 +_EC_COORDINATE_LENGTH = 32 +_MAX_NUMERIC_DATE = 2**53 - 1 +_INVISIBLE_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"}) +_ES256_SIGNATURE_LENGTH = 64 +_BASE64URL = re.compile(r"^[A-Za-z0-9_-]*$") +_CONTROL = re.compile(r"[\x00-\x1F\x7F]") + + +class _Rejected(Exception): + """Internal signal that carries one closed rejection reason.""" + + def __init__(self, reason: str) -> None: + """Remember the closed ``reason`` code.""" + super().__init__(reason) + self.reason = reason + + +@dataclass(frozen=True) +class SubjectAssertionProfile: + """Deployment-pinned trust profile for one RP audience. + + ``trusted_keys`` is a public JWK Set (``{"keys": [...]}``) supplied by the + deployment, never fetched by this module. ``subject_type`` states the + subject identifier semantics the issuer guarantees for this RP: ``public`` + and ``pairwise`` subjects may be persisted, ``ephemeral`` ones may not. + ``leeway_seconds`` bounds clock skew for ``exp``, ``nbf``, and ``iat``. + ``trusted_additional_audiences`` lists the only other ``aud`` values this + RP accepts beside itself; OIDC Core 3.1.3.7 rejects any other audience. + """ + + issuer: str + audience: str + allowed_algorithms: tuple[str, ...] + trusted_keys: Mapping[str, Any] + subject_type: str = "public" + required_tenant_claim: str | None = None + leeway_seconds: int = 60 + trusted_additional_audiences: tuple[str, ...] = () + _keys: Mapping[str, tuple[str, Any]] = field( + init=False, repr=False, compare=False, default_factory=dict + ) + + def __post_init__(self) -> None: + """Refuse an unsafe profile and pre-load its public verification keys.""" + _validate_issuer(self.issuer) + if not _is_client_identifier(self.audience): + raise ValueError("audience must be one non-empty client identifier") + algorithms = self.allowed_algorithms + if ( + not isinstance(algorithms, tuple) + or not algorithms + or not all(isinstance(item, str) for item in algorithms) + or not set(algorithms) <= SUPPORTED_ALGORITHMS + ): + raise ValueError("allowed_algorithms must be a non-empty subset of ES256/RS256") + extra = self.trusted_additional_audiences + if ( + not isinstance(extra, tuple) + or len(set(extra)) != len(extra) + or any(not _is_client_identifier(item) or item == self.audience for item in extra) + ): + raise ValueError( + "trusted_additional_audiences must be a tuple of other unique client IDs" + ) + if self.subject_type not in SUBJECT_TYPES: + raise ValueError("subject_type must be public, pairwise, or ephemeral") + if self.required_tenant_claim is not None and ( + self.required_tenant_claim not in TENANT_CLAIMS + ): + raise ValueError("required_tenant_claim must be null or the canonical 'org' claim") + leeway = self.leeway_seconds + if isinstance(leeway, bool) or not isinstance(leeway, int) or not ( + 0 <= leeway <= _MAX_LEEWAY_SECONDS + ): + raise ValueError("leeway_seconds must be an integer from 0 to 300") + loaded = _load_key_set(self.trusted_keys, frozenset(algorithms)) + object.__setattr__(self, "trusted_keys", _frozen_key_set(self.trusted_keys)) + object.__setattr__(self, "_keys", MappingProxyType(loaded)) + + +@dataclass(frozen=True) +class SubjectAssertionResult: + """Outcome of one verification; rejected results carry only a reason.""" + + outcome: str + reason: str | None = None + contract_version: str = CONTRACT_VERSION + issuer: str | None = None + subject: str | None = None + audience: str | None = None + tenant: str | None = None + subject_type: str | None = None + correlation: str | None = None + correlation_key: str | None = None + key_id: str | None = None + algorithm: str | None = None + issued_at: int | None = None + expires_at: int | None = None + + def receipt(self) -> dict[str, Any]: + """Return a JSON-safe receipt that omits absent fields and token bytes.""" + names = ( + "contract_version", "outcome", "reason", "issuer", "subject", "audience", + "tenant", "subject_type", "correlation", "correlation_key", "key_id", + "algorithm", "issued_at", "expires_at", + ) + return {name: getattr(self, name) for name in names if getattr(self, name) is not None} + + +def verify_subject_assertion( + token: str, + profile: SubjectAssertionProfile, + *, + now: int, + expected_nonce: str | None = None, +) -> SubjectAssertionResult: + """Verify ``token`` against ``profile`` at time ``now`` (seconds since epoch). + + When ``expected_nonce`` is given, the token's ``nonce`` must equal it + exactly. The function never raises for hostile token input; it returns a + rejected result with one closed reason code instead. A caller error (a + non-integer ``now`` or non-text ``expected_nonce``) raises ``TypeError``, + because a NaN or float clock would silently disable the time checks. + """ + if isinstance(now, bool) or not isinstance(now, int): + raise TypeError("now must be an integer number of seconds since the epoch") + if expected_nonce is not None and not isinstance(expected_nonce, str): + raise TypeError("expected_nonce must be text or None") + try: + return _verify(token, profile, now=now, expected_nonce=expected_nonce) + except _Rejected as rejected: + return SubjectAssertionResult(outcome="rejected", reason=rejected.reason) + + +def _verify( + token: str, profile: SubjectAssertionProfile, *, now: int, expected_nonce: str | None +) -> SubjectAssertionResult: + """Run every check in a fixed order and build the verified result.""" + header_part, payload_part, signature_part = _split_token(token) + header = _decode_json_object(header_part) + claims = _decode_json_object(payload_part) + signature = _b64decode(signature_part) + + algorithm = header.get("alg") + if algorithm not in profile.allowed_algorithms: + raise _Rejected("algorithm_not_allowed") + if not set(header) <= _ALLOWED_HEADER_MEMBERS: + raise _Rejected("unsupported_header") + if not _is_jwt_media_type(header.get("typ", "JWT")): + raise _Rejected("token_type_mismatch") + key_id = header.get("kid") + if not isinstance(key_id, str) or key_id not in profile._keys: + raise _Rejected("unknown_key") + key_algorithm, public_key = profile._keys[key_id] + if key_algorithm != algorithm: + raise _Rejected("algorithm_not_allowed") + _verify_signature( + algorithm, public_key, f"{header_part}.{payload_part}".encode("ascii"), signature + ) + + if claims.get("typ", "ID") != "ID": + raise _Rejected("token_type_mismatch") + if claims.get("iss") != profile.issuer: + raise _Rejected("issuer_mismatch") + _check_audience(claims, profile.audience, profile.trusted_additional_audiences) + subject = _check_subject(claims.get("sub")) + issued_at, expires_at = _check_times(claims, now, profile.leeway_seconds) + if expected_nonce is not None: + nonce = claims.get("nonce") + if not isinstance(nonce, str) or not hmac.compare_digest( + nonce.encode("utf-8"), expected_nonce.encode("utf-8") + ): + raise _Rejected("nonce_mismatch") + tenant = ( + _check_tenant(claims.get(profile.required_tenant_claim)) + if profile.required_tenant_claim + else None + ) + + durable = profile.subject_type in _DURABLE_SUBJECT_TYPES + return SubjectAssertionResult( + outcome="verified", + issuer=profile.issuer, + subject=subject, + audience=profile.audience, + tenant=tenant, + subject_type=profile.subject_type, + correlation="durable" if durable else "session_only", + correlation_key=( + _correlation_key( + profile.issuer, profile.audience, profile.subject_type, tenant, subject + ) + if durable + else None + ), + key_id=key_id, + algorithm=algorithm, + issued_at=issued_at, + expires_at=expires_at, + ) + + +def _split_token(token: object) -> tuple[str, str, str]: + """Return the three bounded ASCII base64url segments of a compact JWS.""" + if ( + not isinstance(token, str) + or len(token) > _MAX_TOKEN_LENGTH + or not token.isascii() + ): + raise _Rejected("malformed_token") + parts = token.split(".") + if len(parts) != 3 or not all(_BASE64URL.fullmatch(part) for part in parts): + raise _Rejected("malformed_token") + return parts[0], parts[1], parts[2] + + +def _b64decode(segment: str) -> bytes: + """Decode one canonical unpadded base64url segment (RFC 4648 section 3.5). + + Non-zero padding bits are refused so that each token has one spelling. + """ + try: + raw = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) + except (binascii.Error, ValueError) as error: + raise _Rejected("malformed_token") from error + if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != segment: + raise _Rejected("malformed_token") + return raw + + +def _reject_duplicate_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build a JSON object, refusing duplicate member names (RFC 7515 §4).""" + result: dict[str, Any] = {} + for name, value in pairs: + if name in result: + raise ValueError("duplicate JSON member") + result[name] = value + return result + + +def _reject_constant(name: str) -> None: + """Refuse the non-standard JSON constants NaN and Infinity.""" + raise ValueError(f"non-standard JSON constant {name}") + + +def _decode_json_object(segment: str) -> dict[str, Any]: + """Decode one segment into a strict JSON object or reject the token.""" + raw = _b64decode(segment) + try: + value = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_reject_duplicate_members, + parse_constant=_reject_constant, + ) + # Re-encoding refuses lone surrogates that a \\uD800 escape can create. + json.dumps(value, ensure_ascii=False).encode("utf-8") + except (ValueError, RecursionError) as error: + raise _Rejected("malformed_token") from error + if not isinstance(value, dict): + raise _Rejected("malformed_token") + return value + + +def _verify_signature( + algorithm: str, public_key: Any, signing_input: bytes, signature: bytes +) -> None: + """Verify one ES256 (raw R||S) or RS256 signature with a trusted key.""" + try: + if algorithm == "ES256": + if len(signature) != _ES256_SIGNATURE_LENGTH: + raise InvalidSignature + der = encode_dss_signature( + int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big") + ) + public_key.verify(der, signing_input, ec.ECDSA(hashes.SHA256())) + else: + public_key.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256()) + except InvalidSignature as error: + raise _Rejected("invalid_signature") from error + + +def _is_jwt_media_type(value: object) -> bool: + """Return whether ``typ`` names the ``application/jwt`` media type. + + RFC 7515 4.1.9 makes media types case-insensitive and implies an + ``application/`` prefix when the value contains no ``/``. + """ + if not isinstance(value, str): + return False + lowered = value.lower() + return (lowered if "/" in lowered else "application/" + lowered) == "application/jwt" + + +def _check_audience( + claims: dict[str, Any], audience: str, trusted_additional: tuple[str, ...] +) -> None: + """Require this RP in ``aud``, only trusted extras, and ``azp`` for many audiences.""" + value = claims.get("aud") + if isinstance(value, str): + audiences = [value] + elif isinstance(value, list) and all(isinstance(item, str) for item in value): + audiences = value + else: + raise _Rejected("audience_mismatch") + if audience not in audiences: + raise _Rejected("audience_mismatch") + if not set(audiences) <= {audience, *trusted_additional}: + raise _Rejected("audience_mismatch") + authorized_party = claims.get("azp") + if len(audiences) > 1 and authorized_party is None: + raise _Rejected("audience_mismatch") + if authorized_party is not None and authorized_party != audience: + raise _Rejected("audience_mismatch") + + +def _check_subject(value: object) -> str: + """Return one non-empty, bounded, visible, unpadded opaque subject.""" + if ( + not isinstance(value, str) + or not value + or len(value) > _MAX_SUBJECT_LENGTH + or value != value.strip() + or _has_invisible(value) + ): + raise _Rejected("invalid_claim") + return value + + +def _has_invisible(value: str) -> bool: + """Return whether ``value`` has control, format, surrogate, or line characters.""" + return any(unicodedata.category(character) in _INVISIBLE_CATEGORIES for character in value) + + +def _numeric_date(value: object) -> int: + """Return an integer NumericDate in 0..2**53-1; other values are refused.""" + if ( + isinstance(value, bool) + or not isinstance(value, int) + or not 0 <= value <= _MAX_NUMERIC_DATE + ): + raise _Rejected("invalid_claim") + return value + + +def _check_times(claims: dict[str, Any], now: int, leeway: int) -> tuple[int, int]: + """Check ``exp``, ``iat``, and optional ``nbf`` with bounded leeway.""" + expires_at = _numeric_date(claims.get("exp")) + issued_at = _numeric_date(claims.get("iat")) + not_before = _numeric_date(claims["nbf"]) if "nbf" in claims else None + if expires_at < issued_at: + raise _Rejected("invalid_claim") + if now > expires_at + leeway: + raise _Rejected("expired") + if now + leeway < issued_at or (not_before is not None and now + leeway < not_before): + raise _Rejected("not_yet_valid") + return issued_at, expires_at + + +def _check_tenant(value: object) -> str: + """Return one canonical non-empty opaque tenant key.""" + if ( + not isinstance(value, str) + or not value.strip() + or value != value.strip() + or len(value) > _MAX_TENANT_LENGTH + or _has_invisible(value) + ): + raise _Rejected("tenant_missing") + return value + + +def _correlation_key( + issuer: str, audience: str, subject_type: str, tenant: str | None, subject: str +) -> str: + """Return a stable SHA-256 key over the canonical bound coordinates.""" + canonical = json.dumps( + [CONTRACT_VERSION, issuer, audience, subject_type, tenant, subject], + separators=(",", ":"), + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _validate_issuer(issuer: object) -> None: + """Require one absolute HTTPS issuer without query, fragment, or userinfo.""" + if not isinstance(issuer, str) or any( + character.isspace() or unicodedata.category(character) in _INVISIBLE_CATEGORIES + for character in issuer + ): + raise ValueError("issuer must be an absolute HTTPS URL") + parts = urlsplit(issuer) + try: + parts.port + except ValueError as error: + raise ValueError("issuer must use a valid TCP port") from error + if ( + parts.scheme != "https" + or not parts.hostname + or parts.query + or parts.fragment + or "@" in parts.netloc + ): + raise ValueError("issuer must be an absolute HTTPS URL without query or fragment") + + +def _is_client_identifier(value: object) -> bool: + """Return whether ``value`` is one bounded, visible client identifier.""" + return ( + isinstance(value, str) + and 0 < len(value) <= _MAX_AUDIENCE_LENGTH + and value == value.strip() + and not _has_invisible(value) + ) + + +def _frozen_key_set(key_set: Mapping[str, Any]) -> Mapping[str, Any]: + """Return a read-only deep snapshot of an already validated JWK Set.""" + return MappingProxyType( + { + "keys": tuple( + MappingProxyType( + { + name: tuple(value) if isinstance(value, list) else value + for name, value in jwk.items() + } + ) + for jwk in key_set["keys"] + ) + } + ) + + +def _profile_b64bytes(value: object) -> bytes: + """Decode one base64url JWK member for profile loading.""" + if not isinstance(value, str) or not value or not _BASE64URL.fullmatch(value): + raise ValueError("trusted_keys contains a malformed key integer") + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _profile_b64int(value: object) -> int: + """Decode one base64url JWK integer for profile loading.""" + return int.from_bytes(_profile_b64bytes(value), "big") + + +def _load_key(jwk: object, allowed: frozenset[str]) -> tuple[str, tuple[str, Any]]: + """Validate one public JWK and return ``(kid, (algorithm, public_key))``.""" + if not isinstance(jwk, dict): + raise ValueError("trusted_keys entries must be JWK objects") + key_id = jwk.get("kid") + if not isinstance(key_id, str) or not key_id or _CONTROL.search(key_id): + raise ValueError("trusted_keys entries need a non-empty kid") + if _PRIVATE_JWK_MEMBERS & set(jwk): + raise ValueError("trusted_keys must contain public keys only") + algorithm = _KEY_ALGORITHM_BY_TYPE.get(jwk.get("kty")) + if algorithm is None or algorithm not in allowed: + raise ValueError("trusted_keys key type is not allowed by this profile") + if jwk.get("use", "sig") != "sig" or jwk.get("alg", algorithm) != algorithm: + raise ValueError("trusted_keys key use/alg does not match a signature key") + if "key_ops" in jwk and jwk["key_ops"] != ["verify"]: + raise ValueError("trusted_keys key_ops must be exactly ['verify'] when present") + try: + if algorithm == "ES256": + if jwk.get("crv") != "P-256": + raise ValueError("trusted_keys EC keys must use P-256") + x_bytes = _profile_b64bytes(jwk.get("x")) + y_bytes = _profile_b64bytes(jwk.get("y")) + if len(x_bytes) != _EC_COORDINATE_LENGTH or len(y_bytes) != _EC_COORDINATE_LENGTH: + raise ValueError("trusted_keys EC coordinates must be exactly 32 bytes") + public_key: Any = ec.EllipticCurvePublicNumbers( + int.from_bytes(x_bytes, "big"), int.from_bytes(y_bytes, "big"), ec.SECP256R1() + ).public_key() + else: + exponent = _profile_b64int(jwk.get("e")) + modulus = _profile_b64int(jwk.get("n")) + if not _MIN_RSA_BITS <= modulus.bit_length() <= _MAX_RSA_BITS: + raise ValueError("trusted_keys RSA keys must have 2048 to 8192 bits") + if exponent < _MIN_RSA_EXPONENT: + raise ValueError("trusted_keys RSA public exponent must be at least 65537") + public_key = rsa.RSAPublicNumbers(exponent, modulus).public_key() + except ValueError as error: + raise ValueError(f"trusted_keys contains an invalid key: {error}") from error + return key_id, (algorithm, public_key) + + +def _load_key_set(key_set: object, allowed: frozenset[str]) -> dict[str, tuple[str, Any]]: + """Validate a non-empty public JWK Set with unique key ids.""" + if not isinstance(key_set, dict) or not isinstance(key_set.get("keys"), list): + raise ValueError("trusted_keys must be a JWK Set object") + if not key_set["keys"]: + raise ValueError("trusted_keys must contain at least one key") + loaded: dict[str, tuple[str, Any]] = {} + for jwk in key_set["keys"]: + key_id, entry = _load_key(jwk, allowed) + if key_id in loaded: + raise ValueError("trusted_keys contains a duplicate kid") + loaded[key_id] = entry + return loaded diff --git a/services/account_unification/tests/test_subject_assertion.py b/services/account_unification/tests/test_subject_assertion.py new file mode 100644 index 00000000..496d77a0 --- /dev/null +++ b/services/account_unification/tests/test_subject_assertion.py @@ -0,0 +1,823 @@ +"""Subject-assertion trust contract tests (issue #155). + +The tests sign realistic Keycloak-shaped ID tokens with freshly generated +ES256 and RS256 keys, then check the offline verifier's durable-versus-session +correlation decision and its fail-closed rejection categories. +""" +from __future__ import annotations + +import base64 +import json +import re +from pathlib import Path +from typing import Any + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + +from app.subject_assertion import ( + CONTRACT_VERSION, + REJECTION_REASONS, + SUBJECT_TYPES, + SubjectAssertionProfile, + verify_subject_assertion, +) + +ISSUER = "https://id.contextualwisdom.example/realms/cwl" +AUDIENCE = "orgmetra-web" +NOW = 1_790_000_000 + + +def _b64url(raw: bytes) -> str: + """Return unpadded base64url text, as JWS compact serialization uses.""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _int_b64(value: int, length: int | None = None) -> str: + """Return a big-endian base64url integer for one JWK member.""" + size = length or (value.bit_length() + 7) // 8 + return _b64url(value.to_bytes(size, "big")) + + +class _Signer: + """One test signing key plus its public JWK.""" + + def __init__(self, alg: str, kid: str) -> None: + """Generate a fresh key for ``alg`` and remember its key id.""" + self.alg = alg + self.kid = kid + if alg == "ES256": + self.key = ec.generate_private_key(ec.SECP256R1()) + numbers = self.key.public_key().public_numbers() + self.jwk: dict[str, Any] = { + "kty": "EC", + "crv": "P-256", + "x": _int_b64(numbers.x, 32), + "y": _int_b64(numbers.y, 32), + } + else: + self.key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + numbers = self.key.public_key().public_numbers() + self.jwk = {"kty": "RSA", "n": _int_b64(numbers.n), "e": _int_b64(numbers.e)} + self.jwk.update({"kid": kid, "use": "sig", "alg": alg}) + + def sign(self, signing_input: bytes) -> bytes: + """Return the JWS signature bytes for ``signing_input``.""" + if self.alg == "ES256": + r, s = decode_dss_signature(self.key.sign(signing_input, ec.ECDSA(hashes.SHA256()))) + return r.to_bytes(32, "big") + s.to_bytes(32, "big") + return self.key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()) + + +def _claims(**overrides: Any) -> dict[str, Any]: + """Return a Keycloak-shaped ID token claim set with optional overrides.""" + claims: dict[str, Any] = { + "iss": ISSUER, + "sub": "f3b0c9a2-6d1e-4c55-9b7a-2f0e8d1c4b6a", + "aud": AUDIENCE, + "azp": AUDIENCE, + "exp": NOW + 300, + "iat": NOW - 10, + "auth_time": NOW - 12, + "nonce": "n-0S6_WzA2Mj", + "org": "tenant-acme", + } + for name, value in overrides.items(): + if value is _DROP: + claims.pop(name, None) + else: + claims[name] = value + return claims + + +_DROP = object() + + +def _token(signer: _Signer, claims: dict[str, Any], **header: Any) -> str: + """Return a compact JWS signed by ``signer`` with an optional header override.""" + base_header: dict[str, Any] = {"alg": signer.alg, "typ": "JWT", "kid": signer.kid} + for name, value in header.items(): + if value is _DROP: + base_header.pop(name, None) + else: + base_header[name] = value + encoded = ( + _b64url(json.dumps(base_header).encode()) + + "." + + _b64url(json.dumps(claims).encode()) + ) + return encoded + "." + _b64url(signer.sign(encoded.encode("ascii"))) + + +def _profile(signers: list[_Signer], **overrides: Any) -> SubjectAssertionProfile: + """Return one deployment profile trusting the public keys of ``signers``.""" + values: dict[str, Any] = { + "issuer": ISSUER, + "audience": AUDIENCE, + "allowed_algorithms": ("ES256", "RS256"), + "trusted_keys": {"keys": [signer.jwk for signer in signers]}, + "subject_type": "public", + "required_tenant_claim": "org", + } + values.update(overrides) + return SubjectAssertionProfile(**values) + + +@pytest.fixture(scope="module") +def es_signer() -> _Signer: + """Return one ES256 realm signing key.""" + return _Signer("ES256", "realm-es256-2026-10") + + +@pytest.fixture(scope="module") +def rs_signer() -> _Signer: + """Return one RS256 realm signing key.""" + return _Signer("RS256", "realm-rs256-2026-10") + + +# Slice 1: a verified public subject is durable; an ephemeral one is not. + + +@pytest.mark.parametrize("which", ["es", "rs"]) +def test_verified_public_subject_is_eligible_for_durable_binding( + which: str, es_signer: _Signer, rs_signer: _Signer +) -> None: + """A valid ID token under a public-subject profile yields a durable receipt.""" + signer = es_signer if which == "es" else rs_signer + result = verify_subject_assertion( + _token(signer, _claims()), + _profile([es_signer, rs_signer]), + now=NOW, + expected_nonce="n-0S6_WzA2Mj", + ) + + assert result.outcome == "verified" + assert result.reason is None + assert result.contract_version == CONTRACT_VERSION == "keyverse.subject-assertion/v1" + assert result.issuer == ISSUER + assert result.subject == "f3b0c9a2-6d1e-4c55-9b7a-2f0e8d1c4b6a" + assert result.audience == AUDIENCE + assert result.tenant == "tenant-acme" + assert result.key_id == signer.kid + assert result.algorithm == signer.alg + assert result.expires_at == NOW + 300 + assert result.correlation == "durable" + assert result.correlation_key is not None and len(result.correlation_key) == 64 + + +def test_correlation_key_binds_issuer_audience_tenant_and_subject( + es_signer: _Signer, +) -> None: + """The durable key changes when any bound coordinate changes, never otherwise.""" + profile = _profile([es_signer]) + first = verify_subject_assertion(_token(es_signer, _claims()), profile, now=NOW) + again = verify_subject_assertion( + _token(es_signer, _claims(iat=NOW - 5, nonce="other")), profile, now=NOW + ) + other_tenant = verify_subject_assertion( + _token(es_signer, _claims(org="tenant-globex")), profile, now=NOW + ) + other_subject = verify_subject_assertion( + _token(es_signer, _claims(sub="0d4a")), profile, now=NOW + ) + other_rp = verify_subject_assertion( + _token(es_signer, _claims(aud="calendarweave-web", azp="calendarweave-web")), + _profile([es_signer], audience="calendarweave-web"), + now=NOW, + ) + + pairwise = verify_subject_assertion( + _token(es_signer, _claims()), _profile([es_signer], subject_type="pairwise"), now=NOW + ) + assert first.correlation_key == again.correlation_key + assert pairwise.correlation_key != first.correlation_key + keys = { + first.correlation_key, + other_tenant.correlation_key, + other_subject.correlation_key, + other_rp.correlation_key, + } + assert len(keys) == 4 + + +def test_ephemeral_subject_profile_authenticates_session_only(es_signer: _Signer) -> None: + """An ephemeral-subject profile verifies the login but forbids persistence.""" + result = verify_subject_assertion( + _token(es_signer, _claims()), + _profile([es_signer], subject_type="ephemeral"), + now=NOW, + ) + + assert result.outcome == "verified" + assert result.correlation == "session_only" + assert result.correlation_key is None + assert result.receipt()["correlation"] == "session_only" + assert "correlation_key" not in result.receipt() + + +def test_pairwise_profile_is_durable_and_receipt_has_no_token_material( + es_signer: _Signer, +) -> None: + """A pairwise profile is durable; the receipt never echoes the raw token.""" + token = _token(es_signer, _claims()) + result = verify_subject_assertion( + token, _profile([es_signer], subject_type="pairwise"), now=NOW + ) + receipt = result.receipt() + + assert result.correlation == "durable" + assert receipt == { + "contract_version": CONTRACT_VERSION, + "outcome": "verified", + "issuer": ISSUER, + "subject": "f3b0c9a2-6d1e-4c55-9b7a-2f0e8d1c4b6a", + "audience": AUDIENCE, + "tenant": "tenant-acme", + "subject_type": "pairwise", + "correlation": "durable", + "correlation_key": result.correlation_key, + "key_id": es_signer.kid, + "algorithm": "ES256", + "issued_at": NOW - 10, + "expires_at": NOW + 300, + } + assert token.split(".")[2] not in json.dumps(receipt) + + +# Slice 2: every hostile or ambiguous input is rejected with one closed reason. + + +def _resign(signer: _Signer, header_part: str, payload_part: str) -> str: + """Return a compact JWS over raw encoded parts, signed by ``signer``.""" + signing_input = f"{header_part}.{payload_part}" + return signing_input + "." + _b64url(signer.sign(signing_input.encode("ascii"))) + + +def _raw(obj: Any) -> str: + """Return one base64url JSON segment.""" + return _b64url(json.dumps(obj).encode()) + + +def _hostile_tokens(es: _Signer, rs: _Signer) -> dict[str, tuple[str, str]]: + """Return named hostile tokens with the closed reason each must produce.""" + attacker = _Signer("ES256", es.kid) + good = _token(es, _claims()) + head, body, sig = good.split(".") + header = {"alg": "ES256", "typ": "JWT", "kid": es.kid} + return { + "two_segments": (f"{head}.{body}", "malformed_token"), + "four_segments": (good + ".x", "malformed_token"), + "bad_base64": (f"{head}.{body}!.{sig}", "malformed_token"), + "header_not_json": (_resign(es, _b64url(b"{"), body), "malformed_token"), + "header_array": (_resign(es, _raw(["ES256"]), body), "malformed_token"), + "payload_array": (_resign(es, head, _raw([1])), "malformed_token"), + "duplicate_header_member": ( + _resign(es, _b64url(b'{"alg":"none","alg":"ES256","typ":"JWT","kid":"%s"}' + % es.kid.encode()), body), + "malformed_token", + ), + "oversized": ("a" * 20_000 + "." + body + "." + sig, "malformed_token"), + "non_ascii": (good + "\u00e9", "malformed_token"), + "alg_none": (_raw({**header, "alg": "none"}) + "." + body + ".", "algorithm_not_allowed"), + "alg_hs256": (_resign(es, _raw({**header, "alg": "HS256"}), body), + "algorithm_not_allowed"), + "alg_key_mismatch": (_resign(es, _raw({**header, "kid": rs.kid}), body), + "algorithm_not_allowed"), + "crit_header": (_resign(es, _raw({**header, "crit": ["exp"], "exp": 1}), body), + "unsupported_header"), + "jku_header": (_resign(es, _raw({**header, "jku": "https://evil.example/jwks"}), body), + "unsupported_header"), + "embedded_jwk": (_resign(attacker, _raw({**header, "jwk": attacker.jwk}), body), + "unsupported_header"), + "access_token_typ": (_resign(es, _raw({**header, "typ": "at+jwt"}), body), + "token_type_mismatch"), + "keycloak_bearer_payload": (_token(es, _claims(typ="Bearer")), "token_type_mismatch"), + "missing_kid": (_token(es, _claims(), kid=_DROP), "unknown_key"), + "unknown_kid": (_token(es, _claims(), kid="rotated-away"), "unknown_key"), + "forged_same_kid": (_token(attacker, _claims()), "invalid_signature"), + "truncated_es_signature": (f"{head}.{body}.{_b64url(b'0' * 63)}", "invalid_signature"), + "payload_swapped": (f"{head}.{_raw(_claims(sub='victim'))}.{sig}", "invalid_signature"), + "wrong_issuer": (_token(es, _claims(iss=ISSUER + "-staging")), "issuer_mismatch"), + "issuer_trailing_slash": (_token(es, _claims(iss=ISSUER + "/")), "issuer_mismatch"), + "wrong_audience": (_token(es, _claims(aud="naruon-web", azp="naruon-web")), + "audience_mismatch"), + "audience_list_without_azp": ( + _token(es, _claims(aud=[AUDIENCE, "naruon-web"], azp=_DROP)), "audience_mismatch"), + "foreign_azp": (_token(es, _claims(azp="naruon-web")), "audience_mismatch"), + "audience_object": (_token(es, _claims(aud={"x": AUDIENCE})), "audience_mismatch"), + "expired": (_token(es, _claims(exp=NOW - 61, iat=NOW - 400)), "expired"), + "not_yet_valid": (_token(es, _claims(nbf=NOW + 61)), "not_yet_valid"), + "issued_in_future": (_token(es, _claims(iat=NOW + 61)), "not_yet_valid"), + "missing_exp": (_token(es, _claims(exp=_DROP)), "invalid_claim"), + "missing_iat": (_token(es, _claims(iat=_DROP)), "invalid_claim"), + "boolean_exp": (_token(es, _claims(exp=True)), "invalid_claim"), + "string_iat": (_token(es, _claims(iat=str(NOW))), "invalid_claim"), + "missing_sub": (_token(es, _claims(sub=_DROP)), "invalid_claim"), + "empty_sub": (_token(es, _claims(sub="")), "invalid_claim"), + "numeric_sub": (_token(es, _claims(sub=42)), "invalid_claim"), + "overlong_sub": (_token(es, _claims(sub="s" * 256)), "invalid_claim"), + "missing_tenant": (_token(es, _claims(org=_DROP)), "tenant_missing"), + "array_tenant": (_token(es, _claims(org=["tenant-acme", "tenant-globex"])), + "tenant_missing"), + "blank_tenant": (_token(es, _claims(org=" ")), "tenant_missing"), + } + + +_HOSTILE_NAMES = sorted(_hostile_tokens(_Signer("ES256", "k"), _Signer("RS256", "r"))) + + +@pytest.mark.parametrize("name", _HOSTILE_NAMES) +def test_hostile_assertion_is_rejected_with_closed_reason( + name: str, es_signer: _Signer, rs_signer: _Signer +) -> None: + """Each hostile token is rejected; no identity field leaks into the result.""" + token, reason = _hostile_tokens(es_signer, rs_signer)[name] + result = verify_subject_assertion(token, _profile([es_signer, rs_signer]), now=NOW) + + assert (result.outcome, result.reason) == ("rejected", reason) + assert result.receipt() == { + "contract_version": CONTRACT_VERSION, + "outcome": "rejected", + "reason": reason, + } + + +def test_clock_leeway_is_bounded_and_inclusive(es_signer: _Signer) -> None: + """A token inside the 60-second skew passes; one second beyond is rejected.""" + profile = _profile([es_signer]) + inside_expiry = _token(es_signer, _claims(exp=NOW - 60, iat=NOW - 400)) + inside_start = _token(es_signer, _claims(iat=NOW + 60, nbf=NOW + 60)) + outside = _token(es_signer, _claims(exp=NOW - 61, iat=NOW - 400)) + + assert verify_subject_assertion(inside_expiry, profile, now=NOW).outcome == "verified" + assert verify_subject_assertion(inside_start, profile, now=NOW).outcome == "verified" + assert verify_subject_assertion(outside, profile, now=NOW).reason == "expired" + + +def test_nonce_is_compared_exactly_when_expected(es_signer: _Signer) -> None: + """A replayed callback with another nonce, or none, is rejected.""" + profile = _profile([es_signer]) + no_nonce = _token(es_signer, _claims(nonce=_DROP)) + + assert verify_subject_assertion( + _token(es_signer, _claims()), profile, now=NOW, expected_nonce="other" + ).reason == "nonce_mismatch" + assert verify_subject_assertion( + no_nonce, profile, now=NOW, expected_nonce="n-0S6_WzA2Mj" + ).reason == "nonce_mismatch" + assert verify_subject_assertion(no_nonce, profile, now=NOW).outcome == "verified" + + +def test_audience_list_with_matching_azp_is_accepted(es_signer: _Signer) -> None: + """Multiple audiences pass only when every extra one is trusted and ``azp`` is this RP.""" + token = _token(es_signer, _claims(aud=["account", AUDIENCE], azp=AUDIENCE)) + trusting = _profile([es_signer], trusted_additional_audiences=("account",)) + result = verify_subject_assertion(token, trusting, now=NOW) + + assert result.outcome == "verified" + assert result.audience == AUDIENCE + + +def test_untrusted_additional_audience_is_rejected(es_signer: _Signer) -> None: + """OIDC Core 3.1.3.7: an ID token with an untrusted extra audience is rejected.""" + token = _token(es_signer, _claims(aud=[AUDIENCE, "naruon-web"], azp=AUDIENCE)) + trusting_other = _profile([es_signer], trusted_additional_audiences=("account",)) + + assert verify_subject_assertion(token, _profile([es_signer]), now=NOW).reason == ( + "audience_mismatch" + ) + assert verify_subject_assertion(token, trusting_other, now=NOW).reason == ( + "audience_mismatch" + ) + trusted_without_azp = _token(es_signer, _claims(aud=["account", AUDIENCE], azp=_DROP)) + assert verify_subject_assertion(trusted_without_azp, trusting_other, now=NOW).reason == ( + "audience_mismatch" + ) + + +@pytest.mark.parametrize("typ", ["JWT", "jwt", "application/jwt", "Application/JWT"]) +def test_jwt_media_type_is_case_insensitive(typ: str, es_signer: _Signer) -> None: + """RFC 7515 4.1.9: ``typ`` is a case-insensitive media type with implied prefix.""" + result = verify_subject_assertion( + _token(es_signer, _claims(), typ=typ), _profile([es_signer]), now=NOW + ) + + assert result.outcome == "verified" + + +@pytest.mark.parametrize("typ", ["AT+JWT", "application/at+jwt", "logout+jwt", "JOSE", 7]) +def test_other_media_types_are_not_id_tokens(typ: object, es_signer: _Signer) -> None: + """Access, logout, and generic JOSE types are refused as subject assertions.""" + result = verify_subject_assertion( + _token(es_signer, _claims(), typ=typ), _profile([es_signer]), now=NOW + ) + + assert result.reason == "token_type_mismatch" + + +def test_profile_without_tenant_claim_returns_no_tenant(es_signer: _Signer) -> None: + """A tenant-free profile verifies and binds the key with a null tenant.""" + result = verify_subject_assertion( + _token(es_signer, _claims(org=_DROP)), + _profile([es_signer], required_tenant_claim=None), + now=NOW, + ) + + assert result.outcome == "verified" + assert result.tenant is None + assert result.correlation == "durable" + + +def test_rotation_keeps_old_key_until_profile_removes_it(es_signer: _Signer) -> None: + """A token signed by a retired key fails once the profile drops that key.""" + successor = _Signer("ES256", "realm-es256-2027-01") + old_token = _token(es_signer, _claims()) + + both = _profile([es_signer, successor]) + rotated = _profile([successor]) + + assert verify_subject_assertion(old_token, both, now=NOW).outcome == "verified" + assert verify_subject_assertion(old_token, rotated, now=NOW).reason == "unknown_key" + assert verify_subject_assertion( + _token(successor, _claims()), rotated, now=NOW + ).outcome == "verified" + + +# Slice 3: an unsafe deployment profile is refused before any token is read. + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"issuer": "http://id.contextualwisdom.example/realms/cwl"}, "issuer"), + ({"issuer": ISSUER + "?q=1"}, "issuer"), + ({"audience": ""}, "audience"), + ({"allowed_algorithms": ()}, "allowed_algorithms"), + ({"allowed_algorithms": ("HS256",)}, "allowed_algorithms"), + ({"allowed_algorithms": ("none",)}, "allowed_algorithms"), + ({"subject_type": "opaque"}, "subject_type"), + ({"leeway_seconds": 301}, "leeway_seconds"), + ({"leeway_seconds": -1}, "leeway_seconds"), + ({"trusted_keys": {"keys": []}}, "trusted_keys"), + ({"trusted_keys": []}, "trusted_keys"), + ({"required_tenant_claim": "groups"}, "required_tenant_claim"), + ({"trusted_additional_audiences": ["account"]}, "trusted_additional_audiences"), + ({"trusted_additional_audiences": ("",)}, "trusted_additional_audiences"), + ({"trusted_additional_audiences": (AUDIENCE,)}, "trusted_additional_audiences"), + ], +) +def test_unsafe_profile_is_refused( + overrides: dict[str, Any], message: str, es_signer: _Signer +) -> None: + """Profile construction fails closed and names the unsafe field.""" + with pytest.raises(ValueError, match=message): + _profile([es_signer], **overrides) + + +def test_profile_refuses_duplicate_private_weak_and_mislabeled_keys(es_signer: _Signer) -> None: + """Ambiguous, private, short RSA, and wrong-use keys are refused.""" + weak = rsa.generate_private_key(public_exponent=65537, key_size=1024).public_key() + weak_jwk = {"kty": "RSA", "kid": "weak", "alg": "RS256", + "n": _int_b64(weak.public_numbers().n), "e": _int_b64(65537)} + cases = [ + [es_signer.jwk, dict(es_signer.jwk)], + [{**es_signer.jwk, "d": "c2VjcmV0"}], + [weak_jwk], + [{**es_signer.jwk, "use": "enc"}], + [{**es_signer.jwk, "kid": ""}], + [{**es_signer.jwk, "crv": "P-384"}], + [{**es_signer.jwk, "x": "!!"}], + [{**es_signer.jwk, "alg": "RS256"}], + [{"kty": "oct", "kid": "hmac", "k": "c2VjcmV0"}], + ] + for keys in cases: + with pytest.raises(ValueError, match="trusted_keys"): + _profile([es_signer], trusted_keys={"keys": keys}) + + +def test_invalid_base64_length_and_nan_payload_are_malformed(es_signer: _Signer) -> None: + """A 4n+1 base64url segment and a NaN claim value are malformed, not errors.""" + head, body, sig = _token(es_signer, _claims()).split(".") + nan_body = _b64url(b'{"iss":"x","exp":NaN}') + profile = _profile([es_signer]) + + assert verify_subject_assertion(f"{head}.{body}.a", profile, now=NOW).reason == ( + "malformed_token" + ) + assert verify_subject_assertion( + _resign(es_signer, head, nan_body), profile, now=NOW + ).reason == "malformed_token" + + +def test_profile_refuses_non_string_issuer_non_object_key_and_disallowed_key_type( + es_signer: _Signer, rs_signer: _Signer +) -> None: + """Profile loading refuses wrong shapes and key types outside its algorithms.""" + with pytest.raises(ValueError, match="issuer"): + _profile([es_signer], issuer=None) + with pytest.raises(ValueError, match="trusted_keys"): + _profile([es_signer], trusted_keys={"keys": ["not-a-jwk"]}) + with pytest.raises(ValueError, match="trusted_keys"): + _profile([rs_signer], allowed_algorithms=("ES256",)) + with pytest.raises(ValueError, match="trusted_keys"): + _profile([es_signer], trusted_keys={"keys": [{"kty": "OKP", "kid": "ed"}]}) + + +# Slice 4: the published JSON Schema and conformance fixtures match the code. + +_CONTRACT_DIR = Path(__file__).resolve().parents[3] / "docs" / "contracts" + + +def _schema() -> dict[str, Any]: + """Load the published receipt schema.""" + return json.loads((_CONTRACT_DIR / "subject-assertion-v1.schema.json").read_text()) + + +_SCHEMA_KEYWORDS = frozenset( + {"$schema", "$id", "title", "description", "type", "additionalProperties", "properties", + "required", "oneOf", "not", "anyOf", "const", "enum", "minLength", "maxLength", "pattern", + "format"} +) + + +def _valid(instance: Any, schema: dict[str, Any]) -> bool: + """Evaluate the JSON Schema 2020-12 keyword subset this contract uses. + + Unknown keywords raise, so a schema edit cannot silently weaken this oracle. + ``format`` is annotation-only in 2020-12 and is therefore not asserted. + """ + unknown = set(schema) - _SCHEMA_KEYWORDS + if unknown: + raise AssertionError(f"unsupported schema keywords: {sorted(unknown)}") + checks = [ + "const" not in schema or instance == schema["const"], + "enum" not in schema or instance in schema["enum"], + ] + kind = schema.get("type") + if kind == "object": + checks.append(isinstance(instance, dict)) + elif kind == "string": + checks.append(isinstance(instance, str)) + elif kind == "integer": + checks.append(isinstance(instance, int) and not isinstance(instance, bool)) + if not all(checks): + return False + if isinstance(instance, str): + if len(instance) < schema.get("minLength", 0): + return False + if len(instance) > schema.get("maxLength", len(instance)): + return False + if "pattern" in schema and not re.search(schema["pattern"], instance): + return False + if isinstance(instance, dict): + properties = schema.get("properties", {}) + if schema.get("additionalProperties") is False and set(instance) - set(properties): + return False + if set(schema.get("required", [])) - set(instance): + return False + for name, rule in properties.items(): + if name in instance and not _valid(instance[name], rule): + return False + if "anyOf" in schema and not any(_valid(instance, item) for item in schema["anyOf"]): + return False + if "oneOf" in schema and sum(_valid(instance, item) for item in schema["oneOf"]) != 1: + return False + return not ("not" in schema and _valid(instance, schema["not"])) + + +def _conforms(receipt: dict[str, Any], schema: dict[str, Any]) -> bool: + """Return whether ``receipt`` validates against the published schema.""" + return _valid(receipt, schema) + + +def test_schema_enumerates_exact_contract_constants() -> None: + """Schema enums equal the module's closed reason and subject-type sets.""" + schema = _schema() + + assert schema["$id"] == "https://keyverse.contextualwisdom.dev/contracts/subject-assertion-v1" + assert schema["properties"]["contract_version"]["const"] == CONTRACT_VERSION + assert set(schema["properties"]["reason"]["enum"]) == REJECTION_REASONS + assert set(schema["properties"]["subject_type"]["enum"]) == SUBJECT_TYPES + assert schema["additionalProperties"] is False + + +def test_receipts_conform_to_schema(es_signer: _Signer, rs_signer: _Signer) -> None: + """Verified, session-only, and every hostile receipt conforms to the schema.""" + schema = _schema() + receipts = [ + verify_subject_assertion(_token(es_signer, _claims()), _profile([es_signer]), + now=NOW).receipt(), + verify_subject_assertion(_token(es_signer, _claims()), + _profile([es_signer], subject_type="ephemeral"), + now=NOW).receipt(), + ] + [ + verify_subject_assertion(token, _profile([es_signer, rs_signer]), now=NOW).receipt() + for token, _ in _hostile_tokens(es_signer, rs_signer).values() + ] + + assert all(_conforms(receipt, schema) for receipt in receipts) + forged = dict(receipts[1], correlation="durable", correlation_key="0" * 64) + assert not _conforms(forged, schema) + assert not _conforms(dict(receipts[0], authenticated=True), schema) + assert not _conforms(dict(receipts[2], subject="victim"), schema) + assert not _conforms(dict(receipts[0], reason="expired"), schema) + assert not _conforms(dict(receipts[0], subject_type="ephemeral"), schema) + assert not _conforms({k: v for k, v in receipts[0].items() if k != "correlation_key"}, schema) + with pytest.raises(AssertionError, match="unsupported"): + _valid({}, {"minProperties": 1}) + + +def test_conformance_fixture_catalog_matches_verifier_outcomes() -> None: + """Every published fixture case reproduces its declared outcome offline.""" + catalog = json.loads((_CONTRACT_DIR / "subject-assertion-v1.fixtures.json").read_text()) + profile = SubjectAssertionProfile( + trusted_additional_audiences=tuple(catalog["profile"]["trusted_additional_audiences"]), + issuer=catalog["profile"]["issuer"], + audience=catalog["profile"]["audience"], + allowed_algorithms=tuple(catalog["profile"]["allowed_algorithms"]), + trusted_keys=catalog["profile"]["trusted_keys"], + subject_type=catalog["profile"]["subject_type"], + required_tenant_claim=catalog["profile"]["required_tenant_claim"], + leeway_seconds=catalog["profile"]["leeway_seconds"], + ) + + assert catalog["contract_version"] == CONTRACT_VERSION + assert len(catalog["cases"]) >= 10 + for case in catalog["cases"]: + result = verify_subject_assertion(case["token"], profile, now=catalog["now"]) + assert result.receipt() == case["expected_receipt"], case["name"] + + +# Slice 5: independent adversarial review findings (reproduced before repair). + + +def test_lone_surrogate_strings_are_malformed_not_exceptions(es_signer: _Signer) -> None: + """A signed lone-surrogate nonce or subject is rejected without raising.""" + profile = _profile([es_signer]) + nonce_token = _token(es_signer, _claims(nonce="\ud800")) + subject_token = _token(es_signer, _claims(sub="user-\udfff")) + + assert verify_subject_assertion( + nonce_token, profile, now=NOW, expected_nonce="n-1" + ).reason == "malformed_token" + assert verify_subject_assertion(subject_token, profile, now=NOW).reason == ( + "malformed_token" + ) + + +@pytest.mark.parametrize("now", [float("nan"), float(NOW), True, None, str(NOW)]) +def test_non_integer_clock_is_a_caller_error(now: object, es_signer: _Signer) -> None: + """A NaN, float, boolean, or text clock cannot disable time checks.""" + with pytest.raises(TypeError, match="now"): + verify_subject_assertion( + _token(es_signer, _claims(exp=NOW - 10_000)), _profile([es_signer]), now=now + ) + + +@pytest.mark.parametrize("nonce", [123, b"n-0S6_WzA2Mj"]) +def test_non_text_expected_nonce_is_a_caller_error(nonce: object, es_signer: _Signer) -> None: + """``expected_nonce`` must be text or ``None``.""" + with pytest.raises(TypeError, match="expected_nonce"): + verify_subject_assertion( + _token(es_signer, _claims()), _profile([es_signer]), now=NOW, expected_nonce=nonce + ) + + +def test_profile_keys_are_an_immutable_snapshot(es_signer: _Signer) -> None: + """Caller mutation cannot change trust; rotation means building a new profile.""" + key_set = {"keys": [dict(es_signer.jwk)]} + profile = _profile([es_signer], trusted_keys=key_set) + key_set["keys"].clear() + + assert [key["kid"] for key in profile.trusted_keys["keys"]] == [es_signer.kid] + with pytest.raises((TypeError, AttributeError)): + profile.trusted_keys["keys"].clear() # type: ignore[union-attr] + with pytest.raises((TypeError, AttributeError)): + profile._keys.clear() # type: ignore[attr-defined] + with pytest.raises(TypeError): + profile._keys["forged"] = ("ES256", None) # type: ignore[index] + with pytest.raises(TypeError): + profile.trusted_keys["keys"][0]["kid"] = "other" # type: ignore[index] + + +def test_payload_must_be_strict_utf8_json(es_signer: _Signer) -> None: + """UTF-16 and BOM-prefixed JSON are malformed (RFC 8259 section 8.1).""" + head = _raw({"alg": "ES256", "typ": "JWT", "kid": es_signer.kid}) + text = json.dumps(_claims()) + profile = _profile([es_signer]) + for raw in (text.encode("utf-16"), b"\xef\xbb\xbf" + text.encode()): + token = _resign(es_signer, head, _b64url(raw)) + assert verify_subject_assertion(token, profile, now=NOW).reason == "malformed_token" + + +@pytest.mark.parametrize( + "subject", ["a\x85b", "a\u2028b", "a\u2029b", "\u202eabc", "\ufeffabc", " sub ", "\xa0sub"] +) +def test_invisible_or_padded_subject_is_invalid(subject: str, es_signer: _Signer) -> None: + """Control, format, separator, and edge-whitespace subjects are refused.""" + result = verify_subject_assertion( + _token(es_signer, _claims(sub=subject)), _profile([es_signer]), now=NOW + ) + + assert result.reason == "invalid_claim" + + +@pytest.mark.parametrize("tenant", ["ac\u2028me", "\u200bacme", "ac\x85me"]) +def test_invisible_tenant_characters_are_refused(tenant: str, es_signer: _Signer) -> None: + """Tenant keys reject invisible format and separator characters.""" + result = verify_subject_assertion( + _token(es_signer, _claims(org=tenant)), _profile([es_signer]), now=NOW + ) + + assert result.reason == "tenant_missing" + + +def test_non_canonical_base64url_is_malformed(es_signer: _Signer) -> None: + """Only one string spelling of each segment is accepted (RFC 4648 section 3.5).""" + head, body, sig = _token(es_signer, _claims()).split(".") + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + last = alphabet.index(sig[-1]) + variant = sig[:-1] + alphabet[last ^ 1] + profile = _profile([es_signer]) + + assert verify_subject_assertion(f"{head}.{body}.{sig}", profile, now=NOW).outcome == ( + "verified" + ) + assert verify_subject_assertion(f"{head}.{body}.{variant}", profile, now=NOW).reason == ( + "malformed_token" + ) + + +@pytest.mark.parametrize( + "overrides", + [{"exp": 10**4000}, {"exp": 2**53}, {"iat": -1}, {"nbf": 2**60}, {"iat": NOW + 400, + "exp": NOW + 300}], +) +def test_numeric_dates_are_bounded_and_ordered( + overrides: dict[str, Any], es_signer: _Signer +) -> None: + """NumericDates stay in 0..2**53-1 and ``exp`` is not before ``iat``.""" + result = verify_subject_assertion( + _token(es_signer, _claims(**overrides)), _profile([es_signer], leeway_seconds=300), + now=NOW, + ) + + assert result.reason == "invalid_claim" + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"allowed_algorithms": (["ES256"],)}, "allowed_algorithms"), + ({"issuer": ISSUER + " "}, "issuer"), + ({"issuer": "https://id.example:99999999/realms/cwl"}, "issuer"), + ({"trusted_additional_audiences": ("a\x00b",)}, "trusted_additional_audiences"), + ({"trusted_additional_audiences": ("a" * 256,)}, "trusted_additional_audiences"), + ({"trusted_additional_audiences": ("account", "account")}, + "trusted_additional_audiences"), + ], +) +def test_reviewed_profile_gaps_are_refused( + overrides: dict[str, Any], message: str, es_signer: _Signer +) -> None: + """Profile inputs found unsafe in review raise ValueError naming the field.""" + with pytest.raises(ValueError, match=message): + _profile([es_signer], **overrides) + + +def test_reviewed_key_gaps_are_refused(es_signer: _Signer) -> None: + """Oversized or low-exponent RSA, padded EC coordinates, and non-verify ops fail.""" + big = rsa.generate_private_key(public_exponent=65537, key_size=2048).public_key() + n = big.public_numbers().n + cases = [ + {"kty": "RSA", "kid": "e3", "n": _int_b64(n), "e": _int_b64(3)}, + {"kty": "RSA", "kid": "huge", "n": _int_b64((1 << 8200) - 1), "e": _int_b64(65537)}, + {**es_signer.jwk, "x": _b64url(b"\x00" + base64.urlsafe_b64decode( + es_signer.jwk["x"] + "="))}, + {**es_signer.jwk, "key_ops": ["encrypt"]}, + ] + for jwk in cases: + with pytest.raises(ValueError, match="trusted_keys"): + _profile([es_signer], trusted_keys={"keys": [jwk]}) + assert _profile( + [es_signer], trusted_keys={"keys": [{**es_signer.jwk, "key_ops": ["verify"]}]} + ) + + +def test_schema_correlation_key_is_exactly_64_characters() -> None: + """A trailing newline after 64 hex characters is not a valid correlation key.""" + rule = _schema()["properties"]["correlation_key"] + + assert not _valid("0" * 64 + "\n", rule) + assert _valid("0" * 64, rule) + + +def test_fixture_catalog_covers_audience_and_media_type_rules() -> None: + """The published catalog carries the extra-audience and ``typ`` rules.""" + catalog = json.loads((_CONTRACT_DIR / "subject-assertion-v1.fixtures.json").read_text()) + names = {case["name"] for case in catalog["cases"]} + + assert catalog["profile"]["trusted_additional_audiences"] == ["account"] + assert {"untrusted_extra_audience", "trusted_extra_audience", "lowercase_typ"} <= names diff --git a/services/account_unification/tools/generate_subject_assertion_fixtures.py b/services/account_unification/tools/generate_subject_assertion_fixtures.py new file mode 100644 index 00000000..acf3c499 --- /dev/null +++ b/services/account_unification/tools/generate_subject_assertion_fixtures.py @@ -0,0 +1,134 @@ +"""Generate the public subject-assertion v1 conformance fixture catalog. + +Each run creates a fresh, throwaway P-256 signing key, signs realistic +Keycloak-shaped ID tokens, and writes only the public key, the tokens, and the +expected receipts to ``docs/contracts/subject-assertion-v1.fixtures.json``. +The private key is never written. Consumers in other languages can replay the +catalog to prove that their verifier makes the same decisions. + +Usage (from ``services/account_unification``):: + + python tools/generate_subject_assertion_fixtures.py +""" +from __future__ import annotations + +import base64 +import json +import sys +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.subject_assertion import ( # noqa: E402 + SubjectAssertionProfile, + verify_subject_assertion, +) + +ISSUER = "https://id.contextualwisdom.example/realms/cwl" +AUDIENCE = "orgmetra-web" +NOW = 1_790_000_000 +KEY_ID = "fixture-es256-v1" +OUTPUT = Path(__file__).resolve().parents[3] / "docs/contracts/subject-assertion-v1.fixtures.json" + + +def _b64url(raw: bytes) -> str: + """Return unpadded base64url text.""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _segment(value: dict[str, Any]) -> str: + """Return one base64url JSON segment.""" + return _b64url(json.dumps(value, separators=(",", ":")).encode()) + + +def _sign(key: ec.EllipticCurvePrivateKey, header: dict[str, Any], claims: dict[str, Any]) -> str: + """Return a compact ES256 JWS with a raw R||S signature.""" + signing_input = f"{_segment(header)}.{_segment(claims)}" + r, s = decode_dss_signature(key.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256()))) + return signing_input + "." + _b64url(r.to_bytes(32, "big") + s.to_bytes(32, "big")) + + +def build_catalog() -> dict[str, Any]: + """Return the complete fixture catalog with expected receipts.""" + key = ec.generate_private_key(ec.SECP256R1()) + other = ec.generate_private_key(ec.SECP256R1()) + numbers = key.public_key().public_numbers() + jwk = { + "kty": "EC", "crv": "P-256", "kid": KEY_ID, "use": "sig", "alg": "ES256", + "x": _b64url(numbers.x.to_bytes(32, "big")), + "y": _b64url(numbers.y.to_bytes(32, "big")), + } + header = {"alg": "ES256", "typ": "JWT", "kid": KEY_ID} + claims = { + "iss": ISSUER, "sub": "f3b0c9a2-6d1e-4c55-9b7a-2f0e8d1c4b6a", "aud": AUDIENCE, + "azp": AUDIENCE, "exp": NOW + 300, "iat": NOW - 10, "org": "tenant-acme", + } + cases = { + "valid_public_subject": _sign(key, header, claims), + "wrong_issuer": _sign(key, header, {**claims, "iss": ISSUER + "-staging"}), + "wrong_audience": _sign(key, header, {**claims, "aud": "naruon-web", "azp": "naruon-web"}), + "expired": _sign(key, header, {**claims, "exp": NOW - 61, "iat": NOW - 400}), + "not_yet_valid": _sign(key, header, {**claims, "nbf": NOW + 61}), + "forged_signature_same_kid": _sign(other, header, claims), + "unknown_kid": _sign(key, {**header, "kid": "rotated-away"}, claims), + "access_token_type": _sign(key, {**header, "typ": "at+jwt"}, claims), + "critical_header": _sign(key, {**header, "crit": ["exp"]}, claims), + "alg_none": f"{_segment({**header, 'alg': 'none'})}.{_segment(claims)}.", + "cross_tenant_missing_org": _sign( + key, header, {name: value for name, value in claims.items() if name != "org"} + ), + "empty_subject": _sign(key, header, {**claims, "sub": ""}), + "untrusted_extra_audience": _sign(key, header, {**claims, "aud": [AUDIENCE, "naruon-web"]}), + "trusted_extra_audience": _sign(key, header, {**claims, "aud": ["account", AUDIENCE]}), + "lowercase_typ": _sign(key, {**header, "typ": "jwt"}, claims), + "expiry_before_issue": _sign(key, header, {**claims, "exp": NOW - 20}), + } + profile_data = { + "issuer": ISSUER, + "audience": AUDIENCE, + "allowed_algorithms": ["ES256"], + "trusted_keys": {"keys": [jwk]}, + "subject_type": "public", + "required_tenant_claim": "org", + "leeway_seconds": 60, + "trusted_additional_audiences": ["account"], + } + profile = SubjectAssertionProfile( + **{ # type: ignore[arg-type] + **profile_data, + "allowed_algorithms": ("ES256",), + "trusted_additional_audiences": ("account",), + } + ) + return { + "contract_version": "keyverse.subject-assertion/v1", + "description": ( + "Synthetic conformance cases. The signing key is throwaway test material; " + "it is not a Keyverse deployment key." + ), + "now": NOW, + "profile": profile_data, + "cases": [ + { + "name": name, + "token": token, + "expected_receipt": verify_subject_assertion(token, profile, now=NOW).receipt(), + } + for name, token in cases.items() + ], + } + + +def main() -> None: + """Write the catalog to the published contract directory.""" + OUTPUT.write_text(json.dumps(build_catalog(), indent=2) + "\n", encoding="utf-8") + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + main() From 7ea5cd27338a62237e64a70b7fdef1e0488a6bf2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 13 Oct 2026 20:56:18 +0900 Subject: [PATCH 2/2] docs(identity): retain old signing keys through clock leeway --- docs/adr/0020-subject-assertion-trust-contract.md | 6 ++++-- tests/test_documentation_contract.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/adr/0020-subject-assertion-trust-contract.md b/docs/adr/0020-subject-assertion-trust-contract.md index c9a3ae20..60fcb5c9 100644 --- a/docs/adr/0020-subject-assertion-trust-contract.md +++ b/docs/adr/0020-subject-assertion-trust-contract.md @@ -57,8 +57,10 @@ closed. 6. A rejected receipt carries exactly one closed reason and no identity field. The receipt never contains raw token bytes. 7. Key rotation is a profile change: an RP keeps the old public key in the - profile until tokens signed by it expire, then builds a new profile without - it. A removed key yields `unknown_key`. The profile keeps a read-only deep + profile until every token signed with it has passed its `exp + leeway_seconds` + boundary, then builds a new profile without it. Because `exp` is accepted + through that inclusive skew boundary, removing the key sooner can reject a + still-valid ID token as `unknown_key`. The profile keeps a read-only deep copy of its key set, so editing the caller's dict cannot add or revoke trust. 8. Input hygiene is strict so that every conforming verifier makes the same decision. Segments must be canonical base64url. JSON must be strict UTF-8 diff --git a/tests/test_documentation_contract.py b/tests/test_documentation_contract.py index 4c79e34d..7700c7ff 100644 --- a/tests/test_documentation_contract.py +++ b/tests/test_documentation_contract.py @@ -125,6 +125,16 @@ def test_subject_correlation_adr_matches_verifier_serialization() -> None: assert "session_only" in decision and "no key" in decision +def test_subject_key_retirement_includes_clock_leeway() -> None: + """Retain old signing keys through every token's inclusive skew boundary.""" + adr = _read("docs/adr/0020-subject-assertion-trust-contract.md") + decision = " ".join(adr.split("7. Key rotation", 1)[1].split("8.", 1)[0].split()) + assert "every token" in decision + assert "`exp + leeway_seconds`" in decision + assert "passed" in decision + assert "new profile without" in decision + + def test_baseline_carries_mcp_reference_and_current_rp_checklist() -> None: """Keep product evidence and README guidance aligned with standards."""