kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | Submodules | README | LICENSE

commit 460475ccfa1000e8b70a7b2778c4677ec5da5d98
parent 53d7cd990c3ee4a53598a46e696c1cb39e9bb311
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sun,  1 Nov 2026 11:07:42 +0100

kych: talk DCQL to the verifier, and package the verifier alongside it

The SWIYU verifier dropped DIF Presentation Exchange in its 3.0.0 release, so
the gateway as it stood could not drive any current verifier at all; the
packaging now builds the pinned 5.0.1 upstream as a second binary package,
migrates the gateway's schema from postinst and restarts a running daemon on
upgrade without starting one on first install.

Assisted by LLM

Diffstat:
A.gitmodules | 3+++
MREADME | 22++++++++++++++++++----
Acontrib/kych-dbconfig | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontrib/swiyu-verifier | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontrib/swiyu-verifier-dbconfig | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ddebian/README.Debian | 99-------------------------------------------------------------------------------
Mdebian/README.source | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mdebian/changelog | 6++++++
Adebian/config.d/00-kych-defaults.conf | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdebian/control | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mdebian/copyright | 19+++++++++++++++++++
Mdebian/etc/kych/kych.conf | 134++++++++++++++++++++++++++++---------------------------------------------------
Adebian/etc/swiyu-verifier/application.yml | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adebian/etc/swiyu-verifier/client_metadata.json | 16++++++++++++++++
Adebian/etc/swiyu-verifier/swiyu-verifier.env | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adebian/kych.README.Debian | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdebian/kych.dirs | 12+++++++++---
Mdebian/kych.install | 22+++++++++++++++++++---
Mdebian/kych.manpages | 7+++++--
Adebian/kych.postinst | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdebian/kych.service | 7+++++++
Mdebian/kych.sysusers | 7+++++--
Mdebian/kych.tmpfiles | 4++++
Mdebian/rules | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Adebian/signing-key.yml.example | 22++++++++++++++++++++++
Adebian/swiyu-verifier.README.Debian | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adebian/swiyu-verifier.dirs | 6++++++
Adebian/swiyu-verifier.install | 22++++++++++++++++++++++
Adebian/swiyu-verifier.lintian-overrides | 5+++++
Adebian/swiyu-verifier.manpages | 2++
Adebian/swiyu-verifier.postinst | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adebian/swiyu-verifier.postrm | 24++++++++++++++++++++++++
Adebian/swiyu-verifier.service | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adebian/swiyu-verifier.sysusers | 6++++++
Adebian/swiyu-verifier.tmpfiles | 16++++++++++++++++
Mdocumentation/sequence_diagrams/authorize_sequence.txt | 4++--
Mdocumentation/sphinx-man/conf.py | 36++++++++++++++++++++++++++++++++----
Mdocumentation/taler-docs/README | 35+++++++++++++++++++++++++++++++----
Mdocumentation/taler-docs/core/api-kych.rst | 2+-
Adocumentation/taler-docs/manpages/kych-dbconfig.1.rst | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocumentation/taler-docs/manpages/kych-dbinit.1.rst | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocumentation/taler-docs/manpages/kych-oauth2-gateway.1.rst | 2+-
Mdocumentation/taler-docs/manpages/kych.conf.5.rst | 42++++++++++++++++++++++++++++++++++++------
Adocumentation/taler-docs/manpages/swiyu-verifier-dbconfig.1.rst | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocumentation/taler-docs/manpages/swiyu-verifier.1.rst | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocumentation/taler-docs/taler-kych-manual.rst | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mdocumentation/taler-docs/taler-swiyu-verifier-manual.rst | 280+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mkych_oauth2_gateway/Cargo.toml | 4++++
Mkych_oauth2_gateway/README | 27+++++++++++++++++----------
Mkych_oauth2_gateway/kych.conf.example | 2+-
Akych_oauth2_gateway/src/bin/dbinit.rs | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mkych_oauth2_gateway/src/config.rs | 9++++++++-
Mkych_oauth2_gateway/src/handlers.rs | 427+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mkych_oauth2_gateway/src/models.rs | 111+++++++++++++++++++++++++++++++------------------------------------------------
Mkych_oauth2_gateway/tests/handlers_integration.rs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mswiyu-verifier/README | 149++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mswiyu-verifier/api_requests/get_management_api_verifications.sh | 4+++-
Mswiyu-verifier/api_requests/post_management_api_verifications.sh | 18++++++++++++++----
Mswiyu-verifier/api_requests/request_over_18.json | 50++++++++++++++++----------------------------------
Dswiyu-verifier/api_requests/request_over_18_dcql.json | 65-----------------------------------------------------------------
Aswiyu-verifier/application-kych.yml.template | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dswiyu-verifier/application-local-dockerless.yml.template | 42------------------------------------------
Mswiyu-verifier/client_metadata.json | 16+++++++++-------
Mswiyu-verifier/install_db.sh | 26+++++++++++++++++++++-----
Mswiyu-verifier/run.sh | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mswiyu-verifier/uninstall_db.sh | 16++++++++++++----
Aswiyu-verifier/upstream | 1+
67 files changed, 3689 insertions(+), 884 deletions(-)

diff --git a/.gitmodules b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "swiyu-verifier/upstream"] + path = swiyu-verifier/upstream + url = https://github.com/swiyu-admin-ch/swiyu-verifier.git diff --git a/README b/README @@ -32,7 +32,10 @@ The system consists of two main components working together: OpenID4VP (OID4VP) credential verification. 2. SWIYU Verifier (swiyu-verifier/) - Scripts and configuration for running the SWIYU Generic Verifier locally. + The upstream SWIYU Generic Verifier, maintained by the Swiss + Confederation, as a git submodule pinned to a release tag, plus the + scripts and configuration for running it. Both components are packaged + for Debian from this tree, as "kych" and "swiyu-verifier". The full interaction flow between a Taler exchange, the gateway, and the verifier is documented in: @@ -43,7 +46,9 @@ Components ---------- kych_oauth2_gateway/ Rust OAuth2 gateway service - swiyu-verifier/ Local verifier setup scripts + swiyu-verifier/ The verifier: upstream submodule and setup scripts + taler-config/ Vendored GNU Taler configuration parser + debian/ Packaging for both components documentation/ Technical specifications and sequence diagrams @@ -51,9 +56,9 @@ Technology Stack ---------------- Gateway: Rust (Axum, Tokio, SQLx) - Verifier: Java 21, Spring Boot + Verifier: Java 25, Spring Boot 4 (Java 21 up to verifier 4.2.0) Database: PostgreSQL - Credentials: SD-JWT Verifiable Credentials (vc+sd-jwt) + Credentials: SD-JWT Verifiable Credentials (dc+sd-jwt) Protocols: OAuth 2.0, OpenID4VP, DCQL @@ -69,6 +74,15 @@ Standards Getting Started --------------- +0. Fetch the verifier source: git submodule update --init 1. Set up the SWIYU Verifier - see swiyu-verifier/README 2. Configure and run the OAuth2 Gateway - see kych_oauth2_gateway/README 3. Review the technical documentation in documentation/ + +For a deployment rather than a development setup, build the Debian packages +(debian/README.source) and follow debian/kych.README.Debian and +debian/swiyu-verifier.README.Debian. + +Note that the gateway needs a verifier of release 3.0.0 or newer: it sends a +DCQL query, which replaced the DIF Presentation Exchange format that earlier +verifier releases used. diff --git a/contrib/kych-dbconfig b/contrib/kych-dbconfig @@ -0,0 +1,171 @@ +#!/bin/bash +# This file is part of KyCH. +# Copyright (C) 2026 Taler Systems SA +# +# KyCH is free software; you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation; either version 3, or (at your option) any later version. +# +# KyCH is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with KyCH; see the file COPYING. If not, see +# <http://www.gnu.org/licenses/> +# +# +# Create the PostgreSQL role and database that kych.conf names and load the +# schema into it. This is the one-time setup step; afterwards the schema is +# kept up to date by kych-dbinit, which the Debian package runs on every +# upgrade. +# +# Error checking on +set -eu + +RESET_DB=0 +SKIP_DBINIT=0 +DBUSER="kych" +CFGFILE="/etc/kych/kych.conf" + +while getopts 'c:hrsu:' OPTION; do + case "$OPTION" in + c) + CFGFILE="$OPTARG" + ;; + h) + echo 'Supported options:' + echo " -c FILENAME -- use configuration FILENAME (default: $CFGFILE)" + echo " -h -- print this help text" + echo " -r -- reset database (dangerous, destroys all data)" + echo " -s -- skip database initialization" + echo " -u USER -- kych to be run by USER (default: $DBUSER)" + exit 0 + ;; + r) + RESET_DB="1" + ;; + s) + SKIP_DBINIT="1" + ;; + u) + DBUSER="$OPTARG" + ;; + ?) + echo "Unrecognized command line option" 1>&2 + exit 1 + ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "This script must be run as root" 1>&2 + exit 1 +fi + +if ! id postgres >/dev/null 2>&1; then + echo "Could not find 'postgres' user. Please install PostgreSQL first" 1>&2 + exit 1 +fi + +if ! id "$DBUSER" >/dev/null 2>&1; then + echo "Could not find '$DBUSER' user. Please set it up first" 1>&2 + exit 1 +fi + +if ! command -v kych-dbinit >/dev/null 2>&1; then + echo "Required 'kych-dbinit' not found. Please fix your installation." 1>&2 + exit 1 +fi + +# kych-dbinit understands the full configuration format, including @inline@ +# includes and $-expansion, so it is asked where the database is rather than +# this script guessing at it. This sets PGDATABASE, PGHOST and PGPORT, which +# matters on a host whose PostgreSQL is not on the default port: everything +# below then acts on the same server the daemon will use. +eval "$(kych-dbinit -c "$CFGFILE" --print-connection)" + +if [ -z "${PGDATABASE:-}" ]; then + echo "Could not determine the database from $CFGFILE" 1>&2 + exit 1 +fi +DBNAME="$PGDATABASE" + +# A database on another host is not ours to create. +case "$PGHOST" in +/* | localhost | 127.0.0.1 | ::1) ;; +*) + echo "DATABASE in $CFGFILE names the server '$PGHOST', which is not this" 1>&2 + echo "host. Create the role and the database there by hand, then load the" 1>&2 + echo "schema with 'kych-dbinit -c $CFGFILE'." 1>&2 + exit 1 + ;; +esac + +# The administrative work is done over the local socket as the postgres user, +# which is what peer authentication there expects; only the port is taken from +# the configuration. Asking the postgres superuser to authenticate over TCP +# instead would stop to prompt for a password that nobody is there to type, +# which is also why -w is passed to psql throughout. "sudo -i" resets the +# environment, so PGPORT is passed explicitly. +as_postgres() { + sudo -i -u postgres env PGPORT="$PGPORT" PGCONNECT_TIMEOUT=10 "$@" +} + +# Up front, so that a server which is not running is reported as that and not +# as a series of puzzling failures further down. +if ! as_postgres psql -w -tAc 'SELECT 1' postgres >/dev/null 2>&1; then + echo "Cannot reach PostgreSQL on port $PGPORT as the postgres user over the" 1>&2 + echo "local socket. Check that the server is running and that $CFGFILE" 1>&2 + echo "names its port." 1>&2 + exit 1 +fi + +# The daemon connects over the local socket and authenticates as the +# operating-system user it runs as, which is what peer authentication in a +# stock Debian PostgreSQL expects: the role must have the same name. +if as_postgres psql -w -tAc \ + "SELECT 1 FROM pg_roles WHERE rolname = '$DBUSER'" postgres | grep -q 1; then + echo "Database user '$DBUSER' already exists, continuing anyway." 1>&2 +else + echo "Setting up database user '$DBUSER'." 1>&2 + if ! as_postgres createuser "$DBUSER"; then + echo "Failed to create database user '$DBUSER'" 1>&2 + exit 1 + fi +fi + +DO_CREATE=1 +if as_postgres psql -w -lqtA -F: | cut -d: -f1 | grep -qx "$DBNAME"; then + if [ 1 = "$RESET_DB" ]; then + echo "Deleting existing database '$DBNAME'." 1>&2 + if ! as_postgres dropdb "$DBNAME"; then + echo "Failed to delete existing database '$DBNAME'" 1>&2 + exit 1 + fi + else + echo "Database '$DBNAME' already exists, continuing anyway." 1>&2 + DO_CREATE=0 + fi +fi + +if [ 1 = "$DO_CREATE" ]; then + echo "Creating database '$DBNAME'." 1>&2 + if ! as_postgres createdb -O "$DBUSER" "$DBNAME"; then + echo "Failed to create database '$DBNAME'" 1>&2 + exit 1 + fi +fi + +if [ 0 = "$SKIP_DBINIT" ]; then + echo "Initializing database '$DBNAME'." 1>&2 + if ! sudo -u "$DBUSER" kych-dbinit -c "$CFGFILE"; then + echo "Failed to initialize database schema" 1>&2 + exit 1 + fi +fi + +echo "Database configuration finished." 1>&2 + +exit 0 diff --git a/contrib/swiyu-verifier b/contrib/swiyu-verifier @@ -0,0 +1,91 @@ +#!/bin/sh +# This file is part of KyCH. +# Copyright (C) 2026 Taler Systems SA +# +# KyCH is free software; you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation; either version 3, or (at your option) any later version. +# +# KyCH is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with KyCH; see the file COPYING. If not, see +# <http://www.gnu.org/licenses/> +# +# +# Start the SWIYU Generic Verifier. This is what swiyu-verifier.service runs; +# it exists because three things have to be decided before the JVM starts and +# a systemd unit cannot decide them: +# +# * which Java to use -- the jar is built for Java 25, so the system default +# "java" is not necessarily the right one; +# * whether the private truststore built by the package's postinst exists, +# which it does not on a system without keytool; +# * the configuration files to add, which are under /etc and not in the jar. +# +# Arguments are passed on to the application, so Spring Boot's +# --property=value form works: +# +# swiyu-verifier --server.port=8081 +# +set -eu + +JAR=/usr/share/swiyu-verifier/swiyu-verifier.jar +CONFIG=/etc/swiyu-verifier/application.yml +TRUSTSTORE=/var/lib/swiyu-verifier/cacerts + +if [ ! -r "$JAR" ]; then + echo "$JAR is missing; reinstall the swiyu-verifier package" 1>&2 + exit 1 +fi + +# JAVA_HOME from the environment file wins, so that an administrator can point +# this at another runtime; otherwise prefer the Java the jar was built for and +# fall back to whatever is the system default. +if [ -n "${JAVA_HOME:-}" ] && [ -x "${JAVA_HOME}/bin/java" ]; then + JAVA="${JAVA_HOME}/bin/java" +else + JAVA= + for home in /usr/lib/jvm/java-25-openjdk-*; do + if [ -x "${home}/bin/java" ]; then + JAVA="${home}/bin/java" + break + fi + done + if [ -z "$JAVA" ]; then + JAVA=$(command -v java || true) + if [ -z "$JAVA" ]; then + echo "No Java runtime found; install openjdk-25-jre-headless" 1>&2 + exit 1 + fi + fi +fi + +# Options for the JVM itself. Word splitting below is intentional and safe: +# every value here is a fixed, space-free path or literal. Note that Spring +# Boot reads spring.* from system properties as well as from the command line, +# which is what lets the configuration be passed as -D and keeps "$@" free for +# the caller's own arguments. +# +# The verifier timestamps its authorization requests in Swiss local time, as +# the upstream container image does. +JVM_OPTS="-Duser.timezone=Europe/Zurich -Dfile.encoding=UTF-8" + +if [ -r "$CONFIG" ]; then + JVM_OPTS="${JVM_OPTS} -Dspring.config.additional-location=file:${CONFIG}" +fi + +# The Swiss Government Root CA VI is not in the public trust store that the +# JVM uses by default. The postinst imports it into a truststore of this +# service's own; it is used only if that worked, so that a missing keytool +# leaves the service running with the default store rather than not running. +if [ -r "$TRUSTSTORE" ]; then + JVM_OPTS="${JVM_OPTS} -Djavax.net.ssl.trustStore=${TRUSTSTORE}" + JVM_OPTS="${JVM_OPTS} -Djavax.net.ssl.trustStorePassword=changeit" +fi + +# shellcheck disable=SC2086 # see the note on word splitting above +exec "$JAVA" ${JVM_OPTS} -jar "$JAR" "$@" diff --git a/contrib/swiyu-verifier-dbconfig b/contrib/swiyu-verifier-dbconfig @@ -0,0 +1,205 @@ +#!/bin/bash +# This file is part of KyCH. +# Copyright (C) 2026 Taler Systems SA +# +# KyCH is free software; you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation; either version 3, or (at your option) any later version. +# +# KyCH is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with KyCH; see the file COPYING. If not, see +# <http://www.gnu.org/licenses/> +# +# +# Create the PostgreSQL role and database for the SWIYU verifier and store the +# generated password where the service can read it. +# +# Unlike KyCH, the verifier reaches PostgreSQL through the JDBC driver, which +# cannot use peer authentication over a Unix socket -- hence a password, and +# hence this script rather than a plain createdb. The schema itself is not +# created here: the verifier runs its own Flyway migrations on every start. +# +# Error checking on +set -eu + +RESET_DB=0 +DBUSER= +DBNAME= +ENVFILE="/etc/swiyu-verifier/swiyu-verifier.env" +SECRETFILE="/etc/swiyu-verifier/secrets/db.env" +SERVICEUSER="swiyu-verifier" + +while getopts 'c:d:hn:ru:' OPTION; do + case "$OPTION" in + c) + ENVFILE="$OPTARG" + ;; + d) + SECRETFILE="$OPTARG" + ;; + h) + echo 'Supported options:' + echo " -c FILENAME -- read the connection from FILENAME (default: $ENVFILE)" + echo " -d FILENAME -- write the password to FILENAME (default: $SECRETFILE)" + echo " -h -- print this help text" + echo " -n DBNAME -- database to create (default: from POSTGRES_JDBC)" + echo " -r -- reset database (dangerous, destroys all data)" + echo " -u DBUSER -- database role to create (default: from POSTGRES_USER)" + exit 0 + ;; + n) + DBNAME="$OPTARG" + ;; + r) + RESET_DB="1" + ;; + u) + DBUSER="$OPTARG" + ;; + ?) + echo "Unrecognized command line option" 1>&2 + exit 1 + ;; + esac +done + +# Take the port and the database name from the service's own configuration, so +# that this script and the service cannot end up talking to different servers: +# on a host whose PostgreSQL is not on the default port, a hard-coded default +# would silently set the database up in the wrong place, or in no place at all. +# +# POSTGRES_JDBC=jdbc:postgresql://host:port/dbname?params +jdbc=$(sed -n 's/^[[:space:]]*POSTGRES_JDBC=//p' "$ENVFILE" 2>/dev/null | tail -1) +JDBCHOST=localhost +PGPORT=5432 +case "$jdbc" in +jdbc:postgresql://*) + rest=${jdbc#jdbc:postgresql://} + authority=${rest%%/*} + path=${rest#*/} + path=${path%%\?*} + JDBCHOST=${authority%%:*} + case "$authority" in + *:*) PGPORT=${authority##*:} ;; + esac + [ -n "$DBNAME" ] || DBNAME="$path" + ;; +*) + echo "No POSTGRES_JDBC in $ENVFILE; assuming localhost port 5432." 1>&2 + ;; +esac + +case "$JDBCHOST" in +localhost | 127.0.0.1 | ::1 | "") ;; +*) + echo "POSTGRES_JDBC names the server '$JDBCHOST', which is not this host." 1>&2 + echo "Create the role and the database there by hand -- the verifier needs" 1>&2 + echo "only an empty database owned by its role, since it migrates its own" 1>&2 + echo "schema -- and put the password in $SECRETFILE." 1>&2 + exit 1 + ;; +esac + +if [ -z "$DBUSER" ]; then + DBUSER=$(sed -n 's/^[[:space:]]*POSTGRES_USER=//p' "$ENVFILE" 2>/dev/null | tail -1) +fi + +: "${DBNAME:=swiyu_verifier}" +: "${DBUSER:=swiyu_verifier}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This script must be run as root" 1>&2 + exit 1 +fi + +if ! id postgres >/dev/null 2>&1; then + echo "Could not find 'postgres' user. Please install PostgreSQL first" 1>&2 + exit 1 +fi + +# 32 bytes of base64 from the kernel's pool; no shell history, no temporary +# file, and never printed. +DBPASS=$(head -c 32 /dev/urandom | base64 | tr -d '\n=' | tr '+/' 'ab') + +# The administrative work is done over the local socket as the postgres user, +# which is what peer authentication there expects. Only the port comes from +# the configuration: the service's own TCP connection with a password is a +# different matter, and asking the postgres superuser to authenticate over TCP +# would stop to prompt for a password that nobody is there to type. "sudo -i" +# resets the environment, so PGPORT is passed explicitly, and -w keeps psql +# from prompting in any case. +as_postgres() { + sudo -i -u postgres env PGPORT="$PGPORT" PGCONNECT_TIMEOUT=10 "$@" +} + +# Up front, so that a server which is not running is reported as that and not +# as a series of puzzling failures further down. +if ! as_postgres psql -w -tAc 'SELECT 1' postgres >/dev/null 2>&1; then + echo "Cannot reach PostgreSQL on port $PGPORT as the postgres user over the" 1>&2 + echo "local socket. Check that the server is running and that" 1>&2 + echo "POSTGRES_JDBC in $ENVFILE names its port." 1>&2 + exit 1 +fi + +echo "Setting up database user '$DBUSER'." 1>&2 +if as_postgres psql -w -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$DBUSER'" postgres | grep -q 1; then + echo "Database user '$DBUSER' already existed; setting a new password." 1>&2 +fi +# Both branches in one statement, so the role ends up with the password we are +# about to write out either way. +as_postgres psql -w -q postgres <<SQL +DO \$\$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DBUSER') THEN + ALTER ROLE "$DBUSER" WITH LOGIN PASSWORD '$DBPASS'; + ELSE + CREATE ROLE "$DBUSER" WITH LOGIN PASSWORD '$DBPASS'; + END IF; +END +\$\$; +SQL + +DO_CREATE=1 +if as_postgres psql -w -lqtA -F: | cut -d: -f1 | grep -qx "$DBNAME"; then + if [ 1 = "$RESET_DB" ]; then + echo "Deleting existing database '$DBNAME'." 1>&2 + if ! as_postgres dropdb "$DBNAME"; then + echo "Failed to delete existing database '$DBNAME'" 1>&2 + exit 1 + fi + else + echo "Database '$DBNAME' already exists, continuing anyway." 1>&2 + DO_CREATE=0 + fi +fi + +if [ 1 = "$DO_CREATE" ]; then + echo "Creating database '$DBNAME' on port $PGPORT." 1>&2 + if ! as_postgres createdb -O "$DBUSER" "$DBNAME"; then + echo "Failed to create database '$DBNAME'" 1>&2 + exit 1 + fi +fi + +echo "Writing the generated password to $SECRETFILE." 1>&2 +mkdir -p "$(dirname "$SECRETFILE")" +umask 027 +cat >"$SECRETFILE" <<ENV +# Written by swiyu-verifier-dbconfig; read by swiyu-verifier.service. +# Keep this file mode 0640 and owned by root:$SERVICEUSER. +POSTGRES_PASSWORD=$DBPASS +ENV +chmod 0640 "$SECRETFILE" +if getent group "$SERVICEUSER" >/dev/null 2>&1; then + chown "root:$SERVICEUSER" "$SECRETFILE" +fi + +echo "Database configuration finished. The schema itself is created by the" 1>&2 +echo "service on its next start." 1>&2 + +exit 0 diff --git a/debian/README.Debian b/debian/README.Debian @@ -1,99 +0,0 @@ -kych for Debian -=============== - -The service is installed disabled and will not start until you have done the -three steps below: it has no usable defaults for the database or for the OAuth -2.0 clients it serves. - -1. Create the database ----------------------- - -The daemon runs as the system user "kych" and the shipped configuration -connects over the local PostgreSQL socket using peer authentication, so no -password is stored anywhere. Create a matching role and database: - - sudo -u postgres createuser kych - sudo -u postgres createdb -O kych kych - -Then load the schema. The SQL is installed under /usr/share/kych/sql; the -first file sets up the "Versioning" patch-level bookkeeping that upstream uses -for migrations, the second creates the oauth2gw schema itself: - - sudo -u kych psql kych -f /usr/share/kych/sql/versioning.sql - sudo -u kych psql kych -f /usr/share/kych/sql/oauth2gw-0001.sql - -Later upstream releases add further oauth2gw-000N.sql patches, which are -applied the same way, in order. /usr/share/kych/sql/drop.sql removes -everything again. - -To use a remote database or password authentication instead, set DATABASE in -/etc/kych/kych.conf to a full connection URI. A password does not have to live -in the main file: with - - @inline-secret@ kych-oauth2-gateway /etc/kych/secrets/database.conf - -the DATABASE line can sit in a file of its own, restricted to root:kych, while -kych.conf itself stays readable. - -2. Configure ------------- - -Edit /etc/kych/kych.conf. It is commented throughout; the parts you have to -touch are the credential settings (VC_*), the recommended ALLOWED_SCOPES -ceiling, and at least one [client_*] section. - -Then register the clients in the database -- the running gateway reads clients -from there, not from the configuration file: - - kych-client-management -c /etc/kych/kych.conf sync - kych-client-management -c /etc/kych/kych.conf list - -Re-run "sync" after every change to a [client_*] section. Note that it does -not rotate an existing client's secret; delete and re-create the client for -that. - -3. Start it ------------ - - systemctl enable --now kych - systemctl status kych - -Socket activation (optional) ----------------------------- - -With the shipped SERVE = unix the daemon binds /run/kych/kych.sock itself and -kych.socket must stay disabled -- the two would fight over the same path. To -let systemd own the socket instead, so that it exists from boot and the daemon -starts on the first connection, change SERVE to "systemd" in -/etc/kych/kych.conf, comment out UNIXPATH, and swap the units: - - systemctl disable --now kych.service - systemctl enable --now kych.socket - -The socket path, its group (www-data) and its mode (0660) are the same either -way, so nothing changes for the reverse proxy. kych.socket is the place to -edit them in this mode; UNIXPATH_MODE is not consulted. - -Reverse proxy -------------- - -The gateway speaks plain HTTP on the Unix socket /run/kych/kych.sock and does -not terminate TLS. The socket has group www-data and mode 0660, so a proxy -running as www-data can reach it. No web-server snippets are shipped; for -nginx the essential part is - - location / { - proxy_pass http://unix:/run/kych/kych.sock; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - } - -Do not expose the gateway directly: /notification accepts unauthenticated -webhooks from the SWIYU verifier and should additionally be restricted to the -verifier's address at the proxy. - -Logs go to the journal: - - journalctl -u kych -f - - -- Taler Systems SA <deb@taler.net> diff --git a/debian/README.source b/debian/README.source @@ -1,16 +1,39 @@ Building the kych package ========================= -Binary-only builds from a git checkout, which is how this package is produced: +This source package produces two binary packages: **kych**, the OAuth 2.0 +gateway built from the Rust crate in this tree, and **swiyu-verifier**, the +SWIYU Generic Verifier built from the pinned upstream checkout in +swiyu-verifier/upstream. +Binary-only builds from a git checkout, which is how the packages are +produced: + + git submodule update --init ./debian/stamp-version.sh dpkg-buildpackage -b -uc -us -The first step rewrites debian/changelog with the version reported by +The submodule step is not optional: without it debian/rules stops with +"swiyu-verifier/upstream is empty". + +The second step rewrites debian/changelog with the version reported by scripts/get_version.sh -- the same "git describe --tags" logic the other GNU Taler repositories use -- because dpkg takes the package version from the changelog and from nowhere else. It is idempotent, and it falls back to -0.0.0-<commits>-g<hash> while this repository still has no release tag. +0.0.0-<commits>-g<hash> while this repository still has no release tag. Note +that it needs the .git directory; building from an exported tree means +stamping the changelog before exporting. + +Build dependencies beyond the usual: cargo and rustc for the gateway, +python3-sphinx for the man pages, and maven plus openjdk-25-jdk-headless for +the verifier. + +A build takes a few minutes, most of it Maven fetching some 300 MB into +debian/m2 -- which "clean" removes again, so every build re-downloads it. +While working on the packaging, point it at a cache of your own instead, and +it is left alone: + + MAVEN_REPO=$HOME/.m2/repository dpkg-buildpackage -b -uc -us Notes for whoever touches this next ----------------------------------- @@ -18,7 +41,8 @@ Notes for whoever touches this next * No debian/source/format is present, so the source format is the default 1.0. This matches the other Rust packages in the GNU Taler tree (robocop, taler-rust) and is fine for the "-b" builds above; producing a source package - would need a format decision and an orig tarball first. + would need a format decision and an orig tarball first -- and, with the + submodule, a decision about how the verifier's source travels in it. * The crate has one path dependency, ../taler-config, which is the vendored copy of the GNU Taler configuration parser, logging setup and socket-binding @@ -26,11 +50,39 @@ Notes for whoever touches this next builds it as part of the main crate, so it needs no separate step -- but "debian/rules clean" has to remove its target/ as well. -* The build is not offline: cargo fetches crates from crates.io, into - debian/cargo (CARGO_HOME) so that nothing outside the build tree is written. - There is no Cargo.lock in the repository, so builds are not reproducible - across dependency releases either. Both would have to be fixed before this - could go to the Debian archive; neither matters for the .deb builds we do. +* The build is not offline: cargo fetches crates from crates.io into + debian/cargo (CARGO_HOME) and Maven fetches from Maven Central into + debian/m2, so that nothing outside the build tree is written. There is a + Cargo.lock for the gateway, so its dependency versions are pinned; the + verifier's are pinned by upstream's pom. Both would still have to be + vendored before this could go to the Debian archive; neither matters for the + .deb builds we do. + +* The verifier build passes -Dpgpverify.skip=true. Upstream binds the + pgpverify plugin to the "validate" phase, where it checks the PGP signature + of every dependency against pgp/keys.list by querying public keyservers. + That is a worthwhile check in upstream's CI, with a warm key cache; in a + package build it makes the build depend on keys.openpgp.org being reachable, + and it is upstream's own documented escape hatch. Reconsider if the build + ever moves somewhere that can cache those keys. + +* -DskipTests as well: the verifier's tests need Testcontainers and therefore a + container runtime. + +* debian/rules names the JDK explicitly + (JAVA_HOME=/usr/lib/jvm/java-25-openjdk-$(DEB_HOST_ARCH)). The verifier + moved to Java 25 in its 5.0.0 release, so a host whose default JDK is older + -- which is most hosts -- would otherwise fail the build with a + release-version error. Trixie has openjdk-25; bookworm does not, and + building there means pinning the submodule back to a 4.x tag, which also + reverts to Java 21. + +* The jar the swiyu-verifier package installs is a Spring Boot "fat" jar of + about 140 MB: it embeds every Java dependency of the verifier. That is why + the package is large, and why debian/copyright carries an INCOMPLETE note -- + those dependencies' licenses are not enumerated. Upstream's + THIRD-PARTY-NOTICES.md and the sbom.json published with each release are the + starting point. * Man pages are generated at build time by sphinx-build from documentation/taler-docs/manpages/, the same reStructuredText that the @@ -45,22 +97,49 @@ Notes for whoever touches this next Consequences worth knowing: the man page sources may only use directives that plain Sphinx understands (they currently use "only", "code-block", "note" and "warning"), and the build prints "document isn't included in any toctree" for - two of the three pages. That warning is expected here - the toctree lives in - taler-docs - and cannot be suppressed, as it carries no warning type. + every page but the root document. Those warnings are expected here - the + toctree lives in taler-docs - and cannot be suppressed, as they carry no + warning type. * The daemon serves its QR-code JavaScript from a path relative to the process working directory, so debian/kych.service sets WorkingDirectory to /usr/share/kych, where debian/kych.install puts js/. Changing either without the other breaks the /authorize page. +Things that are easy to get wrong +--------------------------------- + +Each of these was wrong at some point and cost a debugging session: + +* debian/*.sysusers: the third column is the numeric ID, *not* the GECOS + field. With the description in that position systemd-sysusers fails the + installation with "Failed to parse UID". + +* debian/kych.service needs SupplementaryGroups=kych. Group=www-data replaces + the primary group -- which is what gives the listening socket a group the + reverse proxy can use -- and systemd then does not add the groups the kych + user is a member of. Without the line the daemon cannot read its own + root:kych 0640 configuration. + +* dh_installsystemd must not be passed --no-stop-on-upgrade. Despite the name + it also turns off the restart after an upgrade (see dh_installsystemd(1)), + which leaves the old binary running. --no-enable alone is what gives "do not + start on first install, but restart if running". + +* The verifier's signing key cannot live in swiyu-verifier.env: a systemd + EnvironmentFile cannot express the newlines of a PEM document, and an escaped + "\n" reaches the JVM as two characters, which fails with "No PEM key found". + It goes into /etc/swiyu-verifier/secrets/signing-key.yml, which the shipped + application.yml imports with spring.config.import. + Upstreaming the man pages ------------------------- documentation/taler-docs/manpages/ is copied into the taler-docs checkout as described in documentation/taler-docs/README. Sphinx there will not build a -man page unless it is also listed in taler-docs/conf.py, so the three entries -from documentation/sphinx-man/conf.py have to be added to the man_pages list -there as well, with a "manpages/" prefix on the first field: +man page unless it is also listed in taler-docs/conf.py, so the entries from +documentation/sphinx-man/conf.py have to be added to the man_pages list there +as well, with a "manpages/" prefix on the first field: ("manpages/kych-oauth2-gateway.1", "kych-oauth2-gateway", "OAuth 2.0 gateway for SWIYU credential verification", @@ -68,6 +147,18 @@ there as well, with a "manpages/" prefix on the first field: ("manpages/kych-client-management.1", "kych-client-management", "manage OAuth 2.0 clients of the KyCH gateway", ["GNU Taler contributors"], 1), + ("manpages/kych-dbinit.1", "kych-dbinit", + "initialise and upgrade the KyCH database schema", + ["GNU Taler contributors"], 1), + ("manpages/kych-dbconfig.1", "kych-dbconfig", + "create the PostgreSQL database for KyCH", + ["GNU Taler contributors"], 1), + ("manpages/swiyu-verifier.1", "swiyu-verifier", + "SWIYU Generic Verifier service", + ["GNU Taler contributors"], 1), + ("manpages/swiyu-verifier-dbconfig.1", "swiyu-verifier-dbconfig", + "create the PostgreSQL database for the SWIYU verifier", + ["GNU Taler contributors"], 1), ("manpages/kych.conf.5", "kych.conf", "configuration file of the KyCH OAuth 2.0 gateway", ["GNU Taler contributors"], 5), @@ -83,4 +174,22 @@ would be the right move once the page lives in taler-docs -- but it cannot happen here, because the fragments are not in this repository and the Debian build renders these sources standalone. +Updating the verifier +--------------------- + +The verifier is a git submodule pinned to a release tag, not a branch: + + cd swiyu-verifier/upstream + git fetch --tags + git checkout <new tag> + cd ../.. + git add swiyu-verifier/upstream + +Read upstream's CHANGELOG.md and migration-guides/ before doing so. Its +releases have broken things that this packaging and the gateway depend on more +than once: 3.0.0 dropped DIF Presentation Exchange in favour of DCQL (which is +why src/handlers.rs builds a dcql_query), 4.0.0 removed client_id_scheme in +favour of client_id_prefix, and 5.0.0 moved the build to Java 25 and the +disclosed claims into a new dcql_response field. + -- Taler Systems SA <deb@taler.net> diff --git a/debian/changelog b/debian/changelog @@ -1,3 +1,9 @@ +kych (0.0.0-113-g53d7cd99) UNRELEASED; urgency=medium + + * Build from git (0.0.0-113-g53d7cd99). + + -- Christian Grothoff <grothoff@gnu.org> Sun, 01 Nov 2026 01:29:49 +0100 + kych (0.0.0-111-g93b9ea2c) UNRELEASED; urgency=medium * Build from git (0.0.0-111-g93b9ea2c). diff --git a/debian/config.d/00-kych-defaults.conf b/debian/config.d/00-kych-defaults.conf @@ -0,0 +1,66 @@ +#################################################################### +# Default configuration of the KyCH OAuth 2.0 gateway. +# +# This file is part of the "kych" package and is read before +# /etc/kych/kych.conf on every start. Do not edit it: an upgrade +# replaces it without asking. Everything in it can be overridden +# by setting the same option in /etc/kych/kych.conf, which is read +# afterwards and therefore wins. +# +# See kych.conf(5) for what each option means. +#################################################################### + +[kych-oauth2-gateway] + +# Plain HTTP on a Unix socket, for a reverse proxy to pass requests +# to; kych.service creates /run/kych for it and runs the daemon as +# user kych with group www-data, so that a proxy running as www-data +# can reach the socket at 0660. +SERVE = unix +UNIXPATH = /run/kych/kych.sock +UNIXPATH_MODE = 660 + +# Connects over the local socket and authenticates as the operating +# system user the daemon runs as (kych), which is what "peer" +# authentication in a stock Debian PostgreSQL expects -- so no +# password is stored anywhere. kych-dbconfig(1) creates a role and a +# database to match. +# +# "?host=" with a path is what selects a Unix socket directory; the +# port, though, is only read from the authority part, so a server on +# another port needs +# postgres://localhost:5433/kych?host=/var/run/postgresql +# See kych.conf(5). +DATABASE = postgres:///kych?host=/var/run/postgresql + +# Where kych-dbinit(1) looks for versioning.sql and the oauth2gw-*.sql +# patch files. $DATADIR is /usr/share/kych for this package. +SQL_DIR = $DATADIR/sql + +# Bytes of raw randomness behind the /authorize nonce, the access +# tokens and the authorization codes; 32 bytes (256 bits) is the +# recommended value for all three. +NONCE_BYTES = 32 +TOKEN_BYTES = 32 +AUTH_CODE_BYTES = 32 + +# How long an authorization code stays redeemable at /token, in +# minutes. It is consumed on first use in any case. +AUTH_CODE_TTL_MINUTES = 10 + +# The credential KyCH asks the SWIYU verifier for. These are the +# values of the Swiss e-ID beta credential; they must match what the +# issuer actually issues, and are published to clients on /config. +VC_TYPE = betaid-sdjwt + +# dc+sd-jwt is the canonical SD-JWT VC media type; the verifier also +# still accepts the older vc+sd-jwt. +VC_FORMAT = dc+sd-jwt + +# Advertised to clients on /config. DCQL has no place for them in the +# credential query, so they are not sent to the verifier. +VC_ALGORITHMS = {ES256} + +# Every claim that exists in the credential type above. A client may +# only request claims from this set. +VC_CLAIMS = {family_name, given_name, birth_date, sex, place_of_origin, birth_place, nationality, portrait, personal_administrative_number, age_over_16, age_over_18, age_over_65, age_birth_year, document_number, issuance_date, expiry_date, additional_person_info, reference_id_type, reference_id_expiry_date, verification_type, verification_organization, issuing_authority, issuing_country} diff --git a/debian/control b/debian/control @@ -16,17 +16,31 @@ Build-Depends: # Builds the man pages from documentation/taler-docs/manpages/ using the # minimal configuration in documentation/sphinx-man/. No Sphinx extensions # are needed; keep it that way. - python3-sphinx + python3-sphinx, +# For the swiyu-verifier package: the SWIYU Generic Verifier in +# swiyu-verifier/upstream/ is a Spring Boot application, built for Java 25 +# since its 5.0.0 release. Trixie has the JDK; the pom's wrapper would pin +# Maven 3.10.0, but the build is fine with the 3.9.9 the distribution ships, +# which is preferred over letting the wrapper download one. + maven, + openjdk-25-jdk-headless Standards-Version: 4.7.2 Vcs-Git: https://git.taler.net/kych.git Homepage: https://taler.net/ Package: kych Architecture: any -Depends: ${misc:Depends}, ${shlibs:Depends} +Depends: + ${misc:Depends}, + ${shlibs:Depends}, +# kych-dbconfig needs createdb/createuser/psql, and so does the postinst to +# find out whether there is a database to upgrade at all. + postgresql-client, + adduser Recommends: nginx | apache2 | httpd, - postgresql (>= 14.0) + postgresql (>= 14.0), + swiyu-verifier Description: OAuth 2.0 gateway for SWIYU identity verification KyCH bridges OAuth 2.0 clients -- primarily a GNU Taler exchange performing KYC -- to the Swiss SWIYU e-ID trust infrastructure. The client speaks a @@ -36,4 +50,47 @@ Description: OAuth 2.0 gateway for SWIYU identity verification credential claims to be disclosed. . This package provides the gateway daemon, its systemd unit, the database - schema and the kych-client-management tool for registering OAuth 2.0 clients. + schema with the kych-dbconfig and kych-dbinit tools that create and migrate + it, and kych-client-management for registering OAuth 2.0 clients. + . + The SWIYU verifier that the gateway speaks OpenID4VP to is packaged + separately as swiyu-verifier; it can equally well be operated on another + host, or by somebody else entirely. + +Package: swiyu-verifier +Architecture: all +Depends: + ${misc:Depends}, +# The jar is built for Java 25, which is what its 5.0.0 release moved to; +# an older runtime cannot load it. + openjdk-25-jre-headless | java25-runtime, + postgresql-client, + adduser +Recommends: + nginx | apache2 | httpd, + postgresql (>= 14.0) +Suggests: + kych +Description: SWIYU Generic Verifier for e-ID credential verification + The SWIYU Generic Verifier is the service that performs the actual + verification of a credential presented by a SWIYU wallet: it publishes the + OpenID4VP endpoints the wallet talks to, checks the presented SD-JWT + verifiable credential against the issuer's key from the Swiss Base Registry + and its revocation status from the Status Registry, and reports the result to + the business application that asked for the verification -- KyCH, in this + case. + . + It exposes two interfaces that belong on different sides of a firewall: the + OID4VP interface, which wallets must reach over HTTPS with a publicly + trusted certificate, and the management interface under /management/api/, + which must not be reachable from outside the organisation. + . + This package builds the upstream Spring Boot application maintained by the + Swiss Confederation (https://github.com/swiyu-admin-ch/swiyu-verifier), + pinned to a release in the swiyu-verifier/upstream/ submodule of the KyCH + source package, and adds a systemd unit, a configuration under + /etc/swiyu-verifier and swiyu-verifier-dbconfig for the database. + . + Operating a verifier requires onboarding to the SWIYU trust infrastructure + first: the service needs its own DID and signing key before it can serve a + single verification. diff --git a/debian/copyright b/debian/copyright @@ -36,6 +36,25 @@ Comment: davidshimjs/qrcodejs (MIT). Either confirm it and ask upstream to restore the header, or replace the file with a packaged QR-code library. +Files: swiyu-verifier/upstream/* +Copyright: + (C) 2024-2026 Swiss Confederation +License: MIT +Comment: + A separate upstream, pinned to a release tag in the git submodule + swiyu-verifier/upstream and the source of the swiyu-verifier binary package. + Its own LICENSE and LICENSES/ directory are authoritative; a few files in it + are Apache-2.0 rather than MIT, which is recorded there. + . + INCOMPLETE -- needs work before this package is uploaded anywhere. The jar + that the swiyu-verifier package installs is a Spring Boot "fat" jar and + embeds every Java dependency of the verifier, whose licenses are not + enumerated here. Upstream generates a software bill of materials + (THIRD-PARTY-NOTICES.md in the submodule, and an sbom.json published with + each release) which is the starting point for doing so. The same jar is the + reason this package cannot go to the Debian archive as it stands: Debian + would want every one of those dependencies packaged separately. + Files: debian/* Copyright: (C) 2026 Taler Systems SA diff --git a/debian/etc/kych/kych.conf b/debian/etc/kych/kych.conf @@ -1,10 +1,16 @@ #################################################################### # Configuration for the KyCH OAuth 2.0 gateway. # +# Everything has a default in /usr/share/kych/config.d/, which is +# read before this file; this file is read afterwards and therefore +# wins. It starts out holding nothing but comments on purpose, so +# that it stays small enough to review and so that an upgrade of the +# package's defaults does not collide with your edits. +# # See kych.conf(5) for the full reference and # https://docs.taler.net/taler-kych-manual.html for the operator # manual. /usr/share/doc/kych/README.Debian describes the steps -# needed to get from this file to a running service. +# needed to get from here to a running service. # # Syntax notes, because they are easy to get wrong: # @@ -24,9 +30,6 @@ # * $DATADIR and friends expand in options that name a path, as do # environment variables and ${VAR:-default}. # -# * Files in /usr/share/kych/config.d/ are read before this one, and -# this one wins. -# # * The gateway re-reads nothing at runtime: restart the service # after editing ("systemctl restart kych"). #################################################################### @@ -35,100 +38,55 @@ # --- How the gateway listens ------------------------------------- # -# One of "unix", "tcp" or "systemd". The gateway speaks plain HTTP -# and does not terminate TLS; put nginx or Apache in front of it. -SERVE = unix - -# For SERVE = unix: where to put the listening socket, and its -# permissions in octal. The shipped systemd unit creates /run/kych -# for this and runs the daemon as user kych with group www-data, so a -# reverse proxy running as www-data can reach the socket at 0660. -UNIXPATH = /run/kych/kych.sock -UNIXPATH_MODE = 660 - -# For SERVE = tcp. Bind to the loopback interface unless the gateway -# sits behind a firewall -- none of its endpoints are safe to expose -# directly to the internet. -#BIND_TO = 127.0.0.1 -#PORT = 8080 - -# For SERVE = systemd, the gateway takes the already-bound socket from -# the service manager instead of binding one. Switching to it is two -# steps, since the shipped kych.socket listens on the same path that -# UNIXPATH names above: +# The default is a Unix socket at /run/kych/kych.sock with mode 660; +# the gateway speaks plain HTTP and does not terminate TLS, so put +# nginx or Apache in front of it. # -# SERVE = systemd (here, and comment out UNIXPATH) +# To listen on TCP instead -- only on the loopback interface unless +# the gateway sits behind a firewall, as none of its endpoints are +# safe to expose directly to the internet: +# +# SERVE = tcp +# BIND_TO = 127.0.0.1 +# PORT = 8080 +# +# To let systemd own the socket instead, so that it exists from boot +# and the daemon starts on the first connection (kych.socket listens +# on the same path, so nothing changes for the reverse proxy): +# +# SERVE = systemd # systemctl disable --now kych.service # systemctl enable --now kych.socket # --- Database ---------------------------------------------------- # -# PostgreSQL connection string. The default below connects over the -# local Unix socket and authenticates as the operating-system user the -# daemon runs as (kych), which is what "peer" authentication in a -# stock Debian PostgreSQL expects -- no password ends up in this file. +# The default connects over the local Unix socket as the operating +# system user the daemon runs as (kych), so no password is stored +# anywhere. For a remote database use the URI form, # -# For a remote database use the URI form instead, e.g. -# DATABASE = postgres://kych:SECRET@db.example.com/kych -# and consider keeping that line in a file of its own: -# @inline-secret@ kych-oauth2-gateway secrets/database.conf +# DATABASE = postgres://kych:SECRET@db.example.com/kych # -# The schema must be loaded before first start; see README.Debian. -DATABASE = postgres:///kych?host=/var/run/postgresql - -# --- Cryptographic parameters ------------------------------------ +# and consider keeping that one line in a file of its own: # -# Sizes are in bytes of raw randomness; the values are then base64url -# encoded, so 32 bytes yields a 43-character string. 32 bytes (256 -# bits) is the recommended value for all three -- lowering it weakens -# the unguessability that the /authorize nonce and the bearer tokens -# rely on for their security. - -# Nonce handed to the client by /setup and used as the secret part of -# the /authorize/{nonce} URL. -NONCE_BYTES = 32 - -# OAuth 2.0 access token returned by /token. -TOKEN_BYTES = 32 - -# OAuth 2.0 authorization code minted once verification succeeds. -AUTH_CODE_BYTES = 32 - -# How long an authorization code stays redeemable at /token, in -# minutes. RFC 6749 recommends a short lifetime; the code is consumed -# on first use in any case. -AUTH_CODE_TTL_MINUTES = 10 - -# --- Verifiable credential -------------------------------------- +# @inline-secret@ kych-oauth2-gateway secrets/database.conf # -# Describes the credential the gateway asks the SWIYU verifier for. -# These values must match the credential the issuer actually issues; -# they are also published to clients on the /config endpoint. +# The schema is created by kych-dbconfig(1) and kept up to date by +# kych-dbinit(1), which the package runs on every upgrade; see +# README.Debian. -# Credential type; matched against the "vct" claim of the presented -# SD-JWT. "betaid-sdjwt" is the Swiss e-ID beta credential. -VC_TYPE = betaid-sdjwt - -# Credential format. -VC_FORMAT = vc+sd-jwt - -# Signature algorithms accepted for the credential and for the key -# binding JWT. At least one must be listed. -VC_ALGORITHMS = {ES256} - -# Every claim that exists in the credential type above. A client may -# only request claims from this set: the claims it wants are passed as -# the OAuth 2.0 "scope" (space-separated) on /authorize, and anything -# not listed here is rejected with invalid_request. Requesting fewer -# claims is what makes the disclosure selective, so clients should ask -# for the minimum they need -- often just age_over_18. -VC_CLAIMS = {family_name, given_name, birth_date, sex, place_of_origin, birth_place, nationality, portrait, personal_administrative_number, age_over_16, age_over_18, age_over_65, age_birth_year, document_number, issuance_date, expiry_date, additional_person_info, reference_id_type, reference_id_expiry_date, verification_type, verification_organization, issuing_authority, issuing_country} +# --- Verifiable credential --------------------------------------- +# +# VC_TYPE, VC_FORMAT, VC_ALGORITHMS and VC_CLAIMS describe the +# credential the gateway asks for and default to the Swiss e-ID beta +# credential. Override them here if the issuer issues something +# else; `kych-oauth2-gateway`'s /config endpoint publishes whatever +# is in effect. -# Optional, and strongly recommended: an operator-side ceiling on what -# clients may ask for, independent of what the credential contains. -# When set, a scope containing any claim outside this list is refused, -# even though the claim appears in VC_CLAIMS above. Leave it -# commented out to allow any claim of the credential to be requested. +# Optional, and strongly recommended: an operator-side ceiling on +# what clients may ask for, independent of what the credential +# contains. When set, a scope containing any claim outside this list +# is refused, even though the claim appears in VC_CLAIMS. Leave it +# unset to allow any claim of the credential to be requested. #ALLOWED_SCOPES = {age_over_18} #################################################################### @@ -164,7 +122,9 @@ VC_CLAIMS = {family_name, given_name, birth_date, sex, place_of_origin, birth_pl #CLIENT_SECRET = SECRET # Base URL of the SWIYU verifier this client's verifications go -# through, and the path of its management API on that host. +# through, and the path of its management API on that host. With the +# swiyu-verifier package on the same host and its shipped defaults, +# the URL is http://localhost:8080. #VERIFIER_URL = https://verifier.example.com #VERIFIER_MANAGEMENT_API_PATH = /management/api/verifications diff --git a/debian/etc/swiyu-verifier/application.yml b/debian/etc/swiyu-verifier/application.yml @@ -0,0 +1,54 @@ +# Additional Spring Boot configuration for the SWIYU Generic Verifier, read +# after the application.yml inside the jar and therefore overriding it. +# +# Most settings are better made in /etc/swiyu-verifier/swiyu-verifier.env, +# which this file deliberately does not duplicate: anything expressed as an +# environment variable there wins over the defaults in the jar as well. What +# is pinned here are the few things that must not silently change with an +# upstream default, plus anything that cannot be an environment variable. + +spring: + config: + # The signing key is a multi-line PEM document, which a systemd + # EnvironmentFile cannot carry: it holds one line per variable, and an + # escaped "\n" in it reaches the JVM as two characters rather than a + # newline, which fails with "No PEM key found". So the key lives in a + # YAML file of its own, where a block scalar expresses it directly, and is + # imported here. "optional:" so that the service still starts and says + # what is missing when that file has not been created yet. + # + # Copy /usr/share/swiyu-verifier/signing-key.yml.example to this path. + import: + - "optional:file:/etc/swiyu-verifier/secrets/signing-key.yml" + + # The verifier owns its schema and migrates it itself on every start, which + # is what makes an upgrade of this package a restart and nothing more. + # "validate" means Hibernate checks the schema that Flyway produced and + # refuses to start on a mismatch -- never set this to "create" or + # "create-drop", which would silently drop every verification in the + # database. + flyway: + enabled: true + jpa: + hibernate: + ddl-auto: validate + + # No docker compose in a package installation; the database is the one + # swiyu-verifier-dbconfig created. + docker: + compose: + enabled: false + +application: + # Read from /etc, not from the classpath, so that the verifier's name and + # logo as shown in the wallet can be edited without rebuilding the jar. + client-metadata-file: "file:/etc/swiyu-verifier/client_metadata.json" + +management: + endpoints: + web: + exposure: + # /actuator/health is what a reverse proxy or monitoring can poll. + # Note that /actuator/env echoes a filtered part of the configuration, + # so keep the whole /actuator tree unreachable from outside the host. + include: health, info, prometheus diff --git a/debian/etc/swiyu-verifier/client_metadata.json b/debian/etc/swiyu-verifier/client_metadata.json @@ -0,0 +1,16 @@ +{ + "client_id": "${client-id}", + "client_name": "SWIYU verifier", + "vp_formats_supported": { + "dc+sd-jwt": { + "sd-jwt_alg_values": [ + "ES256", + "Ed25519" + ], + "kb-jwt_alg_values": [ + "ES256", + "Ed25519" + ] + } + } +} diff --git a/debian/etc/swiyu-verifier/swiyu-verifier.env b/debian/etc/swiyu-verifier/swiyu-verifier.env @@ -0,0 +1,120 @@ +#################################################################### +# Environment of the SWIYU Generic Verifier service. +# +# This file is read by swiyu-verifier.service as an EnvironmentFile, +# and every variable in it is a Spring Boot property of the verifier +# (see swiyu-verifier(1) and the upstream reference at +# https://github.com/swiyu-admin-ch/swiyu-verifier). +# +# Syntax is systemd's, not the shell's: VAR=value, one per line, no +# export, no command substitution, and quotes only where the value +# itself needs them. +# +# PUT YOUR OWN SETTINGS IN /etc/swiyu-verifier/local.env INSTEAD. +# That file is read after this one, so it overrides it, and it does +# not belong to the package -- which means an upgrade that changes +# the defaults here will not stop to ask you about a file you have +# edited. This file is the reference for what can be set. +# +# Secrets do not belong here, because this file is readable by the +# service user: the database password is in secrets/db.env (written +# by swiyu-verifier-dbconfig) and the signing key in +# secrets/signing-key.yml, both mode 0640 root:swiyu-verifier. +# +# Restart after editing: systemctl restart swiyu-verifier +#################################################################### + +# --- Identity (required; no default can be right here) ------------ +# +# These three come out of onboarding to the SWIYU Base Registry; see +# https://swiyu-admin-ch.github.io/cookbooks/onboarding-generic-verifier/ +# and the operator manual at +# https://docs.taler.net/taler-swiyu-verifier-manual.html. +# +# The public HTTPS URL at which wallets reach this verifier. The +# SWIYU wallet only accepts HTTPS with a certificate from a public +# CA, so this is the URL of the reverse proxy in front of the +# service, never the loopback address below. +#EXTERNAL_URL=https://verifier.example.com + +# The DID of this verifier, and the verification method inside that +# DID document whose key signs the authorization requests. +#VERIFIER_DID=did:tdw:QmExample:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:0000 +#DID_VERIFICATION_METHOD=did:tdw:QmExample:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:0000#auth-key-01 + +# The signing key itself does NOT go here: a PEM document has newlines in +# it, and a systemd environment file cannot express those. It lives in +# /etc/swiyu-verifier/secrets/signing-key.yml +# which the shipped application.yml imports; copy +# /usr/share/swiyu-verifier/signing-key.yml.example to it. +# +# A key held in an HSM is configured here after all, since those settings are +# single-line: set SIGNING_KEY_MANAGEMENT_METHOD and the HSM_* variables; see +# swiyu-verifier(1). + +# --- Where the service listens ------------------------------------ +# +# Plain HTTP on the loopback interface only: the OID4VP endpoints +# must be reachable by wallets over HTTPS, which is the reverse +# proxy's job, and the management API under /management/api/ must not +# be reachable from outside the host at all. +SERVER_ADDRESS=127.0.0.1 +SERVER_PORT=8080 + +# --- Database ----------------------------------------------------- +# +# swiyu-verifier-dbconfig(1) creates this database and writes the +# password it generated to /etc/swiyu-verifier/secrets/db.env. The +# connection is over TCP because the PostgreSQL JDBC driver cannot +# use peer authentication on a Unix socket, which is why there is a +# password at all. +POSTGRES_JDBC=jdbc:postgresql://localhost:5432/swiyu_verifier +POSTGRES_USER=swiyu_verifier + +# Schema inside that database. The service creates and migrates it +# itself with Flyway on every start. +POSTGRES_DB_SCHEMA=public + +# --- Talking to KyCH ---------------------------------------------- +# +# Where to report a finished verification. With kych on the same +# host behind a reverse proxy this is the proxy's /notification path; +# the webhook carries no authentication of its own, so restrict that +# path to this host in the proxy configuration. +#WEBHOOK_CALLBACK_URI=https://kych.example.com/notification + +# How often an unanswered callback is retried, in milliseconds. +WEBHOOK_INTERVAL=2000 + +# --- Trust infrastructure ----------------------------------------- +# +# The defaults built into the verifier point at the swiyu-int +# *sandbox*. Switch both to the production registry before serving +# real credentials; see the cookbook linked above for the current +# values. +SWIYU_TRUST_REGISTRY_API_URL=https://trust-reg.trust-infra.swiyu-int.admin.ch +#SWIYU_TRUST_ISSUER_DID= + +# --- What the management API reports back ------------------------- +# +# KyCH reads the disclosed claims out of the management API's +# response, so this must stay on; it is the verifier's default. +ADDITIONAL_AUDIT_INFORMATION_CREDENTIAL_SUBJECT_DATA_ENABLED=true + +# The raw presentation and the per-credential trust evaluation are +# off by default. Turning them on makes the verifier hand out more +# personal data than the verification needs, so only do it if the +# audit trail is worth that. +ADDITIONAL_AUDIT_INFORMATION_VP_TOKEN_ENABLED=false +ADDITIONAL_AUDIT_INFORMATION_CREDENTIAL_EVALUATION_ENABLED=false + +# --- Miscellaneous ------------------------------------------------ + +# How long a verification stays open for the wallet, in seconds. +VERIFICATION_TTL_SEC=900 + +# Deeplink scheme of the QR code; the wallet has to support it. +DEEPLINK_SCHEMA=swiyu-verify + +# Set to DEBUG to follow a verification through the logs. +LOGGING_LEVEL_CH_ADMIN_BJ_SWIYU=INFO diff --git a/debian/kych.README.Debian b/debian/kych.README.Debian @@ -0,0 +1,165 @@ +kych for Debian +=============== + +The service is installed disabled and will not start on its own: it has no +usable defaults for the database or for the OAuth 2.0 clients it serves, so +the three steps below have to happen first. Later upgrades of the package +do restart a running service, and migrate the database schema before doing +so, without any of this having to be repeated. + +What is configured where +------------------------ + + /usr/share/kych/config.d/00-kych-defaults.conf defaults, owned by the + package -- do not edit + /etc/kych/kych.conf your settings + +Both are read on every start, in that order, and the second one wins. The +defaults cover every option the gateway requires, which is why kych.conf +ships holding nothing but comments; put only what you actually change into +it, and an upgrade of the defaults will never prompt you about a conffile. + +See kych.conf(5) for the options, and note the one syntax wart: a comment +must stand on a line of its own, never after a value. + +1. Create the database +---------------------- + + kych-dbconfig + +That creates the PostgreSQL role "kych" and a database "kych" owned by it, +and loads the schema. The daemon runs as the system user kych and the +shipped configuration connects over the local PostgreSQL socket using peer +authentication, so no password is stored anywhere. + +If your PostgreSQL is not the stock one on port 5432, set DATABASE in +/etc/kych/kych.conf *before* running kych-dbconfig -- it takes the server to +act on from there, so the database ends up where the daemon will look for it: + + [kych-oauth2-gateway] + DATABASE = postgres://localhost:5433/kych?host=/var/run/postgresql + +Note the shape of that line: "?host=" with a path selects the Unix socket +directory, while the port is only read from the authority part ("?port=" is +ignored), so both have to be written as above. kych.conf(5) spells this out. + +To use a remote database or password authentication instead, set DATABASE to +a full connection URI before running kych-dbconfig. A +password does not have to live in the main file: with + + @inline-secret@ kych-oauth2-gateway /etc/kych/secrets/database.conf + +the DATABASE line can sit in a file of its own, restricted to root:kych, +while kych.conf itself stays readable. + +Schema upgrades are not your job: the package runs kych-dbinit(1) from its +postinst on every upgrade, which applies the SQL patches the database does +not have yet and does nothing when there is nothing to apply. You can run it +by hand too, and ask what it would do: + + sudo -u kych kych-dbinit -c /etc/kych/kych.conf --dry-run + +2. Configure +------------ + +Edit /etc/kych/kych.conf. The parts worth touching are the recommended +ALLOWED_SCOPES ceiling and at least one [client_*] section; the credential +settings (VC_*) already describe the Swiss e-ID beta credential. + +Then register the clients in the database -- the running gateway reads +clients from there, not from the configuration file: + + kych-client-management -c /etc/kych/kych.conf sync + kych-client-management -c /etc/kych/kych.conf list + +Re-run "sync" after every change to a [client_*] section. Note that it does +not rotate an existing client's secret; delete and re-create the client for +that. + +3. Start it +----------- + + systemctl enable --now kych + systemctl status kych + +Behaviour on upgrade +-------------------- + +Installing a new version of the package: + + * applies any new schema patches (kych-dbinit, from the postinst), and + * restarts the service if it was running -- including when it was started + without being enabled. + +A service that was not running stays stopped, and a first install never +starts anything. Nothing has to be done by hand in either case. + +Removing the package +-------------------- + +A purge removes /etc/kych/kych.conf, as always. The kych database is not +touched -- it is site state, and dropping it would throw away the registered +clients -- so remove it by hand if that is what you want: + + sudo -u postgres dropdb kych + sudo -u postgres dropuser kych + +Anything you put in /etc/kych/secrets/ also stays behind, since the package +does not own those files. + + +The verifier +------------ + +KyCH does not verify credentials itself; it drives a SWIYU verifier over +OpenID4VP. The swiyu-verifier package (recommended by this one) runs that +service on the same host, and /usr/share/doc/swiyu-verifier/README.Debian +describes its setup. A verifier operated elsewhere works just as well: all +the gateway needs is its URL in the client's VERIFIER_URL. + +Note that the verifier must be reachable *from the gateway* for the +management API, and reachable *from the user's wallet* over public HTTPS for +the OID4VP API. + +Socket activation (optional) +---------------------------- + +With the default SERVE = unix the daemon binds /run/kych/kych.sock itself and +kych.socket must stay disabled -- the two would fight over the same path. To +let systemd own the socket instead, so that it exists from boot and the daemon +starts on the first connection, set in /etc/kych/kych.conf + + SERVE = systemd + +and swap the units: + + systemctl disable --now kych.service + systemctl enable --now kych.socket + +The socket path, its group (www-data) and its mode (0660) are the same either +way, so nothing changes for the reverse proxy. kych.socket is the place to +edit them in this mode; UNIXPATH_MODE is not consulted. + +Reverse proxy +------------- + +The gateway speaks plain HTTP on the Unix socket /run/kych/kych.sock and does +not terminate TLS. The socket has group www-data and mode 0660, so a proxy +running as www-data can reach it. No web-server snippets are shipped; for +nginx the essential part is + + location / { + proxy_pass http://unix:/run/kych/kych.sock; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + +Do not expose the gateway directly: /notification accepts unauthenticated +webhooks from the SWIYU verifier and should additionally be restricted to the +verifier's address at the proxy. + +Logs go to the journal: + + journalctl -u kych -f + + -- Taler Systems SA <deb@taler.net> diff --git a/debian/kych.dirs b/debian/kych.dirs @@ -1,4 +1,10 @@ -# Drop-in directory read before /etc/kych/kych.conf on every start. The -# configuration parser warns on every start if it is missing, and it is where -# a future version of this package would put shipped defaults. +# Read before /etc/kych/kych.conf on every start, and where +# 00-kych-defaults.conf is installed. Also declared here, and not only in +# debian/kych.install, because the configuration parser warns on every start +# if the directory is missing -- so it has to exist even if the file in it is +# ever removed. usr/share/kych/config.d +# For the @inline-secret@ pattern that keeps the database password and the +# client secrets out of the world-readable main file; see kych.conf(5) and +# README.Debian. Mode and ownership come from debian/kych.tmpfiles. +etc/kych/secrets diff --git a/debian/kych.install b/debian/kych.install @@ -1,18 +1,34 @@ kych_oauth2_gateway/target/release/kych-oauth2-gateway usr/bin/ kych_oauth2_gateway/target/release/kych-client-management usr/bin/ +# Schema creation and migration. kych-dbconfig creates the role and the +# database once; kych-dbinit applies the SQL patches and runs again from +# postinst on every upgrade. +kych_oauth2_gateway/target/release/kych-dbinit usr/bin/ +contrib/kych-dbconfig usr/bin/ + # The daemon serves /js from a path relative to its *working directory* # (ServeDir::new("js") in main.rs), which is why the systemd unit sets # WorkingDirectory=/usr/share/kych. Keep the two in sync. kych_oauth2_gateway/js/qrcode.min.js usr/share/kych/js/ -# Database schema; loaded by the administrator, see README.Debian. +# Database schema, loaded by kych-dbinit; SQL_DIR in the shipped defaults +# points at this directory. The glob is deliberate: a new oauth2gw-000N.sql +# patch should need no change here, and kych-dbinit applies whatever it finds +# in ascending order. kych_oauth2_gateway/oauth2_gatewaydb/versioning.sql usr/share/kych/sql/ -kych_oauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql usr/share/kych/sql/ -kych_oauth2_gateway/oauth2_gatewaydb/drop.sql usr/share/kych/sql/ +kych_oauth2_gateway/oauth2_gatewaydb/oauth2gw-*.sql usr/share/kych/sql/ + +# Not in usr/share/kych/sql: kych-dbinit would take drop.sql for a patch +# file, and dropping the schema is not a migration. +kych_oauth2_gateway/oauth2_gatewaydb/drop.sql usr/share/kych/ # Man pages are generated into debian/man by debian/rules and installed from # there by dh_installman; see debian/kych.manpages. +# Read before the conffile on every start, and replaced without asking on +# upgrade -- which is why the defaults live here and not in /etc. +debian/config.d/00-kych-defaults.conf usr/share/kych/config.d/ + # Installed to /etc, hence registered as a conffile automatically. debian/etc/kych/kych.conf etc/kych/ diff --git a/debian/kych.manpages b/debian/kych.manpages @@ -1,2 +1,5 @@ -debian/man/*.1 -debian/man/*.5 +debian/man/kych-oauth2-gateway.1 +debian/man/kych-client-management.1 +debian/man/kych-dbinit.1 +debian/man/kych-dbconfig.1 +debian/man/kych.conf.5 diff --git a/debian/kych.postinst b/debian/kych.postinst @@ -0,0 +1,52 @@ +#!/bin/sh +# Bring the database schema up to date on every upgrade. +# +# On a first install there is no database yet -- kych.conf has to be reviewed +# and kych-dbconfig run before the service can work at all (see +# README.Debian) -- so a failure here is reported and then ignored: it must +# not break the installation of the package. + +set -e + +CFGFILE="/etc/kych/kych.conf" + +case "${1}" in +configure) + + if command -v kych-dbinit >/dev/null 2>&1 && id kych >/dev/null 2>&1; then + # Run as the user the daemon runs as, since that is whose peer + # authentication the shipped configuration relies on. kych-dbinit exits + # 2 when there is no database to work on, which is the normal state on a + # first install and must not fail this script. + STATUS=0 + su -s /bin/sh kych -c "kych-dbinit -c ${CFGFILE}" || STATUS=$? + case "${STATUS}" in + 0) ;; + 2) + echo "On a first install this is expected: review ${CFGFILE} and run" + echo "'kych-dbconfig' once, as described in" + echo "/usr/share/doc/kych/README.Debian." + ;; + *) + echo "Failed to update the KyCH database schema." >&2 + echo "Fix the cause and run 'kych-dbinit -c ${CFGFILE}' as user kych," >&2 + echo "then restart the service with 'systemctl try-restart kych'." >&2 + ;; + esac + fi + + ;; + +abort-upgrade | abort-remove | abort-deconfigure) ;; + +*) + echo "postinst called with unknown argument \`${1}'" >&2 + exit 1 + ;; +esac + +# Scripts generated by dh_installdeb are inserted below + +#DEBHELPER# + +exit 0 diff --git a/debian/kych.service b/debian/kych.service @@ -12,6 +12,13 @@ User=kych # UNIXPATH_MODE = 660 in kych.conf). Group=www-data +# Because Group= replaces the primary group, systemd does not give the process +# the groups the kych user is a member of -- they have to be named here. The +# one that matters is kych itself: /etc/kych/kych.conf is root:kych 0640, since +# it holds the client secrets, so without this the daemon cannot read its own +# configuration and exits with "permission denied". +SupplementaryGroups=kych + # kych-oauth2-gateway serves the QR-code helper from "js/" relative to its # working directory (ServeDir::new("js")); without this the /authorize page # loads but the QR code silently fails. diff --git a/debian/kych.sysusers b/debian/kych.sysusers @@ -1,5 +1,8 @@ -#Type Name Gecos Home directory Shell -u kych "KyCH OAuth 2.0 gateway" - - +# systemd-sysusers(8) format. Note the third column: it is the numeric ID, +# not the GECOS field -- leaving it out makes systemd-sysusers try to parse +# the description as a UID and fail the installation. +#Type Name ID GECOS Home directory Shell +u kych - "KyCH OAuth 2.0 gateway" - - # Supplementary membership in www-data, so that the reverse proxy's group can # be used for the Unix socket. debian/kych.service additionally runs the # daemon with www-data as its primary group, which is what actually determines diff --git a/debian/kych.tmpfiles b/debian/kych.tmpfiles @@ -3,3 +3,7 @@ # package (hence root:root 0644 on unpack); "z" re-applies ownership and mode # on every install and upgrade without touching the contents. z /etc/kych/kych.conf 0640 root kych - - +# Where an administrator puts files pulled in with @inline-secret@. Shipped +# empty, so the mode is set here rather than by dpkg. +z /etc/kych/secrets 0750 root kych - - +Z /etc/kych/secrets/*.conf 0640 root kych - - diff --git a/debian/rules b/debian/rules @@ -14,6 +14,45 @@ MANSRC = documentation/taler-docs/manpages MANCONF = documentation/sphinx-man MANOUT = debian/man +# The SWIYU Generic Verifier: a separate upstream, maintained by the Swiss +# Confederation and pinned to a release tag in this submodule. The checkout +# has to be there before the build -- "git submodule update --init" -- which +# the build step checks for rather than producing a confusing Maven error. +VERIFIER = swiyu-verifier/upstream + +# Where the jar lands, under a name that does not carry the version, so that +# debian/swiyu-verifier.install and the systemd unit do not have to change +# with every upstream release. +VERIFIER_JAR = debian/jar/swiyu-verifier.jar + +# The jar is built for Java 25 since the verifier's 5.0.0 release, so the JDK +# is named explicitly: a host that also has an older default JDK would +# otherwise fail the build with a release-version error. +DEB_HOST_ARCH ?= $(shell dpkg-architecture -qDEB_HOST_ARCH) +JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk-$(DEB_HOST_ARCH) + +# Keep Maven's repository inside the build tree, for the same reason as +# CARGO_HOME below: the build fetches from Maven Central and must not write to +# $HOME under Rules-Requires-Root: no. +# +# "clean" removes it, as it must for a package build, which means every build +# re-downloads some 300 MB from Maven Central. For repeated builds while +# developing, point it at a cache outside the tree -- it is then not removed +# either: +# MAVEN_REPO=$HOME/.m2/repository dpkg-buildpackage -b -uc -us +MAVEN_REPO ?= $(CURDIR)/debian/m2 + +# -Dpgpverify.skip=true: upstream binds the pgpverify plugin to the "validate" +# phase, which checks the PGP signature of every dependency against +# pgp/keys.list by querying public keyservers. That is a worthwhile check in +# upstream's CI, where its key cache is warm, but in a package build it makes +# the build depend on keys.openpgp.org being reachable and is upstream's own +# documented escape hatch (see the comment in their pom.xml). +# +# -DskipTests: the verifier's own test suite needs Testcontainers, hence a +# container runtime, which a package build does not have. +MAVEN_FLAGS = -B -DskipTests -Dpgpverify.skip=true -Dmaven.repo.local=$(MAVEN_REPO) + # Keep cargo's registry and cache inside the build tree. cargo fetches crates # from the network at build time (this is not an archive-policy-clean offline # build), and without this it would write to $HOME, which is not ours to touch @@ -36,6 +75,23 @@ override_dh_auto_configure: override_dh_auto_build: cargo build --release --manifest-path $(CRATE)/Cargo.toml sphinx-build -b man -q -c $(MANCONF) $(MANSRC) $(MANOUT) +# The submodule is not part of a plain "git clone" and is not in a source +# tarball either, so say so clearly instead of letting Maven complain about a +# missing pom. + @test -f $(VERIFIER)/pom.xml || { \ + echo "$(VERIFIER) is empty: run 'git submodule update --init'" >&2; \ + exit 1; \ + } + @test -x $(JAVA_HOME)/bin/javac || { \ + echo "No JDK at $(JAVA_HOME); install openjdk-25-jdk-headless" >&2; \ + exit 1; \ + } + cd $(VERIFIER) && JAVA_HOME=$(JAVA_HOME) mvn $(MAVEN_FLAGS) package + mkdir -p $(dir $(VERIFIER_JAR)) +# Exactly one jar, and not the ".jar.original" that the Spring Boot plugin +# leaves next to it: a glob that matched both would install the wrong one. + cp $(VERIFIER)/verifier-application/target/swiyu-verifier-application-*.jar \ + $(VERIFIER_JAR) # The unit tests would run without a database, but the two integration suites # in $(CRATE)/tests/ need a live PostgreSQL reachable through $DATABASE_URL and @@ -48,11 +104,37 @@ override_dh_auto_install: override_dh_auto_clean: rm -rf $(CARGO_HOME) $(CRATE)/target $(VENDORED)/target $(MANOUT) + rm -rf $(dir $(VERIFIER_JAR)) +# Only the in-tree Maven repository; one the developer pointed elsewhere with +# MAVEN_REPO is a cache of theirs and not ours to delete. + test "$(MAVEN_REPO)" != "$(CURDIR)/debian/m2" || rm -rf $(MAVEN_REPO) +# Only if the submodule is actually checked out; "clean" has to work on a +# tree where it is not. + @test ! -f $(VERIFIER)/pom.xml || \ + rm -rf $(VERIFIER)/verifier-application/target $(VERIFIER)/verifier-service/target -# Both units ship disabled. The service cannot run before the administrator -# has created the database and edited kych.conf (see README.Debian), and +# Both kych units ship disabled, and so does swiyu-verifier.service. The +# gateway cannot run before the administrator has created the database and +# edited kych.conf (see README.Debian), and # kych.socket is only correct once SERVE is switched to "systemd" -- with the # shipped SERVE = unix the daemon binds UNIXPATH itself and an enabled socket -# unit would fight it for the same path. +# unit would fight it for the same path. The verifier is in the same +# position: without a DID and a signing key of its own it has nothing to +# verify with. +# +# Being disabled is also what keeps a first install from starting anything, +# which is why the flags are only --no-enable: debhelper's default +# --restart-after-upgrade then generates a postinst that calls +# "deb-systemd-invoke start" on a first install and "... restart" on an +# upgrade. deb-systemd-invoke skips a disabled unit on "start", so nothing +# comes up on a first install, but on "restart" it does act on a disabled unit +# that is running -- which is what makes an upgrade pick up the new binary, +# even for a service an administrator started without enabling it. The +# service is not stopped during the unpack either, so the restart is the only +# interruption. +# +# Do NOT add --no-stop-on-upgrade here: despite the name it also turns off the +# restart (see its description in dh_installsystemd(1)), leaving the old +# binary running after an upgrade. override_dh_installsystemd: - dh_installsystemd --no-enable --no-start --no-stop-on-upgrade + dh_installsystemd --no-enable --restart-after-upgrade diff --git a/debian/signing-key.yml.example b/debian/signing-key.yml.example @@ -0,0 +1,22 @@ +# Signing key of the SWIYU verifier. +# +# Copy to /etc/swiyu-verifier/secrets/signing-key.yml, replace the key below +# with the private key that belongs to the verification method published in +# your verifier's DID document, and keep the file mode 0640 owned by +# root:swiyu-verifier (which the package's tmpfiles entry re-applies). +# +# The block scalar below ("|") is what makes the multi-line PEM work. This +# cannot go into swiyu-verifier.env: a systemd EnvironmentFile has no way to +# express a newline inside a value. +# +# After editing: systemctl restart swiyu-verifier + +application: + signing_key: | + -----BEGIN EC PRIVATE KEY----- + REPLACE THIS WITH YOUR OWN KEY -- the line above and below have to stay. + -----END EC PRIVATE KEY----- + +# For a key held in an HSM, leave the above out and set +# SIGNING_KEY_MANAGEMENT_METHOD plus the HSM_* variables in +# swiyu-verifier.env instead; those are single-line values and belong there. diff --git a/debian/swiyu-verifier.README.Debian b/debian/swiyu-verifier.README.Debian @@ -0,0 +1,199 @@ +swiyu-verifier for Debian +========================= + +This package builds and installs the SWIYU Generic Verifier, the service +maintained by the Swiss Confederation that performs the actual OpenID4VP +credential verification. It is a separate upstream from KyCH +(https://github.com/swiyu-admin-ch/swiyu-verifier), pinned to a release tag +in the swiyu-verifier/upstream submodule of the KyCH source package. + +The service is installed disabled and cannot work until it has an identity of +its own in the SWIYU trust infrastructure. There is no way around that step: +a verifier signs its authorization requests with a key that wallets check +against its DID document in the Base Registry. + +1. Onboard +---------- + +Follow the official cookbook, + + https://swiyu-admin-ch.github.io/cookbooks/onboarding-generic-verifier/ + +which walks through the Swiss Confederacy ePortal account, the business +partner registration, the API keys, the DID space, generating the key pair +and uploading the DID log. The operator manual at +https://docs.taler.net/taler-swiyu-verifier-manual.html summarises it. + +You end up with three things: a DID, the identifier of the verification +method inside it, and the private key. + +2. Create the database +---------------------- + + swiyu-verifier-dbconfig + +That creates the role "swiyu_verifier" with a generated password, the +database of the same name owned by it, and writes the password to +/etc/swiyu-verifier/secrets/db.env (0640 root:swiyu-verifier). + +The connection is over TCP to localhost, not the PostgreSQL socket, because +the JDBC driver cannot do peer authentication -- which is the only reason +there is a password at all. + +The schema is not created here: the verifier runs its own Flyway migrations +on every start. That is also why an upgrade of this package needs nothing +but the restart it already does. + +3. Configure +------------ + + /etc/swiyu-verifier/swiyu-verifier.env the package's defaults and + the reference for what can + be set -- do not edit + /etc/swiyu-verifier/local.env YOUR settings; read last, so + it overrides the above + /etc/swiyu-verifier/secrets/signing-key.yml the signing key + /etc/swiyu-verifier/secrets/db.env written by the step above + /etc/swiyu-verifier/application.yml Spring settings without an + environment variable + /etc/swiyu-verifier/client_metadata.json name and logo the wallet shows + +Writing into local.env rather than the shipped file is not a matter of taste: +swiyu-verifier.env is a conffile, so an upgrade that changes it has to ask +what to do about your edits, and an unattended upgrade will then either stop +and wait or quietly pick one side. local.env belongs to nobody but you. + +The environment file is commented throughout; what you must set -- in +local.env -- is + + EXTERNAL_URL the public HTTPS URL of this verifier + VERIFIER_DID your DID + DID_VERIFICATION_METHOD the verification method in it + +for example + + cat > /etc/swiyu-verifier/local.env <<EOF + EXTERNAL_URL=https://verifier.example.com + VERIFIER_DID=did:tdw:QmExample:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:0000 + DID_VERIFICATION_METHOD=did:tdw:QmExample:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:0000#auth-key-01 + WEBHOOK_CALLBACK_URI=https://kych.example.com/notification + EOF + chmod 0640 /etc/swiyu-verifier/local.env + chown root:swiyu-verifier /etc/swiyu-verifier/local.env + +The key goes in a file of its own, because a PEM document has newlines and +an environment file cannot carry those: + + cp /usr/share/swiyu-verifier/signing-key.yml.example \ + /etc/swiyu-verifier/secrets/signing-key.yml + editor /etc/swiyu-verifier/secrets/signing-key.yml + systemd-tmpfiles --create swiyu-verifier.conf # re-applies 0640 root:swiyu-verifier + +It is YAML, and the block scalar is what makes the key work: + + application: + signing_key: | + -----BEGIN EC PRIVATE KEY----- + MHcC... + -----END EC PRIVATE KEY----- + +For a key in an HSM, leave that out and set SIGNING_KEY_MANAGEMENT_METHOD and +the HSM_* variables in swiyu-verifier.env instead; see swiyu-verifier(1). + +Two defaults deserve a decision rather than being left alone: + + * SWIYU_TRUST_REGISTRY_API_URL points at the swiyu-int *sandbox*. Switch + it to production before serving real credentials. + * WEBHOOK_CALLBACK_URI is unset, so nothing is told about a finished + verification. With KyCH behind a reverse proxy on the same host this is + that proxy's /notification path. + +4. Start it +----------- + + systemctl enable --now swiyu-verifier + systemctl status swiyu-verifier + curl -s http://127.0.0.1:8080/actuator/health + +The health endpoint resolves your own DID in the identifier registry, so it +is the quickest check that onboarding actually worked. A JVM with a cold +Spring context and a Flyway migration to run takes a few seconds to answer. + +If the service fails to start five times -- almost always a configuration +that cannot work, a missing signing key above all -- systemd stops trying. +After fixing the cause: + + systemctl reset-failed swiyu-verifier + systemctl start swiyu-verifier + +Reverse proxy +------------- + +The service listens on 127.0.0.1:8080 and speaks plain HTTP. Two parts of it +belong on different sides of the firewall: + + /oid4vp/api/ must be reachable from the internet over HTTPS with a + publicly trusted certificate -- the SWIYU wallet + accepts nothing else + /management/api/ must NOT be reachable from outside the host; anybody + who can reach it can create verifications in your + verifier's name + /actuator/ likewise internal; /actuator/env echoes part of the + configuration + +For nginx, that is + + location /oid4vp/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + +and nothing else proxied. EXTERNAL_URL must be the URL this location is +served at, because it is what the verifier puts into the QR code. + +Working with KyCH +----------------- + +In /etc/kych/kych.conf, the client section points at this service's +management API: + + VERIFIER_URL = http://localhost:8080 + VERIFIER_MANAGEMENT_API_PATH = /management/api/verifications + +then "kych-client-management -c /etc/kych/kych.conf sync". + +Note that KyCH requires a verifier of release 3.0.0 or newer: it sends a DCQL +query, which replaced the DIF Presentation Exchange format that older +verifiers used. + +Logs go to the journal: + + journalctl -u swiyu-verifier -f + +Removing the package +-------------------- + +A purge removes the conffiles, as always -- which means the identity settings +in swiyu-verifier.env go with it. Two things do *not* go, because the package +does not own them: + + * /etc/swiyu-verifier/secrets/signing-key.yml and .../db.env, which are + written by you and by swiyu-verifier-dbconfig. Delete them by hand if + the host is not to keep the key. + * the swiyu_verifier database. "swiyu-verifier-dbconfig -r" drops and + recreates it; dropping it for good is + "sudo -u postgres dropdb swiyu_verifier". + + +Upstream version +---------------- + + dpkg -s swiyu-verifier | grep ^Version + +carries the KyCH version, not the verifier's. The verifier release that was +built is recorded in /usr/share/doc/swiyu-verifier/copyright and in the +submodule the package was built from; the running service reports it at +/actuator/info. + + -- Taler Systems SA <deb@taler.net> diff --git a/debian/swiyu-verifier.dirs b/debian/swiyu-verifier.dirs @@ -0,0 +1,6 @@ +# Holds the signing key and the database password; swiyu-verifier-dbconfig +# writes db.env here. Mode and ownership come from debian/swiyu-verifier.tmpfiles. +etc/swiyu-verifier/secrets +# The truststore built by the postinst. Also declared as StateDirectory in +# the unit, which is what makes it writable for the service. +var/lib/swiyu-verifier diff --git a/debian/swiyu-verifier.install b/debian/swiyu-verifier.install @@ -0,0 +1,22 @@ +# The Spring Boot application, built from the pinned upstream checkout in +# swiyu-verifier/upstream/ and renamed to a stable name by debian/rules. +debian/jar/swiyu-verifier.jar usr/share/swiyu-verifier/ + +# Starts the jar; see the comments in it for why this is not "java -jar". +contrib/swiyu-verifier usr/bin/ + +# One-time database setup. The schema is Flyway's job, not this script's. +contrib/swiyu-verifier-dbconfig usr/bin/ + +# Not trusted by the JVM's default store; the postinst imports it into a +# truststore of the service's own. +swiyu-verifier/upstream/certs/root_ca_vi.crt usr/share/swiyu-verifier/certs/ + +# Template for the signing key, which cannot be a conffile: it is a secret +# the administrator writes into /etc/swiyu-verifier/secrets/. +debian/signing-key.yml.example usr/share/swiyu-verifier/ + +# Installed to /etc, hence registered as conffiles automatically. +debian/etc/swiyu-verifier/swiyu-verifier.env etc/swiyu-verifier/ +debian/etc/swiyu-verifier/application.yml etc/swiyu-verifier/ +debian/etc/swiyu-verifier/client_metadata.json etc/swiyu-verifier/ diff --git a/debian/swiyu-verifier.lintian-overrides b/debian/swiyu-verifier.lintian-overrides @@ -0,0 +1,5 @@ +# The jar is built for Java 25, class file version 69, which is what the +# verifier moved to in its 5.0.0 release and what the package depends on a +# runtime for. Lintian's table of class versions simply does not go that far +# yet, so this says nothing about the package. +swiyu-verifier: unknown-java-class-version * diff --git a/debian/swiyu-verifier.manpages b/debian/swiyu-verifier.manpages @@ -0,0 +1,2 @@ +debian/man/swiyu-verifier.1 +debian/man/swiyu-verifier-dbconfig.1 diff --git a/debian/swiyu-verifier.postinst b/debian/swiyu-verifier.postinst @@ -0,0 +1,70 @@ +#!/bin/sh +# Build the service's own Java truststore, and say what is left to do. +# +# The Swiss Government Root CA VI that the SWIYU infrastructure may present is +# not in the public trust store the JVM uses by default. Rather than adding it +# to the host's trust store, where it would be trusted by everything on the +# machine, it is imported into a copy of the default store that only this +# service uses (/usr/bin/swiyu-verifier passes it to the JVM, and silently does +# not if this failed). + +set -e + +CERT=/usr/share/swiyu-verifier/certs/root_ca_vi.crt +TRUSTSTORE=/var/lib/swiyu-verifier/cacerts +SOURCE_STORE=/etc/ssl/certs/java/cacerts + +case "${1}" in +configure) + + if [ -r "${CERT}" ] && command -v keytool >/dev/null 2>&1; then + # Rebuilt from scratch on every configure, so that a new upstream cert in + # the package replaces the old one instead of piling up next to it. + rm -f "${TRUSTSTORE}" + if [ -r "${SOURCE_STORE}" ]; then + cp "${SOURCE_STORE}" "${TRUSTSTORE}" + fi + if keytool -importcert -noprompt \ + -alias root_ca_vi \ + -file "${CERT}" \ + -keystore "${TRUSTSTORE}" \ + -storepass changeit >/dev/null 2>&1; then + chmod 0644 "${TRUSTSTORE}" + else + echo "Could not build ${TRUSTSTORE}; the service will use the default" >&2 + echo "Java trust store, which does not include the Swiss Government" >&2 + echo "Root CA VI." >&2 + rm -f "${TRUSTSTORE}" + fi + fi + + if [ ! -e /etc/swiyu-verifier/secrets/db.env ]; then + echo "No database configured for the SWIYU verifier yet; run" + echo "'swiyu-verifier-dbconfig' once and fill in the identity settings in" + echo "/etc/swiyu-verifier/swiyu-verifier.env. See" + echo "/usr/share/doc/swiyu-verifier/README.Debian." + fi + + if [ ! -e /etc/swiyu-verifier/secrets/signing-key.yml ]; then + echo "No signing key yet; copy" + echo "/usr/share/swiyu-verifier/signing-key.yml.example to" + echo "/etc/swiyu-verifier/secrets/signing-key.yml and put your own key in" + echo "it, or configure an HSM. Without a key the verifier answers the" + echo "wallet with 'Failed to sign request object'." + fi + + ;; + +abort-upgrade | abort-remove | abort-deconfigure) ;; + +*) + echo "postinst called with unknown argument \`${1}'" >&2 + exit 1 + ;; +esac + +# Scripts generated by dh_installdeb are inserted below + +#DEBHELPER# + +exit 0 diff --git a/debian/swiyu-verifier.postrm b/debian/swiyu-verifier.postrm @@ -0,0 +1,24 @@ +#!/bin/sh +# The truststore is generated by the postinst, not shipped, so dpkg does not +# know about it and it has to be removed here. Only on purge: an upgrade or a +# plain remove may well be followed by the package coming back. + +set -e + +case "${1}" in +purge) + rm -f /var/lib/swiyu-verifier/cacerts + rmdir --ignore-fail-on-non-empty /var/lib/swiyu-verifier 2>/dev/null || true + ;; + +remove | upgrade | failed-upgrade | abort-install | abort-upgrade | disappear) ;; + +*) + echo "postrm called with unknown argument \`${1}'" >&2 + exit 1 + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/debian/swiyu-verifier.service b/debian/swiyu-verifier.service @@ -0,0 +1,83 @@ +[Unit] +Description=SWIYU Generic Verifier (OpenID4VP credential verification) +Documentation=https://docs.taler.net/taler-swiyu-verifier-manual.html +Documentation=https://github.com/swiyu-admin-ch/swiyu-verifier +After=network-online.target postgresql.service +Wants=network-online.target postgresql.service + +[Service] +Type=exec +User=swiyu-verifier +Group=swiyu-verifier + +# The operator's settings, and then the database password, which is optional +# ("-") so that the service can be started before swiyu-verifier-dbconfig has +# run -- it then fails its own health checks and says why, instead of systemd +# refusing to start it with a message about a missing file. +# +# The signing key is not here: a PEM document has newlines, which an +# environment file cannot express, so it is YAML imported by +# /etc/swiyu-verifier/application.yml. See swiyu-verifier(1). +EnvironmentFile=/etc/swiyu-verifier/swiyu-verifier.env + +# Your settings go here, not in the file above. local.env is read last, so it +# overrides it, and -- unlike swiyu-verifier.env, which is a conffile -- the +# package never touches it: an upgrade that changes the shipped defaults then +# has no conffile question to ask about a file you have edited. Optional, so +# a host that edits the conffile anyway still works. +EnvironmentFile=-/etc/swiyu-verifier/local.env + +EnvironmentFile=-/etc/swiyu-verifier/secrets/db.env + +# Not "java -jar" directly: the wrapper picks the Java 25 runtime the jar was +# built for, adds /etc/swiyu-verifier/application.yml and uses the service's +# own truststore if the postinst managed to build one. See swiyu-verifier(1). +ExecStart=/usr/bin/swiyu-verifier + +# Where the postinst puts the truststore. The directory is shipped by the +# package, so this only keeps it writable for the service. +StateDirectory=swiyu-verifier +StateDirectoryMode=0750 + +# A JVM that catches SIGTERM exits with 143 (128 + 15) rather than dying from +# the signal, and systemd counts a non-zero exit code as a failure -- so +# without this a plain "systemctl stop" leaves the unit in the failed state, +# and the next "start" after a few of those runs into the start limit below. +SuccessExitStatus=143 + +Restart=always +RestartSec=10s +# Give up after five failed starts in five minutes rather than restarting for +# ever: the usual reason for a failure here is a configuration that cannot +# work (no signing key, wrong database), and flapping only hides it. After +# fixing it, "systemctl reset-failed swiyu-verifier" clears the limit. +StartLimitBurst=5 +StartLimitInterval=300s + +# A JVM with a cold Spring context and a Flyway migration to run needs longer +# than the default 90s on a loaded machine before systemd gives up on it. +TimeoutStartSec=300 + +StandardOutput=journal +StandardError=journal + +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ProtectClock=yes +ProtectHostname=yes +ProtectControlGroups=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectProc=invisible +PrivateDevices=yes +NoNewPrivileges=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +LockPersonality=yes +# No MemoryDenyWriteExecute: the JVM compiles bytecode at runtime and needs +# writable-executable pages for it. + +[Install] +WantedBy=multi-user.target diff --git a/debian/swiyu-verifier.sysusers b/debian/swiyu-verifier.sysusers @@ -0,0 +1,6 @@ +# systemd-sysusers(8) format; see the note on the third column in +# debian/kych.sysusers. The verifier needs no supplementary group: it listens +# on a TCP port rather than a socket a reverse proxy has to open, and only +# ever reads files of its own. +#Type Name ID GECOS Home directory Shell +u swiyu-verifier - "SWIYU Generic Verifier" /var/lib/swiyu-verifier - diff --git a/debian/swiyu-verifier.tmpfiles b/debian/swiyu-verifier.tmpfiles @@ -0,0 +1,16 @@ +# The environment file names the verifier's DID and its database; the two +# files under secrets/ hold the signing key and the database password. All of +# them are conffiles or operator-written, so "z" re-applies ownership and mode +# on every install and upgrade without touching the contents. +z /etc/swiyu-verifier/swiyu-verifier.env 0640 root swiyu-verifier - - +# The operator's own settings, if they made the file; no secrets belong in it +# either, but it is kept as tight as the shipped one. +z /etc/swiyu-verifier/local.env 0640 root swiyu-verifier - - +z /etc/swiyu-verifier/client_metadata.json 0644 root root - - +z /etc/swiyu-verifier/application.yml 0644 root root - - +z /etc/swiyu-verifier/secrets 0750 root swiyu-verifier - - +# The signing key (signing-key.yml) and the database password (db.env); both +# are written by the administrator or by swiyu-verifier-dbconfig, so the mode +# is re-applied rather than assumed. +Z /etc/swiyu-verifier/secrets/*.env 0640 root swiyu-verifier - - +Z /etc/swiyu-verifier/secrets/*.yml 0640 root swiyu-verifier - - diff --git a/documentation/sequence_diagrams/authorize_sequence.txt b/documentation/sequence_diagrams/authorize_sequence.txt @@ -27,9 +27,9 @@ sequenceDiagram else Session already authorized (idempotent) KyCH OAuth2 Gateway -->> Client: 200 OK\n{verification_id, verification_url,\nverification_deeplink, state} else Session valid and pending - proceed - KyCH OAuth2 Gateway ->> KyCH OAuth2 Gateway: Validate scope against allowed_scopes\nbuild_presentation_definition(scope) + KyCH OAuth2 Gateway ->> KyCH OAuth2 Gateway: Validate scope against allowed_scopes\nbuild_dcql_query(scope) - KyCH OAuth2 Gateway ->> Swiyu Verifier: POST /management/api/verifications\n{presentation_definition, response_mode,\naccepted_issuer_dids, ...} + KyCH OAuth2 Gateway ->> Swiyu Verifier: POST /management/api/verifications\n{dcql_query, response_mode,\naccepted_issuer_dids, ...} Swiyu Verifier -->> KyCH OAuth2 Gateway: Response alt Verifier error diff --git a/documentation/sphinx-man/conf.py b/documentation/sphinx-man/conf.py @@ -26,14 +26,14 @@ author = "GNU Taler contributors" # "code-block", "note"). Keep it that way, or this build grows dependencies. extensions = [] -# There is no index in the source directory -- it holds nothing but the three -# man pages -- so nominate one of them as the root document. Which one does +# There is no index in the source directory -- it holds nothing but the man +# pages -- so nominate one of them as the root document. Which one does # not matter for the man builder; each entry in man_pages below is rendered # from its own source file. root_doc = "kych-oauth2-gateway.1" -# Building this way emits "document isn't included in any toctree" for the two -# pages that are not the root document. That is expected and harmless: the +# Building this way emits "document isn't included in any toctree" for every +# page that is not the root document. That is expected and harmless: the # toctree those pages belong to lives in the taler-docs repository, not here. # The warning carries no type, so suppress_warnings cannot switch it off, and # the alternative -- an ":orphan:" field in the shared sources -- would be a @@ -61,6 +61,34 @@ man_pages = [ 1, ), ( + "kych-dbinit.1", + "kych-dbinit", + "initialise and upgrade the KyCH database schema", + ["GNU Taler contributors"], + 1, + ), + ( + "kych-dbconfig.1", + "kych-dbconfig", + "create the PostgreSQL database for KyCH", + ["GNU Taler contributors"], + 1, + ), + ( + "swiyu-verifier.1", + "swiyu-verifier", + "SWIYU Generic Verifier service", + ["GNU Taler contributors"], + 1, + ), + ( + "swiyu-verifier-dbconfig.1", + "swiyu-verifier-dbconfig", + "create the PostgreSQL database for the SWIYU verifier", + ["GNU Taler contributors"], + 1, + ), + ( "kych.conf.5", "kych.conf", "configuration file of the KyCH OAuth 2.0 gateway", diff --git a/documentation/taler-docs/README b/documentation/taler-docs/README @@ -9,9 +9,14 @@ Files ----- taler-kych-manual.rst - KyCH Operator Manual + taler-swiyu-verifier-manual.rst - Swiyu Verifier Operator Manual core/api-kych.rst - KyCH OAuth2 Gateway REST API specification - manpages/kych-client-management.1.rst - Man page for kych-client-management manpages/kych-oauth2-gateway.1.rst - Man page for kych-oauth2-gateway + manpages/kych-client-management.1.rst - Man page for kych-client-management + manpages/kych-dbinit.1.rst - Man page for kych-dbinit + manpages/kych-dbconfig.1.rst - Man page for kych-dbconfig + manpages/swiyu-verifier.1.rst - Man page for swiyu-verifier + manpages/swiyu-verifier-dbconfig.1.rst - Man page for swiyu-verifier-dbconfig manpages/kych.conf.5.rst - Man page for kych.conf configuration images/kych_overview.jpg - Architecture overview diagram images/swiyu_taler_sequence_diagram.png - Sequence diagram @@ -37,6 +42,11 @@ After building, the files will be available at: -> For System Administrators > UNIX Man Pages > kych-oauth2-gateway(1) -> URL: /manpages/kych-oauth2-gateway.1.html + manpages/kych-dbinit.1.rst, manpages/kych-dbconfig.1.rst, + manpages/swiyu-verifier.1.rst, manpages/swiyu-verifier-dbconfig.1.rst + -> For System Administrators > UNIX Man Pages + -> URL: /manpages/<name>.html + manpages/kych.conf.5.rst -> For System Administrators > UNIX Man Pages > kych.conf(5) -> URL: /manpages/kych.conf.5.html @@ -78,6 +88,7 @@ Building the Documentation 5. Add the files to the toctree in index.rst (under "For Exchange Operators"): taler-kych-manual + taler-swiyu-verifier-manual And in core/index.rst (after api-challenger): @@ -94,13 +105,25 @@ Building the Documentation ("manpages/kych-client-management.1", "kych-client-management", "manage OAuth 2.0 clients of the KyCH gateway", ["GNU Taler contributors"], 1), + ("manpages/kych-dbinit.1", "kych-dbinit", + "initialise and upgrade the KyCH database schema", + ["GNU Taler contributors"], 1), + ("manpages/kych-dbconfig.1", "kych-dbconfig", + "create the PostgreSQL database for KyCH", + ["GNU Taler contributors"], 1), + ("manpages/swiyu-verifier.1", "swiyu-verifier", + "SWIYU Generic Verifier service", + ["GNU Taler contributors"], 1), + ("manpages/swiyu-verifier-dbconfig.1", "swiyu-verifier-dbconfig", + "create the PostgreSQL database for the SWIYU verifier", + ["GNU Taler contributors"], 1), ("manpages/kych.conf.5", "kych.conf", "configuration file of the KyCH OAuth 2.0 gateway", ["GNU Taler contributors"], 5), - The same three entries live in kych's own - documentation/sphinx-man/conf.py, which is what the Debian package builds - the man pages with; keep the two copies identical. + The same entries live in kych's own documentation/sphinx-man/conf.py, + which is what the Debian package builds the man pages with; keep the two + copies identical. 6. Build the HTML documentation: @@ -126,4 +149,8 @@ View them with: man ./_build/man/kych-oauth2-gateway.1 man ./_build/man/kych-client-management.1 + man ./_build/man/kych-dbinit.1 + man ./_build/man/kych-dbconfig.1 + man ./_build/man/swiyu-verifier.1 + man ./_build/man/swiyu-verifier-dbconfig.1 man ./_build/man/kych.conf.5 diff --git a/documentation/taler-docs/core/api-kych.rst b/documentation/taler-docs/core/api-kych.rst @@ -93,7 +93,7 @@ Receiving Configuration // KyCH instance. For Swiss Beta ID, this is "betaid-sdjwt". vc_type: string; - // Credential format. SD-JWT credentials use "vc+sd-jwt". + // Credential format. SD-JWT credentials use "dc+sd-jwt". vc_format: string; // List of cryptographic signature algorithms accepted for diff --git a/documentation/taler-docs/manpages/kych-dbconfig.1.rst b/documentation/taler-docs/manpages/kych-dbconfig.1.rst @@ -0,0 +1,97 @@ +kych-dbconfig(1) +################ + +.. only:: html + + Name + ==== + + **kych-dbconfig** - create the PostgreSQL database for KyCH + + +Synopsis +======== + +**kych-dbconfig** +[**-c** *CONFIG*] +[**-r**] +[**-s**] +[**-u** *USER*] +[**-h**] + + +Description +=========== + +**kych-dbconfig** performs the one-time database setup for +kych-oauth2-gateway(1): it creates the PostgreSQL role the daemon +authenticates as, creates the database named by the ``DATABASE`` option of +kych.conf(5) with that role as its owner, and then runs kych-dbinit(1) to load +the schema. + +It must be run as root, because it works through the ``postgres`` +administrative user, and the role it creates has the same name as the +operating-system user the daemon runs as -- which is what ``peer`` +authentication over the local socket expects, and why no password has to be +stored in the configuration. + +The Debian package does not run this for you: a database is site state, not +something a package installation should create. Schema *upgrades* afterwards +are a different matter and do happen automatically; see kych-dbinit(1). + + +Options +======= + +**-c** *CONFIG* + Use the configuration file *CONFIG* instead of ``/etc/kych/kych.conf``. The + database name is read from it by kych-dbinit(1), so ``@inline@`` includes + and ``$``-expansion work as everywhere else. + +**-r** + Reset: drop an existing database before creating it again. **This destroys + all sessions, clients and issued tokens** and is never needed for an + upgrade. + +**-s** + Skip the schema initialisation, creating only the role and the database. + kych-dbinit(1) can then be run separately. + +**-u** *USER* + The operating-system user the daemon runs as, and therefore the name of the + role to create. Defaults to ``kych``, which is the user the shipped + ``kych.service`` uses. + +**-h** + Print short help. + + +Examples +======== + +Set up the database as the package expects it: + +.. code-block:: shell + + $ sudo kych-dbconfig + +Start over from an empty schema, having stopped the service first: + +.. code-block:: shell + + $ sudo systemctl stop kych + $ sudo kych-dbconfig -r + + +See Also +======== + +kych-dbinit(1), kych-oauth2-gateway(1), kych-client-management(1), +kych.conf(5). + + +Bugs +==== + +Report bugs by using https://bugs.taler.net/ or by sending electronic +mail to <taler@gnu.org>. diff --git a/documentation/taler-docs/manpages/kych-dbinit.1.rst b/documentation/taler-docs/manpages/kych-dbinit.1.rst @@ -0,0 +1,131 @@ +kych-dbinit(1) +############## + +.. only:: html + + Name + ==== + + **kych-dbinit** - initialise and upgrade the KyCH database schema + + +Synopsis +======== + +**kych-dbinit** +[**-c** *CONFIG*] +[**-L** *LEVEL*] +[**-v**] +[**--sql-dir** *DIR*] +[**-n** \| **--dry-run**] +[**--print-connection**] + + +Description +=========== + +**kych-dbinit** creates the ``oauth2gw`` schema that kych-oauth2-gateway(1) +works on, and brings an existing one up to the patch level of the installed +SQL files. Both are the same operation: every schema change is a numbered +patch file, ``oauth2gw-000N.sql``, which registers itself with the +``Versioning`` bookkeeping in the ``_v`` schema, and this tool applies those +patches the database does not have yet, in ascending order, each in its own +transaction. + +It is idempotent: running it against a database that is already current +changes nothing and succeeds. That is what makes it safe to run unattended, +which the Debian package does from its ``postinst`` on every upgrade, so that +a newly installed patch is applied before the service is restarted. + +It does not create the database or the role that owns it; kych-dbconfig(1) +does that once, and calls this tool afterwards. + + +Options +======= + +**-c** *CONFIG* \| **--config=**\ *CONFIG* + Use the configuration file *CONFIG*. Without it the standard search path + applies, ending at ``/etc/kych/kych.conf``; see kych.conf(5). The database + to act on is the ``DATABASE`` option in it. + +**-L** *LEVEL* \| **--log=**\ *LEVEL* + Set the logging verbosity: ``ERROR``, ``WARN``, ``INFO``, ``DEBUG`` or + ``TRACE``, or the numbers 1 to 5. Defaults to ``INFO``. + +**-v** \| **--verbose** + Also log what the libraries have to say, ``sqlx`` in particular, which is + the way to see the statements this tool runs. + +**--sql-dir=**\ *DIR* + Read ``versioning.sql`` and the ``oauth2gw-*.sql`` patches from *DIR* + instead of the ``SQL_DIR`` of the configuration, which defaults to + ``$DATADIR/sql`` -- ``/usr/share/kych/sql`` for the Debian package. + +**-n** \| **--dry-run** + Report which patches would be applied and change nothing. Note that in a + database without the ``_v`` bookkeeping every patch is reported as pending, + since there is nothing yet to say otherwise. + +**--print-connection** + Print the connection the configuration names as ``PGDATABASE``, ``PGHOST`` + and ``PGPORT`` assignments for a shell to ``eval``, and exit without + connecting. kych-dbconfig(1) uses this instead of parsing kych.conf(5) + itself, which is what makes it work against a PostgreSQL that is not on the + default port. + +**-h** \| **--help** + Print short help. + +**--version** + Print version information. + + +Exit Status +=========== + +0 + The database is at the latest patch level, whether or not anything had to be + applied. + +2 + There is no database to work on: it does not exist, or the server is not + listening where the configuration says it is. The Debian package's + ``postinst`` treats this as the expected state of a first install, where + kych-dbconfig(1) has not run yet, and does not fail the installation over + it. + +1 + Anything else: the configuration could not be loaded, the connection was + refused for a reason of its own, or a patch failed. A failing patch is + rolled back, so the database keeps the patch level it had. + + +Examples +======== + +Apply whatever is pending, as the user the daemon runs as: + +.. code-block:: shell + + $ sudo -u kych kych-dbinit -c /etc/kych/kych.conf + +Check before an upgrade what it would do: + +.. code-block:: shell + + $ sudo -u kych kych-dbinit -c /etc/kych/kych.conf --dry-run + + +See Also +======== + +kych-dbconfig(1), kych-oauth2-gateway(1), kych-client-management(1), +kych.conf(5). + + +Bugs +==== + +Report bugs by using https://bugs.taler.net/ or by sending electronic +mail to <taler@gnu.org>. diff --git a/documentation/taler-docs/manpages/kych-oauth2-gateway.1.rst b/documentation/taler-docs/manpages/kych-oauth2-gateway.1.rst @@ -30,7 +30,7 @@ from the Swiss SWIYU e-ID trust infrastructure. Towards the client it speaks a plain OAuth 2.0 authorization code flow. Towards the SWIYU verifier it speaks OpenID for Verifiable Presentations -(OID4VP): it builds a presentation definition, has the user's SWIYU wallet +(OID4VP): it builds a DCQL query, has the user's SWIYU wallet present an SD-JWT verifiable credential against it, and hands the disclosed claims back to the client. The OAuth 2.0 *scope* of the authorization request names the credential claims to disclose, which is what makes the disclosure diff --git a/documentation/taler-docs/manpages/kych.conf.5.rst b/documentation/taler-docs/manpages/kych.conf.5.rst @@ -164,8 +164,34 @@ DATABASE without a password in the file. The ``~/.pgpass`` file is *not* consulted for a connection string in URI form. - The schema is not created automatically; it must be loaded from the SQL - shipped with the software before the first start. + Two details of that syntax are worth knowing, because getting them wrong + produces a connection to somewhere else rather than an error: + + * A ``host=`` *parameter* whose value begins with ``/`` selects a Unix + socket directory, not a host name. Written as the authority instead it + has to be percent-encoded: + ``postgres://%2Fvar%2Frun%2Fpostgresql/kych``. + + * The port is read from the authority part only; ``?port=`` is ignored. A + server on another port is therefore + ``postgres://localhost:5433/kych?host=/var/run/postgresql`` -- the + ``localhost`` there is a placeholder that the socket parameter overrides. + + With neither given, the socket directory is found by looking for + ``.s.PGSQL.5432`` in ``/var/run/postgresql`` and then ``/tmp``, falling back + to a TCP connection to ``localhost``. + + The schema is created by kych-dbconfig(1) and kept up to date by + kych-dbinit(1); see those pages. The daemon itself never creates or migrates + it. + +SQL_DIR + Directory holding ``versioning.sql`` and the ``oauth2gw-000N.sql`` patch + files. Read by kych-dbinit(1) only; the daemon ignores it. + Optional, and defaults to ``$DATADIR/sql``, which resolves to + ``/usr/share/kych/sql`` for an installation under ``/usr``. In a source tree + the patches are in ``oauth2_gatewaydb/``, which kych-dbinit's ``--sql-dir`` + can also name directly. Cryptographic Parameters @@ -207,12 +233,16 @@ VC_TYPE Required. Example: ``betaid-sdjwt``. VC_FORMAT - The format of the verifiable credential. - Required. Example: ``vc+sd-jwt``. + The format of the verifiable credential, sent as the ``format`` of the DCQL + credential query. ``dc+sd-jwt`` is the canonical SD-JWT VC media type; the + verifier also still accepts the older ``vc+sd-jwt``. + Required. Example: ``dc+sd-jwt``. VC_ALGORITHMS List of acceptable cryptographic algorithms for the credential and for the - key binding JWT. + key binding JWT. DCQL has no place for them in the credential query, so + they are no longer sent to the verifier and only appear on the ``/config`` + endpoint, where clients read them. Required, and must not be empty. Example: ``{ES256}`` or ``{ES256, ES384}``. VC_CLAIMS @@ -330,7 +360,7 @@ EXAMPLE CONFIGURATION # Verifiable Credential configuration VC_TYPE = betaid-sdjwt - VC_FORMAT = vc+sd-jwt + VC_FORMAT = dc+sd-jwt VC_ALGORITHMS = {ES256} VC_CLAIMS = {family_name, given_name, birth_date, nationality} diff --git a/documentation/taler-docs/manpages/swiyu-verifier-dbconfig.1.rst b/documentation/taler-docs/manpages/swiyu-verifier-dbconfig.1.rst @@ -0,0 +1,111 @@ +swiyu-verifier-dbconfig(1) +########################## + +.. only:: html + + Name + ==== + + **swiyu-verifier-dbconfig** - create the PostgreSQL database for the SWIYU verifier + + +Synopsis +======== + +**swiyu-verifier-dbconfig** +[**-c** *FILENAME*] +[**-n** *DBNAME*] +[**-u** *DBUSER*] +[**-d** *FILENAME*] +[**-r**] +[**-h**] + + +Description +=========== + +**swiyu-verifier-dbconfig** performs the one-time database setup for +swiyu-verifier(1): it creates the PostgreSQL role the service connects as with +a freshly generated password, creates the database with that role as its owner, +and writes the password to ``/etc/swiyu-verifier/secrets/db.env``, mode 0640 +and owned by root:swiyu-verifier, which is where ``swiyu-verifier.service`` +reads it from. + +It must be run as root, because it works through the ``postgres`` +administrative user. + +Unlike kych-dbconfig(1) this does *not* create a schema: the verifier runs its +own Flyway migrations on every start, so the database it is handed only has to +exist and be owned by its role. For the same reason there is nothing to do +after an upgrade of the package. + +A password is needed -- rather than ``peer`` authentication over the local +socket, as KyCH uses -- because the connection is made by the PostgreSQL JDBC +driver, which cannot authenticate that way. + +Running the script again generates a new password and sets it on the existing +role, which is how a password is rotated; restart the service afterwards. + + +Where the database goes is read from the service's own configuration rather +than assumed: ``POSTGRES_JDBC`` in ``/etc/swiyu-verifier/swiyu-verifier.env`` +supplies the host, the port and the database name, and ``POSTGRES_USER`` the +role. On a host whose PostgreSQL is not on port 5432 that is the difference +between setting up the database the service will use and setting one up +somewhere else. + + +Options +======= + +**-c** *FILENAME* + Read the connection from *FILENAME* instead of + ``/etc/swiyu-verifier/swiyu-verifier.env``. + +**-n** *DBNAME* + The database to create, overriding the name in ``POSTGRES_JDBC``. + +**-u** *DBUSER* + The role to create, overriding ``POSTGRES_USER``. + +**-d** *FILENAME* + Write the generated password to *FILENAME* instead of + ``/etc/swiyu-verifier/secrets/db.env``. + +**-r** + Reset: drop an existing database before creating it again. **This destroys + every verification in it** and is never needed for an upgrade. + +**-h** + Print short help. + + +Examples +======== + +Set up the database as the package expects it: + +.. code-block:: shell + + $ sudo swiyu-verifier-dbconfig + $ sudo systemctl restart swiyu-verifier + +Rotate the database password: + +.. code-block:: shell + + $ sudo swiyu-verifier-dbconfig + $ sudo systemctl restart swiyu-verifier + + +See Also +======== + +swiyu-verifier(1), kych-dbconfig(1), kych-oauth2-gateway(1). + + +Bugs +==== + +Report bugs by using https://bugs.taler.net/ or by sending electronic +mail to <taler@gnu.org>. diff --git a/documentation/taler-docs/manpages/swiyu-verifier.1.rst b/documentation/taler-docs/manpages/swiyu-verifier.1.rst @@ -0,0 +1,193 @@ +swiyu-verifier(1) +################# + +.. only:: html + + Name + ==== + + **swiyu-verifier** - SWIYU Generic Verifier service + + +Synopsis +======== + +**swiyu-verifier** +[*--PROPERTY=VALUE* ...] + + +Description +=========== + +**swiyu-verifier** runs the SWIYU Generic Verifier, the Spring Boot service +that performs OpenID4VP credential verification against the Swiss SWIYU trust +infrastructure. kych-oauth2-gateway(1) creates verifications on its management +API and is told the result through a webhook; the user's wallet talks to its +OID4VP API. + +This program is a thin wrapper around ``java -jar +/usr/share/swiyu-verifier/swiyu-verifier.jar``. It exists because three things +have to be settled before the JVM starts: + +* **Which Java.** The jar is built for Java 25, which the verifier moved to in + its 5.0.0 release, so the system default ``java`` is not necessarily the + right one. ``$JAVA_HOME`` wins if it is set and holds a runtime; otherwise + an ``/usr/lib/jvm/java-25-openjdk-*`` is preferred over the default. + +* **The configuration under /etc.** ``/etc/swiyu-verifier/application.yml`` is + added to the configuration compiled into the jar, overriding it. + +* **The trust store.** If ``/var/lib/swiyu-verifier/cacerts`` exists -- the + package's ``postinst`` builds it from the system Java trust store plus the + Swiss Government Root CA VI, which is not publicly trusted -- it is used + instead of the JVM default. + +Any arguments are passed on to the application, so Spring Boot's +``--property=value`` form works; see **Examples**. + +The service is normally not run from a terminal but through +``swiyu-verifier.service``, which is where the environment comes from. + + +Configuration +============= + +The verifier is configured through environment variables, which +``swiyu-verifier.service`` reads from + +``/etc/swiyu-verifier/swiyu-verifier.env`` + The package's defaults, and the reference for what can be set: the identity + of the verifier (``EXTERNAL_URL``, ``VERIFIER_DID``, + ``DID_VERIFICATION_METHOD``), where it listens (``SERVER_ADDRESS``, + ``SERVER_PORT``), its database (``POSTGRES_JDBC``, ``POSTGRES_USER``), where + to report results (``WEBHOOK_CALLBACK_URI``) and which trust registry to + use. It is commented throughout; the full list of properties is upstream's, + at https://github.com/swiyu-admin-ch/swiyu-verifier. + +``/etc/swiyu-verifier/local.env`` + Where the operator's own settings belong. Read after the file above, and + therefore overriding it, and not owned by the package -- so an upgrade that + changes the shipped defaults has no conffile question to ask. Not shipped; + create it. + +``/etc/swiyu-verifier/secrets/db.env`` + ``POSTGRES_PASSWORD``, written by swiyu-verifier-dbconfig(1). + +The signing key is not among them, because a PEM document has newlines in it +and an environment file cannot express those -- an escaped ``\n`` reaches the +JVM as two characters, which fails with ``No PEM key found``. It is a YAML +file instead, imported by ``application.yml`` through +``spring.config.import``: + +``/etc/swiyu-verifier/secrets/signing-key.yml`` + ``application.signing_key``, the private key whose public half is published + in the verifier's DID document, as a block scalar. Mode 0640, + root:swiyu-verifier. Copy + ``/usr/share/swiyu-verifier/signing-key.yml.example`` to it. For a key in an + HSM, leave it out and set ``SIGNING_KEY_MANAGEMENT_METHOD`` and the + ``HSM_*`` variables in the environment file instead -- those are + single-line values. + +Two further files are read by the service itself: + +``/etc/swiyu-verifier/application.yml`` + Spring Boot configuration for the few settings that have no environment + variable, and for pinning those that must not change with an upstream + default -- ``spring.jpa.hibernate.ddl-auto`` above all, which must stay + ``validate``. + +``/etc/swiyu-verifier/client_metadata.json`` + The OpenID4VP client metadata: the name and logo the wallet shows the user + when it asks for consent, and the credential formats and algorithms this + verifier accepts. ``${client-id}`` in it is replaced with ``VERIFIER_DID`` + at runtime. + + +Database +======== + +The service owns its schema and migrates it with Flyway on every start, so +upgrading this package is a restart and nothing more. The database and the +role it connects as are created once by swiyu-verifier-dbconfig(1). + +The connection goes over TCP to localhost rather than the PostgreSQL socket, +because the JDBC driver cannot use ``peer`` authentication; that is why there +is a generated password at all. + + +Endpoints +========= + +``/oid4vp/api/`` + The OpenID4VP interface the wallet talks to. It must be reachable from the + internet over HTTPS with a certificate from a publicly trusted CA -- the + SWIYU wallet accepts nothing else -- which is the reverse proxy's job, as + this service speaks plain HTTP. + +``/management/api/verifications`` + Creates a verification and retrieves its result. **Must not be reachable + from outside the host**: anybody who can reach it can create verifications + in this verifier's name. KyCH is its only intended user. + +``/actuator/health``, ``/actuator/prometheus`` + Health and metrics. ``/actuator/env`` echoes part of the configuration and + is therefore not exposed by the shipped ``application.yml``. + +``/swagger-ui.html`` + Interactive API documentation for the above. + + +Files +===== + +``/usr/share/swiyu-verifier/swiyu-verifier.jar`` + The application. + +``/usr/share/swiyu-verifier/certs/root_ca_vi.crt`` + Swiss Government Root CA VI, as shipped by upstream. + +``/var/lib/swiyu-verifier/cacerts`` + The service's Java trust store, built by the package's ``postinst`` and + removed on purge. + + +Examples +======== + +Follow a verification through the logs: + +.. code-block:: shell + + $ sudo journalctl -u swiyu-verifier -f + +Check that the service considers itself healthy, which includes resolving its +own DID in the identifier registry: + +.. code-block:: shell + + $ curl -s http://127.0.0.1:8080/actuator/health | python3 -m json.tool + +Run it by hand on another port, as the service user, to try a configuration +change out: + +.. code-block:: shell + + $ sudo -u swiyu-verifier swiyu-verifier --server.port=8081 + + +See Also +======== + +swiyu-verifier-dbconfig(1), kych-oauth2-gateway(1), kych.conf(5). + +The operator manual at https://docs.taler.net/taler-swiyu-verifier-manual.html +covers onboarding to the SWIYU trust infrastructure, which has to happen +before this service can verify anything. + + +Bugs +==== + +Report bugs in the packaging by using https://bugs.taler.net/ or by sending +electronic mail to <taler@gnu.org>. Bugs in the verifier itself belong to its +upstream at https://github.com/swiyu-admin-ch/swiyu-verifier/issues. diff --git a/documentation/taler-docs/taler-kych-manual.rst b/documentation/taler-docs/taler-kych-manual.rst @@ -74,7 +74,7 @@ The flow proceeds as follows: an HTML page with a QR code (or JSON with ``verification_url``). 5. The user scans the QR code with their Swiyu Wallet, which retrieves the - presentation definition and requests user consent. + DCQL query and requests user consent. 6. Upon user consent, the Swiyu Wallet presents the requested credentials to the Swiyu Verifier. @@ -105,9 +105,38 @@ Prerequisites Before installing KyCH, ensure you have the following: -* Rust toolchain (stable, version 1.70 or later) +* Rust toolchain. The crate is edition 2024, so rustc 1.85 or later. * PostgreSQL 12 or later -* A Swiyu Verifier instance with API access +* A Swiyu Verifier instance with API access, of release 3.0.0 or newer -- + KyCH sends a DCQL query, and that release dropped the DIF Presentation + Exchange format older verifiers took. The verifier can be run from the + ``swiyu-verifier`` package built from the same source tree; see + :doc:`taler-swiyu-verifier-manual`. + + +From the Debian package +----------------------- + +On Debian, ``kych`` packages the gateway with its systemd unit, a +configuration under ``/etc/kych`` and the database tools: + +.. code-block:: shell-session + + [root@server]# apt install ./kych_*.deb + [root@server]# kych-dbconfig + +``kych-dbconfig`` creates the PostgreSQL role and database and loads the +schema; afterwards the package keeps the schema up to date by itself, running +``kych-dbinit`` from its ``postinst`` on every upgrade and restarting the +service if it was running. + +The service ships disabled, since it has nothing to serve before at least one +OAuth 2.0 client is registered. See +``/usr/share/doc/kych/README.Debian`` for the remaining steps. Note that the +package's defaults live in ``/usr/share/kych/config.d/``, which is read +*before* ``/etc/kych/kych.conf``; put only your own settings in the latter. + +The rest of this manual describes the gateway itself and applies either way. Building from source @@ -147,18 +176,26 @@ The ``createuser`` command creates a PostgreSQL role named ``kych``. The ``createdb`` command creates a database named ``kych`` owned by the ``kych`` role (``-O kych``). -Next, initialize the database schema. KyCH uses a versioning system to manage -database migrations. First, install the versioning support, then apply the -schema migration: +Next, initialize the database schema with ``kych-dbinit``: .. code-block:: shell-session - $ psql -U kych -d kych -f oauth2_gatewaydb/versioning.sql - $ psql -U kych -d kych -f oauth2_gatewaydb/oauth2gw-0001.sql + $ sudo -u kych kych-dbinit -c /etc/kych/kych.conf -The ``versioning.sql`` script installs the ``_v`` schema which tracks applied -database patches. The ``oauth2gw-0001.sql`` script creates the ``oauth2gw`` -schema with the following tables: +That is also the command that *upgrades* an existing schema: KyCH uses a +versioning system in which every schema change is a numbered patch file that +registers itself in the ``_v`` schema, and the tool applies those the database +does not have yet, in order. It is idempotent, which is why the Debian package +runs it unattended on every upgrade. ``--dry-run`` reports what it would do. + +The tool reads the patch files from ``SQL_DIR``, which defaults to +``$DATADIR/sql`` -- ``/usr/share/kych/sql`` for the Debian package, and +``oauth2_gatewaydb/`` in a source tree, which can be given with +``--sql-dir``. On a host where the database or the role does not exist yet, +it says so and exits with status 2 instead of failing; ``kych-dbconfig`` +creates both and then calls it. + +The first patch creates the ``oauth2gw`` schema with the following tables: - ``clients``: Registered OAuth 2.0 clients and their configurations. - ``verification_sessions``: Active and completed verification sessions. @@ -167,9 +204,9 @@ schema with the following tables: .. note:: - The SQL migration files are located in the ``oauth2_gatewaydb/`` directory - of the KyCH source tree. Adjust the path if you installed KyCH to a - different location. + In a source tree the patch files are in ``oauth2_gatewaydb/``; pass + ``--sql-dir oauth2_gatewaydb`` or set ``SQL_DIR`` in the configuration if + they are somewhere else. Configuration @@ -567,6 +604,15 @@ Deployment systemd service --------------- +The Debian package ships a unit; what follows is for an installation built +from source. Note two things that the packaged unit gets right and that are +easy to miss here: the daemon serves its QR-code JavaScript from ``js/`` +*relative to the working directory*, so ``WorkingDirectory`` has to point at +the directory holding it; and if the configuration file is kept readable only +by ``root:kych`` -- which it should be, as it holds the client secrets -- +then a unit that overrides the primary group with ``Group=`` also needs +``SupplementaryGroups=kych``, or the daemon cannot read it. + Create a systemd service file for KyCH: .. code-block:: ini diff --git a/documentation/taler-docs/taler-swiyu-verifier-manual.rst b/documentation/taler-docs/taler-swiyu-verifier-manual.rst @@ -109,7 +109,8 @@ System requirements Before deploying the Swiyu Verifier, ensure your system meets these requirements: -- **Java Runtime Environment (JRE)**: Version 21 or higher +- **Java Runtime Environment (JRE)**: Version 25 or higher. The verifier + moved to Java 25 with its 5.0.0 release; releases up to 4.2.0 need Java 21. - **PostgreSQL**: Version 12 or higher - **Disk space**: Approximately 100 MB - **Network access**: Outbound access to Swiyu registries and inbound access @@ -274,6 +275,27 @@ for details on trust registry registration. Installation ============ +From the Debian package +----------------------- + +On Debian, the verifier is packaged as ``swiyu-verifier``, built from the +KyCH source package (which pins an upstream release in a submodule). It +installs the service, a systemd unit, a configuration under +``/etc/swiyu-verifier`` and the ``swiyu-verifier-dbconfig`` tool: + +.. code-block:: shell-session + + $ sudo apt install ./swiyu-verifier_*.deb + $ sudo swiyu-verifier-dbconfig + +The service ships disabled, because it cannot verify anything before it has +a DID and a signing key of its own. See +``/usr/share/doc/swiyu-verifier/README.Debian`` and swiyu-verifier(1) for the +remaining steps, and the `Configuration`_ section below for what the settings +mean. The rest of this manual describes the service itself, and applies +either way. + + Building from source -------------------- @@ -286,7 +308,8 @@ Clone the Swiyu Verifier repository and build using Maven: $ ./mvnw clean install -DskipTests This produces the executable JAR files in the ``verifier-application/target`` -directory. +directory. A JDK 25 is required; with an older default JDK on the host, point +``JAVA_HOME`` at it explicitly. Database setup @@ -311,9 +334,17 @@ service will use to authenticate with the database. The ``CREATE DATABASE`` command creates the database and assigns ownership to the verifier user. The ``GRANT`` command ensures the user has full access to the database. -The verifier service uses Hibernate to automatically create the required -schema on first startup when configured with ``ddl-auto: create`` or -``ddl-auto: update``. +The verifier creates and migrates its own schema with Flyway on every start, +so an empty database owned by its role is all it needs -- including after an +upgrade, where there is nothing to do by hand. + +.. warning:: + + Do not set ``spring.jpa.hibernate.ddl-auto`` to ``create`` or + ``create-drop`` to achieve this. Those drop the schema, and with it every + verification in the database, on each start. The correct value is + ``validate``, which has Hibernate check the schema Flyway produced; it is + the upstream default. Configuration @@ -344,17 +375,21 @@ container orchestration: application: external-url: "${EXTERNAL_URL:}" client_id: "${VERIFIER_DID:}" - client_id_scheme: "did" + client_id_prefix: "decentralized_identifier" signing_key: "${secret.signing_key:${SIGNING_KEY:}}" signing-key-verification-method: "${DID_VERIFICATION_METHOD:}" client-metadata-file: "${OPENID_CLIENT_METADATA_FILE:}" deeplink-schema: ${DEEPLINK_SCHEMA:swiyu-verify} + additional-audit-information: + credential-subject-data-enabled: ${ADDITIONAL_AUDIT_INFORMATION_CREDENTIAL_SUBJECT_DATA_ENABLED:true} spring: datasource: url: "${POSTGRES_JDBC}" username: "${secret.db.username:${POSTGRES_USER}}" password: "${secret.db.password:${POSTGRES_PASSWORD}}" + flyway: + enabled: true jpa: hibernate: ddl-auto: validate @@ -364,11 +399,13 @@ environment variable ``VARIABLE``, falling back to ``default`` if not set. The nested form ``${secret.value:${ENV_VAR:}}`` first checks for a mounted secret file, then an environment variable. -**Local profile with Docker Compose (application-local.yml)** +**Local profile (application-local.yml)** -The ``local`` profile is designed for development with Docker Compose -managing the PostgreSQL database. When this profile is active, Spring Boot -automatically starts a PostgreSQL container: +The ``local`` profile is for development with Docker Compose managing the +PostgreSQL database. When it is active, Spring Boot starts a PostgreSQL +container from ``compose.yaml``, and the profile supplies a throwaway signing +key and example DID values -- which are of course not valid for real +verifications: .. code-block:: yaml @@ -376,7 +413,6 @@ automatically starts a PostgreSQL container: signing_key: "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n" signing-key-verification-method: "did:example:12345#key-1" external-url: "http://${server.host}:${server.port}" - client_id: "did:example:12345" client-metadata-file: "classpath:client_metadata.json" spring: @@ -390,58 +426,40 @@ automatically starts a PostgreSQL container: password: "secret" jpa: hibernate: - ddl-auto: create + ddl-auto: validate - logging: - level: - ch.admin.bj.swiyu: DEBUG +.. note:: -Key characteristics: + Earlier releases also shipped a ``local-dockerless`` profile. It is gone; + for development against a PostgreSQL of your own, supply a configuration + file instead of activating a profile, as the next section describes. -- ``spring.docker.compose.enabled: true`` enables Spring Boot's Docker - Compose support, which starts containers defined in ``compose.yaml`` -- ``ddl-auto: create`` generates the database schema automatically -- Uses example DID values for testing (not valid for real verifications) -- Debug logging enabled for development visibility -- Database runs on port 5434 (mapped from container's 5432) -The accompanying ``compose.yaml`` defines the PostgreSQL container: +**A configuration file of your own** -.. code-block:: yaml +Rather than editing the upstream tree or activating a profile, pass an extra +configuration file, which Spring Boot reads after the one inside the jar and +which therefore overrides it: - services: - postgres: - image: postgres:15-alpine - environment: - POSTGRES_USER: "verifier_user" - POSTGRES_PASSWORD: "secret" - POSTGRES_DB: "verifier_db" - ports: - - '5434:5432' - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s - retries: 5 +.. code-block:: shell-session -**Local dockerless profile (application-local-dockerless.yml)** + $ ./mvnw spring-boot:run -pl verifier-application \ + -Dspring-boot.run.jvmArguments=\ + "-Dspring.config.additional-location=file:/path/to/my-verifier.yml" -The ``local-dockerless`` profile runs without Docker, connecting directly -to a PostgreSQL instance on your machine. This is useful when you have -PostgreSQL installed locally or want more control over the database: +with ``my-verifier.yml``: .. code-block:: yaml application: external-url: "https://your-ngrok-url.ngrok-free.app" client_id: "did:tdw:QmHash:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:your-uuid" - client_id_scheme: "did" + signing-key-verification-method: "did:tdw:QmHash:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:your-uuid#auth-key-01" signing_key: | -----BEGIN EC PRIVATE KEY----- MHcCAQEEI...your_actual_key... -----END EC PRIVATE KEY----- - signing-key-verification-method: "did:tdw:QmHash:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:your-uuid#auth-key-01" - client-metadata-file: "classpath:/client_metadata.json" + client-metadata-file: "file:./client_metadata.json" spring: docker: @@ -453,50 +471,47 @@ PostgreSQL installed locally or want more control over the database: password: "secret" jpa: hibernate: - ddl-auto: create - - logging: - level: - ch.admin.bj.swiyu: DEBUG + ddl-auto: validate webhook: callback-uri: "https://kych.example.com/notification" callback-interval: 5000 -Key characteristics: +This is what ``swiyu-verifier/run.sh`` in the KyCH tree does, and what the +Debian package does with ``/etc/swiyu-verifier/application.yml``. -- ``spring.docker.compose.enabled: false`` disables Docker Compose support -- Uses real DID values from the Swiyu Trust Infrastructure onboarding -- Requires manual PostgreSQL setup (see Database Setup section) -- Webhook configuration for integration testing with KyCH -- Uses ngrok or similar for HTTPS exposure to wallets +Note that ``client_id_scheme`` is no longer a setting: release 4.0.0 replaced +it with ``client_id_prefix``, which defaults to ``decentralized_identifier`` +and is prepended to ``client_id`` in what the wallet receives. -Environment variables file (.env) ---------------------------------- +Environment variables +--------------------- -For convenience, you can define environment variables in a ``.env`` file -in the resources directory. Spring Boot loads these automatically: +Every setting of the base configuration has an environment variable, which is +how a container deployment and the Debian package both configure the service: .. code-block:: bash # Verifier identity - EXTERNAL_URL="https://your-verifier.ngrok-free.app" - VERIFIER_DID="did:tdw:QmHash:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:your-uuid" - DID_VERIFICATION_METHOD="did:tdw:QmHash:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:your-uuid#auth-key-01" - OPENID_CLIENT_METADATA_FILE="classpath:/client_metadata.json" - - SIGNING_KEY="-----BEGIN EC PRIVATE KEY----- - MHcCAQEEI...your_key_content... - -----END EC PRIVATE KEY-----" + EXTERNAL_URL="https://your-verifier.example.com" + VERIFIER_DID="did:tdw:QmHash:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:your-uuid" + DID_VERIFICATION_METHOD="did:tdw:QmHash:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:your-uuid#auth-key-01" + OPENID_CLIENT_METADATA_FILE="file:/etc/swiyu-verifier/client_metadata.json" # Database connection - POSTGRES_USER="verifier_user" - POSTGRES_PASSWORD="secret" - POSTGRES_JDBC="jdbc:postgresql://localhost:5432/verifier_db" + POSTGRES_USER="swiyu_verifier" + POSTGRES_PASSWORD="..." + POSTGRES_JDBC="jdbc:postgresql://localhost:5432/swiyu_verifier" -This approach separates sensitive values from the configuration files and -works well with the base ``application.yml`` profile. +The signing key is the exception worth knowing about. It is a multi-line PEM +document, so it cannot be carried in a systemd environment file, and an +escaped ``\n`` in such a file reaches the JVM as two characters, which fails +with ``No PEM key found``. Put it in a YAML file -- where a block scalar +expresses it directly -- and have the service import it; the Debian package +ships ``/usr/share/swiyu-verifier/signing-key.yml.example`` for exactly this +and imports ``/etc/swiyu-verifier/secrets/signing-key.yml`` with +``spring.config.import``. Deployment Options @@ -522,17 +537,19 @@ Create the database and user: $ psql -d postgres -c "CREATE DATABASE verifier_db OWNER verifier_user;" $ psql -d verifier_db -c "GRANT ALL PRIVILEGES ON DATABASE verifier_db TO verifier_user;" -**2. Configure the profile** +**2. Write a configuration file** -Create or edit ``application-local-dockerless.yml`` with your DID credentials -obtained from the Swiyu onboarding process. +Put your DID, verification method and signing key from the Swiyu onboarding +into a YAML file of your own, as "A configuration file of your own" above +describes. (There is no ``local-dockerless`` profile any more.) **3. Start the verifier** .. code-block:: shell-session $ ./mvnw spring-boot:run -pl verifier-application \ - -Dspring-boot.run.profiles=local-dockerless + -Dspring-boot.run.jvmArguments=\ + "-Dspring.config.additional-location=file:/path/to/my-verifier.yml" **4. Expose via ngrok (for wallet testing)** @@ -662,9 +679,8 @@ To run the verifier with remote debugging enabled: .. code-block:: shell-session $ ./mvnw spring-boot:run -pl verifier-application \ - -Dspring-boot.run.profiles=local-dockerless \ -Dspring-boot.run.fork=true \ - -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" + -Dspring-boot.run.jvmArguments="-Dspring.config.additional-location=file:/path/to/my-verifier.yml -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" This starts the JVM debug agent on port 5005. Connect your IDE's remote debugger to ``localhost:5005``. @@ -682,9 +698,11 @@ process during onboarding. identifies your service to wallets and other participants. Example: ``did:tdw:QmHash:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:uuid`` -``application.client_id_scheme`` - The client identifier scheme. Set to ``did`` when using Decentralized - Identifiers. +``application.client_id_prefix`` + Prefix put in front of ``client_id`` in what the wallet receives, as in + ``decentralized_identifier:did:tdw:...``. Defaults to + ``decentralized_identifier`` and rarely needs changing. It replaced the + ``client_id_scheme`` setting in release 4.0.0. ``application.signing-key-verification-method`` The full DID with key fragment that identifies the public key in your @@ -732,10 +750,12 @@ Configure the PostgreSQL connection for storing verification state: Database password for authentication. ``spring.jpa.hibernate.ddl-auto`` - Schema management strategy. Use ``create`` for initial setup (creates - schema from entity definitions), ``update`` for adding new fields to - existing tables, or ``validate`` for production (verifies schema matches - entities without modifications). + Schema management strategy. Leave it at ``validate``, the upstream + default: Flyway creates and migrates the schema (``spring.flyway.enabled``, + also on by default), and ``validate`` has Hibernate check the result and + refuse to start on a mismatch. ``create`` and ``create-drop`` drop the + schema and every verification in it on each start, and ``update`` lets + Hibernate make changes behind Flyway's back. Operational parameters @@ -835,7 +855,6 @@ Complete configuration example for production deployment: application: external-url: "https://verifier.example.com" client_id: "did:tdw:QmHash:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:abc123" - client_id_scheme: "did" signing-key-verification-method: "did:tdw:QmHash:identifier-reg.trust-infra.swiyu.admin.ch:api:v1:did:abc123#auth-key-01" signing_key: | -----BEGIN EC PRIVATE KEY----- @@ -993,22 +1012,15 @@ Where ``request.json`` contains: { "accepted_issuer_dids": ["did:tdw:issuer_did"], - "presentation_definition": { - "id": "00000000-0000-0000-0000-000000000000", - "input_descriptors": [{ - "id": "11111111-1111-1111-1111-111111111111", - "format": { - "vc+sd-jwt": { - "sd-jwt_alg_values": ["ES256"], - "kb-jwt_alg_values": ["ES256"] - } - }, - "constraints": { - "fields": [ - {"path": ["$.vct"], "filter": {"type": "string", "const": "betaid-sdjwt"}}, - {"path": ["$.age_over_18"]} - ] - } + "jwt_secured_authorization_request": true, + "response_mode": "direct_post", + "dcql_query": { + "credentials": [{ + "id": "age_check", + "format": "dc+sd-jwt", + "meta": { "vct_values": ["betaid-sdjwt"] }, + "claims": [{ "path": ["age_over_18"] }], + "require_cryptographic_holder_binding": true }] } } @@ -1027,9 +1039,14 @@ Request parameters DIDs. The verifier accepts credentials from any issuer trusted by these anchors. -``presentation_definition`` - DIF Presentation Exchange format specifying required credentials and - claims. See the Presentation Definition section below. +``dcql_query`` + **Required.** What the wallet is asked to present, as a Digital + Credentials Query Language query. See the `DCQL query format`_ section + below. + + Release 3.0.0 dropped the DIF Presentation Exchange + ``presentation_definition`` that older versions took here, so a request + built for one of those is rejected. ``jwt_secured_authorization_request`` Whether to sign the request object. Default: ``true`` (recommended). @@ -1077,28 +1094,47 @@ The response includes the current ``state``: - ``EXPIRED``: Verification request timed out -Presentation definition format ------------------------------- +DCQL query format +----------------- -The ``presentation_definition`` follows the DIF Presentation Exchange -specification. Each ``input_descriptor`` specifies one credential requirement: +The ``dcql_query`` follows the Digital Credentials Query Language of OpenID4VP +1.0, section 6. Each entry of ``credentials`` asks for one credential: + +``id`` + Names this credential query. The verifier reports the disclosed claims + back under this key in ``wallet_response.dcql_response``, so it is worth + choosing something recognisable; it must match ``^[a-zA-Z0-9_-]+$``. ``format`` - Accepted credential formats. For Swiss Beta ID, use:: + ``dc+sd-jwt``, the canonical SD-JWT VC media type. The older + ``vc+sd-jwt`` is still accepted during the migration window. + +``meta.vct_values`` + The credential types that satisfy this query, matched against the ``vct`` + claim of the presented credential -- ``betaid-sdjwt`` for the Swiss Beta + ID. This is what lets a wallet holding several credentials tell which one + is being asked for. - "vc+sd-jwt": { - "sd-jwt_alg_values": ["ES256"], - "kb-jwt_alg_values": ["ES256"] - } +``claims`` + The claims to disclose, one entry per claim, each a *claims path pointer*: + ``{"path": ["age_over_18"]}`` for a top-level claim, and + ``{"path": ["address", "street_address"]}`` for a nested one. Every claim + of the Beta ID is top-level. Leave the member out entirely rather than + sending an empty array, which is rejected. -``constraints.fields`` - List of claim requirements. Each field specifies: +``require_cryptographic_holder_binding`` + Whether the wallet must prove it holds the credential's key. Defaults to + true, and there is no good reason to turn it off. - - ``path``: JSONPath to the claim (e.g., ``["$.age_over_18"]``) - - ``filter``: Optional filter for exact value matching +``trusted_authorities`` + Optional, and normally not sent: when ``accepted_issuer_dids`` is given on + the request, the verifier fills this in from it, so that the wallet can + pre-filter the credentials it offers the user. If you do send it, it has to + agree with ``accepted_issuer_dids``. -The first field should filter by credential type (``$.vct``). Subsequent -fields specify the claims to disclose. +KyCH builds exactly such a query from the OAuth 2.0 scope, one claims query +per scope token; ``swiyu-verifier/api_requests/request_over_18.json`` in the +KyCH tree is a complete example in the same shape. Error codes @@ -1206,7 +1242,7 @@ See also - kych-oauth2-gateway(1) - KyCH OAuth2 Gateway server - `Swiyu Trust Infrastructure <https://swiyu-admin-ch.github.io/>`_ - `OID4VP Specification <https://openid.net/specs/openid-4-verifiable-presentations-1_0.html>`_ -- `DIF Presentation Exchange <https://identity.foundation/presentation-exchange/>`_ +- `DCQL, in OpenID for Verifiable Presentations 1.0 <https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-6>`_ Bugs diff --git a/kych_oauth2_gateway/Cargo.toml b/kych_oauth2_gateway/Cargo.toml @@ -16,6 +16,10 @@ path = "src/main.rs" name = "kych-client-management" path = "src/bin/client_management_cli.rs" +[[bin]] +name = "kych-dbinit" +path = "src/bin/dbinit.rs" + [dependencies] # Configuration parsing, logging and socket binding, vendored from taler-rust taler-config = { path = "../taler-config" } diff --git a/kych_oauth2_gateway/README b/kych_oauth2_gateway/README @@ -25,22 +25,28 @@ Building cargo build --release -The build produces two binaries: +The build produces three binaries: - kych-oauth2-gateway Main gateway service - kych-client-management CLI tool for client management + - kych-dbinit Schema creation and migration Database Setup -------------- -Set up the PostgreSQL database using the scripts in oauth2_gatewaydb/: +kych-dbinit creates the schema and brings an existing one up to date; it +applies the oauth2gw-000N.sql patches the database does not have yet, in +order, and does nothing when there are none: - cd oauth2_gatewaydb - ./install_db.sh + kych-dbinit -c kych.conf --sql-dir oauth2_gatewaydb --dry-run + kych-dbinit -c kych.conf --sql-dir oauth2_gatewaydb -To remove: +It does not create the database itself. ../contrib/kych-dbconfig does that +for an installed system (as root, reading /etc/kych/kych.conf), and for a +throwaway development database there is - ./uninstall_db.sh + ./oauth2_gatewaydb/install_db.sh # run from here, not from inside it + ./oauth2_gatewaydb/uninstall_db.sh Configuration @@ -63,7 +69,7 @@ Copy kych.conf.example to kych.conf and configure: # Verifiable Credential settings VC_TYPE = betaid-sdjwt - VC_FORMAT = vc+sd-jwt + VC_FORMAT = dc+sd-jwt VC_ALGORITHMS = {ES256} VC_CLAIMS = {family_name, given_name, birth_date, ...} @@ -134,12 +140,13 @@ Project Structure src/bin/ client_management_cli.rs Client management CLI + dbinit.rs Schema creation and migration templates/ authorize.html Authorization page template oauth2_gatewaydb/ - install_db.sh Database installation - oauth2gw-0001.sql Schema migration - versioning.sql Version control + install_db.sh Development database creation + oauth2gw-0001.sql Schema patch 1; later patches are 000N.sql + versioning.sql Patch-level bookkeeping (the _v schema) drop.sql Cleanup script diff --git a/kych_oauth2_gateway/kych.conf.example b/kych_oauth2_gateway/kych.conf.example @@ -11,7 +11,7 @@ AUTH_CODE_TTL_MINUTES = 10 #ALLOWED_SCOPES = {family_name, given_name, birth_date} VC_TYPE = betaid-sdjwt -VC_FORMAT = vc+sd-jwt +VC_FORMAT = dc+sd-jwt VC_ALGORITHMS = {ES256} VC_CLAIMS = {family_name, given_name, birth_date, sex, place_of_origin, birth_place, nationality, portrait, personal_administrative_number, age_over_16, age_over_18, age_over_65, age_birth_year, document_number, issuance_date, expiry_date, additional_person_info, reference_id_type, reference_id_expiry_date, verification_type, verification_organization, issuing_authority, issuing_country} diff --git a/kych_oauth2_gateway/src/bin/dbinit.rs b/kych_oauth2_gateway/src/bin/dbinit.rs @@ -0,0 +1,278 @@ +//! KyCH database initialisation and migration. +//! +//! Creates the `oauth2gw` schema in a fresh database and brings an existing one +//! up to the patch level of the installed SQL files. Both are the same +//! operation: every schema change is a numbered patch file +//! (`oauth2gw-000N.sql`) that registers itself with the depesz `Versioning` +//! bookkeeping in the `_v` schema, and this tool applies those that the +//! database does not have yet, in order. +//! +//! It is idempotent and says nothing but which patches it applied, so that it +//! can run unattended from the Debian package's postinst on every upgrade. +//! +//! Usage: +//! kych-dbinit [-c kych.conf] [--sql-dir DIR] [--dry-run] +//! kych-dbinit [-c kych.conf] --print-connection + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use clap::Parser; +use kych_oauth2_gateway_lib::config::{CONFIG_SOURCE, Config}; +use sqlx::{Connection, Executor, PgConnection, Row}; +use taler_config::{CommonArgs, taler_main}; + +/// Bookkeeping, loaded before the first patch. Idempotent, so it is applied +/// unconditionally rather than guessed at. +const VERSIONING_SQL: &str = "versioning.sql"; + +/// Patch files are `<PATCH_PREFIX><number>.sql`, and the file name without the +/// extension is the patch name they register. +const PATCH_PREFIX: &str = "oauth2gw-"; + +#[derive(Parser, Debug)] +#[command(name = "kych-dbinit")] +#[command(version)] +#[command(about = "initialise and upgrade the KyCH database schema")] +struct Args { + #[command(flatten)] + common: CommonArgs, + + /// Directory holding versioning.sql and the oauth2gw-*.sql patches + /// (default: SQL_DIR from the configuration) + #[arg(long)] + sql_dir: Option<PathBuf>, + + /// Report what would be applied, without changing the database + #[arg(long, short = 'n')] + dry_run: bool, + + /// Print the connection the configuration names as PG* environment + /// assignments and exit, without connecting. This is how kych-dbconfig + /// learns where the database is without having to parse kych.conf itself. + #[arg(long)] + print_connection: bool, +} + +/// Exit status for "there is no database here (yet)": either it does not +/// exist or the server cannot be reached. The Debian package's postinst +/// tells this apart from a real failure, because on a first install it is the +/// expected outcome and must not make the installation fail. +const EXIT_NO_DATABASE: i32 = 2; + +fn main() { + let args = Args::parse(); + + taler_main(CONFIG_SOURCE, args.common, async |cfg| { + let config = Config::parse(cfg)?; + if args.print_connection { + print_connection(&config)?; + return Ok(()); + } + let sql_dir = match args.sql_dir { + Some(dir) => dir, + None => PathBuf::from(&config.sql_dir), + }; + run(&config, &sql_dir, args.dry_run).await + }) +} + +async fn run(config: &Config, sql_dir: &Path, dry_run: bool) -> Result<()> { + let patches = available_patches(sql_dir)?; + if patches.is_empty() { + bail!( + "no {}*.sql patch file in {}", + PATCH_PREFIX, + sql_dir.display() + ); + } + + let mut conn = match PgConnection::connect_with(&config.database).await { + Ok(conn) => conn, + Err(err) if is_absent_database(&err) => { + // Not an error worth a stack of context: on a first install this + // is simply the state before kych-dbconfig has run. + eprintln!( + "No database at {}: {}", + describe_target(config), + err + ); + eprintln!("Run kych-dbconfig to create it."); + std::process::exit(EXIT_NO_DATABASE); + } + Err(err) => return Err(err).context("Failed to connect to database"), + }; + + if dry_run { + // Without the bookkeeping there is nothing to compare against, and + // creating it would be a change: everything is pending in that case. + let applied = if versioning_present(&mut conn).await? { + applied_patches(&mut conn).await? + } else { + println!("{VERSIONING_SQL} would be loaded"); + HashSet::new() + }; + let mut pending = 0; + for (name, path) in &patches { + if !applied.contains(name) { + println!("{} would be applied", path.display()); + pending += 1; + } + } + if pending == 0 { + println!("database is at the latest patch level"); + } + return Ok(()); + } + + // Idempotent: CREATE SCHEMA / TABLE IF NOT EXISTS and CREATE OR REPLACE + // FUNCTION throughout, so an existing installation is left as it is. + execute_sql_file(&mut conn, &sql_dir.join(VERSIONING_SQL)).await?; + + let applied = applied_patches(&mut conn).await?; + let mut count = 0; + for (name, path) in &patches { + if applied.contains(name) { + continue; + } + tracing::info!(target: "dbinit", "applying patch {}", name); + execute_sql_file(&mut conn, path).await?; + println!("applied {name}"); + count += 1; + } + + if count == 0 { + tracing::info!(target: "dbinit", "database is at the latest patch level"); + } + + conn.close().await.context("Failed to close connection")?; + Ok(()) +} + +/// Whether the connection failed because there is nothing to connect to: the +/// database does not exist (PostgreSQL `invalid_catalog_name`), or the server +/// is not listening where the configuration says it is. Anything else -- a +/// rejected password, a TLS failure, a protocol error -- is a real problem +/// and is reported as one. +fn is_absent_database(err: &sqlx::Error) -> bool { + match err { + sqlx::Error::Database(db_err) => db_err.code().as_deref() == Some("3D000"), + sqlx::Error::Io(_) => true, + _ => false, + } +} + +/// Where the configured connection goes. `?host=/some/dir` in the DATABASE +/// URI makes sqlx connect over that Unix socket directory and leaves the TCP +/// host at its default, so the socket has to be asked for first or the answer +/// is a plausible-looking lie about localhost. +fn connection_host(config: &Config) -> String { + match config.database.get_socket() { + Some(socket) => socket.to_string_lossy().into_owned(), + None => config.database.get_host().to_owned(), + } +} + +/// Where we tried to connect, for a message that is useful without having to +/// go and read the configuration file. +fn describe_target(config: &Config) -> String { + format!( + "{}@{}:{}", + config.database.get_database().unwrap_or("?"), + connection_host(config), + config.database.get_port() + ) +} + +/// `PGDATABASE`/`PGHOST`/`PGPORT` assignments for a shell to eval. Only +/// these three, and deliberately not PGUSER: the caller connects as the +/// PostgreSQL administrator, not as the daemon. +fn print_connection(config: &Config) -> Result<()> { + let name = config + .database + .get_database() + .ok_or_else(|| anyhow!("DATABASE in the configuration names no database"))?; + let host = connection_host(config); + // A shell would mangle anything exotic, and a database or socket path + // with a quote in it is a configuration mistake rather than a use case. + for (name, value) in [("PGDATABASE", name), ("PGHOST", host.as_str())] { + if value.contains(['\'', '"', '$', '`', '\\', '\n']) { + bail!("{name} value '{value}' contains a character a shell would mangle"); + } + } + println!("PGDATABASE='{name}'"); + println!("PGHOST='{host}'"); + println!("PGPORT='{}'", config.database.get_port()); + Ok(()) +} + +/// The patch files in `sql_dir`, as (patch name, path), ordered by the number +/// in the file name -- `oauth2gw-0002.sql` must not be applied before +/// `oauth2gw-0001.sql`, and `register_patch` would refuse it anyway if it +/// declared the earlier one as a requirement. +fn available_patches(sql_dir: &Path) -> Result<Vec<(String, PathBuf)>> { + let entries = std::fs::read_dir(sql_dir) + .with_context(|| format!("Failed to read SQL directory {}", sql_dir.display()))?; + + let mut patches = Vec::new(); + for entry in entries { + let path = entry + .with_context(|| format!("Failed to read SQL directory {}", sql_dir.display()))? + .path(); + let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if path.extension().and_then(|e| e.to_str()) != Some("sql") { + continue; + } + let Some(number) = name.strip_prefix(PATCH_PREFIX) else { + continue; + }; + let number: u32 = number.parse().map_err(|_| { + anyhow!( + "patch file {} does not end in a number", + path.display() + ) + })?; + patches.push((number, name.to_owned(), path)); + } + + patches.sort_by_key(|(number, _, _)| *number); + Ok(patches + .into_iter() + .map(|(_, name, path)| (name, path)) + .collect()) +} + +async fn versioning_present(conn: &mut PgConnection) -> Result<bool> { + let present: Option<String> = + sqlx::query_scalar("SELECT to_regclass('_v.patches')::text") + .fetch_one(&mut *conn) + .await + .context("Failed to look for the _v.patches table")?; + Ok(present.is_some()) +} + +async fn applied_patches(conn: &mut PgConnection) -> Result<HashSet<String>> { + let rows = sqlx::query("SELECT patch_name FROM _v.patches") + .fetch_all(&mut *conn) + .await + .context("Failed to read the applied patches from _v.patches")?; + Ok(rows + .into_iter() + .map(|row| row.get::<String, _>("patch_name")) + .collect()) +} + +/// Run a whole SQL file. The files carry their own `BEGIN`/`COMMIT`, which is +/// what makes each patch all-or-nothing, so they must not be wrapped in +/// another transaction here. +async fn execute_sql_file(conn: &mut PgConnection, path: &Path) -> Result<()> { + let sql = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + conn.execute(sqlx::raw_sql(&sql)) + .await + .with_context(|| format!("Failed to apply {}", path.display()))?; + Ok(()) +} diff --git a/kych_oauth2_gateway/src/config.rs b/kych_oauth2_gateway/src/config.rs @@ -27,6 +27,7 @@ const DEFAULT_VERIFIER_MANAGEMENT_API_PATH: &str = "/management/api/verification pub struct Config { pub serve: Serve, pub database: PgConnectOptions, + pub sql_dir: String, pub crypto: CryptoConfig, pub vc: VcConfig, pub allowed_scopes: Option<Vec<String>>, @@ -103,6 +104,12 @@ impl Config { Ok(Config { serve: Serve::parse(&main)?, database: main.postgres("DATABASE").require()?, + // Only kych-dbinit reads this; $DATADIR expands to + // <prefix>/share/kych, so the installed location needs no + // configuration at all. + sql_dir: main + .path("SQL_DIR") + .default("$DATADIR/sql".to_owned())?, crypto, vc, allowed_scopes: list(&main, "ALLOWED_SCOPES").opt()?, @@ -156,7 +163,7 @@ NONCE_BYTES = 32 TOKEN_BYTES = 32 AUTH_CODE_BYTES = 32 VC_TYPE = betaid-sdjwt -VC_FORMAT = vc+sd-jwt +VC_FORMAT = dc+sd-jwt VC_ALGORITHMS = {ES256} VC_CLAIMS = {given_name, family_name, age_over_18} "; diff --git a/kych_oauth2_gateway/src/handlers.rs b/kych_oauth2_gateway/src/handlers.rs @@ -396,11 +396,10 @@ pub async fn authorize( )); } - let presentation_definition = build_presentation_definition( + let dcql_query = build_dcql_query( &data.scope, &state.config.vc.vc_type, &state.config.vc.vc_format, - &state.config.vc.vc_algorithms, ); // Call Swiyu Verifier @@ -428,13 +427,10 @@ pub async fn authorize( let verifier_request = SwiyuCreateVerificationRequest { accepted_issuer_dids, - trust_anchors: None, jwt_secured_authorization_request: Some(true), response_mode: ResponseMode::DirectPost, - response_type: "vp_token".to_string(), - presentation_definition, + dcql_query, configuration_override: ConfigurationOverride::default(), - dcql_query: None, }; tracing::debug!( @@ -570,68 +566,121 @@ pub async fn authorize( }).into_response()) } -fn build_presentation_definition( - scope: &str, - vc_type: &str, - vc_format: &str, - vc_algorithms: &[String], -) -> PresentationDefinition { - use std::collections::HashMap; - use uuid::Uuid; - +/// Build the DCQL query for a verification. +/// +/// The OAuth 2.0 scope is the list of claims to disclose: one claims query per +/// scope token, each a single-element claims path pointer, since every claim of +/// the Beta-ID credential is top-level. `vc_type` constrains the credential's +/// `vct`, so a wallet holding several credentials can tell which one is asked +/// for. +/// +/// A scope that names no claim at all yields a credential query without a +/// `claims` member -- the verifier rejects an empty `claims` array, and leaving +/// it out means "no claim in particular", which is what an empty scope asks +/// for. +fn build_dcql_query(scope: &str, vc_type: &str, vc_format: &str) -> DcqlQuery { let attributes: Vec<&str> = scope.split_whitespace().collect(); - tracing::debug!( - "Building presentation definition for attributes: {:?}", - attributes - ); - - let vct_field = Field { - path: vec!["$.vct".to_string()], - id: None, - name: None, - purpose: None, - filter: Some(Filter { - filter_type: "string".to_string(), - const_value: Some(vc_type.to_string()), - }), + tracing::debug!("Building DCQL query for attributes: {:?}", attributes); + + let claims: Option<Vec<DcqlClaim>> = if attributes.is_empty() { + None + } else { + Some( + attributes + .iter() + .map(|attr| DcqlClaim { + path: vec![(*attr).to_string()], + }) + .collect(), + ) }; - let mut fields: Vec<Field> = vec![vct_field]; - for attr in &attributes { - fields.push(Field { - path: vec![format!("$.{}", attr)], - id: None, - name: None, - purpose: None, - filter: None, - }); + DcqlQuery { + credentials: vec![DcqlCredential { + id: DCQL_CREDENTIAL_ID.to_string(), + format: vc_format.to_string(), + meta: DcqlCredentialMeta { + vct_values: vec![vc_type.to_string()], + }, + claims, + require_cryptographic_holder_binding: true, + }], } +} - let mut format = HashMap::new(); - format.insert( - vc_format.to_string(), - FormatAlgorithm { - sd_jwt_alg_values: vc_algorithms.to_vec(), - kb_jwt_alg_values: vc_algorithms.to_vec(), - }, - ); +/// Pick the disclosed claims out of the verifier's `wallet_response` and return +/// them as the flat `{claim: value}` object that `GET /info` is documented to +/// serve (see `core/api-kych.rst`). +/// +/// Since 5.0.0 the claims arrive under `dcql_response`, keyed first by the +/// credential query id and then by claim id or claims path, with each value +/// wrapped in an array. The deprecated `credential_subject_data` is still sent +/// by default and is read as a fallback; it is keyed the same way in recent +/// releases but was a flat map of claims in older ones, so both shapes are +/// accepted. +/// +/// `None` means there was nothing to extract -- a failed verification, for +/// instance, where `wallet_response` carries `error_code` instead of claims. +/// The caller then stores the response unchanged, so that the reason survives +/// in the session for the operator to look at. +fn extract_disclosed_claims(wallet_response: &serde_json::Value) -> Option<serde_json::Value> { + let per_credential = wallet_response + .get("dcql_response") + .and_then(|v| v.as_object()) + .or_else(|| { + wallet_response + .get("credential_subject_data") + .and_then(|v| v.as_object()) + })?; - let input_descriptor = InputDescriptor { - id: Uuid::new_v4().to_string(), - name: None, - purpose: None, - format: Some(format), - constraints: Constraint { fields }, + // Our own credential query first; fall back to the sole entry, so that a + // verifier which keys the map differently still works. + let claims = per_credential + .get(DCQL_CREDENTIAL_ID) + .or_else(|| { + if per_credential.len() == 1 { + per_credential.values().next() + } else { + None + } + }) + .and_then(|v| v.as_object()); + + let claims = match claims { + Some(claims) => claims, + // Older verifiers put the claims directly into + // `credential_subject_data` without a credential-query level. That is + // this map, as long as it holds no nested objects. + None if per_credential.values().all(|v| !v.is_object()) => per_credential, + None => { + tracing::warn!( + "No claims for credential query '{}' in verifier response", + DCQL_CREDENTIAL_ID + ); + return None; + } }; - PresentationDefinition { - id: Uuid::new_v4().to_string(), - name: Some("Over 18 Verification".to_string()), - purpose: Some("Verify age is over 18".to_string()), - format: None, - input_descriptors: vec![input_descriptor], + let flattened: serde_json::Map<String, serde_json::Value> = claims + .iter() + .map(|(claim, value)| { + // DCQL reports every value as an array, since a claim may occur + // more than once; a single value is unwrapped to keep the /info + // contract unchanged. + let value = match value.as_array() { + Some(values) if values.len() == 1 => values[0].clone(), + _ => value.clone(), + }; + (claim.clone(), value) + }) + .collect(); + + if flattened.is_empty() { + return None; } + + Some(serde_json::Value::Object(flattened)) } // POST /token @@ -967,6 +1016,22 @@ pub async fn notification_webhook( } }; + // What ends up in the session as the verifiable credential, and is handed + // to the client by /info: the disclosed claims on success, and otherwise + // the verifier's response unchanged, which is where its error_code is. + let credential = swiyu_result.wallet_response.as_ref().map(|response| { + match extract_disclosed_claims(response) { + Some(claims) => claims, + None => { + tracing::warn!( + "No disclosed claims in verifier response for verification {}, storing it unchanged", + webhook.verification_id + ); + response.clone() + } + } + }); + // Generate authorization code let authorization_code = crypto::generate_authorization_code(state.config.crypto.authorization_code_bytes); @@ -982,7 +1047,7 @@ pub async fn notification_webhook( auth_code_ttl, session_data.client_id, "", // Empty body for GET request - swiyu_result.wallet_response.as_ref(), + credential.as_ref(), ) .await { @@ -1262,141 +1327,181 @@ mod tests { } #[test] - fn test_build_presentation_definition_single_attribute() { - let scope = "age_over_18"; - let pd = build_presentation_definition( - scope, + fn test_build_dcql_query_single_attribute() { + let query = build_dcql_query("age_over_18", "betaid-sdjwt", "dc+sd-jwt"); + + assert_eq!(query.credentials.len(), 1); + let credential = &query.credentials[0]; + assert_eq!(credential.id, DCQL_CREDENTIAL_ID); + assert_eq!(credential.format, "dc+sd-jwt"); + assert_eq!(credential.meta.vct_values, vec!["betaid-sdjwt"]); + assert!(credential.require_cryptographic_holder_binding); + + let claims = credential.claims.as_ref().unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].path, vec!["age_over_18"]); + } + + #[test] + fn test_build_dcql_query_multiple_attributes() { + let query = build_dcql_query( + "first_name last_name date_of_birth", "betaid-sdjwt", - "vc+sd-jwt", - &["ES256".to_string()], + "dc+sd-jwt", ); - assert!(!pd.id.is_empty()); - assert_eq!(pd.name, Some("Over 18 Verification".to_string())); - assert_eq!(pd.input_descriptors.len(), 1); - - let fields = &pd.input_descriptors[0].constraints.fields; - assert_eq!(fields.len(), 2); + let claims = query.credentials[0].claims.as_ref().unwrap(); + assert_eq!(claims.len(), 3); + assert_eq!(claims[0].path, vec!["first_name"]); + assert_eq!(claims[1].path, vec!["last_name"]); + assert_eq!(claims[2].path, vec!["date_of_birth"]); + } - assert_eq!(fields[0].path, vec!["$.vct"]); - assert!(fields[0].filter.is_some()); - let filter = fields[0].filter.as_ref().unwrap(); - assert_eq!(filter.filter_type, "string"); - assert_eq!(filter.const_value, Some("betaid-sdjwt".to_string())); + #[test] + fn test_build_dcql_query_extra_whitespace() { + let query = build_dcql_query("first_name last_name", "betaid-sdjwt", "dc+sd-jwt"); - assert_eq!(fields[1].path, vec!["$.age_over_18"]); - assert!(fields[1].filter.is_none()); + let claims = query.credentials[0].claims.as_ref().unwrap(); + assert_eq!(claims.len(), 2); + assert_eq!(claims[0].path, vec!["first_name"]); + assert_eq!(claims[1].path, vec!["last_name"]); } + /// An empty scope must not produce `"claims": []`, which the verifier + /// rejects; the member is left out instead. #[test] - fn test_build_presentation_definition_multiple_attributes() { - let scope = "first_name last_name date_of_birth"; - let pd = build_presentation_definition( - scope, - "betaid-sdjwt", - "vc+sd-jwt", - &["ES256".to_string()], - ); + fn test_build_dcql_query_empty_scope() { + let query = build_dcql_query("", "betaid-sdjwt", "dc+sd-jwt"); + + assert_eq!(query.credentials.len(), 1); + assert!(query.credentials[0].claims.is_none()); + let json = serde_json::to_value(&query).unwrap(); + assert!(json["credentials"][0].get("claims").is_none()); + } - let fields = &pd.input_descriptors[0].constraints.fields; - assert_eq!(fields.len(), 4); + #[test] + fn test_build_dcql_query_custom_vc_config() { + let query = build_dcql_query("age_over_18", "custom-type", "vc+sd-jwt"); - assert_eq!(fields[0].path, vec!["$.vct"]); - assert_eq!(fields[1].path, vec!["$.first_name"]); - assert_eq!(fields[2].path, vec!["$.last_name"]); - assert_eq!(fields[3].path, vec!["$.date_of_birth"]); + let credential = &query.credentials[0]; + assert_eq!(credential.format, "vc+sd-jwt"); + assert_eq!(credential.meta.vct_values, vec!["custom-type"]); } + /// The wire format the verifier expects, as in + /// `swiyu-verifier/api_requests/request_over_18_dcql.json`. #[test] - fn test_build_presentation_definition_extra_whitespace() { - let scope = "first_name last_name"; - let pd = build_presentation_definition( - scope, - "betaid-sdjwt", - "vc+sd-jwt", - &["ES256".to_string()], + fn test_build_dcql_query_serialises_to_expected_json() { + let query = build_dcql_query("age_over_18", "betaid-sdjwt", "dc+sd-jwt"); + + assert_eq!( + serde_json::to_value(&query).unwrap(), + serde_json::json!({ + "credentials": [{ + "id": "kych_verification", + "format": "dc+sd-jwt", + "meta": { "vct_values": ["betaid-sdjwt"] }, + "claims": [{ "path": ["age_over_18"] }], + "require_cryptographic_holder_binding": true + }] + }) ); + } + + #[test] + fn test_extract_disclosed_claims_from_dcql_response() { + let wallet_response = serde_json::json!({ + "dcql_response": { + "kych_verification": { + "age_over_18": [true], + "given_name": ["Max"] + } + } + }); - let fields = &pd.input_descriptors[0].constraints.fields; - assert_eq!(fields.len(), 3); - assert_eq!(fields[0].path, vec!["$.vct"]); - assert_eq!(fields[1].path, vec!["$.first_name"]); - assert_eq!(fields[2].path, vec!["$.last_name"]); + let claims = extract_disclosed_claims(&wallet_response).unwrap(); + assert_eq!(claims["age_over_18"], serde_json::json!(true)); + assert_eq!(claims["given_name"], serde_json::json!("Max")); } + /// A claim disclosed more than once keeps its array. #[test] - fn test_build_presentation_definition_empty_scope() { - let scope = ""; - let pd = build_presentation_definition( - scope, - "betaid-sdjwt", - "vc+sd-jwt", - &["ES256".to_string()], - ); + fn test_extract_disclosed_claims_keeps_multiple_values() { + let wallet_response = serde_json::json!({ + "dcql_response": { + "kych_verification": { "nationality": ["CH", "DE"] } + } + }); - let fields = &pd.input_descriptors[0].constraints.fields; - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].path, vec!["$.vct"]); + let claims = extract_disclosed_claims(&wallet_response).unwrap(); + assert_eq!(claims["nationality"], serde_json::json!(["CH", "DE"])); } + /// A response keyed by some other credential-query id is still read, as + /// long as it is the only one. #[test] - fn test_build_presentation_definition_no_top_level_format() { - let scope = "age_over_18"; - let pd = build_presentation_definition( - scope, - "betaid-sdjwt", - "vc+sd-jwt", - &["ES256".to_string()], - ); + fn test_extract_disclosed_claims_from_single_other_credential() { + let wallet_response = serde_json::json!({ + "dcql_response": { + "some_other_query": { "age_over_18": [true] } + } + }); - assert!(pd.format.is_none()); + let claims = extract_disclosed_claims(&wallet_response).unwrap(); + assert_eq!(claims["age_over_18"], serde_json::json!(true)); } #[test] - fn test_build_presentation_definition_input_descriptor_structure() { - let scope = "age_over_18"; - let pd = build_presentation_definition( - scope, - "betaid-sdjwt", - "vc+sd-jwt", - &["ES256".to_string()], - ); + fn test_extract_disclosed_claims_falls_back_to_credential_subject_data() { + let wallet_response = serde_json::json!({ + "credential_subject_data": { + "kych_verification": { "age_over_18": [true] } + } + }); + + let claims = extract_disclosed_claims(&wallet_response).unwrap(); + assert_eq!(claims["age_over_18"], serde_json::json!(true)); + } - let descriptor = &pd.input_descriptors[0]; + /// Older verifiers put the claims straight into `credential_subject_data`, + /// without a credential-query level. + #[test] + fn test_extract_disclosed_claims_flat_credential_subject_data() { + let wallet_response = serde_json::json!({ + "credential_subject_data": { + "age_over_18": true, + "given_name": "Max" + } + }); - assert!(!descriptor.id.is_empty()); + let claims = extract_disclosed_claims(&wallet_response).unwrap(); + assert_eq!(claims["age_over_18"], serde_json::json!(true)); + assert_eq!(claims["given_name"], serde_json::json!("Max")); + } - assert!(descriptor.name.is_none()); - assert!(descriptor.purpose.is_none()); + /// A failed verification carries an error instead of claims, and must not + /// be mistaken for a disclosure. + #[test] + fn test_extract_disclosed_claims_none_on_error_response() { + let wallet_response = serde_json::json!({ + "error_code": "authorization_request_object_not_found", + "error_description": "no such request" + }); - assert!(descriptor.format.is_some()); - let format = descriptor.format.as_ref().unwrap(); - assert!(format.contains_key("vc+sd-jwt")); - let alg = &format["vc+sd-jwt"]; - assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); - assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); + assert!(extract_disclosed_claims(&wallet_response).is_none()); } #[test] - fn test_build_presentation_definition_custom_vc_config() { - let scope = "age_over_18"; - let pd = build_presentation_definition( - scope, - "custom-type", - "custom-format", - &["ES384".to_string(), "ES512".to_string()], + fn test_extract_disclosed_claims_none_when_empty() { + assert!(extract_disclosed_claims(&serde_json::json!({})).is_none()); + assert!( + extract_disclosed_claims(&serde_json::json!({ "dcql_response": {} })).is_none() + ); + assert!( + extract_disclosed_claims( + &serde_json::json!({ "dcql_response": { "kych_verification": {} } }) + ) + .is_none() ); - - let filter = pd.input_descriptors[0].constraints.fields[0] - .filter - .as_ref() - .unwrap(); - assert_eq!(filter.const_value, Some("custom-type".to_string())); - - let format = pd.input_descriptors[0].format.as_ref().unwrap(); - assert!(format.contains_key("custom-format")); - let alg = &format["custom-format"]; - assert_eq!(alg.sd_jwt_alg_values, vec!["ES384", "ES512"]); - assert_eq!(alg.kb_jwt_alg_values, vec!["ES384", "ES512"]); } } diff --git a/kych_oauth2_gateway/src/models.rs b/kych_oauth2_gateway/src/models.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use std::collections::HashMap; use axum::{ response::{IntoResponse, Response}, http::{StatusCode, header}, @@ -131,11 +130,16 @@ impl ErrorResponse { /// Request body for creating a verification with Swiyu Verifier /// POST /management/api/verifications +/// +/// The verifier dropped DIF Presentation Exchange with its 3.0.0 release +/// ("Dropped support for SWIYU-API-Version 1"); `dcql_query` is the only way +/// to state what is to be presented and is a required field. Trust anchors +/// moved out of this request and into the verifier's own configuration +/// (`SWIYU_TRUST_ISSUER_DID`) in 5.0.0, and `response_type` is no longer part +/// of the schema, so neither is sent any more. #[derive(Debug, Serialize, Deserialize)] pub struct SwiyuCreateVerificationRequest { pub accepted_issuer_dids: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub trust_anchors: Option<Vec<TrustAnchor>>, /// If omitted, Swiyu defaults to true (beta requires true) #[serde(skip_serializing_if = "Option::is_none")] @@ -145,23 +149,10 @@ pub struct SwiyuCreateVerificationRequest { /// REQUIRED - will throw NullPointerException if omitted pub response_mode: ResponseMode, - /// Response type - must be "vp_token" for VP requests - pub response_type: String, - - pub presentation_definition: PresentationDefinition, + /// What the wallet is asked to present. REQUIRED by the verifier. + pub dcql_query: DcqlQuery, pub configuration_override: ConfigurationOverride, - - /// Optional - (wallet migration in progress) - #[serde(skip_serializing_if = "Option::is_none")] - pub dcql_query: Option<serde_json::Value>, -} - -/// Trust anchor for credential verification -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct TrustAnchor { - pub did: String, - pub trust_registry_uri: String, } /// Response mode type @@ -199,68 +190,51 @@ pub struct ConfigurationOverride { pub key_pin: Option<String>, } -/// Presentation Definition according to DIF Presentation Exchange +/// Identifier of the one credential query KyCH sends, and therefore the key +/// under which the verifier reports the disclosed claims back in +/// `wallet_response.dcql_response`. Must match `^[a-zA-Z0-9_-]+$`. +pub const DCQL_CREDENTIAL_ID: &str = "kych_verification"; + +/// A DCQL query as defined in OpenID4VP 1.0 section 6. #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct PresentationDefinition { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option<HashMap<String, FormatAlgorithm>>, - pub input_descriptors: Vec<InputDescriptor>, +pub struct DcqlQuery { + pub credentials: Vec<DcqlCredential>, } -/// Input descriptor describing required credential attributes +/// One credential query (OpenID4VP 1.0 section 6.1). #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct InputDescriptor { +pub struct DcqlCredential { + /// Identifies this credential in the response; see `DCQL_CREDENTIAL_ID`. pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option<HashMap<String, FormatAlgorithm>>, - pub constraints: Constraint, -} -/// Constraints on credential fields -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Constraint { - pub fields: Vec<Field>, -} + /// Credential format identifier; the verifier accepts `dc+sd-jwt` (the + /// canonical SD-JWT VC type) and still `vc+sd-jwt`. + pub format: String, -/// Field specification with JSONPath -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Field { - pub path: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option<String>, + pub meta: DcqlCredentialMeta, + + /// The claims to disclose. Omitted rather than sent empty, because the + /// verifier rejects an empty `claims` array. #[serde(skip_serializing_if = "Option::is_none")] - pub filter: Option<Filter>, + pub claims: Option<Vec<DcqlClaim>>, + + /// KyCH always requires holder binding: the credential must be presented + /// by the wallet that holds its key, not merely be known to it. + pub require_cryptographic_holder_binding: bool, } -/// Filter for field constraints +/// Metadata constraints of a credential query; for SD-JWT VCs the accepted +/// values of the `vct` claim. #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Filter { - #[serde(rename = "type")] - pub filter_type: String, - #[serde(rename = "const", skip_serializing_if = "Option::is_none")] - pub const_value: Option<String>, +pub struct DcqlCredentialMeta { + pub vct_values: Vec<String>, } -/// Format algorithm specification for SD-JWT +/// One claims query (OpenID4VP 1.0 section 6.3). `path` is a claims path +/// pointer, so a top-level claim is a single-element path. #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct FormatAlgorithm { - #[serde(rename = "sd-jwt_alg_values")] - pub sd_jwt_alg_values: Vec<String>, - #[serde(rename = "kb-jwt_alg_values")] - pub kb_jwt_alg_values: Vec<String>, +pub struct DcqlClaim { + pub path: Vec<String>, } /// Response from Swiyu Verifier Management API @@ -274,11 +248,14 @@ pub struct SwiyuManagementResponse { pub verification_url: String, #[serde(skip_serializing_if = "Option::is_none")] pub verification_deeplink: Option<String>, - pub presentation_definition: PresentationDefinition, #[serde(skip_serializing_if = "Option::is_none")] pub dcql_query: Option<serde_json::Value>, #[serde(skip_serializing_if = "Option::is_none")] pub wallet_response: Option<serde_json::Value>, + /// Per-credential trust and status results. Only present when the + /// verifier runs with `additional-audit-information.credential-evaluation-enabled`. + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_evaluation: Option<serde_json::Value>, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] diff --git a/kych_oauth2_gateway/tests/handlers_integration.rs b/kych_oauth2_gateway/tests/handlers_integration.rs @@ -9,13 +9,11 @@ use kych_oauth2_gateway_lib::{ config::Config, db::{authorization_codes, clients, sessions}, handlers, - models::{ - Constraint, Field, Filter, InputDescriptor, PresentationDefinition, SwiyuManagementResponse, - SwiyuVerificationStatus, TokenResponse, - }, + models::{SwiyuManagementResponse, SwiyuVerificationStatus, TokenResponse}, state::AppState, }; use mockito::Server; +use std::sync::{Arc, Mutex}; use taler_config::config::Config as TalerConfig; use serde_json::Value; use sqlx::{PgPool, postgres::PgPoolOptions}; @@ -55,7 +53,7 @@ TOKEN_BYTES = 32 AUTH_CODE_BYTES = 32 AUTH_CODE_TTL_MINUTES = 10 VC_TYPE = betaid-sdjwt -VC_FORMAT = vc+sd-jwt +VC_FORMAT = dc+sd-jwt VC_ALGORITHMS = {{ES256}} VC_CLAIMS = {{first_name, last_name, family_name, given_name, birth_date, age_over_18}} " @@ -150,31 +148,19 @@ async fn create_second_client(pool: &PgPool) -> Result<TestClient> { Ok(TestClient { client, secret }) } -fn sample_presentation_definition() -> PresentationDefinition { - PresentationDefinition { - id: "pd-1".to_string(), - name: None, - purpose: None, - format: None, - input_descriptors: vec![InputDescriptor { - id: "descriptor-1".to_string(), - name: None, - purpose: None, - format: None, - constraints: Constraint { - fields: vec![Field { - path: vec!["$.vct".to_string()], - id: None, - name: None, - purpose: None, - filter: Some(Filter { - filter_type: "string".to_string(), - const_value: Some("betaid-sdjwt".to_string()), - }), - }], - }, - }], - } +async fn get_session_credential(pool: &PgPool, session_id: Uuid) -> Result<Option<Value>> { + let credential = sqlx::query_scalar::<_, Option<Value>>( + r#" + SELECT verifiable_credential + FROM oauth2gw.verification_sessions + WHERE id = $1 + "#, + ) + .bind(session_id) + .fetch_one(pool) + .await?; + + Ok(credential) } async fn get_session_status(pool: &PgPool, session_id: Uuid) -> Result<sessions::SessionStatus> { @@ -325,7 +311,7 @@ async fn test_config_endpoint() -> Result<()> { assert!(json.get("version").and_then(|v| v.as_str()).is_some()); assert_eq!(json.get("status").and_then(|v| v.as_str()), Some("healthy")); assert_eq!(json.get("vc_type").and_then(|v| v.as_str()), Some("betaid-sdjwt")); - assert_eq!(json.get("vc_format").and_then(|v| v.as_str()), Some("vc+sd-jwt")); + assert_eq!(json.get("vc_format").and_then(|v| v.as_str()), Some("dc+sd-jwt")); assert!(json.get("vc_algorithms").and_then(|v| v.as_array()).is_some()); assert!(json.get("vc_claims").and_then(|v| v.as_array()).is_some()); @@ -946,14 +932,29 @@ async fn test_authorize_success() -> Result<()> { state: SwiyuVerificationStatus::Pending, verification_url: "https://verifier.example/verify/1".to_string(), verification_deeplink: Some("swiyu-verify://verify/1".to_string()), - presentation_definition: sample_presentation_definition(), dcql_query: None, wallet_response: None, + credential_evaluation: None, }; let response_json = serde_json::to_string(&response_body)?; + // The verifier has required a DCQL query since its 3.0.0 release and no + // longer accepts a presentation_definition, so keep a copy of what went + // over the wire and check it below. The matcher itself must not panic: + // mockito runs it on the server thread, where a failing assertion aborts + // the whole test binary instead of failing this test. + let sent_body: Arc<Mutex<Option<Value>>> = Arc::new(Mutex::new(None)); + let captured = Arc::clone(&sent_body); let _mock = server .mock("POST", "/management/api/verifications") + .match_request(move |request| { + let body = request + .body() + .ok() + .and_then(|raw| serde_json::from_slice::<Value>(raw).ok()); + *captured.lock().unwrap() = body; + true + }) .with_status(200) .with_header("content-type", "application/json") .with_body(response_json) @@ -983,6 +984,36 @@ async fn test_authorize_success() -> Result<()> { .unwrap_or(""); assert_eq!(verification_id_str, verification_id.to_string()); + let body = sent_body + .lock() + .unwrap() + .clone() + .expect("the gateway sent a JSON body to the verifier"); + assert!( + body.get("presentation_definition").is_none(), + "presentation_definition is no longer accepted by the verifier: {body}" + ); + assert!( + body.get("response_type").is_none(), + "response_type is not part of the verifier's schema any more: {body}" + ); + assert!( + body.get("trust_anchors").is_none(), + "trust anchors are configured on the verifier, not per request: {body}" + ); + assert_eq!( + body["dcql_query"], + serde_json::json!({ + "credentials": [{ + "id": "kych_verification", + "format": "dc+sd-jwt", + "meta": { "vct_values": ["betaid-sdjwt"] }, + "claims": [{ "path": ["first_name"] }], + "require_cryptographic_holder_binding": true + }] + }) + ); + let _ = clients::delete_client(&pool, test_client.client.id).await?; Ok(()) } @@ -1010,9 +1041,9 @@ async fn test_authorize_html_response() -> Result<()> { state: SwiyuVerificationStatus::Pending, verification_url: "https://verifier.example/verify/1".to_string(), verification_deeplink: Some("swiyu-verify://verify/1".to_string()), - presentation_definition: sample_presentation_definition(), dcql_query: None, wallet_response: None, + credential_evaluation: None, }; let response_json = serde_json::to_string(&response_body)?; @@ -1084,9 +1115,9 @@ async fn test_authorize_invalid_verification_url() -> Result<()> { state: SwiyuVerificationStatus::Pending, verification_url: "http://verifier.example/verify/1".to_string(), verification_deeplink: Some("swiyu-verify://verify/1".to_string()), - presentation_definition: sample_presentation_definition(), dcql_query: None, wallet_response: None, + credential_evaluation: None, }; let response_json = serde_json::to_string(&response_body)?; @@ -1140,9 +1171,9 @@ async fn test_authorize_invalid_verification_deeplink() -> Result<()> { state: SwiyuVerificationStatus::Pending, verification_url: "https://verifier.example/verify/1".to_string(), verification_deeplink: Some("ftp://bad.example".to_string()), - presentation_definition: sample_presentation_definition(), dcql_query: None, wallet_response: None, + credential_evaluation: None, }; let response_json = serde_json::to_string(&response_body)?; @@ -1616,9 +1647,16 @@ async fn test_notification_success() -> Result<()> { state: SwiyuVerificationStatus::Success, verification_url: "https://verifier.example/verify/1".to_string(), verification_deeplink: Some("swiyu-verify://verify/1".to_string()), - presentation_definition: sample_presentation_definition(), dcql_query: None, - wallet_response: Some(serde_json::json!({"vc": "data"})), + wallet_response: Some(serde_json::json!({ + "dcql_response": { + "kych_verification": { + "age_over_18": [true], + "given_name": ["Max"] + } + } + })), + credential_evaluation: None, }; let response_json = serde_json::to_string(&response_body)?; @@ -1651,6 +1689,16 @@ async fn test_notification_success() -> Result<()> { let status = get_session_status(&pool, session.id).await?; assert_eq!(status, sessions::SessionStatus::Verified); + // The claims arrive nested under dcql_response and wrapped in arrays; what + // is stored -- and later served by /info -- is the flat claim set. + let credential = get_session_credential(&pool, session.id) + .await? + .expect("credential should be stored"); + assert_eq!( + credential, + serde_json::json!({ "age_over_18": true, "given_name": "Max" }) + ); + let code = authorization_codes::get_code_by_session(&pool, session.id) .await? .expect("authorization code should exist"); @@ -1678,9 +1726,9 @@ async fn test_notification_pending_ignored() -> Result<()> { state: SwiyuVerificationStatus::Pending, verification_url: "https://verifier.example/verify/1".to_string(), verification_deeplink: Some("swiyu-verify://verify/1".to_string()), - presentation_definition: sample_presentation_definition(), dcql_query: None, wallet_response: None, + credential_evaluation: None, }; let response_json = serde_json::to_string(&response_body)?; @@ -1831,9 +1879,9 @@ async fn test_notification_failed_verification() -> Result<()> { state: SwiyuVerificationStatus::Failed, verification_url: "https://verifier.example/verify/1".to_string(), verification_deeplink: Some("swiyu-verify://verify/1".to_string()), - presentation_definition: sample_presentation_definition(), dcql_query: None, wallet_response: None, + credential_evaluation: None, }; let response_json = serde_json::to_string(&response_body)?; diff --git a/swiyu-verifier/README b/swiyu-verifier/README @@ -1,96 +1,129 @@ -SWIYU Verifier Local Setup -========================== +SWIYU Verifier +============== -Scripts and configuration for running the SWIYU Generic Verifier locally -for development. The verifier is a Java Spring Boot application that -implements OpenID4VP (OID4VP) credential verification. +The SWIYU Generic Verifier is the service that performs the actual OpenID4VP +credential verification: it publishes the endpoints the SWIYU wallet talks to, +checks the presented SD-JWT credential against the issuer's key in the Swiss +Base Registry and its revocation status in the Status Registry, and reports +the result back to KyCH. -The upstream verifier source code is maintained separately. This directory -provides convenience scripts for local development with KYCH. +It is a separate upstream, maintained by the Swiss Confederation: + + https://github.com/swiyu-admin-ch/swiyu-verifier + +This directory holds our checkout of it and the pieces around it. + + +Contents +-------- + + upstream/ git submodule, pinned to a release tag + run.sh run it from upstream/ for development + application-kych.yml.template development configuration to copy and fill in + client_metadata.json name and logo the wallet shows the user + install_db.sh create the development database + uninstall_db.sh drop it again + api_requests/ example management API calls + +For a deployment, use the swiyu-verifier Debian package instead of anything in +here; see ../debian/swiyu-verifier.README.Debian. The packaging builds the +jar from the same upstream/ submodule. + + +Getting the source +------------------ + +The submodule is not part of a plain clone: + + git submodule update --init + +It is pinned to a tag rather than tracking a branch. To move it, see +"Updating the verifier" in ../debian/README.source -- upstream's releases have +broken the interface with KyCH more than once. Prerequisites ------------- - - Java 21 - - Maven 3.x + - JDK 25. The verifier moved to Java 25 with its 5.0.0 release; an older + JDK fails the build with a release-version error. On Debian trixie: + apt install openjdk-25-jdk-headless maven - PostgreSQL - - SWIYU verifier source code (clone from upstream) + - For a wallet to take part: a DID and signing key from SWIYU onboarding, + and a public HTTPS URL (ngrok will do for development). -Database Setup --------------- +Development setup +----------------- - ./install_db.sh +1. Database. The verifier creates and migrates its own schema with Flyway on + every start, so only a role and an empty database are needed: -To remove: + ./install_db.sh - ./uninstall_db.sh + It needs a password, because the PostgreSQL JDBC driver cannot use peer + authentication over a Unix socket. +2. Configuration: -Configuration -------------- - -Copy the template and fill in your values: + cp application-kych.yml.template application-kych.yml - cp application-local-dockerless.yml.template application-local-dockerless.yml + and fill in external-url, client_id (your DID), + signing-key-verification-method and signing_key. run.sh passes this file + to Spring Boot as an additional configuration location, so the upstream + checkout stays untouched. -Key configuration options: +3. Run: - application: - external-url: "" # Public URL of the verifier - client_id: "" # Verifier DID - signing_key: "" # PEM-encoded signing key - client-metadata-file: "classpath:/client_metadata.json" + ./run.sh # run + ./run.sh clean # rebuild first + ./run.sh -d # with a debugger on port 5005 - spring: - datasource: - url: "jdbc:postgresql://localhost:5432/verifier_db" - username: "verifier_user" - password: "secret" + The service then answers on http://localhost:8080, with the API + documentation at /swagger-ui.html and health at /actuator/health. - webhook: - callback-uri: "" # KYCH gateway notification endpoint +4. For wallet testing, expose it over HTTPS -- the wallet accepts nothing else: + ngrok http 8080 -Running -------- + and put that URL in external-url. -Start the verifier: - ./run.sh +Talking to KyCH +--------------- -Build and run: +In the gateway's configuration, the client section points at this service: - ./run.sh clean + VERIFIER_URL = http://localhost:8080 + VERIFIER_MANAGEMENT_API_PATH = /management/api/verifications -Run with debugger (port 5005): +and the verifier's webhook.callback-uri points back at KyCH's /notification. - ./run.sh -d +KyCH needs a verifier of release 3.0.0 or newer: it sends a DCQL query, and +that release dropped the DIF Presentation Exchange format older ones used. -API Request Examples +API request examples -------------------- -The api_requests/ directory contains example scripts and payloads: - - post_management_api_verifications.sh - Create a new verification request. + post_management_api_verifications.sh <file.json> + Create a verification from a JSON body and render the resulting + verification_url as a QR code (needs jq and qrencode). - get_management_api_verifications.sh - Retrieve verification status. + get_management_api_verifications.sh <verificationId> + Fetch a verification and its result. request_over_18.json - Example verification request using Presentation Exchange. + A DCQL query for age_over_18, in the shape KyCH itself sends -- same + credential query id, same dc+sd-jwt format. Note that the + accepted_issuer_dids in it is a sandbox issuer and will have to be + replaced. -Files ------ +Two upstream interfaces to know about +------------------------------------- - run.sh Start the verifier - install_db.sh Create PostgreSQL database - uninstall_db.sh Drop PostgreSQL database - application-local-dockerless.yml.template - Configuration template - client_metadata.json OpenID4VP client metadata - api_requests/ Example API requests + /management/api/ internal: creates verifications and returns results. + Must not be reachable from outside the organisation. + /oid4vp/api/ public: what the wallet fetches and posts to. Needs + HTTPS with a publicly trusted certificate. diff --git a/swiyu-verifier/api_requests/get_management_api_verifications.sh b/swiyu-verifier/api_requests/get_management_api_verifications.sh @@ -11,6 +11,8 @@ if [ $# -eq 0 ]; then exit 1 fi -curl -X GET "http://localhost:8080/management/api/verifications/$1" \ +VERIFIER=${VERIFIER:-http://localhost:8080} + +curl -s -X GET "$VERIFIER/management/api/verifications/$1" \ -H "Accept: application/json" | jq diff --git a/swiyu-verifier/api_requests/post_management_api_verifications.sh b/swiyu-verifier/api_requests/post_management_api_verifications.sh @@ -3,7 +3,9 @@ # # requires: # - jq -# - qrencode. +# - qrencode +# +# Set VERIFIER to reach a verifier elsewhere than http://localhost:8080. if [ $# -eq 0 ]; then echo "Error: No JSON request file provided" echo "Usage: $0 <json_file>" @@ -25,12 +27,20 @@ base_name="${input_file%.json}" response_file="${base_name}_response.json" qr_code_file="${base_name}_qr_code.png" -curl -v -X POST http://localhost:8080/management/api/verifications \ +VERIFIER=${VERIFIER:-http://localhost:8080} + +curl -s -X POST "$VERIFIER/management/api/verifications" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d @"$input_file" \ | tee "$response_file" | jq -r '.verification_url' | tee /dev/tty | xargs qrencode -o "$qr_code_file" -# open .png with default image viewer app -open "$qr_code_file" +# Show the QR code with whatever this system uses for images. +if command -v xdg-open >/dev/null 2>&1; then + xdg-open "$qr_code_file" +elif command -v open >/dev/null 2>&1; then + open "$qr_code_file" +else + echo "QR code written to $qr_code_file" +fi diff --git a/swiyu-verifier/api_requests/request_over_18.json b/swiyu-verifier/api_requests/request_over_18.json @@ -1,45 +1,27 @@ { "accepted_issuer_dids": [ - "did:tdw:QmPEZPhDFR4nEYSFK5bMnvECqdpf1tPTPJuWs9QrMjCumw:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:9a5559f0-b81c-4368-a170-e7b4ae424527" + "did:tdw:QmPEZPhDFR4nEYSFK5bMnvECqdpf1tPTPJuWs9QrMjCumw:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:9a5559f0-b81c-4368-a170-e7b4ae424527" ], "jwt_secured_authorization_request": true, "response_mode": "direct_post", - "response_type": "vp_token", - "presentation_definition": { - "id": "00000000-0000-0000-0000-000000000000", - "name": "Over 18 Verification", - "purpose": "Verify age is over 18", - "input_descriptors": [ + "dcql_query": { + "credentials": [ { - "id": "11111111-1111-1111-1111-111111111111", - "format": { - "vc+sd-jwt": { - "sd-jwt_alg_values": [ - "ES256" - ], - "kb-jwt_alg_values": [ - "ES256" + "id": "kych_verification", + "format": "dc+sd-jwt", + "meta": { + "vct_values": [ + "betaid-sdjwt" + ] + }, + "claims": [ + { + "path": [ + "age_over_18" ] } - }, - "constraints": { - "fields": [ - { - "path": [ - "$.vct" - ], - "filter": { - "type": "string", - "const": "betaid-sdjwt" - } - }, - { - "path": [ - "$.age_over_18" - ] - } - ] - } + ], + "require_cryptographic_holder_binding": true } ] }, diff --git a/swiyu-verifier/api_requests/request_over_18_dcql.json b/swiyu-verifier/api_requests/request_over_18_dcql.json @@ -1,65 +0,0 @@ -{ - "jwt_secured_authorization_request": true, - "response_mode": "direct_post", - "response_type": "vp_token", - "presentation_definition": { - "id": "00000000-0000-0000-0000-000000000000", - "name": "Over 18 Verification", - "purpose": "Verify age is over 18", - "input_descriptors": [ - { - "id": "11111111-1111-1111-1111-111111111111", - "format": { - "vc+sd-jwt": { - "sd-jwt_alg_values": [ - "ES256" - ], - "kb-jwt_alg_values": [ - "ES256" - ] - } - }, - "constraints": { - "fields": [ - { - "path": [ - "$.vct" - ], - "filter": { - "type": "string", - "const": "betaid-sdjwt" - } - }, - { - "path": [ - "$.age_over_18" - ] - } - ] - } - } - ] - }, - "dcql_query": { - "credentials": [ - { - "id": "age_verification_credential", - "format": "dc+sd-jwt", - "meta": { - "vct_values": [ - "betaid-sdjwt" - ] - }, - "claims": [ - { - "path": [ - "age_over_18" - ] - } - ], - "require_cryptographic_holder_binding": true - } - ] - }, - "configuration_override": {} -} diff --git a/swiyu-verifier/application-kych.yml.template b/swiyu-verifier/application-kych.yml.template @@ -0,0 +1,76 @@ +# Development configuration for the SWIYU Generic Verifier, used by ./run.sh. +# +# Copy to application-kych.yml and fill in the three values from your SWIYU +# onboarding. ./run.sh passes this file to Spring Boot as an additional +# configuration location, so it overrides the application.yml inside the +# upstream tree without that tree having to be touched. +# +# This is the development path. A deployment uses the swiyu-verifier Debian +# package, whose configuration lives in /etc/swiyu-verifier; see +# ../debian/swiyu-verifier.README.Debian. + +application: + # The URL the *wallet* will fetch the request object from. The wallet + # accepts HTTPS with a publicly trusted certificate only, so for local work + # put a tunnel in front: ngrok http 8080 and paste its URL here. + external-url: "" + + # Your verifier's DID, and the verification method in its DID document + # whose key signs the authorization requests. + client_id: "" + signing-key-verification-method: "" + + # PEM-encoded EC private key matching that verification method, as a single + # line with literal \n. A development key can be made with + # openssl ecparam -name prime256v1 -genkey -noout + signing_key: "" + + # Note: client_id_scheme is gone since the verifier's 4.0.0 release; the + # prefix is now client_id_prefix and defaults to decentralized_identifier. + + # Shown to the user by the wallet when it asks for consent. "${client-id}" + # inside the file is replaced with the DID above. + client-metadata-file: "file:./client_metadata.json" + +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: "jdbc:postgresql://localhost:5432/verifier_db" + username: "verifier_user" + password: "secret" + + jpa: + hibernate: + # Flyway owns the schema; "validate" only checks it. Never "create" + # here -- that drops every verification on each start, which is what the + # template this file replaced used to do. + ddl-auto: validate + + flyway: + enabled: true + + docker: + compose: + enabled: false + + mvc: + log-resolved-exception: false + +logging: + level: + ch.admin.bj.swiyu: DEBUG + +springdoc: + show-actuator: true + +webhook: + # KyCH's /notification endpoint. Running KyCH on a Unix socket, as its own + # development configuration does, means putting a reverse proxy in front of + # it or switching it to SERVE = tcp for this to be reachable. + callback-uri: "" + callback-interval: 5000 + +# The claims KyCH needs are in credential_subject_data / dcql_response, which +# this flag controls; it is on by default and listed here as a reminder that +# turning it off breaks KyCH. +application.additional-audit-information.credential-subject-data-enabled: true diff --git a/swiyu-verifier/application-local-dockerless.yml.template b/swiyu-verifier/application-local-dockerless.yml.template @@ -1,42 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Swiss Confederation -# -# SPDX-License-Identifier: MIT - -application: - external-url: "" - client_id: "" - client_id_scheme: "did" - signing_key: - signing-key-verification-method: "" - client-metadata-file: "classpath:/client_metadata.json" - - -spring: - docker: - compose: - enabled: false - file: compose.yaml - datasource: - driver-class-name: org.postgresql.Driver - url: "jdbc:postgresql://localhost:5432/verifier_db" - username: "verifier_user" - password: "secret" - - mvc: - log-resolved-exception: false # see https://stackoverflow.com/a/77147791 - jpa: - hibernate: - ddl-auto: create # when starting locally we connect to the db from compose.yml and generate the schema from hibernate - -logging: - level: - ch.admin.bj.swiyu: DEBUG - -springdoc: - show-actuator: true - -webhook: - callback-uri: "" - #api-key-header: "${WEBHOOK_API_KEY_HEADER:}" - #api-key-value: "${WEBHOOK_API_KEY_VALUE:}" - callback-interval: 5000 diff --git a/swiyu-verifier/client_metadata.json b/swiyu-verifier/client_metadata.json @@ -1,17 +1,20 @@ { - "client_id": "${VERIFIER_DID}", + "client_id": "${client-id}", "client_name#en": "KyCH Demo Verifier", - "client_name#fr": "KyCH Vérificateur de démonstration", + "client_name#fr": "KyCH V\u00e9rificateur de d\u00e9monstration", "client_name#de-DE": "KyCH Demo-Verifizierer", "client_name#de-CH": "KyCH Demo-Verifizierer", "client_name#de": "KyCH Demo-Verifizierer", "client_name": "KyCH Demo Verifier", "logo_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAADICAYAAABBLcuIAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAtKADAAQAAAABAAAAyAAAAABq95u3AAAM/0lEQVR4Ae2dXYhdVxXHzySSTL5tDAElxEw+hFbFh1BU8pA+1ILR+qLSKtKHQvtQ0Jf2QRBFig+CCAVBsFUQQcTqk2j9RCmiqJCE1qohH51OQoOmCTGZTJypTcb1T2eZO3fu3HvOuWet/fXfsNn3nnvO3nv99i97zt3n3JOqYiIBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABOwITdlWz5iUCk1I+svT6GSnnl16zIIGkCEDkz0p+VfLiUsZrbMNnTCSQBIFBIqvQWlLsJIay7E7WEVmF1pJil+1MlNG3EVmF1pJiRzm0ZXWqC5FVaC0pdlkORRGthcgqtJYUO4qhzrsTHiKr0FpS7LydChJdCJFVaC0pdpChz6vRGERWobWk2Hk55hJNjCKr0FpSbBcV0m4kBZFVaC0pdtrOmfQ+RZFVaC0ptokaaVWag8gqtJYUOy0HO+ltjiKr0FpS7E5UibuSEkRWobWk2HE72ap3JYqsQmtJsVupE9dBFPn2fdgUOy43G/WGIq8UWYXWkjN2I6XC7EyRR4usQmtJscO4OrRVitxcZBVaS4o9VDGfDyny+CKr0FpSbB93l7VCkbsXWYXWkmIvU87mDUW2F1mF1pJiG7hMkf1FVqG1pNgdiE2Rw4usQmtJsVuITZHjE1mF1pJi1xCbIscvsgqtJcUeIDZFTk9kFVpLii1iU+T0RVahtSxSbIqcn8gqtJZFif0lmZ01cJZ5s8BYu6Y1rq292ViINgOEySaFgPtYuzfIYSYBSwIU2pIu63YnQKHdkbNBSwIU2pIu63YnQKHdkbNBSwIU2pIu63YnQKHdkbNBSwIU2pIu63YnQKHdkbNBSwIU2pIu63YnQKHdkbNBSwIU2pIu63YnQKHdkbNBSwIU2pIu63Yn8Bb3FjNpcHERt3LbpYmJCbvKM66ZM3TGg1tiaBS6xFHPOGYKnfHglhgahS5x1DOOmUJnPLglhkahSxz1jGOm0BkPbomhUegSRz3jmCl0xoNbYmgUusRRzzhmCp3x4JYYGoUucdQzjplCZzy4JYZGoUsc9YxjptAZD26JoVHoEkc945gpdMaDW2JoFLrEUc84Zgqd8eCWGBqFLnHUM46ZQmc8uCWGRqFLHPWMY876MQbWjxqw9MKy7zk/IoEztKWVrNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudAIV2R84GLQlQaEu6rNudQAihb7hHyQZDEXAf6xBC/zcUXbbrTsB9rEM8l8MtSMvnT1g+NwPaWfbdUWu3sdaYQszQb2jjLLMnUITQ17IfRgaoBOb0hVcZYoa+4hUc2wlOwH2sQwh9NThmdsCLgPtYhxD631402U5wAu5jHULofwXHzA54EXAf6wmvyHraWS+v53veJ/mSy3a1hm1S9lqotWdHO4VYh0aA+FP01o5iYDVxEsAY35L54MGD77958+YhdHPNmjV/OHr06J+tuhxihkYsf5X8HqugPOrlDD2S8kv79+8/tG3bth/Inkf69n7uypUrnzp9+nTnXxpDnEMjtpf7AuTbzAjIlc7prVu3/kTC6pcZkR5ZEr3zqCl050hZIQjI7DwlUh8eQuMITkWGfN7qIwrdChsPGkZg586dlczOI08p9bx6WF1NPwsl9D+adpT7p0FARK527doVrLOhhH4pWMRs2IzA5ORktXfv3tp3CmLFo+vOhBL6nxLIpa6DYX3hCKxduxbnzRXKmuk5i+W7UEIj5hdrBs7dIieAe7f37dtXrV+Pa2a10stzc3MP1dqz4U4hhf5Lw75y90gJ7N69u9qyZUvd3l2VL4P3nzhxwuQvNIWuOwzcbyABrGjs2LFj4GcDNt4QmR88fvz43wd81smmkEKbXf7shAwrGUmg6YqGXF19QmT++ciKx9ghpNCvSr9fGaPvPDQggaYrGtLVbx87duwp6y6HFBqx/c46QNbfPYGmKxoyMz8vvXis+56srJFCr2TCLUMItFnRuH79+sdlic7lB7Mhbh/txfXb3jcpvcbAxpI2bdpUbd68+VZ3rl27VsmSmFnXmq5oyOz8UasVjUFBhhYa59EvSH7foM5x23AC+NM/NTVVyZ1ry3aUWzOr6enp6saNbh9c1HRFQ2R+QM6bXW9zCH3KgYH42bLR4JvaBAbJjIMh+IEDB5pctRvZZpsVDZH5FyMr7niHGIT+accxFVEdTjP6Z+bewPF5V1LHuqLRG6++jkHoP0lnzmuHWNYjoOfMw/buQuqYVzQGxR6D0IvSsR8P6hy3jU9gHKljX9EYRCcGodGvZwd1jttWJ4DVjLqprdRNVjTkC+Cc5T0adWONReg/Sodn6naa+1W3luawmlE3NZW6yYqGyIz0Sct7NOrGWfvm1boVjrEfHmtwzxjHF3cohMZdbuvWrasVO/bD/pcvX65EwFWPwYrGnj17at+of+nSpV/KWvOXV63Q8YOYhMYM/TnJ8VyxcByINk1BSsjZpdRY0cDqiPyapFaXLl68uDgzM3Of7IzncARP9Xrt081paSbZK4c+iFa2gosnp06danR1cLXTj6YrGrOzs9XZs2dxP84rK3sWZktMMzQIzEp+IAyKdFvtYqbGigZ+QrVx48ZaIBYWFqqTJ09W8kXwcTngRK2DHHaK7c87/oHhITS7HWLPrgnMsDhdwAxcN+G+D8zw+KV23Rv18VdBzpmr+fn5s9LOXsndXmOv2/kB+8U2Q+ObCh7wd++AvnLTCAJtZ+rt27fX/gkV2jhz5oye4nxVuvT7Ed1y/Ti2GRrB3yEZXxBr/0gNBzHdJtBmpr599PBX586dqy5cuICdcHr4TsmX8SaWFNOXQmUCQE/rG5bNCbT5olinFVnRUJmx+7ckRyUzOhXjDI1+vUMyVj3qLbDiCKYVBLqcqbGigXNtnHJIel3ylOTzeBNTiu0cWtngz9nbJd+tG1g2J9DmnHpQKz0rGvoxZucf6puYylhnaDDCLH1GMr4kMo1BYJyZumdFQ3uA/31hn+ToZmd0MNYZGn3DLP02yR/EG6b2BNrO1H0rGtqBb8iLaO+OjHmGBkA8weS05OW/McInTI0JNJ2pe1Y0tC3cDbVf8kXdEFsZ8wwNVtclY9H+Q3jDNB6BJjM1VjTOn19xVvFF6cFvxuuF7dGxz9CIHk8AxA8t8a2aqQMCo2bqvhUNbRGrTndKXtANMZaxz9Bghhl6RvKDeMM0PgGdqTds2FDh7rrehFtScSUQ+/Slh+X93/q2Rfc2hRlaoeHHtB/RNyy7IYD7PvTeD9zXscozPcD+/m5atK0lJaFxyoEZYoMtEtbeR+A/8v7dknHKEX1K4ZRDIeIGcjxOil8QlYhP+QVphs9OMWKNf4B4DC9O8JjtGYB1SpOedDe9dJd0GVerKLQtAzAG66RSiv/6XhPCgH1fUqTT6+znpcv4n2CZHAjgy+yvJXOWtmEAtiktGDgoZ98Ebl7CJVhK3S0DMAVbpgAEPixt3pRMqbthAJZgmmxK8Ry6FzZuXEIMh3s38nVrAl+RI59pfTQP7IQAfkb2K8mcpcdjAIYx/iSvE0lSq2S7dPiUZErdjgHYgSFTRATulL7gfl1K3YwBmIEdU4QE8IXmDcmUuh4DsEr6S2CEDnbepUcodO1/0GDFlACBJ6WPnKWHMwAjpoQIfFP6SqkHMwAbpsQI4NIt1lQp9XIGYMLL2onJrN3Fuup3JVPqNxmABdeaBULKCQP4fcmlSw0GlDllk3v6jsvjz0ouVWrEnvptDj3DyZcggP/XvMSZGjEjdqYMCeDL0NcllzJTI1Z+AcxQ5P6QHpcNOd92itgQI1NBBD4tsb4uObfZGjEhNqYCCdwrMV+VnIvUiAUxMRVM4L0SO34okLrUiAGxMJFAhf+WGQ9TSVVq9B0xMJHA/wngosOTklP6soi+os+8YCIQmAYT+JhsxmPHYp+t0Uf0lYkERhJ4l+zxguRYpUbf0EcmEqhNAA9a/5pkPJ86FrHRF/QJfWMigVYE7pGjZiSHlhp9OCyZiQTGJoD/tOh7kkNJjbb5HyeNPYysoJ/AJ2SD5+PH0BbaZCIBMwI7pObvSLZc3kPdaANtMZGAC4FD0sqLkrs+DUGdqJuJBNwJ4D5j3NU2K3lcsVEH6uK9ywKBKSyBXdL8jyS3lRrHog4mEoiKwAekN89Lris29sUxTCQQNYEj0rthVxrxGfZhIoFkCOCGoc9InpasMzZeYxtvJhIIFmnColLWuYzAOnn36NKWp6XEL0qYSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAEEiTwP+heh6/XL4R4AAAAAElFTkSuQmCC", - "vp_formats": { - "jwt_vp": { - "alg": [ + "vp_formats_supported": { + "dc+sd-jwt": { + "sd-jwt_alg_values": [ + "ES256" + ], + "kb-jwt_alg_values": [ "ES256" ] } } -} -\ No newline at end of file +} diff --git a/swiyu-verifier/install_db.sh b/swiyu-verifier/install_db.sh @@ -1,10 +1,26 @@ #!/bin/bash +# Create the development database for the SWIYU verifier. +# +# The schema itself is not created here: the verifier runs its own Flyway +# migrations on every start. Only the role and the database are needed, and +# the role needs a password because the PostgreSQL JDBC driver cannot use peer +# authentication over a Unix socket. +# +# The values match application-kych.yml.template; override them in the +# environment if your PostgreSQL is elsewhere. +set -eu -DB_PORT=5455 -DB_NAME=verifier_db -DB_USER=verifier_user -DB_PASS=secret +DB_HOST=${DB_HOST:-localhost} +DB_PORT=${DB_PORT:-5432} +DB_NAME=${DB_NAME:-verifier_db} +DB_USER=${DB_USER:-verifier_user} +DB_PASS=${DB_PASS:-secret} + +export PGHOST="$DB_HOST" +export PGPORT="$DB_PORT" psql -d postgres -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';" psql -d postgres -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" -psql -d $DB_NAME -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" + +echo "Created database '$DB_NAME' owned by '$DB_USER' on $DB_HOST:$DB_PORT." +echo "The verifier creates its schema on first start." diff --git a/swiyu-verifier/run.sh b/swiyu-verifier/run.sh @@ -1,15 +1,87 @@ #!/bin/bash +# Run the SWIYU Generic Verifier from the pinned upstream checkout in +# upstream/, for development alongside KyCH. +# +# This is the development path; for a deployment use the swiyu-verifier +# Debian package instead (see ../debian/swiyu-verifier.README.Debian). +# +# Requirements: +# - JDK 25. The verifier moved to Java 25 with its 5.0.0 release, and an +# older JDK fails the build with a release-version error. On Debian +# trixie: apt install openjdk-25-jdk-headless maven +# - PostgreSQL, with the database from ./install_db.sh +# - the submodule: git submodule update --init +# +# Configuration comes from application-kych.yml in this directory, which is +# passed as an additional config location rather than copied into the +# upstream tree -- keeping the checkout clean and the configuration in version +# control here. Copy the template first: +# +# cp application-kych.yml.template application-kych.yml +# +set -eu -if [ "$1" == "clean" ]; then - echo "Building the project" - ./mvnw clean install -DskipTests +here=$(cd "$(dirname "$0")" && pwd) +upstream="$here/upstream" +config="$here/application-kych.yml" + +if [ ! -f "$upstream/pom.xml" ]; then + echo "upstream/ is empty; run: git submodule update --init" 1>&2 + exit 1 +fi + +if [ ! -f "$config" ]; then + echo "$config is missing; copy application-kych.yml.template to it" 1>&2 + echo "and fill in your DID, signing key and webhook URL." 1>&2 + exit 1 fi -if [ "$1" == "-d" ]; then - echo "Running in debug mode. Attach debugger to port 5005" - ./mvnw spring-boot:run -pl verifier-application -Dspring-boot.run.profiles=local-dockerless -Dspring-boot.run.fork=true -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" - exit 0 +# Prefer a JDK 25 over whatever "java" happens to be, as the package's +# launcher does. +if [ -z "${JAVA_HOME:-}" ]; then + for home in /usr/lib/jvm/java-25-openjdk-*; do + if [ -x "$home/bin/javac" ]; then + JAVA_HOME="$home" + export JAVA_HOME + break + fi + done fi -echo "Running the application" -./mvnw spring-boot:run -pl verifier-application -Dspring-boot.run.profiles=local-dockerless +cd "$upstream" + +# -Dpgpverify.skip=true: upstream verifies the PGP signature of every +# dependency against public keyservers in the "validate" phase, which is slow +# and needs network beyond Maven Central; it is upstream's own documented +# escape hatch. +MVN_FLAGS="-DskipTests -Dpgpverify.skip=true" + +case "${1:-}" in +clean) + echo "Building the project" + # shellcheck disable=SC2086 + ./mvnw clean install $MVN_FLAGS + shift + ;; +esac + +# No Spring profile is activated: the base application.yml in the jar plus our +# own file is all that is wanted. Upstream's "local" profile (the +# "local-dockerless" one that older versions of this script used no longer +# exists) would bring a docker-compose datasource and a dev signing key with +# it. Our file overrides spring.datasource.*, so the ${POSTGRES_JDBC} +# placeholders in the base configuration are never evaluated and need no +# environment. +case "${1:-}" in +-d) + echo "Running in debug mode. Attach debugger to port 5005" + ./mvnw spring-boot:run -pl verifier-application \ + -Dspring-boot.run.fork=true \ + -Dspring-boot.run.jvmArguments="-Dspring.config.additional-location=file:$config -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" + ;; +*) + echo "Running the application" + ./mvnw spring-boot:run -pl verifier-application \ + -Dspring-boot.run.jvmArguments="-Dspring.config.additional-location=file:$config" + ;; +esac diff --git a/swiyu-verifier/uninstall_db.sh b/swiyu-verifier/uninstall_db.sh @@ -1,6 +1,14 @@ #!/bin/bash -DB_NAME=verifier_db -DB_USER=verifier_user +# Drop the development database created by install_db.sh. +set -eu -dropdb $DB_NAME -dropuser $DB_USER +DB_HOST=${DB_HOST:-localhost} +DB_PORT=${DB_PORT:-5432} +DB_NAME=${DB_NAME:-verifier_db} +DB_USER=${DB_USER:-verifier_user} + +export PGHOST="$DB_HOST" +export PGPORT="$DB_PORT" + +dropdb --if-exists "$DB_NAME" +dropuser --if-exists "$DB_USER" diff --git a/swiyu-verifier/upstream b/swiyu-verifier/upstream @@ -0,0 +1 @@ +Subproject commit d619c928f6dd91c2625478a6b5fbabe9da976c5b