jellyfin-tools/jellyfin-year-review/scripts/export-jellyfin-auth.py
jellyfin-year-review/scripts/export-jellyfin-auth.pyRaw
1#!/usr/bin/env python3
2"""Export local Jellyfin 12.2 verifiers without passwords or database writes."""
3
4import argparse
5import contextlib
6import datetime
7import json
8import os
9from pathlib import Path
10import re
11import sqlite3
12import stat
13import sys
14import tempfile
15import time
16import uuid
17
18PROVIDER = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider"
19MAX_USERS = 1000
20MAX_BYTES = 1 << 20
21
22
23def identity(value):
24 """Match Jellyfin's immutable Guid identity across its two string forms."""
25 if not isinstance(value, str):
26 raise ValueError("Expected text UUIDs in the Jellyfin 12.2 schema")
27 try:
28 return uuid.UUID(value).hex
29 except ValueError:
30 raise ValueError("Invalid account UUID") from None
31
32
33def login_key(name):
34 """Use ASCII case folding and exact spelling for Unicode account names."""
35 return ("ascii:" + name.lower()) if name.isascii() else ("unicode:" + name)
36
37
38def validate_hash(encoded):
39 """Accept the two bounded, salted PBKDF2 encodings supported by Go serving."""
40 if not isinstance(encoded, str):
41 raise ValueError("Unsupported password verifier; sign in to Jellyfin to migrate it")
42 match = re.fullmatch(
43 r"\$(PBKDF2-SHA512|PBKDF2)\$iterations=([0-9]{1,7})\$([0-9a-fA-F]+)\$([0-9a-fA-F]+)",
44 encoded,
45 )
46 if not match:
47 raise ValueError("Unsupported password verifier; sign in to Jellyfin to migrate it")
48 algorithm, iterations, salt_hex, hash_hex = match.groups()
49 size = 64 if algorithm == "PBKDF2-SHA512" else 32
50 if not 1 <= int(iterations) <= 1_000_000 or len(salt_hex) % 2 or not 16 <= len(salt_hex) <= 256 or len(hash_hex) != size * 2:
51 raise ValueError("Password verifier parameters are outside supported bounds")
52
53
54def read_accounts(database):
55 """Pin one read-only SQLite transaction, including committed WAL contents."""
56 counts = {"external": 0, "passwordless": 0, "disabled": 0, "restricted": 0}
57 deadline = time.monotonic() + 60
58 with contextlib.closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=5)) as db:
59 db.execute("PRAGMA query_only=ON")
60 db.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000)
61 db.execute("BEGIN")
62 required = {
63 "Users": {"Id", "Username", "Password", "AuthenticationProviderId", "MustUpdatePassword"},
64 "Permissions": {"UserId", "Kind", "Value"},
65 }
66 for table, columns in required.items():
67 actual = {row[1] for row in db.execute(f'PRAGMA table_info("{table}")')}
68 if not columns <= actual:
69 raise ValueError("Unsupported database schema; expected Jellyfin 12.2 Users and Permissions")
70 rows = db.execute('SELECT Id, Username, Password, AuthenticationProviderId, MustUpdatePassword FROM Users LIMIT 1001').fetchall()
71 if len(rows) > MAX_USERS:
72 raise ValueError("Account count exceeds the supported limit")
73 permissions = {}
74 for user_id, kind, value in db.execute('SELECT UserId, Kind, Value FROM Permissions WHERE Kind IN (2, 4)'):
75 key = (identity(user_id), kind)
76 if key in permissions or value not in (0, 1):
77 raise ValueError("Invalid or duplicate account status permissions")
78 permissions[key] = bool(value)
79 scheduled = set()
80 if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='AccessSchedules'").fetchone():
81 scheduled = {identity(row[0]) for row in db.execute('SELECT DISTINCT UserId FROM AccessSchedules')}
82 users, ids, names = [], set(), set()
83 for user_id, name, password_hash, provider, must_update in rows:
84 user_id = identity(user_id)
85 if not isinstance(name, str) or not name or len(name.encode("utf-8")) > 256 or any(c in name for c in "\x00\r\n"):
86 raise ValueError("Invalid account username")
87 name_key = login_key(name)
88 if user_id in ids or name_key in names:
89 raise ValueError("Duplicate account identity or login name")
90 ids.add(user_id)
91 names.add(name_key)
92 if not isinstance(provider, str) or provider.lower() != PROVIDER.lower():
93 counts["external"] += 1
94 continue
95 if (user_id, 2) not in permissions or (user_id, 4) not in permissions:
96 raise ValueError("Missing explicit disabled or remote-access account permission")
97 disabled = permissions[user_id, 2]
98 restricted = not permissions[user_id, 4] or bool(must_update) or user_id in scheduled
99 if disabled or restricted:
100 counts["disabled" if disabled else "restricted"] += 1
101 users.append({"id": user_id, "username": name, "passwordHash": "", "disabled": True})
102 continue
103 if password_hash is None or password_hash == "":
104 counts["passwordless"] += 1
105 continue
106 validate_hash(password_hash)
107 users.append({"id": user_id, "username": name, "passwordHash": password_hash, "disabled": False})
108 db.rollback()
109 return sorted(users, key=lambda user: user["id"]), counts
110
111
112def private_existing(path):
113 """Refuse accidental symlinks, oversized files or permissive secret copies."""
114 try:
115 info = path.lstat()
116 except FileNotFoundError:
117 return None
118 if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > MAX_BYTES:
119 raise ValueError("Existing credential file must be regular, private (0600/0400) and within 1 MiB")
120 return path.read_bytes()
121
122
123def atomic_write(path, content):
124 """Publish a private synced file with atomic rename on the same filesystem."""
125 descriptor, temporary = tempfile.mkstemp(prefix=".auth-", dir=path.parent)
126 try:
127 with os.fdopen(descriptor, "wb") as file:
128 file.write(content)
129 file.flush()
130 os.fsync(file.fileno())
131 os.replace(temporary, path)
132 directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
133 try:
134 os.fsync(directory)
135 finally:
136 os.close(directory)
137 finally:
138 if os.path.exists(temporary):
139 os.unlink(temporary)
140
141
142def export(database, output, exported_at=None):
143 """Keep the last successful credential export intact on source failures."""
144 database = Path(database).resolve(strict=True)
145 output = Path(output).absolute()
146 backup = output.with_name(output.name + ".previous")
147 if database in (output.resolve(), backup.resolve()):
148 raise ValueError("Credential output cannot replace the source database")
149 users, counts = read_accounts(database)
150 document = {
151 "schemaVersion": 1,
152 "sourceVersion": "12.2",
153 "exportedAt": exported_at or datetime.datetime.now(datetime.timezone.utc).isoformat(),
154 "users": users,
155 }
156 payload = (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
157 if len(payload) > MAX_BYTES:
158 raise ValueError("Credential export exceeds 1 MiB")
159 output.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
160 previous = private_existing(output)
161 private_existing(backup)
162 if previous is not None:
163 atomic_write(backup, previous)
164 atomic_write(output, payload)
165 return len([user for user in users if not user["disabled"]]), counts
166
167
168def main():
169 parser = argparse.ArgumentParser(description=__doc__)
170 parser.add_argument("--database", required=True, help="readable live Jellyfin 12.2 SQLite DB or safe backup")
171 parser.add_argument("--output", required=True, help="private auth/users.json, outside web and review data")
172 args = parser.parse_args()
173 try:
174 enabled, counts = export(args.database, args.output)
175 except ValueError as error:
176 print(f"export-auth: {error}", file=sys.stderr)
177 return 1
178 except (sqlite3.Error, OSError):
179 print("export-auth: cannot read the database or publish the private export; check schema, paths and permissions", file=sys.stderr)
180 return 1
181 print(f"Exported {enabled} enabled local accounts. Excluded: {counts['external']} external, {counts['passwordless']} passwordless; blocked: {counts['disabled']} disabled, {counts['restricted']} restricted.")
182 return 0
183
184
185if __name__ == "__main__":
186 sys.exit(main())