ReFineID

FINEID client-certificate authentication

Fine login, with a fine card.

A working card-authenticated guestbook and the complete deployment used to run it.

The implementation follows the TLS exchange through Apache verification, application identity handling, trust refresh, and deployment. The browser proves possession of the on-card authentication key; the website never receives the PIN or private key.

Login to guestbook with your ID card

01 / Authentication flow

TLS client-certificate authentication

PIN1 authorizes the card to sign the TLS handshake. The server validates the signature, authentication-certificate chain, validity period, revocation status, and client-auth purpose before the application sees an identity.

  1. Server requests a certificate

    The TLS server sends CertificateRequest naming the accepted DVV citizen certificate authorities.

  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. The authentication private key never leaves the card.

  4. Application receives verified identity

    After TLS verification, the application reads PEUIN from subject serialNumber, OID 2.5.4.5.

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 + card PKCS #11 + chip TLS server mod_ssl ClientHello CertificateRequest Choose authentication certificate PIN1 prompt PIN1 Card signs TLS transcript Certificate + CertificateVerify Verify chain + signature Authenticated HTTPS

02 / Server boundary

Application trust boundary

Apache validates the client certificate and passes the result directly to the CGI process. The application accepts identity only from this local mod_ssl boundary; public request headers are never identity inputs.

Accepted inputs

  • SSL_CLIENT_VERIFY=SUCCESS from local mod_ssl
  • Exactly one structured SSL_CLIENT_S_DN_serialNumber value
  • Eight ASCII digits and the statutory modulo-31 PEUIN check character
  • Parameterized database operations

Rejected inputs

  • X-Client-DN, X-PEUIN, and every other browser-supplied identity header
  • Ambiguous string parsing of the complete subject DN
  • A PIN sent to the application
  • A certificate that was parsed but not chain-verified
  • A qualified-signature certificate used as an authentication identity

03 / Deployment

Complete deployment procedure

The steps are in execution order: prerequisites, permissions, application, trust refresh, scheduling, Apache, and acceptance checks.

1. Install the operating-system tools

On Debian or Ubuntu, install Apache, Python, OpenSSL, and curl, then enable the modules used by the configuration below.

sudo apt install apache2 curl openssl python3
sudo a2enmod cgid headers http2 ssl

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. Install the application and private state

The CGI is executable but owned by root. Apache may write only the database directory and read only the server-keyed CSRF key.

sudo install -d -o root -g root -m u=rwx,go=rx /usr/local/libexec
sudo install -o root -g root -m u=rwx,go=rx guestbook.py /usr/local/libexec/refineid-guestbook
sudo install -d -o root -g www-data -m u=rwx,g=rwx,o= /var/lib/refineid-guestbook
sudo install -d -o root -g root -m u=rwx,go=rx /etc/refineid-guestbook
sudo openssl rand 32 | sudo tee /etc/refineid-guestbook/guestbook.key >/dev/null
sudo chown root:www-data /etc/refineid-guestbook/guestbook.key
sudo chmod u=rw,g=r,o= /etc/refineid-guestbook/guestbook.key
guestbook.py
#!/usr/bin/env python3
# Copyright 2026 Petri Koistinen <petri.koistinen@iki.fi>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing
# permissions and limitations under the License.

"""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


MAX_BODY_BYTES = 4096
MAX_MESSAGE_CHARS = 280
MAX_DISPLAY_NAME_CHARS = 160
DISPLAY_LIMIT = 50
CSRF_LIFETIME_SECONDS = 10 * 60
CSRF_CLOCK_SKEW_SECONDS = 30
PEUIN_CHECK_CHARACTERS = "0123456789ABCDEFHJKLMNPRSTUVWXY"
PAGE_STYLE = """
<style>
:root {
  --paper: #f3f5f7;
  --raised: #fafbfc;
  --ink: #101820;
  --muted: #536273;
  --blue: #002f6c;
  --line: #d4dde8;
  --max: 96rem;
  --sans: Inter, "Avenir Next", "Helvetica Neue", Arial, sans-serif;
  --serif: Iowan Old Style, Baskerville, Georgia, serif;
  --mono: "SF Mono", "Cascadia Code", "Liberation Mono", Menlo, monospace;
}
* {
  box-sizing: border-box;
}
body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font: 18px/1.58 var(--sans);
}
main {
  width: min(var(--max), calc(100% - 40px));
  margin: 0 auto;
  padding: 64px 0 80px;
}
a {
  color: var(--blue);
  text-underline-offset: 4px;
}
h1, h2 {
  font-family: var(--serif);
}
h1 {
  margin: .15em 0 .4em;
  font-size: clamp(3rem, 7vw, 6.5rem);
  line-height: 1;
  letter-spacing: 0;
}
h2 {
  margin-top: 2em;
  font-size: 2rem;
}
.eyebrow, .status, .meta, .entry footer, label, button, .button {
  font-family: var(--mono);
}
.eyebrow, .status {
  color: var(--blue);
  font-size: .8rem;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
}
.proof, .reason, .help {
  margin: 32px 0;
  border-left: 4px solid var(--blue);
  background: #e8eef6;
  padding: 24px 28px;
}
.help h2 {
  margin: 0 0 16px;
}
.meta, .entry footer, .empty {
  color: var(--muted);
  font-size: .85rem;
}
form.compose {
  margin: 48px 0;
}
label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}
textarea {
  display: block;
  width: 100%;
  min-height: 120px;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--raised);
  color: var(--ink);
  padding: 16px;
  font: inherit;
}
button, .button {
  display: inline-block;
  margin-top: 12px;
  border: 1px solid var(--blue);
  border-radius: 4px;
  background: var(--blue);
  color: #fff;
  padding: 12px 22px;
  font-weight: 700;
  text-decoration: none;
  cursor: pointer;
}
button:disabled {
  cursor: default;
  opacity: .6;
}
.entry {
  border-top: 1px solid var(--line);
  padding: 22px 0;
}
.entry p {
  font-size: 1.2rem;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.notice {
  border-left: 4px solid var(--blue);
  padding-left: 14px;
}
li {
  margin: .65em 0;
}
@media (max-width: 560px) {
  body {
    font-size: 16px;
  }
}
</style>
"""


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

    entry_id: int
    public_name: str
    message: str
    created_at: int


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 valid_peuin(value: str) -> bool:
    """Check the statutory eight digits and modulo-31 check character."""

    if not re.fullmatch(r"[0-9]{8}[0-9A-Z]", value):
        return False
    expected = PEUIN_CHECK_CHARACTERS[int(value[:8]) % 31]
    return hmac.compare_digest(value[-1], expected)


def extract_peuin_from_environment(environ: dict[str, str]) -> str:
    """Require mod_ssl's unambiguous structured serialNumber component."""

    structured = environ.get("SSL_CLIENT_S_DN_serialNumber", "")
    if not structured or environ.get("SSL_CLIENT_S_DN_serialNumber_1", ""):
        raise RequestError(
            "403 Forbidden",
            "The authentication certificate has no unique PEUIN.",
            card_help=True,
        )
    peuin = structured.upper()
    if not valid_peuin(peuin):
        raise RequestError(
            "403 Forbidden",
            "The authentication certificate contains a malformed PEUIN.",
            card_help=True,
        )
    return peuin


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

    if environ.get("HTTPS", "").lower() != "on":
        raise RequestError("403 Forbidden", "HTTPS is required.", card_help=True)
    verification = environ.get("SSL_CLIENT_VERIFY", "")
    if not hmac.compare_digest(verification, "SUCCESS"):
        if verification in ("", "NONE"):
            message = (
                "No authentication certificate was presented by the browser. "
                "The card was not used for this connection."
            )
        else:
            message = (
                "The browser presented a certificate, but the server could not "
                "verify it as a current FINEID authentication certificate."
            )
        raise RequestError("403 Forbidden", message, 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_display_name(environ: dict[str, str], peuin: str) -> str:
    """Return a given-name-first public name from structured DN components."""

    given_names = " ".join(environ.get("SSL_CLIENT_S_DN_G", "").split())
    surname = " ".join(environ.get("SSL_CLIENT_S_DN_S", "").split())
    duplicated = environ.get("SSL_CLIENT_S_DN_G_1", "") or environ.get(
        "SSL_CLIENT_S_DN_S_1", ""
    )
    components = (given_names, surname)
    valid = (
        not duplicated
        and all(components)
        and sum(map(len, components)) + 1 <= MAX_DISPLAY_NAME_CHARS
        and all(
            character.isalpha() or character in " -'"
            for component in components
            for character in component
        )
    )
    if not valid:
        raise RequestError(
            "403 Forbidden",
            "The authentication certificate has no usable holder name.",
            card_help=True,
        )
    public_name = f"{emrtd_name(given_names)} {emrtd_name(surname)}"
    if peuin.upper() in public_name.upper():
        raise RequestError(
            "403 Forbidden",
            "The authentication certificate holder name exposes PEUIN.",
            card_help=True,
        )
    return public_name


def certificate_given_name(environ: dict[str, str]) -> str:
    """Return the first given name from mod_ssl's structured field."""

    given_names = environ.get("SSL_CLIENT_S_DN_G", "")
    candidate = given_names.split()[0] if given_names else ""
    valid = candidate and all(
        character.isalpha() or character in "-'" for character in candidate
    )
    if not valid:
        raise RequestError(
            "403 Forbidden",
            "The authentication certificate has no usable given name.",
            card_help=True,
        )
    return emrtd_name(candidate)


def load_key(path: Path) -> bytes:
    """Load the server-only key used for identity-bound CSRF tokens."""

    key = path.read_bytes()
    if len(key) < 32:
        raise RuntimeError("guestbook key must contain at least 32 bytes")
    return key


def keyed_token(key: bytes, purpose: bytes, peuin: str) -> bytes:
    """Domain-separated HMAC over a PEUIN."""

    return hmac.new(key, purpose + b"\0" + peuin.encode("ascii"), hashlib.sha256).digest()


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

    issued = str(now)
    payload = f"{peuin}\0{issued}"
    mac = keyed_token(key, b"csrf-v2", payload).hex()
    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."""

    issued_text, separator, _mac = supplied.partition(".")
    if not separator or not issued_text.isascii() or not issued_text.isdigit():
        raise RequestError("403 Forbidden", "The form expired. Reload and try again.")
    issued = int(issued_text)
    age = now - issued
    expected = csrf_token(key, peuin, issued)
    valid_age = -CSRF_CLOCK_SKEW_SECONDS <= age <= CSRF_LIFETIME_SECONDS
    if not valid_age or not hmac.compare_digest(supplied, expected):
        raise RequestError("403 Forbidden", "The form expired. Reload and try again.")


def remove_stored_identities(db: sqlite3.Connection) -> None:
    """Preserve messages while removing legacy identity columns and votes."""

    columns = {row[1] for row in db.execute("PRAGMA table_info(entries)")}
    if not ({"peuin", "account_id"} & columns):
        return
    db.execute("BEGIN IMMEDIATE")
    try:
        db.execute(
            "CREATE TABLE entries_without_identity ("
            "id INTEGER PRIMARY KEY AUTOINCREMENT, "
            "public_id TEXT NOT NULL, "
            "message TEXT NOT NULL, "
            "created_at INTEGER NOT NULL)"
        )
        db.execute(
            "INSERT INTO entries_without_identity(id, public_id, message, created_at) "
            "SELECT id, public_id, message, created_at FROM entries"
        )
        db.execute("DROP TABLE IF EXISTS deletion_votes")
        db.execute("DROP TABLE entries")
        db.execute("ALTER TABLE entries_without_identity RENAME TO entries")
        db.commit()
    except Exception:
        db.rollback()
        raise


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

    db = sqlite3.connect(path, timeout=5.0)
    db.execute("PRAGMA busy_timeout = 5000")
    db.execute("PRAGMA journal_mode = WAL")
    db.execute("PRAGMA foreign_keys = ON")
    remove_stored_identities(db)
    db.execute(
        """
        CREATE TABLE IF NOT EXISTS entries (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            public_id 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)"
    )
    return db


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

    rows = db.execute(
        "SELECT id, public_id, message, created_at FROM entries "
        "ORDER BY entries.created_at DESC, entries.id DESC LIMIT ?",
        (DISPLAY_LIMIT,),
    ).fetchall()
    return [Entry(*row) for row in rows]


def read_form(environ: dict[str, str], stream: object) -> dict[str, list[str]]:
    """Read a tightly bounded URL-encoded POST body."""

    content_type = environ.get("CONTENT_TYPE", "").split(";", 1)[0].strip().lower()
    if content_type != "application/x-www-form-urlencoded":
        raise RequestError("415 Unsupported Media Type", "Use the guestbook form.")
    try:
        length = int(environ.get("CONTENT_LENGTH", "0"))
    except ValueError as error:
        raise RequestError("400 Bad Request", "Invalid request length.") from error
    if length < 0 or length > MAX_BODY_BYTES:
        raise RequestError("413 Content Too Large", "The form submission is too large.")
    body = stream.read(length)
    if not isinstance(body, bytes) or len(body) != length:
        raise RequestError("400 Bad Request", "The form submission was incomplete.")
    try:
        text = body.decode("utf-8", errors="strict")
    except UnicodeDecodeError as error:
        raise RequestError("400 Bad Request", "The form must be UTF-8.") from error
    return urllib.parse.parse_qs(text, keep_blank_values=True, strict_parsing=False)


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,
    public_name: str,
    form: dict[str, list[str]],
    now: int,
) -> None:
    """Validate and insert one public message without storing 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(public_id, message, created_at) VALUES (?, ?, ?)",
            (public_name, message, now),
        )


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))
        cards.append(
            "<article class=entry>"
            f"<p>{html.escape(entry.message)}</p>"
            f"<footer>{html.escape(entry.public_name)} -- {timestamp}</footer>"
            "</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>
<p class="meta">Logging in creates no guestbook record. Publishing stores only your name, message, and publication time -- never PEUIN (SATU), a derived identifier, or PIN1.</p>
{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 required_path(environ: dict[str, str], name: str) -> Path:
    """Require an explicit deployment path; never open a shadow data store."""

    value = environ.get(name, "")
    if not value:
        raise RuntimeError(f"required environment variable {name} is unset")
    return Path(value)


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)
    public_name = certificate_display_name(environ, peuin)
    given_name = certificate_given_name(environ)
    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, public_name, form, now)
                return "303 See Other", "", "/guestbook?posted=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."}
        notice = notices.get(environ.get("QUERY_STRING"), "")
        return (
            "200 OK",
            page(
                peuin,
                public_name,
                given_name,
                key,
                recent_entries(db),
                now,
                notice,
            ),
            None,
        )


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

    # Keep messages owner-only even though the database contains no PEUIN.
    os.umask(0o077)
    environ = dict(os.environ)
    try:
        db_path = required_path(environ, "REFINEID_GUESTBOOK_DB")
        key_path = required_path(environ, "REFINEID_GUESTBOOK_KEY")
        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())

3. Build a verified FINEID trust bundle

Fetch the two published DVV Government Root CA G3 certificates and verify the pinned SHA-256 values before installing them. The refresh program verifies each citizen intermediate against a pinned root, checks intermediate status with signed nonce-bearing OCSP, verifies leaf and root CRL signatures and freshness, rejects CRL rollback, and switches the complete CA and CRL generation atomically.

curl -f http://proxy.fineid.fi/ca/dvvroot3ec.crt -o dvv-gov-root-ca-g3-ecc.der
curl -f http://proxy.fineid.fi/ca/dvvroot3rc.crt -o dvv-gov-root-ca-g3-rsa.der
sha256sum -c - <<'SHA256'
5546a52504fba74f61ffd4890067529ade3b9c9d07e502592831ccda9b369fd3  dvv-gov-root-ca-g3-ecc.der
d3ed3fc40ad26b52e001e1e18f4b9449529deb75a81d5eb680d7b62db23ba96d  dvv-gov-root-ca-g3-rsa.der
SHA256
sudo install -m u=rw,go=r dvv-gov-root-ca-g3-ecc.der /etc/refineid-guestbook/dvv-gov-root-ca-g3-ecc.der
sudo install -m u=rw,go=r dvv-gov-root-ca-g3-rsa.der /etc/refineid-guestbook/dvv-gov-root-ca-g3-rsa.der
sudo install -m u=rwx,go=rx refresh-trust.sh /usr/local/sbin/refineid-guestbook-refresh-trust
sudo REFINEID_GUESTBOOK_TRUST_DIR=/etc/refineid-guestbook /usr/local/sbin/refineid-guestbook-refresh-trust
/usr/local/sbin/refineid-guestbook-refresh-trust
#!/usr/bin/env bash
# Copyright 2026 Petri Koistinen <petri.koistinen@iki.fi>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing
# permissions and limitations under the License.

# Refresh FINEID citizen intermediates and their signed CRLs.

set -euo pipefail
umask 077

DEST="${REFINEID_GUESTBOOK_TRUST_DIR:-${REFINEID_DEMO_TRUST_DIR:-/etc/refineid-demo}}"
INSTALL_OWNER="${REFINEID_GUESTBOOK_TRUST_OWNER:-${REFINEID_DEMO_TRUST_OWNER:-root}}"
INSTALL_GROUP="${REFINEID_GUESTBOOK_TRUST_GROUP:-${REFINEID_DEMO_TRUST_GROUP:-root}}"
ROOT_ECC="$DEST/dvv-gov-root-ca-g3-ecc.der"
ROOT_RSA="$DEST/dvv-gov-root-ca-g3-rsa.der"
ROOT_ECC_SHA256='5546a52504fba74f61ffd4890067529ade3b9c9d07e502592831ccda9b369fd3'
ROOT_RSA_SHA256='d3ed3fc40ad26b52e001e1e18f4b9449529deb75a81d5eb680d7b62db23ba96d'
DVV_PROXY='http://proxy.fineid.fi'
DVV_OCSP='http://ocsp.fineid.fi'
MAX_CLOCK_SKEW_SECONDS=300
MAX_LEAF_CRL_AGE_SECONDS=43200
MIN_LEAF_CRL_REMAINING_SECONDS=1800
MAX_ROOT_CRL_AGE_SECONDS=34560000
MIN_ROOT_CRL_REMAINING_SECONDS=86400

fail() {
    printf 'refineid-guestbook trust refresh: %s\n' "$*" >&2
    exit 1
}

check_digest() {
    local file="$1" expected="$2" actual
    actual="$(sha256sum "$file" | awk '{print $1}')"
    [[ "$actual" == "$expected" ]] || fail "pinned root digest mismatch: $file"
}

crl_epoch() {
    local file="$1" field="$2" value
    value="$(openssl crl -in "$file" -noout "-$field")"
    value="${value#*=}"
    python3 -c 'from datetime import datetime, timezone; import sys; print(int(datetime.strptime(sys.argv[1], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc).timestamp()))' "$value"
}

check_crl_window() {
    local file="$1" previous="$2" label="$3" max_age="$4" min_remaining="$5"
    local now this_update next_update previous_update
    now="$(date -u +%s)"
    this_update="$(crl_epoch "$file" lastupdate)"
    next_update="$(crl_epoch "$file" nextupdate)"
    (( this_update <= now + MAX_CLOCK_SKEW_SECONDS )) || fail "$label CRL thisUpdate is in the future"
    (( now - this_update <= max_age )) || fail "$label CRL is older than policy permits"
    (( next_update - now >= min_remaining )) || fail "$label CRL expires too soon"
    if [[ -f "$previous" ]]; then
        previous_update="$(crl_epoch "$previous" lastupdate)"
        (( this_update >= previous_update )) || fail "$label CRL rollback detected"
    fi
    printf '%s CRL: thisUpdate=%s nextUpdate=%s\n' "$label" "$(date -u -r "$this_update" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -d "@$this_update" '+%Y-%m-%dT%H:%M:%SZ')" "$(date -u -r "$next_update" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -d "@$next_update" '+%Y-%m-%dT%H:%M:%SZ')"
}

[[ -f "$ROOT_ECC" ]] || fail "missing pinned ECC root"
[[ -f "$ROOT_RSA" ]] || fail "missing pinned RSA root"
check_digest "$ROOT_ECC" "$ROOT_ECC_SHA256"
check_digest "$ROOT_RSA" "$ROOT_RSA_SHA256"

work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT

openssl x509 -inform DER -in "$ROOT_ECC" -out "$work/root-ecc.pem"
openssl x509 -inform DER -in "$ROOT_RSA" -out "$work/root-rsa.pem"
cat "$work/root-ecc.pem" "$work/root-rsa.pem" > "$work/roots.pem"

for family in ec rc; do
    name="dvvcqc4${family}"
    root_name="dvvroot3${family}"
    if [[ "$family" == 'ec' ]]; then
        root="$work/root-ecc.pem"
    else
        root="$work/root-rsa.pem"
    fi
    curl --fail --silent --show-error --max-time 30 "$DVV_PROXY/ca/${name}.crt" -o "$work/${name}.der"
    openssl x509 -inform DER -in "$work/${name}.der" -out "$work/${name}.pem"
    curl --fail --silent --show-error --max-time 30 "$DVV_PROXY/crl/${root_name}.crl" -o "$work/${root_name}.crl.der"
    openssl crl -inform DER -in "$work/${root_name}.crl.der" -out "$work/${root_name}.crl.pem"
    openssl crl -in "$work/${root_name}.crl.pem" -CAfile "$root" -verify -noout
    check_crl_window "$work/${root_name}.crl.pem" "$DEST/current/${root_name}.crl.pem" "$root_name" "$MAX_ROOT_CRL_AGE_SECONDS" "$MIN_ROOT_CRL_REMAINING_SECONDS"
    openssl verify -CAfile "$root" -CRLfile "$work/${root_name}.crl.pem" -crl_check "$work/${name}.pem"

    ocsp_command=(openssl ocsp -issuer "$root" -cert "$work/${name}.pem" -url "$DVV_OCSP/${root_name}" -CAfile "$root" -nonce -timeout 30)
    ocsp_result="$("${ocsp_command[@]}" 2>&1)" || fail "$name OCSP verification failed"
    grep -Fq "$work/${name}.pem: good" <<< "$ocsp_result" || fail "$name is not good according to OCSP"

    curl --fail --silent --show-error --max-time 30 "$DVV_PROXY/crl/${name}.crl" -o "$work/${name}.crl.der"
    openssl crl -inform DER -in "$work/${name}.crl.der" -out "$work/${name}.crl.pem"
    openssl crl -in "$work/${name}.crl.pem" -CAfile "$work/${name}.pem" -verify -noout
    check_crl_window "$work/${name}.crl.pem" "$DEST/current/${name}.crl.pem" "$name" "$MAX_LEAF_CRL_AGE_SECONDS" "$MIN_LEAF_CRL_REMAINING_SECONDS"
done

cat "$work/roots.pem" "$work/dvvcqc4ec.pem" "$work/dvvcqc4rc.pem" > "$work/client-ca.pem"
crl_files=("$work/dvvcqc4ec.crl.pem" "$work/dvvcqc4rc.crl.pem" "$work/dvvroot3ec.crl.pem" "$work/dvvroot3rc.crl.pem")
cat "${crl_files[@]}" > "$work/client-crl.pem"

install -d -o "$INSTALL_OWNER" -g "$INSTALL_GROUP" -m u=rwx,go=rx "$DEST/trust-a" "$DEST/trust-b"
if [[ "$(readlink "$DEST/current" 2>/dev/null || true)" == 'trust-a' ]]; then
    next='trust-b'
else
    next='trust-a'
fi
for file in client-ca.pem client-crl.pem dvvcqc4ec.crl.pem dvvcqc4rc.crl.pem dvvroot3ec.crl.pem dvvroot3rc.crl.pem; do
    install -o "$INSTALL_OWNER" -g "$INSTALL_GROUP" -m u=rw,go=r "$work/$file" "$DEST/$next/$file"
done
current_link="$DEST/.current.$$"
ln -s "$next" "$current_link"
python3 -c 'import os, sys; os.replace(sys.argv[1], sys.argv[2])' "$current_link" "$DEST/current"

printf '%s\n' 'refineid-guestbook trust refresh: verified CA and CRL bundles installed'

4. Refresh revocation data automatically

The service verifies and replaces the trust material. The timer runs it every two hours and reloads Apache only after a successful refresh.

/etc/systemd/system/refineid-guestbook-trust.service
[Unit]
Description=Refresh verified FINEID client CA and CRL bundles
After=network-online.target
Wants=network-online.target
OnFailure=refineid-guestbook-trust-failure.service

[Service]
Type=oneshot
Environment=REFINEID_GUESTBOOK_TRUST_DIR=/etc/refineid-guestbook
ExecStart=/usr/local/sbin/refineid-guestbook-refresh-trust
ExecStartPost=/bin/systemctl reload apache2
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/etc/refineid-guestbook
CapabilityBoundingSet=
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
/etc/systemd/system/refineid-guestbook-trust-failure.service
[Unit]
Description=Report failed FINEID trust refresh

[Service]
Type=oneshot
ExecStart=/usr/bin/logger --priority auth.crit --tag refineid-guestbook "FINEID trust refresh failed; card login will stop when the installed CRL expires"
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
CapabilityBoundingSet=
LockPersonality=yes
MemoryDenyWriteExecute=yes
/etc/systemd/system/refineid-guestbook-trust.timer
[Unit]
Description=Refresh FINEID client revocation data every two hours

[Timer]
OnBootSec=2min
OnUnitActiveSec=2h
RandomizedDelaySec=10min
Persistent=true

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

5. Give card login its own TLS identity

Issue a dedicated certificate for the card host. Do not include that name in an ordinary site's SAN certificate: RFC 9113 permits cross-origin HTTP/2 connection reuse when one certificate covers both names. A separate certificate forces the TLS handshake that requests the card certificate while retaining h2 and keep-alive.

Replace the four Define values, then install this complete virtual host.

/etc/apache2/sites-available/refineid-guestbook.conf
# Replace these four values before enabling this virtual host.
Define REFINEID_SERVER_NAME card.example.fi
Define REFINEID_TLS_CERT /etc/letsencrypt/live/card.example.fi/fullchain.pem
Define REFINEID_TLS_KEY /etc/letsencrypt/live/card.example.fi/privkey.pem
Define REFINEID_APACHE_LOG_DIR /var/log/apache2

<VirtualHost *:443>
    ServerName ${REFINEID_SERVER_NAME}
    SSLEngine on
    SSLProtocol -all +TLSv1.3
    # 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 ${REFINEID_TLS_CERT}
    SSLCertificateKeyFile ${REFINEID_TLS_KEY}

    # 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 /etc/refineid-guestbook/current/client-ca.pem
    SSLCARevocationFile /etc/refineid-guestbook/current/client-crl.pem
    SSLCARevocationCheck chain
    SSLOptions +StdEnvVars

    ScriptAliasMatch "^/(?:guestbook)?$" /usr/local/libexec/refineid-guestbook

    <Directory /usr/local/libexec>
        Options +ExecCGI -Indexes
        Require all granted
    </Directory>

    <LocationMatch "^/(?:guestbook)?$">
        SetEnv REFINEID_GUESTBOOK_DB /var/lib/refineid-guestbook/guestbook.sqlite3
        SetEnv REFINEID_GUESTBOOK_KEY /etc/refineid-guestbook/guestbook.key
        <LimitExcept GET POST>
            Require all denied
        </LimitExcept>
    </LocationMatch>

    Header always set Strict-Transport-Security "max-age=63072000"
    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 ${REFINEID_APACHE_LOG_DIR}/refineid-guestbook-error.log
    CustomLog ${REFINEID_APACHE_LOG_DIR}/refineid-guestbook-access.log combined
</VirtualHost>
sudo install -m u=rw,go=r apache-card-login.conf /etc/apache2/sites-available/refineid-guestbook.conf
sudo a2ensite refineid-guestbook
sudo apache2ctl configtest
sudo systemctl reload apache2

6. Prove both failure and success

Without a client certificate, Apache still completes TLS and the CGI returns a useful 403 page. It releases identity and guestbook data only when mod_ssl reports exact verification success.

curl --http2 --location --write-out '%{http_code}\n' https://card.example.fi/ --output missing-card.html
grep 'Card login did not complete' missing-card.html

Then open the same URL in a browser with ReFineID installed. Select the FINEID authentication certificate and authorize the on-card TLS signature with PIN1. Confirm that a submitted entry contains the certificate holder name without PEUIN and that the access log contains no PEUIN.

04 / Identity data

PEUIN as the account identifier

Personal Electronic Unique Identification Number (PEUIN) is sähköinen asiointitunnus (SATU) in Finnish. The certificate carries PEUIN in the subject serialNumber attribute; it is not the card serial number.

Logging in creates no guestbook record. Publishing stores only the holder name from the verified certificate, the message, and its publication time. The database stores neither PEUIN nor a derived account identifier. Production systems also need a retention period, access controls, encrypted backups, deletion handling, and a documented lawful purpose.

05 / Documentation

Primary technical references

Start with DVV's description of the citizen certificate on an identity card, then use the FINEID specifications for certificate profiles and implementation details.