"""Single administrator credentials, independent API token and atomic persistence.""" import hashlib import hmac import json import os from pathlib import Path import re import secrets import threading import time ITERATIONS = 600000 def password_hash(password): if not isinstance(password, str) or not 16 <= len(password) <= 256: raise ValueError('Das Kennwort muss 16 bis 256 Zeichen lang sein.') salt = secrets.token_bytes(16) return dict(salt=salt.hex(), hash=hashlib.pbkdf2_hmac('sha256', password.encode(), salt, ITERATIONS).hex()) def verify_password(password, record): if not isinstance(password, str) or len(password) > 256 or not record: return False actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(record['salt']), ITERATIONS).hex() return hmac.compare_digest(actual, record['hash']) def token_hash(token): if not isinstance(token, str) or not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', token): raise ValueError('Der API-Token muss 32 bis 256 Zeichen enthalten: Buchstaben, Ziffern, - oder _.') return hashlib.sha256(token.encode()).hexdigest() def normalize(record): # Existing 0.2 installs keep their password; API access stays disabled until a token is set. if record and set(record) == {'salt', 'hash'}: return dict(version=1, password=record, revision=record['hash'], api_token_hash=None, password_changed_at=None, token_changed_at=None) return record def validate_record(record): if not isinstance(record, dict) or set(record) != {'version','password','revision','api_token_hash','password_changed_at','token_changed_at'}: raise ValueError('Ungültiger Zugangsdatenstand.') p = record['password'] if record['version'] != 1 or not isinstance(p, dict) or set(p) != {'salt','hash'}: raise ValueError('Ungültiger Zugangsdatenstand.') if not re.fullmatch('[a-f0-9]{32}', str(p['salt'])) or not re.fullmatch('[a-f0-9]{64}', str(p['hash'])): raise ValueError('Ungültiger Zugangsdatenstand.') if not re.fullmatch('[a-f0-9]{32,64}', str(record['revision'])): raise ValueError('Ungültiger Zugangsdatenstand.') if record['api_token_hash'] is not None and not re.fullmatch('[a-f0-9]{64}', str(record['api_token_hash'])): raise ValueError('Ungültiger Zugangsdatenstand.') for field in ('password_changed_at','token_changed_at'): if record[field] is not None and (not isinstance(record[field], (float,int)) or not 0 < record[field] < 1e12): raise ValueError('Ungültiger Zugangsdatenstand.') return record def initial_record(password, token): if password == token: raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.') now = time.time() return dict(version=1, password=password_hash(password), revision=secrets.token_hex(16), api_token_hash=token_hash(token), password_changed_at=now, token_changed_at=now) def atomic_write(path, record): path = Path(path) path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) temporary = path.with_name(path.name+'.'+secrets.token_hex(8)+'.tmp') fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) try: with os.fdopen(fd,'w') as stream: json.dump(record,stream) stream.flush() os.fsync(stream.fileno()) os.replace(temporary,path) finally: temporary.unlink(missing_ok=True) class CredentialStore: def __init__(self, path=None, rpc=None): self.path = Path(path) if path else None self.rpc = rpc self.lock = threading.RLock() def read(self): with self.lock: if self.rpc: record = self.rpc({'action':'credentials-read'})['credentials'] else: try: record = json.loads(self.path.read_text()) except FileNotFoundError: return None return validate_record(normalize(record)) if record else None def write(self, record, expected): validate_record(record) if self.rpc: self.rpc({'action':'credentials-write','expected_revision':expected,'credentials':record}) else: current = self.read() if (current['revision'] if current else None) != expected: raise ValueError('Zugangsdaten wurden inzwischen geändert. Bitte erneut anmelden.') atomic_write(self.path, record) def setup(self, password, token): with self.lock: if self.read() is not None: raise ValueError('Die Ersteinrichtung ist bereits abgeschlossen.') record = initial_record(password,token) self.write(record,None) return record def change(self, kind, current_password, value): with self.lock: record = self.read() if not record or not verify_password(current_password,record['password']): raise ValueError('Das aktuelle Kennwort ist nicht korrekt.') expected = record['revision'] if kind == 'password': if verify_password(value,record['password']): raise ValueError('Bitte ein anderes neues Kennwort wählen.') if record['api_token_hash'] and isinstance(value,str) and hmac.compare_digest(hashlib.sha256(value.encode()).hexdigest(),record['api_token_hash']): raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.') record.update(password=password_hash(value), password_changed_at=time.time(), revision=secrets.token_hex(16)) elif kind == 'token': digest = token_hash(value) if verify_password(value,record['password']): raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.') if record['api_token_hash'] and hmac.compare_digest(digest,record['api_token_hash']): raise ValueError('Bitte einen anderen neuen API-Token wählen.') record.update(api_token_hash=digest,token_changed_at=time.time(),revision=secrets.token_hex(16)) else: raise ValueError('Unbekannte Zugangsdatenaktion.') self.write(record,expected) return record