Not a claim in a privacy policy, but the real code for the one part of our server that's allowed to see your WebUntis password, published so you can check it yourself instead of taking our word for it.
The one piece of code that can ever unlock your password was never built to show, save, or send it anywhere, it only ever uses it once to log into WebUntis, then forgets it, and every single time it's used, that gets logged.
NotiClass needs your WebUntis password or access key to check your timetable automatically in the background. Here is the part that actually matters: the part of our server that talks to the internet, the one handling every request from every phone, never has the ability to decrypt it. Not "isn't supposed to." Not "is blocked from." The decryption key is not present anywhere in that program, not in its code, not in its files, not in its memory. It cannot hand over something it does not have.
Think of it like a hotel. The front desk deals with every guest and every request, but has no key to the safe in the back office. When a guest needs something from the safe, the front desk calls the back office and asks for that one specific thing. It's never handed a copy of the key, and the back office can't be reached from the lobby directly, only through that one phone line, for that one kind of request.
Why that matters: a security problem in the large, public-facing part of NotiClass, a bug, a bad input, anything at all, still cannot expose a password, because that part of the system genuinely does not have the capability to decrypt one. Someone who fully broke into that public server would find no key to steal there, because there is no key there to find. That's the claim. Below is how to verify it yourself.
You don't need to run this code, reading it is enough. In the file below:
get_or_create_fernet_key, encrypt/decrypt): a standard, well-known library, nothing custom._store_credential/_get_credential): its own small database, never alongside the rest of the app's data./store, /poll, /poll_group, /homework, /delete): read each one, a device id goes in, a timetable or homework list comes out, never a password, in either direction._audit): every single use of a stored credential is logged, noting when and for which device, never the secret itself._check_internal_token, LISTEN_HOST): bound to localhost only, and even then requires a shared secret only the public app has.#!/usr/bin/env python3
"""
credential_service.py: the ONLY process that ever holds a decrypted
WebUntis credential, as plaintext, in memory, even briefly.
Binds to 127.0.0.1 only. Never reachable over Tailscale, never reachable
from the public internet. Only something running on this exact machine
can reach it at all. The main app (app.py) is the only intended caller.
Owns, and app.py no longer does:
- The Fernet encryption key
- The encrypted credentials themselves, in their own database
(data/credentials.db), physically separate from the main app's
data/app.db, so a leak of one alone is useless without the other
- All WebUntis network/login code (WebUntisClient, fetch_timetable,
fetch_homework, fetch_timetable_smart), moved here verbatim from
app.py; the actual HTTP calls to WebUntis are byte-for-byte
unchanged, only the process they run in has moved
Why this exists: app.py is the large, public-facing, constantly-exercised
part of the system, and every real request from every phone goes through
it. A bug there (an injection, a logic error, anything) is the realistic
way credentials could ever leak, far more than someone breaking into the
NAS itself. Splitting this out means a bug in app.py can't directly expose
a plaintext credential, because app.py's process never has the capability
to decrypt one. It can only ask this service to use one on its behalf,
and only ever gets back results (a timetable, a homework list), never the
secret itself.
API (all requests/responses are JSON, all internal-only):
POST /store: verify, encrypt, and store a freshly-submitted
credential (registration / reconnect)
POST /poll: one device, timetable fetch (reusing a cached session
across polls when possible), plus optionally homework,
on one WebUntis login
POST /poll_group: class-representative flow, try each candidate device
in order until one logs in, fetch the class-wide
timetable plus (optionally) that device's own
homework, all on that one login
POST /homework: one device, homework only, fresh login (the
force_poll / on-demand / cascade-check path)
POST /delete: remove a device's stored credential
GET /health: liveness check
Every call is logged (device_token's first 8 chars, endpoint, outcome,
NEVER the secret) to data/credential_access.log, a plain, append-only
audit trail of every single time a credential was ever used, by what,
and when.
A shared internal token (data/internal_token, generated once, read by
both this process and app.py) is required on every request via the
X-Internal-Token header. That's defense in depth in case some other
process on the same machine is ever compromised; binding to localhost
alone assumes nothing else running locally is hostile, this doesn't.
"""
import os
import re
import json
import time
import secrets
import sqlite3
import logging
from urllib.parse import quote, urlencode
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests
from flask import Flask, request, jsonify
from cryptography.fernet import Fernet
# ─── CONFIG ───────────────────────────────────────────────────────────────────
DATA_DIR = "data"
DB_PATH = os.path.join(DATA_DIR, "credentials.db")
ENCRYPTION_KEY_PATH = os.path.join(DATA_DIR, "encryption.key")
INTERNAL_TOKEN_PATH = os.path.join(DATA_DIR, "internal_token")
AUDIT_LOG_PATH = os.path.join(DATA_DIR, "credential_access.log")
LISTEN_HOST = "127.0.0.1" # never anything else, see module docstring
LISTEN_PORT = 5055
os.makedirs(DATA_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler(os.path.join(DATA_DIR, "credential_service.log")),
logging.StreamHandler()]
)
log = logging.getLogger(__name__)
audit = logging.getLogger("audit")
audit.setLevel(logging.INFO)
audit.propagate = False
_audit_handler = logging.FileHandler(AUDIT_LOG_PATH)
_audit_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
audit.addHandler(_audit_handler)
def _audit(endpoint, device_token, outcome, detail=""):
"""Every single credential use, logged. device_token is truncated (enough
to correlate with app.py's own logs, not enough alone to be useful to
anyone who only has this log), endpoint, outcome, and a short detail.
NEVER the secret, NEVER anything decrypted, see module docstring."""
tok = (device_token or "?")[:8]
audit.info(f"{endpoint} token={tok}... outcome={outcome} {detail}")
SCHOOL_TZ = ZoneInfo("Europe/Berlin") # == Europe/Luxembourg (CET/CEST)
def _now(): return datetime.now(SCHOOL_TZ)
def _date_int(dt): return int(dt.strftime("%Y%m%d"))
def _parse_date_int(d):
return datetime.strptime(str(d), "%Y%m%d").replace(tzinfo=SCHOOL_TZ)
# A reused WebUntis session/cookie has simply gone stale, confirmed live via
# a deliberate probe: WebUntis answers a stale session with HTTP 403
# ("no right for anonymous user") on the weekly-data endpoint, or a JSON-RPC
# error body like {"message": "not authenticated", "code": -8520} on the
# other. Distinct from a rejected password/key: this only means "this
# cached session needs replacing".
_SESSION_EXPIRED_MARKERS = ("not authenticated", "-8520")
def _looks_like_expired_session(err_str):
return any(m in err_str.lower() for m in _SESSION_EXPIRED_MARKERS)
# ─── ENCRYPTION ───────────────────────────────────────────────────────────────
# The Fernet key lives ONLY here now. app.py no longer has it, no longer has
# the cryptography import for it, and no longer has anywhere to put a
# decrypted secret even if it wanted to.
def get_or_create_fernet_key():
if os.path.exists(ENCRYPTION_KEY_PATH):
with open(ENCRYPTION_KEY_PATH, "rb") as f:
return Fernet(f.read())
key = Fernet.generate_key()
with open(ENCRYPTION_KEY_PATH, "wb") as f:
f.write(key)
os.chmod(ENCRYPTION_KEY_PATH, 0o600)
return Fernet(key)
fernet = get_or_create_fernet_key()
def encrypt(text): return fernet.encrypt(text.encode()).decode()
def decrypt(text): return fernet.decrypt(text.encode()).decode()
def get_or_create_internal_token():
if os.path.exists(INTERNAL_TOKEN_PATH):
with open(INTERNAL_TOKEN_PATH) as f:
return f.read().strip()
tok = secrets.token_hex(32)
with open(INTERNAL_TOKEN_PATH, "w") as f:
f.write(tok)
os.chmod(INTERNAL_TOKEN_PATH, 0o600)
return tok
INTERNAL_TOKEN = get_or_create_internal_token()
# ─── CREDENTIAL STORE ─────────────────────────────────────────────────────────
def _init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS credentials (
device_token TEXT PRIMARY KEY,
school_server TEXT NOT NULL,
school_name TEXT NOT NULL,
username TEXT NOT NULL,
secret_enc TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.commit()
conn.close()
try:
os.chmod(DB_PATH, 0o600)
except FileNotFoundError:
pass
_init_db()
def _get_credential(device_token):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT school_server, school_name, username, secret_enc FROM credentials WHERE device_token = ?",
(device_token,)
).fetchone()
conn.close()
if not row:
return None
return {
"school_server": row["school_server"],
"school_name": row["school_name"],
"username": row["username"],
"secret": decrypt(row["secret_enc"]),
}
def _store_credential(device_token, school_server, school_name, username, secret):
conn = sqlite3.connect(DB_PATH)
conn.execute("""
INSERT INTO credentials (device_token, school_server, school_name, username, secret_enc, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(device_token) DO UPDATE SET
school_server = excluded.school_server,
school_name = excluded.school_name,
username = excluded.username,
secret_enc = excluded.secret_enc,
updated_at = excluded.updated_at
""", (device_token, school_server, school_name, username, encrypt(secret),
_now().isoformat(timespec="seconds")))
conn.commit()
conn.close()
def _delete_credential(device_token):
conn = sqlite3.connect(DB_PATH)
conn.execute("DELETE FROM credentials WHERE device_token = ?", (device_token,))
conn.commit()
conn.close()
# ─── WEBUNTIS API ─────────────────────────────────────────────────────────────
# Everything below this line through the end of _normalise_from_weekly is
# moved VERBATIM from app.py (lines 440-1087 as of the 2026-11-06 copy).
# Not rewritten, not redesigned, just relocated. The actual HTTP calls to
# WebUntis, the tiered-fallback logic, the session-reuse safety checks are
# all byte-for-byte what was already live and tested. Only WHERE this code
# runs has changed.
# ─── WEBUNTIS INTEGRATION: REDACTED IN THIS PUBLIC COPY ───────────────────────
#
# This is the one part of the file deliberately not included here. In the
# real, running service, this section contains the actual WebUntis login
# and data-fetching code, the reverse-engineered integration this app is
# built on (which endpoints to call, session handling, how to interpret
# WebUntis's response shapes, retry/fallback behavior tuned against real
# schools over months of production use). That's a different kind of
# intellectual property than the question this repo exists to answer, so
# it's been removed from this public copy rather than published alongside
# it.
#
# What's NOT redacted, and what actually matters for verifying the security
# claim, is everything else in this file: the encryption, the credential
# storage, the HTTP boundary below, and the fact that a decrypted secret
# only ever exists for the duration of a single function call and is never
# returned across that boundary. The two functions below are real only in
# their SIGNATURE and their CONTRACT, meaning what goes in and what comes
# back out, which is the part you can actually verify from this file. Their
# bodies in production do real WebUntis logins; here they're stubbed so this
# file still runs and is honest about what it isn't showing you.
#
# The shape of the real implementation is, approximately:
#
# def login_and_fetch_timetable(school_server, school_name, username, secret, ...):
# client = WebUntisClient(school_server, school_name, username, secret)
# client.log_in() # `secret` is used here, once,
# # and never stored or returned
# timetable = client.get_timetable(...)
# return timetable, client.session_cache() # no credential in this result
#
# In other words, the decrypted secret goes IN to a login call and never
# comes back OUT, only timetable/homework data does. That property is what
# every route below actually depends on, and it holds regardless of what
# the redacted internals look like.
class AuthError(Exception):
"""Raised when WebUntis rejects a credential. Kept here, unredacted,
because the routes below need to know this type exists to handle it,
see poll() and poll_group()."""
def login_and_fetch_timetable(school_server, school_name, username, secret,
cached_session=None, tiers=(42, 14, 7),
element_override=None):
"""Redacted, see the module comment above. Signature and contract are
real: takes a decrypted secret, returns (timetable_json, new_cached_session,
end_date_used) and NEVER returns the secret itself. Raises AuthError if
WebUntis rejects the credential."""
raise NotImplementedError("WebUntis integration omitted from this public copy, see README.md")
def login_and_fetch_homework(school_server, school_name, username, secret,
reuse_session=None):
"""Redacted, see the module comment above. Takes a decrypted secret,
returns a homework list (or None if unsupported by the school) and
NEVER returns the secret itself."""
raise NotImplementedError("WebUntis integration omitted from this public copy, see README.md")
def verify_credential(school_server, school_name, username, secret):
"""Redacted, see the module comment above. Takes a decrypted secret,
attempts a login, and returns nothing on success or raises AuthError on
rejection. Used only by /store to confirm a freshly-submitted credential
actually works before it gets encrypted and saved. Never returns the
secret, never returns any WebUntis data."""
raise NotImplementedError("WebUntis integration omitted from this public copy, see README.md")
app = Flask(__name__)
@app.before_request
def _check_internal_token():
if request.path == "/health":
return
if request.headers.get("X-Internal-Token") != INTERNAL_TOKEN:
return jsonify({"error": "unauthorized"}), 401
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"}), 200
@app.errorhandler(Exception)
def _handle_unexpected(e):
"""Anything that escapes a route's own try/except (a malformed request
body, a corrupted DB row, a genuine bug) still comes back as the same
{"ok": false, "error": ...} JSON shape every endpoint already uses on a
handled failure, not Flask's default HTML error page, which app.py's
HTTP client has no reason to know how to parse. Never includes the
secret: this only ever fires from code paths that don't have one in
scope yet (request parsing, credential lookup) or that already failed
before ever decrypting one."""
log.error(f"Unhandled error in {request.path}: {e}")
return jsonify({"ok": False, "error": "internal error"}), 200
@app.route("/store", methods=["POST"])
def store():
"""Verify a freshly-submitted credential actually logs in, then encrypt
and store it. Used by app.py's /register for both first-time
registration and reconnect (re-entering a changed password/key), the
same single call either way, since storing always overwrites. Returns
{"ok": true} on success, or {"ok": false, "is_auth_error": bool,
"error": "..."}. app.py does its own existing string-based
classification (wrong_credentials / school_not_found / connection_failed)
on that error string, exactly as it already did inline before this
split; that classification logic itself didn't move, only what it's
classifying."""
data = request.get_json(force=True)
device_token = data["device_token"]
school_server = data["school_server"]
school_name = data["school_name"]
username = data["username"]
secret = data["secret"]
try:
verify_credential(school_server, school_name, username, secret)
except AuthError as e:
_audit("/store", device_token, "auth_rejected")
return jsonify({"ok": False, "is_auth_error": True, "error": str(e)}), 200
except Exception as e:
_audit("/store", device_token, "failed", detail=f"error_class={type(e).__name__}")
return jsonify({"ok": False, "is_auth_error": False, "error": str(e)}), 200
_store_credential(device_token, school_server, school_name, username, secret)
_audit("/store", device_token, "stored")
return jsonify({"ok": True}), 200
@app.route("/poll", methods=["POST"])
def poll():
"""One device: timetable fetch (reusing cached_session when possible),
then optionally homework on that same session. Body:
{device_token, cached_session: {...} or null, tiers: [a,b,c],
want_homework: bool}. Returns on success:
{"ok": true, "fresh_json": "...", "new_cache": {...}, "end_used": int,
"homework_items": [...] or null}
homework_items is null both when want_homework was false AND when the
homework fetch itself failed/isn't supported by this school. app.py
already treats "no items" and "not supported" as the same "nothing to
do" case for homework (see fetch_homework's own None-vs-list
distinction), so collapsing the want_homework=false case into the same
null doesn't lose anything app.py was relying on.
On failure: {"ok": false, "is_auth_error": bool, "error": "..."}.
app.py applies its own existing error-string classification (no-data /
auth-failure / transient) to this, unchanged from before the split."""
data = request.get_json(force=True)
device_token = data["device_token"]
cached_session = data.get("cached_session")
tiers = tuple(data.get("tiers", (42, 14, 7)))
want_homework = bool(data.get("want_homework"))
cred = _get_credential(device_token)
if cred is None:
_audit("/poll", device_token, "unknown_device")
return jsonify({"ok": False, "is_auth_error": False, "error": "no stored credential"}), 200
try:
fresh_json, new_cache, end_used = login_and_fetch_timetable(
cred["school_server"], cred["school_name"], cred["username"], cred["secret"],
cached_session=cached_session, tiers=tiers,
)
except Exception as e:
is_auth = isinstance(e, AuthError)
_audit("/poll", device_token, "timetable_failed", detail=f"auth={is_auth}")
return jsonify({"ok": False, "is_auth_error": is_auth, "error": str(e)}), 200
homework_items = None
if want_homework:
try:
# Reuses new_cache's session rather than a fresh login. The
# real implementation threads that session through here; see
# the module comment above for why that detail is redacted.
homework_items = login_and_fetch_homework(
cred["school_server"], cred["school_name"], cred["username"], cred["secret"],
reuse_session=new_cache,
)
except Exception as e:
log.warning(f" /poll: homework piggyback failed for a device: {e}")
_audit("/poll", device_token, "ok", detail=f"homework={'yes' if homework_items is not None else 'no'}")
return jsonify({
"ok": True,
"fresh_json": fresh_json,
"new_cache": new_cache,
"end_used": end_used,
"homework_items": homework_items,
}), 200
@app.route("/poll_group", methods=["POST"])
def poll_group():
"""Class-representative flow. Body: {candidates: [device_token, ...],
klasse_id: int, tiers: [a,b,c], want_homework: bool}. Tries each
candidate's stored credential in order until one logs in and fetches
the class-wide timetable successfully (element_override=(1,klasse_id)),
exactly like the pre-split loop in poll_class_group. First success
wins, becomes "the representative" for this cycle. If want_homework,
also fetches that representative's own homework on the same session
before logging out. Returns on success:
{"ok": true, "rep_device_token": "...", "shared_json": "...",
"homework_items": [...] or null}
On total failure (no candidate worked): {"ok": false, "error": "..."}.
app.py falls back to polling each member individually, unchanged from
before the split."""
data = request.get_json(force=True)
candidates = data["candidates"]
klasse_id = data["klasse_id"]
tiers = tuple(data.get("tiers", (42, 14, 7)))
want_homework = bool(data.get("want_homework"))
shared_json = None
rep_token = None
rep_cred = None
rep_cache = None
last_error = None
for device_token in candidates:
cred = _get_credential(device_token)
if cred is None:
continue
try:
shared_json, rep_cache, _ = login_and_fetch_timetable(
cred["school_server"], cred["school_name"], cred["username"], cred["secret"],
tiers=tiers, element_override=(1, klasse_id),
)
rep_token = device_token
rep_cred = cred
break
except Exception as e:
last_error = e
log.warning(f" /poll_group {klasse_id}: candidate failed, trying next: {e}")
if shared_json is None:
_audit("/poll_group", "n/a", "no_candidate_worked", detail=f"klasse={klasse_id}")
return jsonify({"ok": False, "error": str(last_error) if last_error else "no candidates"}), 200
homework_items = None
if want_homework:
try:
homework_items = login_and_fetch_homework(
rep_cred["school_server"], rep_cred["school_name"],
rep_cred["username"], rep_cred["secret"], reuse_session=rep_cache,
)
except Exception as e:
log.warning(f" /poll_group {klasse_id}: representative homework fetch failed: {e}")
# The real implementation explicitly logs out this representative
# session here, since (unlike /poll's cached_session) it's never
# persisted anywhere. Redacted along with the rest of the session
# handling; see the module comment above.
_audit("/poll_group", rep_token, "ok", detail=f"klasse={klasse_id}")
return jsonify({
"ok": True,
"rep_device_token": rep_token,
"shared_json": shared_json,
"homework_items": homework_items,
}), 200
@app.route("/homework", methods=["POST"])
def homework():
"""One device, homework only, fresh login. This is the force_poll/
on-demand path (_fetch_and_update_homework_for) and the
cascade-to-other-members path (_homework_piggyback_for_group), both
collapsing to this same call. Body: {device_token}. Returns
{"ok": true, "items": [...] or null}, where null specifically means
this school doesn't support the homework endpoint, same distinction
fetch_homework already made before the split. On failure:
{"ok": false, "error": "..."}."""
data = request.get_json(force=True)
device_token = data["device_token"]
cred = _get_credential(device_token)
if cred is None:
_audit("/homework", device_token, "unknown_device")
return jsonify({"ok": False, "error": "no stored credential"}), 200
try:
items = login_and_fetch_homework(cred["school_server"], cred["school_name"],
cred["username"], cred["secret"])
except Exception as e:
_audit("/homework", device_token, "failed")
return jsonify({"ok": False, "error": str(e)}), 200
_audit("/homework", device_token, "ok", detail=f"items={'none' if items is None else len(items)}")
return jsonify({"ok": True, "items": items}), 200
@app.route("/delete", methods=["POST"])
def delete():
"""Removes a device's stored credential, called from /unregister and
/delete_account. Body: {device_token}."""
data = request.get_json(force=True)
device_token = data["device_token"]
_delete_credential(device_token)
_audit("/delete", device_token, "deleted")
return jsonify({"ok": True}), 200
if __name__ == "__main__":
log.info(f"Credential service starting on {LISTEN_HOST}:{LISTEN_PORT} (localhost-only)")
app.run(host=LISTEN_HOST, port=LISTEN_PORT)