141 lines
6.3 KiB
Python
141 lines
6.3 KiB
Python
"""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
|