ReFineID

FINEID client-certificate authentication

Fine identity? Login Finnished!

A Finnish ID card-authenticated guestbook and its implementation guide.

A / Identity data

Finnish Electronic Identity

A Finnish ID card authentication certificate identifies its holder with a Personal Electronic Unique Identification Number (PEUIN), called sähköinen asiointitunnus (SATU) in Finnish. During login, PIN1 authorizes the card to sign the TLS handshake with its non-exportable authentication key. Apache validates the certificate and its revocation status before passing the verified name and identifier to the guestbook.

B / Authentication flow

TLS client-certificate authentication

  1. Server requests a certificate

    The TLS server sends CertificateRequest naming the accepted citizen certificate authorities of the Finnish Digital and Population Data Services Agency (DPDSA).

  2. User authorizes the card

    ReFineID or the operating system selects the authentication certificate and asks the card holder for PIN1.

  3. Card proves private-key possession

    The chip signs CertificateVerify with its non-exportable authentication private key.

  4. Application receives verified identity

    After TLS verification, the application reads the PEUIN from the verified certificate.

TLS client-certificate authentication sequence The server requests a client certificate, ReFineID asks for PIN1, the card signs the TLS transcript, and the server verifies the certificate and signature before establishing authenticated HTTPS. Browser / program TLS client ReFineID + Finnish ID Card PKCS #11 + FINEID TLS server mod_ssl ClientHello CertificateRequest Choose authentication certificate PIN1 prompt PIN1 Card signs TLS transcript Certificate, CertificateVerify, Finished Verify chain + signature Authenticated HTTPS

C / Server boundary

From verified certificate to application identity

SSLCACertificateFile identifies the trusted client-certificate authorities. A certificate outside that bundle is rejected during TLS and never reaches the CGI as a verified identity. mod_ssl also verifies revocation status and client-authentication purpose before the guestbook accepts the certificate name and PEUIN.

D / Deployment

Complete server deployment procedure

1. Install the operating-system tools

Run these installation steps as root. On Debian or Ubuntu, install Apache, Python, OpenSSL, and curl, then enable the modules used by the configuration below.

Shell
apt install apache2 apache2-suexec-custom bind9 bind9-utils curl dehydrated openssl python3 python3-cryptography
a2enmod cgid headers http2 ssl suexec
install -m u=rw,go=r suexec-www-data /etc/apache2/suexec/www-data
/etc/apache2/suexec/www-data
/home/guestbook
cgi-bin

The Debian suEXEC policy limits this virtual host to the guestbook home directory.

On Fedora, use httpd, curl, openssl, and python3. Adjust the Apache service, group, CGI directory, and log directory names, but keep the authentication directives unchanged.

2. Application and private state

All guestbook files, including its logs, live under /home/guestbook. suEXEC requires the CGI directory and program to belong to guestbook. The key, trust material, and logs remain root-owned.

guestbook.py
"""FINEID client-certificate guestbook CGI.

Apache authenticates the card certificate before invoking this program.
The application accepts identity only from mod_ssl's CGI environment; an
HTTP request header with a similar name becomes HTTP_* and is ignored.
"""

from __future__ import annotations

import hashlib
import hmac
import html
import os
import re
import sqlite3
import sys
import time
import urllib.parse
from contextlib import closing
from dataclasses import dataclass
from pathlib import Path

from cryptography import x509
from cryptography.x509.oid import NameOID


MAX_MESSAGE_CHARS = 280
MAX_FORM_BYTES = 4096
DISPLAY_LIMIT = 50
DELETION_QUORUM = 2
CSRF_LIFETIME_SECONDS = 10 * 60
DB_PATH = Path("/home/guestbook/data/guestbook.sqlite3")
KEY_PATH = Path("/home/guestbook/guestbook.key")
PAGE_STYLE = """
<style>
body {
  margin: 0;
  background: #f3f5f7;
  color: #101820;
  font: 18px/1.5 system-ui, sans-serif;
}
main {
  max-width: 96rem;
  margin: 0 auto;
  padding: 3rem 1.25rem;
}
a {
  color: #002f6c;
}
h1, h2 {
  font-family: serif;
}
h1 {
  font-size: clamp(3rem, 7vw, 6rem);
  line-height: 1.05;
}
.reason, .help, .notice {
  border-left: .25rem solid #002f6c;
  padding-left: 1rem;
}
.compose {
  margin: 3rem 0;
}
label {
  display: block;
}
textarea {
  box-sizing: border-box;
  display: block;
  width: 100%;
  min-height: 7rem;
  border: 1px solid #b8c0c8;
  padding: 1rem;
  font: inherit;
}
button, .button {
  display: inline-block;
  border: 0;
  background: #002f6c;
  color: #fff;
  padding: .75rem 1.25rem;
  font: inherit;
  font-weight: 700;
  text-decoration: none;
}
.entry {
  border-top: 1px solid #b8c0c8;
  padding: 1.25rem 0;
}
.entry p {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.entry footer {
  font-size: .9rem;
}
</style>
"""


@dataclass(frozen=True)
class Entry:
    """One public guestbook entry."""

    entry_id: int
    given_names: str
    surname: str
    message: str
    created_at: int
    deletion_votes: int
    viewer_voted: bool


class RequestError(Exception):
    """Expected request rejection with an HTTP status."""

    def __init__(self, status: str, message: str, *, card_help: bool = False) -> None:
        super().__init__(message)
        self.status = status
        self.message = message
        self.card_help = card_help


def extract_peuin_from_environment(environ: dict[str, str]) -> str:
    """Return serialNumber from the certificate verified by mod_ssl."""

    structured = environ.get("SSL_CLIENT_S_DN_serialNumber", "")
    if structured:
        return structured.upper()

    pem = environ.get("SSL_CLIENT_CERT", "")
    try:
        certificate = x509.load_pem_x509_certificate(pem.encode("ascii"))
        attributes = certificate.subject.get_attributes_for_oid(NameOID.SERIAL_NUMBER)
    except (UnicodeError, ValueError):
        attributes = []
    if len(attributes) != 1:
        raise RequestError(
            "403 Forbidden",
            "The authentication certificate has no PEUIN.",
            card_help=True,
        )
    return attributes[0].value.upper()


def verified_peuin(environ: dict[str, str]) -> str:
    """Return PEUIN only when mod_ssl reports a verified client chain."""

    if environ.get("SSL_CLIENT_VERIFY") != "SUCCESS":
        raise RequestError(
            "403 Forbidden",
            "Card login did not complete. Choose the authentication certificate and try again.",
            card_help=True,
        )
    return extract_peuin_from_environment(environ)


def emrtd_name(name: str) -> str:
    """Render uppercase Finnish certificate spelling as a human name."""

    native = name.upper()
    replacements = (
        ("AA", "\u00c5"),
        ("AE", "\u00c4"),
        ("OE", "\u00d6"),
    )
    for encoded, character in replacements:
        native = native.replace(encoded, character)
    return re.sub(
        r"[^\W\d_]+",
        lambda match: match.group(0).capitalize(),
        native,
    )


def certificate_names(environ: dict[str, str]) -> tuple[str, str]:
    """Return the structured certificate name fields."""

    given_names = " ".join(environ.get("SSL_CLIENT_S_DN_G", "").split())
    surname = " ".join(environ.get("SSL_CLIENT_S_DN_S", "").split())
    if not given_names or not surname:
        raise RequestError(
            "403 Forbidden",
            "The authentication certificate has no usable holder name.",
            card_help=True,
        )
    return given_names, surname


def display_name(given_names: str, surname: str) -> str:
    """Format stored certificate name fields for public display."""

    return f"{emrtd_name(given_names)} {emrtd_name(surname)}"


def load_key(path: Path) -> bytes:
    """Load the server-only form-token key."""

    return path.read_bytes()


def csrf_token(key: bytes, peuin: str, now: int) -> str:
    """Create a short-lived identity-bound CSRF token."""

    issued = str(now)
    mac = hmac.new(key, f"{peuin}\0{issued}".encode(), hashlib.sha256).hexdigest()
    return f"{issued}.{mac}"


def require_csrf(key: bytes, peuin: str, supplied: str, now: int) -> None:
    """Reject malformed, expired, future, or incorrectly bound CSRF tokens."""

    try:
        issued = int(supplied.split(".", 1)[0])
    except ValueError:
        raise RequestError("403 Forbidden", "The form expired. Reload and try again.")
    expected = csrf_token(key, peuin, issued)
    if now - issued > CSRF_LIFETIME_SECONDS or not hmac.compare_digest(supplied, expected):
        raise RequestError("403 Forbidden", "The form expired. Reload and try again.")


def connect_db(path: Path) -> sqlite3.Connection:
    """Open and initialize the durable SQLite store."""

    db = sqlite3.connect(path)
    db.execute("PRAGMA foreign_keys = ON")
    db.execute(
        """
        CREATE TABLE IF NOT EXISTS entries (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            author_peuin TEXT NOT NULL,
            given_names TEXT NOT NULL,
            surname TEXT NOT NULL,
            message TEXT NOT NULL,
            created_at INTEGER NOT NULL
        )
        """
    )
    db.execute(
        "CREATE INDEX IF NOT EXISTS entries_created_at_idx "
        "ON entries(created_at DESC)"
    )
    db.execute(
        """
        CREATE TABLE IF NOT EXISTS deletion_votes (
            entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
            voter_peuin TEXT NOT NULL,
            PRIMARY KEY (entry_id, voter_peuin)
        )
        """
    )
    return db


def recent_entries(db: sqlite3.Connection, viewer_peuin: str) -> list[Entry]:
    """Fetch recent public entries."""

    rows = db.execute(
        "SELECT entries.id, entries.given_names, entries.surname, entries.message, "
        "entries.created_at, COUNT(deletion_votes.voter_peuin), "
        "COUNT(CASE WHEN deletion_votes.voter_peuin = ? THEN 1 END) "
        "FROM entries LEFT JOIN deletion_votes "
        "ON deletion_votes.entry_id = entries.id "
        "GROUP BY entries.id "
        "ORDER BY entries.created_at DESC, entries.id DESC LIMIT ?",
        (viewer_peuin, DISPLAY_LIMIT),
    ).fetchall()
    return [Entry(*row[:-1], viewer_voted=bool(row[-1])) for row in rows]


def read_form(environ: dict[str, str], stream: object) -> dict[str, list[str]]:
    """Read the guestbook form."""

    try:
        length = int(environ.get("CONTENT_LENGTH", "0"))
    except ValueError as error:
        raise RequestError("400 Bad Request", "The form is invalid.") from error
    if not 0 <= length <= MAX_FORM_BYTES:
        raise RequestError("400 Bad Request", "The form is invalid.")
    body = stream.read(length)
    try:
        return urllib.parse.parse_qs(body.decode(), keep_blank_values=True)
    except UnicodeDecodeError as error:
        raise RequestError("400 Bad Request", "The form is invalid.") from error


def single_field(form: dict[str, list[str]], name: str) -> str:
    """Require exactly one value for a form field."""

    values = form.get(name, [])
    if len(values) != 1:
        raise RequestError("400 Bad Request", "The form is invalid.")
    return values[0]


def add_entry(
    db: sqlite3.Connection,
    key: bytes,
    peuin: str,
    given_names: str,
    surname: str,
    form: dict[str, list[str]],
    now: int,
) -> None:
    """Validate and insert one public message with its certificate identity."""

    supplied_csrf = single_field(form, "csrf")
    require_csrf(key, peuin, supplied_csrf, now)
    message = single_field(form, "message").strip()
    if not message:
        raise RequestError("400 Bad Request", "Write a message first.")
    if len(message) > MAX_MESSAGE_CHARS:
        raise RequestError(
            "400 Bad Request",
            f"Messages may contain at most {MAX_MESSAGE_CHARS} characters.",
        )
    with db:
        db.execute(
            "INSERT INTO entries(author_peuin, given_names, surname, message, created_at) "
            "VALUES (?, ?, ?, ?, ?)",
            (peuin, given_names, surname, message, now),
        )


def request_deletion(
    db: sqlite3.Connection,
    key: bytes,
    peuin: str,
    form: dict[str, list[str]],
    now: int,
) -> tuple[bool, bool]:
    """Delete an entry after two distinct card holders agree."""

    require_csrf(key, peuin, single_field(form, "csrf"), now)
    try:
        entry_id = int(single_field(form, "entry_id"))
    except ValueError as error:
        raise RequestError("400 Bad Request", "The deletion request is invalid.") from error
    if entry_id < 1:
        raise RequestError("400 Bad Request", "The deletion request is invalid.")
    with db:
        added = db.execute(
            "INSERT OR IGNORE INTO deletion_votes(entry_id, voter_peuin) VALUES (?, ?)",
            (entry_id, peuin),
        ).rowcount == 1
        votes = db.execute(
            "SELECT COUNT(*) FROM deletion_votes WHERE entry_id = ?", (entry_id,)
        ).fetchone()[0]
        deleted = votes >= DELETION_QUORUM
        if deleted:
            db.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
    return added, deleted


def page(
    peuin: str,
    public_name: str,
    given_name: str,
    key: bytes,
    entries: list[Entry],
    now: int,
    notice: str = "",
) -> str:
    """Render the authenticated guestbook without disclosing any PEUIN."""

    token = csrf_token(key, peuin, now)
    cards = []
    for entry in entries:
        timestamp = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(entry.created_at))
        if entry.viewer_voted:
            moderation = f"Deletion requested ({entry.deletion_votes}/{DELETION_QUORUM})"
        else:
            moderation = (
                '<form class="moderation" method="post" action="/guestbook">'
                '<input type="hidden" name="action" value="request-deletion">'
                f'<input type="hidden" name="entry_id" value="{entry.entry_id}">'
                f'<input type="hidden" name="csrf" value="{token}">'
                f'<button type="submit">Vote for deletion '
                f'({entry.deletion_votes}/{DELETION_QUORUM})</button></form>'
            )
        cards.append(
            "<article class=entry>"
            f"<p>{html.escape(entry.message)}</p>"
            f"<footer>{html.escape(display_name(entry.given_names, entry.surname))} -- {timestamp}</footer>"
            f"{moderation}"
            "</article>"
        )
    entry_html = "".join(cards) or "<p class=empty>No messages yet. Be the first.</p>"
    notice_html = f"<p class=notice>{html.escape(notice)}</p>" if notice else ""
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Card-authenticated guestbook -- ReFineID</title>
{PAGE_STYLE}
</head>
<body><main>
<h1>Hi {html.escape(given_name)}!</h1>
{notice_html}
<form class="compose" method="post" action="/guestbook">
<input type="hidden" name="action" value="post">
<input type="hidden" name="csrf" value="{token}">
<label for="message">Sign the guestbook</label>
<textarea id="message" name="message" maxlength="{MAX_MESSAGE_CHARS}" required></textarea>
<button type="submit">Publish as {html.escape(public_name)}</button>
</form>
<section><h2>Recent messages</h2>{entry_html}</section>
<p><a href="https://www.refineid.fi/demo/">Read how this login works</a></p>
<p><a class="button" href="https://www.refineid.fi/">Log out</a></p>
</main></body></html>"""


def emit(status: str, body: str, *, location: str | None = None) -> None:
    """Emit a CGI response with strict security headers."""

    print(f"Status: {status}")
    if location is not None:
        print(f"Location: {location}")
    print("Content-Type: text/html; charset=utf-8")
    print("Cache-Control: no-store, max-age=0")
    print("Pragma: no-cache")
    print("X-Content-Type-Options: nosniff")
    print("X-Frame-Options: DENY")
    print("Referrer-Policy: no-referrer")
    print("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
    print()
    print(body)


def error_page(error: RequestError) -> str:
    """Render a non-sensitive rejection page with card-login recovery help."""

    if error.card_help:
        heading = "Card login did not complete"
        recovery = """
<section class="help">
<h2>What to check</h2>
<ol>
<li>Connect the reader and insert the card before reloading this page.</li>
<li>In Firefox, enable the ReFineID PKCS #11 security module.</li>
<li>Select the authentication certificate when the browser asks.</li>
<li>Do not keep guessing PIN1. Check its retry counter in ReFineID Card Manager.</li>
</ol>
</section>"""
    else:
        heading = "Request not accepted"
        recovery = ""
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(heading)} -- ReFineID</title>
{PAGE_STYLE}
</head>
<body>
<main>
<p class="status">{html.escape(error.status)}</p>
<h1>{html.escape(heading)}</h1>
<p class="reason">{html.escape(error.message)}</p>
{recovery}
<p><a class="button" href="https://www.refineid.fi/demo/">Open the card-login guide</a></p>
</main></body></html>"""


def application(
    environ: dict[str, str],
    stream: object,
    db_path: Path,
    key_path: Path,
    now: int,
) -> tuple[str, str, str | None]:
    """Handle one request; separated for deterministic tests."""

    peuin = verified_peuin(environ)
    given_names, surname = certificate_names(environ)
    public_name = display_name(given_names, surname)
    given_name = emrtd_name(given_names.split()[0])
    key = load_key(key_path)
    method = environ.get("REQUEST_METHOD", "GET").upper()
    with closing(connect_db(db_path)) as db:
        if method == "POST":
            form = read_form(environ, stream)
            action = single_field(form, "action")
            if action == "post":
                add_entry(db, key, peuin, given_names, surname, form, now)
                return "303 See Other", "", "/guestbook?posted=1"
            if action == "request-deletion":
                added, deleted = request_deletion(db, key, peuin, form, now)
                if deleted:
                    return "303 See Other", "", "/guestbook?deleted=1"
                if added:
                    return "303 See Other", "", "/guestbook?deletion-requested=1"
                return "303 See Other", "", "/guestbook?already-requested=1"
            raise RequestError("400 Bad Request", "The form action is invalid.")
        if method != "GET":
            raise RequestError("405 Method Not Allowed", "Use GET or POST.")
        notices = {
            "posted=1": "Message published.",
            "deleted=1": "Entry deleted after two card holders agreed.",
            "deletion-requested=1": "Deletion requested. One more card holder must agree.",
            "already-requested=1": "You already requested deletion of that entry.",
        }
        notice = notices.get(environ.get("QUERY_STRING"), "")
        return (
            "200 OK",
            page(
                peuin,
                public_name,
                given_name,
                key,
                recent_entries(db, peuin),
                now,
                notice,
            ),
            None,
        )


def main() -> int:
    """CGI entry point."""

    environ = dict(os.environ)
    try:
        status, body, location = application(
            environ,
            sys.stdin.buffer,
            DB_PATH,
            KEY_PATH,
            int(time.time()),
        )
        emit(status, body, location=location)
        return 0
    except RequestError as error:
        emit(error.status, error_page(error))
        return 0
    except Exception as error:
        print(f"guestbook: internal {type(error).__name__}", file=sys.stderr)
        emit(
            "500 Internal Server Error",
            "<!doctype html><html lang=en><meta charset=utf-8>"
            "<title>Server error</title><h1>The guestbook is temporarily unavailable.</h1>",
        )
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
Shell
groupadd --system guestbook
useradd --system --gid guestbook --home-dir /home/guestbook --shell /usr/sbin/nologin guestbook
install -d -o root -g root -m u=rwx,go=rx /home/guestbook
install -d -o guestbook -g guestbook -m u=rwx,go= /home/guestbook/data
install -d -o guestbook -g guestbook -m u=rwx,go=rx /home/guestbook/cgi-bin
install -o guestbook -g guestbook -m u=rx,go= guestbook.py /home/guestbook/cgi-bin/guestbook
install -d -o root -g root -m u=rwx,go=rx /home/guestbook/trust
install -d -o root -g adm -m u=rwx,g=rx,o= /home/guestbook/log
openssl rand 32 >guestbook.key
install -o root -g guestbook -m u=rw,g=r,o= guestbook.key /home/guestbook/guestbook.key

3. Install the FINEID trust anchors

Install the two published DPDSA Government Root CA G3 certificates and their citizen intermediates. Including the intermediates lets the browser match the card certificate issuer named in the TLS request. Apache checks the authentication certificate's live OCSP status during the handshake.

Shell
cd ~guestbook/trust
proxy=http://proxy.fineid.fi/ca
for ca in dvvroot3ec dvvroot3rc dvvcqc4ec dvvcqc4rc
do
    curl -f $proxy/$ca.crt -o $ca.der
    openssl x509 -inform DER -in $ca.der -out $ca.pem
done
sha256sum -c - <<SHA256
5546a52504fba74f61ffd4890067529ade3b9c9d07e502592831ccda9b369fd3  dvvroot3ec.der
d3ed3fc40ad26b52e001e1e18f4b9449529deb75a81d5eb680d7b62db23ba96d  dvvroot3rc.der
SHA256
openssl verify -CAfile dvvroot3ec.pem dvvcqc4ec.pem
openssl verify -CAfile dvvroot3rc.pem dvvcqc4rc.pem
cat dvvroot3ec.pem dvvroot3rc.pem dvvcqc4ec.pem dvvcqc4rc.pem > dpdsa-trust-bundle.pem

4. Adapt Apache's OCSP connection

Apache 2.4.68 can decode a partial DPDSA OCSP response before the complete body has arrived. This localhost-only adapter relays the binary request with curl and returns an explicitly framed HTTP/1.0 response. It accepts only the two DPDSA citizen-certificate responder URLs. Apache still validates the signed OCSP response.

/usr/local/libexec/refineid-ocsp-proxy
# Adapt one Apache mod_ssl proxy request to an explicitly framed response.
# systemd supplies the accepted localhost socket as standard input/output.

set -eu

MAX_REQUEST_BYTES=4096
MAX_RESPONSE_BYTES=65536

reply() {
    status=$1
    body=$2
    printf 'HTTP/1.0 %s\r\n' "$status"
    printf 'Content-Type: text/plain\r\n'
    printf 'Content-Length: %s\r\n' "${#body}"
    printf 'Connection: close\r\n\r\n'
    printf %s "$body"
    exit
}

IFS= read -r request_line || exit
request_line=$(printf %s "$request_line" | tr -d '\r')

case $request_line in
    'POST http://ocsp.fineid.fi:80/dvvcqc4ec HTTP/1.0' | 'POST http://ocsp.fineid.fi/dvvcqc4ec HTTP/1.0')
        responder=http://ocsp.fineid.fi/dvvcqc4ec
        ;;
    'POST http://ocsp.fineid.fi:80/dvvcqc4rc HTTP/1.0' | 'POST http://ocsp.fineid.fi/dvvcqc4rc HTTP/1.0')
        responder=http://ocsp.fineid.fi/dvvcqc4rc
        ;;
    *)
        reply '400 Bad Request' 'Unsupported OCSP request.'
        ;;
esac

content_length=
while IFS= read -r header; do
    header=$(printf %s "$header" | tr -d '\r')
    [ -n "$header" ] || break
    name=$(printf %s "${header%%:*}" | tr '[:upper:]' '[:lower:]')
    if [ "$name" = content-length ]; then
        content_length=$(printf %s "${header#*:}" | tr -d ' ')
    fi
done

case $content_length in
    ''|*[!0-9]*) reply '400 Bad Request' 'Missing Content-Length.' ;;
esac
if [ "$content_length" -eq 0 ] || [ "$content_length" -gt "$MAX_REQUEST_BYTES" ]; then
    reply '413 Content Too Large' 'Invalid OCSP request size.'
fi

proxy_work=$(mktemp -d)
trap 'rm -rf "$proxy_work"' EXIT HUP INT TERM
request_file=$proxy_work/request.der
response_file=$proxy_work/response.der

dd bs=1 count="$content_length" of="$request_file" 2>/dev/null
received=$(wc -c < "$request_file" | tr -d ' ')
[ "$received" -eq "$content_length" ] || reply '400 Bad Request' 'Incomplete OCSP request.'

if ! curl -fsS --max-time 5 -H 'Content-Type: application/ocsp-request' --data-binary "@$request_file" -o "$response_file" "$responder"; then
    reply '502 Bad Gateway' 'OCSP responder failed.'
fi

response_length=$(wc -c < "$response_file" | tr -d ' ')
if [ "$response_length" -eq 0 ] || [ "$response_length" -gt "$MAX_RESPONSE_BYTES" ]; then
    reply '502 Bad Gateway' 'Invalid OCSP response size.'
fi

printf 'HTTP/1.0 200 OK\r\n'
printf 'Content-Type: application/ocsp-response\r\n'
printf 'Content-Length: %s\r\n' "$response_length"
printf 'Connection: close\r\n\r\n'
cat "$response_file"
/etc/systemd/system/refineid-ocsp-proxy.socket
[Unit]
Description=ReFineID OCSP response-framing proxy

[Socket]
ListenStream=127.0.0.1:8888
Accept=yes
NoDelay=yes

[Install]
WantedBy=sockets.target
/etc/systemd/system/refineid-ocsp-proxy@.service
[Unit]
Description=ReFineID OCSP proxy connection

[Service]
ExecStart=/usr/local/libexec/refineid-ocsp-proxy
StandardInput=socket
StandardOutput=socket
DynamicUser=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
Shell
install -d -o root -g root -m u=rwx,go=rx /usr/local/libexec
install -m u=rwx,go=rx refineid-ocsp-proxy /usr/local/libexec/refineid-ocsp-proxy
install -m u=rw,go=r refineid-ocsp-proxy.socket refineid-ocsp-proxy@.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now refineid-ocsp-proxy.socket

5. Renew TLS certificates automatically

card.refineid.fi is a separate DNS name and a separate Dehydrated certificate. DNS-01 uses a TSIG key whose secret is generated on the server and is not published.

/etc/bind/zone-refineid-fi (card record)
card.refineid.fi. CNAME refineid.fi.
/etc/bind/named.conf.local (refineid.fi zone)
include "/etc/bind/keys/refineid-fi-acme-updater.key";

zone "refineid.fi" {
	type primary;
	file "/etc/bind/zone-refineid-fi";
	inline-signing yes;

	update-policy {
		grant refineid-fi-acme-updater name _acme-challenge.refineid.fi. TXT;
		grant refineid-fi-acme-updater name _acme-challenge.www.refineid.fi. TXT;
		grant refineid-fi-acme-updater name _acme-challenge.mx1.refineid.fi. TXT;
		grant refineid-fi-acme-updater name _acme-challenge.imaps.refineid.fi. TXT;
		grant refineid-fi-acme-updater name _acme-challenge.submission.refineid.fi. TXT;
		grant refineid-fi-acme-updater name _acme-challenge.smtp.refineid.fi. TXT;
		grant refineid-fi-acme-updater name _acme-challenge.card.refineid.fi. TXT;
	};

};
Shell
tsig-keygen -a hmac-sha256 refineid-fi-acme-updater >refineid-fi-acme-updater.key
install -d -o root -g bind -m u=rwx,g=rx,o= /etc/bind/keys
install -o root -g bind -m u=rw,g=r,o= refineid-fi-acme-updater.key /etc/bind/keys/refineid-fi-acme-updater.key
named-checkconf
named-checkzone refineid.fi /etc/bind/zone-refineid-fi
systemctl reload bind9
/etc/dehydrated/config
CONFIG_D=/etc/dehydrated/conf.d
BASEDIR=/var/lib/dehydrated
WELLKNOWN="${BASEDIR}/acme-challenges"
/etc/dehydrated/conf.d/dns.sh
CHALLENGETYPE="dns-01"
HOOK="/usr/local/lib/dehydrated/hook.sh"
PRIVATE_KEY_RENEW="yes"
/usr/local/lib/dehydrated/hook.sh
set -eu

DNS_SERVER="127.0.0.1"
TTL="60"

zone_for_domain() {
	case "$1" in
		refineid.fi|*.refineid.fi) echo "refineid.fi" ;;
		*) echo "Unsupported domain: $1" >&2; return 1 ;;
	esac
}

key_for_zone() {
	case "$1" in
		refineid.fi) echo "/etc/bind/keys/refineid-fi-acme-updater.key" ;;
		*) echo "No key configured for zone: $1" >&2; return 1 ;;
	esac
}

auth_ns_list() {
	dig +short @"$DNS_SERVER" NS "$1" +norecurse
}

txt_rrset() {
	dig +short @"$1" TXT "_acme-challenge.$2"
}

txt_seen_on_ns() {
	txt_rrset "$1" "$2" | grep -Fx "\"$3\"" >/dev/null 2>&1
}

txt_count_on_ns() {
	txt_rrset "$1" "$2" | wc -l | tr -d ' '
}

wait_for_dns() {
	DOMAIN="$1"
	ZONE="$2"
	TOKEN_VALUE="$3"
	i=0
	while [ "$i" -lt 24 ]; do
		ALL_OK=1
		echo "Waiting for TXT on authoritative nameservers for _acme-challenge.$DOMAIN" >&2
		for NS in $(auth_ns_list "$ZONE"); do
			COUNT="$(txt_count_on_ns "$NS" "$DOMAIN")"
			if [ "$COUNT" -eq 1 ] && txt_seen_on_ns "$NS" "$DOMAIN" "$TOKEN_VALUE"; then
				echo "    OK   $NS" >&2
			else
				echo "    MISS $NS" >&2
				txt_rrset "$NS" "$DOMAIN" >&2 || true
				ALL_OK=0
			fi
		done
		[ "$ALL_OK" -eq 1 ] && return 0
		sleep 5
		i=$((i + 1))
	done
	echo "TXT record for _acme-challenge.$DOMAIN did not propagate in time" >&2
	return 1
}

deploy_challenge() {
	DOMAIN="$1"
	TOKEN_VALUE="$3"
	ZONE="$(zone_for_domain "$DOMAIN")"
	NSUPDATE_KEY="$(key_for_zone "$ZONE")"
	nsupdate -k "$NSUPDATE_KEY" <<EOF
server $DNS_SERVER
zone $ZONE
update delete _acme-challenge.$DOMAIN. TXT
update add _acme-challenge.$DOMAIN. $TTL TXT "$TOKEN_VALUE"
send
EOF
	rndc notify "$ZONE" >/dev/null 2>&1 || true
	sleep 2
	COUNT="$(txt_count_on_ns "$DNS_SERVER" "$DOMAIN")"
	[ "$COUNT" -eq 1 ] || return 1
	txt_seen_on_ns "$DNS_SERVER" "$DOMAIN" "$TOKEN_VALUE" || return 1
	wait_for_dns "$DOMAIN" "$ZONE" "$TOKEN_VALUE"
}

clean_challenge() {
	DOMAIN="$1"
	ZONE="$(zone_for_domain "$DOMAIN")"
	NSUPDATE_KEY="$(key_for_zone "$ZONE")"
	nsupdate -k "$NSUPDATE_KEY" <<EOF
server $DNS_SERVER
zone $ZONE
update delete _acme-challenge.$DOMAIN. TXT
send
EOF
	rndc notify "$ZONE" >/dev/null 2>&1 || true
}

deploy_cert() {
	apache2ctl configtest
	postfix check
	systemctl reload apache2 postfix dovecot
}

case "${1:-}" in
	deploy_challenge) deploy_challenge "$2" "$3" "$4" ;;
	clean_challenge) clean_challenge "$2" "$3" "$4" ;;
	deploy_cert) deploy_cert "$2" "$3" "$4" "$5" "$6" "$7" ;;
	unchanged_cert|startup_hook|exit_hook|generate_csr|deploy_ocsp|sync_cert) exit 0 ;;
	invalid_challenge) echo "invalid_challenge: $2 $3" >&2 ;;
	request_failure) echo "request_failure: status=$2 reason=$3 type=$4" >&2 ;;
	*) exit 0 ;;
esac
Shell
install -m u=rw,go=r dehydrated-config /etc/dehydrated/config
install -m u=rw,go=r dehydrated-dns.sh /etc/dehydrated/conf.d/dns.sh
install -d -o root -g root -m u=rwx,go=rx /usr/local/lib/dehydrated
install -m u=rwx,go=rx dehydrated-hook.sh /usr/local/lib/dehydrated/hook.sh
dehydrated --cron --force --alias card.refineid.fi --domain card.refineid.fi
/etc/systemd/system/refineid-certificates.service
[Unit]
Description=Renew Let's Encrypt certificates
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/dehydrated --cron
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/dehydrated
CapabilityBoundingSet=
/etc/systemd/system/refineid-certificates.timer
[Unit]
Description=Renew Let's Encrypt certificates daily

[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
Shell
install -m u=rw,go=r refineid-certificates.service refineid-certificates.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now refineid-certificates.timer

6. Give card login its own TLS identity

The card host has its own certificate and virtual host. It is deliberately absent from the ordinary website certificate so that HTTP/2 connection reuse cannot skip the client-certificate request.

/etc/apache2/sites-available/card.refineid.fi.conf
<VirtualHost *:443>
    ServerName card.refineid.fi
    SuexecUserGroup guestbook guestbook
    SSLEngine on
    SSLProtocol -all +TLSv1.3
    SSLOpenSSLConfCmd Groups SecP384r1MLKEM1024:X25519MLKEM768:secp384r1
    # Use a dedicated certificate whose SAN set does not overlap an ordinary
    # site on this server. That prevents cross-origin HTTP/2 connection reuse
    # from bypassing this host's client-certificate request.
    Protocols h2 http/1.1
    SSLCertificateFile /var/lib/dehydrated/certs/card.refineid.fi/fullchain.pem
    SSLCertificateKeyFile /var/lib/dehydrated/certs/card.refineid.fi/privkey.pem

    # Ask for a card certificate. "optional" is deliberate: a browser that
    # supplies none reaches the CGI and receives its useful 403 page.
    SSLVerifyClient optional
    SSLVerifyDepth 2
    SSLCACertificateFile /home/guestbook/trust/dpdsa-trust-bundle.pem
    SSLOCSPEnable leaf
    SSLOCSPProxyURL http://127.0.0.1:8888
    SSLOptions +StdEnvVars +ExportCertData

    ScriptAliasMatch "^/(?:guestbook)?$" /home/guestbook/cgi-bin/guestbook

    <Directory /home/guestbook/cgi-bin>
        Options +ExecCGI -Indexes
        Require all granted
    </Directory>

    <LocationMatch "^/(?:guestbook)?$">
        <LimitExcept GET POST>
            Require all denied
        </LimitExcept>
    </LocationMatch>

    Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "DENY"
    Header always set Referrer-Policy "no-referrer"
    Header always set Cache-Control "no-store"

    ErrorLog /home/guestbook/log/error.log
    CustomLog /home/guestbook/log/access.log combined
</VirtualHost>
Shell
chmod u=rw,go=r /etc/apache2/sites-available/card.refineid.fi.conf
a2ensite card.refineid.fi
apache2ctl configtest
systemctl restart apache2

E / Documentation

Primary technical references

Finnish Digital and Population Data Services Agency