Accepted inputs
SSL_CLIENT_VERIFY=SUCCESSfrom local mod_ssl- Exactly one structured
SSL_CLIENT_S_DN_serialNumbervalue - Eight ASCII digits and the statutory modulo-31 PEUIN check character
- Parameterized database operations
FINEID client-certificate authentication
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 card01 / Authentication flow
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.
The TLS server sends CertificateRequest naming the accepted DVV citizen certificate authorities.
ReFineID or the operating system selects the authentication certificate and asks the card holder for PIN1.
The chip signs CertificateVerify. The authentication private key never leaves the card.
After TLS verification, the application reads PEUIN from subject serialNumber, OID 2.5.4.5.
02 / Server 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.
SSL_CLIENT_VERIFY=SUCCESS from local mod_sslSSL_CLIENT_S_DN_serialNumber valueX-Client-DN, X-PEUIN, and every other browser-supplied identity header03 / Deployment
The steps are in execution order: prerequisites, permissions, application, trust refresh, scheduling, Apache, and acceptance checks.
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.
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
#!/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())
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'
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
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.
# 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
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
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.
05 / Documentation
Start with DVV's description of the citizen certificate on an identity card, then use the FINEID specifications for certificate profiles and implementation details.