Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,91 @@ REFRESH_TOKEN_LIFESPAN=604800
# characters each, 64 KiB total.
# OID4VP_STATUS_LIST_URI_ALLOWLIST=https://issuer.example/statuslists,https://status.other.example

# ---------------------------------------------------------------------------
# The VERIFIER's own identity (#377) — how QAuth proves who IT is to a wallet.
# ---------------------------------------------------------------------------
#
# The FIFTH X.509 trust question in this file, and the only one pointing
# outward. The four above decide what QAuth may BELIEVE (which issuers, which
# keys, which status CAs); these three are what QAuth PRESENTS.
#
# Needed only by a VerifierProfile that mandates signed Authorization Requests —
# `haip-1.0` does, via the `x509_hash` Client Identifier Prefix (HAIP §5).
# `oid4vp-1.0-base` prefers the certificate-free `redirect_uri` prefix and needs
# none of this. Unset everything is the default posture and boots fine; setting
# SOME of it is a boot failure, because a half-provisioned verifier identity is
# the one state that would silently fall back to a weaker identity than the
# operator configured.
#
# NOT a token-issuance key. JWT_PRIVATE_KEY and JWT_RS256_PRIVATE_KEY sign the
# access and ID tokens QAuth issues, and their public halves are published at
# GET /.well-known/jwks.json. This key is published NOWHERE, never reaches the
# JWT plugin, and signs nothing a relying party sees. The two key sets must not
# be interchangeable, and a test asserts they are not.

# The ES256 (EC P-256) private key, PKCS#8 PEM. That it is P-256 and that it
# BELONGS TO the leaf below are both checked at boot.
# OID4VP_VERIFIER_SIGNING_KEY="-----BEGIN PRIVATE KEY-----
# MIGH...
# -----END PRIVATE KEY-----"

# The same key, read from a file. The form to prefer in any real deployment: a
# private key is what an orchestrator mounts as a secret.
#
# WINS over the inline form, like JWT_PRIVATE_KEY_PATH and unlike the anchor
# variables — a signing key has one correct value, so two sources are a
# precedence rather than a set. A path that is SET but unreadable fails the boot.
# OID4VP_VERIFIER_SIGNING_KEY_PATH=/etc/qauth/verifier-signing.key

# The certificate chain that accompanies the key: concatenated PEM CERTIFICATE
# blocks, LEAF FIRST, with the trust anchor EXCLUDED.
#
# This becomes the `x5c` header of the signed request object, and both
# properties are load-bearing. Each entry must be issued by the next, so a
# reversed bundle is refused at boot rather than reordered. The anchor belongs
# in OID4VP_VERIFIER_TRUST_ANCHORS below and nowhere else: a chain that shipped
# a copy of its own anchor would satisfy a path check against itself, which
# turns "chains to an anchor" into "carries one".
#
# In the EU this is the QTSP-issued WRPAC and its issuing CA. A public CA such
# as Let's Encrypt does NOT satisfy it — the wallet has to recognise the anchor.
# Cap: 8 certificates, 16 KiB.
# OID4VP_VERIFIER_CERTIFICATE_CHAIN="-----BEGIN CERTIFICATE-----
# MIIB...
# -----END CERTIFICATE-----"

# The same chain, read from a file. WINS over the inline form rather than being
# unioned with it: `x5c` is an ORDERED sequence, and concatenating two
# independently authored chains would produce one whose middle link does not
# issue the one after it.
# OID4VP_VERIFIER_CERTIFICATE_CHAIN_PATH=/etc/qauth/verifier-chain.pem

# The anchors QAuth's OWN chain must terminate at. Distinct from
# OID4VP_STATUS_LIST_TRUST_ANCHORS and never a substitute for it: that set says
# which CA may vouch for a STATUS issuer, this one is the CA that vouches for
# QAuth. Sharing them would let a status-issuer CA mint a certificate that
# identifies this deployment to a wallet.
#
# REQUIRED whenever a chain is configured. A chain validated against nothing is
# a chain nobody vouched for, so the boot refuses it rather than accepting it.
# OID4VP_VERIFIER_TRUST_ANCHORS="-----BEGIN CERTIFICATE-----
# MIIB...
# -----END CERTIFICATE-----"

# The same anchors, read from a file. UNIONED with the inline variable, like the
# status-list anchors and unlike the chain above — an anchor set is a SET.
# OID4VP_VERIFIER_TRUST_ANCHORS_PATH=/etc/qauth/verifier-anchors.pem

# How many request-object fetches one IP may make per window
# (GET /oid4vp/request/:handle, RFC 9101 / HAIP §5.1).
#
# Its own budget rather than OID4VP_RESPONSE_RATE_LIMIT's: the two are opposite
# halves of the exchange — a wallet fetches once before it shows the user
# anything and posts once after they consent — so a slow-wallet retry budget
# must not also be a state-guessing budget.
# OID4VP_REQUEST_OBJECT_RATE_LIMIT=30
# OID4VP_REQUEST_OBJECT_RATE_WINDOW=60

# Which Verifiable Credential Types the wallet SIGN-IN SCREEN asks for (#239).
# Comma-separated list of `vct` values, matched by the wallet against the
# credentials it holds.
Expand Down
41 changes: 37 additions & 4 deletions apps/auth-server/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ import {
credentialStatusProvisioningOf,
federationPlugin,
type VerifierCryptoCapabilities,
verifierMaterialProvisionedBy,
} from '@qauth-labs/fastify-plugin-federation';
import { jwtPlugin } from '@qauth-labs/fastify-plugin-jwt';
import { passwordPlugin } from '@qauth-labs/fastify-plugin-password';
import { pkcePlugin } from '@qauth-labs/fastify-plugin-pkce';
import { resolveStatusListTrustAnchorPems } from '@qauth-labs/server-config';
import {
resolveStatusListTrustAnchorPems,
resolveVerifierCertificateChainPems,
resolveVerifierSigningKeyPem,
} from '@qauth-labs/server-config';
import type { FastifyInstance } from 'fastify';

import { env } from '../config/env';
import { deriveCryptoCapabilities } from './crypto-capabilities';
import { isJtiRevoked } from './helpers/token-revocation';
import { verifierSigningMaterial } from './helpers/verifier-identity';
import errorHandler from './plugins/error-handler';
import { metricsPlugin } from './plugins/metrics';
import { rateLimitPlugin } from './plugins/rate-limit';
Expand Down Expand Up @@ -50,6 +56,12 @@ import { securityHeadersPlugin } from './plugins/security-headers';
*/
const CRYPTO_CAPABILITIES: VerifierCryptoCapabilities = deriveCryptoCapabilities({
rs256PrivateKey: env.JWT_RS256_PRIVATE_KEY,
// The OID4VP verifier's own key and chain (#377). Read here as CONFIGURED
// rather than as validated: validation happens inside `app()` below and is a
// boot refusal, so a deployment that reaches a request with this descriptor
// has already had its chain accepted.
verifierEs256PrivateKey: resolveVerifierSigningKeyPem(env),
verifierCertificateChainPems: resolveVerifierCertificateChainPems(env),
});

export async function app(fastify: FastifyInstance, opts: object) {
Expand Down Expand Up @@ -141,6 +153,21 @@ export async function app(fastify: FastifyInstance, opts: object) {
uriAllowlist: env.OID4VP_STATUS_LIST_URI_ALLOWLIST,
});

// The VERIFIER identity (#377), and the same posture again for the third
// trust direction: the two calls above validate material QAuth will BELIEVE,
// this one validates the material QAuth will PRESENT. It parses the chain,
// checks it terminates at a configured anchor with the anchor itself excluded,
// and confirms the signing key belongs to the leaf — every one of which is an
// operator mistake whose only runtime symptom is a wallet rejecting 100% of
// requests with nothing in QAuth's logs naming the cause.
//
// NOT gated on WALLET_FEDERATION_ENABLED, for the reason the two gates above
// give: a mis-pasted certificate is mis-pasted whether or not wallet flows are
// switched on today, and finding it at boot beats finding it on the first
// presentation. A deployment that configured nothing gets `undefined` and is
// unaffected.
const verifierMaterial = verifierSigningMaterial();

await fastify.register(federationPlugin, {
providers: createConfiguredProviders({
walletFederationEnabled: env.WALLET_FEDERATION_ENABLED,
Expand All @@ -155,9 +182,15 @@ export async function app(fastify: FastifyInstance, opts: object) {
trustAnchorPems: statusListTrustAnchorPems,
uriAllowlist: env.OID4VP_STATUS_LIST_URI_ALLOWLIST,
}),
// `provisionedVerifierMaterial` is deliberately not passed: no certificate
// configuration surface exists until #233, and the option's default is the
// refusing one. Threading real material through here is that issue's job.
// What the operator provisioned for the VERIFIER identity (#377) — the
// ES256 key and the X.509 chain a wallet establishes QAuth's identity
// from. Derived from the material that VALIDATED above rather than from
// the raw variables, so the marker set the gate reads can never claim a
// capability the chain did not earn: a chain that failed to anchor took
// the boot down before this line, and a deployment that configured
// nothing yields `NO_VERIFIER_MATERIAL`, which is what makes a profile
// requiring a WRPAC refuse.
provisionedVerifierMaterial: verifierMaterialProvisionedBy(verifierMaterial),
}),
});

Expand Down
Loading