diora-web/static/js/app/33-unlock.js
mrwnslz 077e6d4543
Some checks failed
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 11m22s
Test / e2e (push) Failing after 1m29s
Anmelden mit Authelia als zweiter Weg, Bücher auf neuen Geräten per Code entsperren (sw CACHE v161)
Auf der Login-Seite gibt es „Mit Authelia anmelden“, wenn der Server dafür
eingerichtet ist. Das eigene Konto wird einmal in den Einstellungen verknüpft,
die Anmeldung mit Passwort bleibt. Fremde Authelia-Konten kommen nicht hinein,
neue Konten entstehen darüber nicht.

Fehlt einem Gerät der Bücher-Schlüssel (nach der Authelia-Anmeldung oder nach
gelöschtem Speicher), erzeugt diora keinen neuen mehr, der die Bücher
unlesbar machen würde. Stattdessen fragt es: Ein schon entsperrtes Gerät zeigt
denselben Code und gibt den Schlüssel verschlüsselt frei, oder man gibt einmal
das diora-Passwort ein. Der Server sieht den Schlüssel dabei nie.

Details: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:01:25 +01:00

254 lines
12 KiB
JavaScript

'use strict';
// ---------------------------------------------------------------------------
// Unlocking the books on a device without the key (accounts/keyshare.py)
//
// A password login derives the book key on the device (login.html). A device signed in
// another way (Authelia, accounts/oidc.py) has none: getOrCreateEncKey() then asks here
// instead of making a new key. Two ways in:
// - an unlocked device of the account hands it over: this device posts an ECDH P-256
// public key, the other one shows the same 6-digit code (made from that key), the user
// approves there and the book key comes back encrypted for this device only;
// - or the diora password, the same derivation as at login.
// The server only ever sees public keys and ciphertext.
// ---------------------------------------------------------------------------
const KEYSHARE_INFO = new TextEncoder().encode('diora keyshare v1');
let _unlockPromise = null;
let _unlockDismissed = false;
function dioraLockedError() {
const e = new Error('Bücher sind auf diesem Gerät noch nicht entsperrt');
e.name = 'DioraLocked';
return e;
}
function _csrf() {
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
}
async function _ksFetch(url, body) {
const opts = body === undefined ? {credentials: 'same-origin'} : {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': _csrf()},
body: JSON.stringify(body),
};
const res = await fetch(url, opts);
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
// 6-digit code from the asking device's public key, shown on both devices.
async function keyshareCode(pubB64) {
const h = new Uint8Array(await crypto.subtle.digest('SHA-256', base64ToBytes(pubB64)));
const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0;
const s = String(n % 1000000).padStart(6, '0');
return s.slice(0, 3) + ' ' + s.slice(3);
}
async function _sharedAesKey(privateKey, otherPubB64) {
const otherPub = await crypto.subtle.importKey('raw', base64ToBytes(otherPubB64),
{name: 'ECDH', namedCurve: 'P-256'}, false, []);
const bits = await crypto.subtle.deriveBits({name: 'ECDH', public: otherPub}, privateKey, 256);
const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
return crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: KEYSHARE_INFO},
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
}
function _deviceName() {
const ua = navigator.userAgent || '';
const app = /diora-desktop|Electron/i.test(ua) ? 'diora Desktop'
: /; wv\)|diora/i.test(ua) && /Android/i.test(ua) ? 'diora App'
: /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge'
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
const os = /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS'
: /Windows/i.test(ua) ? 'Windows' : /Mac OS/i.test(ua) ? 'macOS' : /Linux/i.test(ua) ? 'Linux' : '';
return os ? `${app} auf ${os}` : app;
}
// Is this raw key the account's book key? Tried on one book's metadata; without books
// there is nothing to compare with, so it is taken.
async function _keyFits(raw) {
let books = [];
try { books = await _ksFetch('/books/'); } catch (e) { return true; }
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
if (!b) return true;
try {
const key = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
await decryptBytes(key, b.meta_iv, b.meta_ct);
return true;
} catch (e) { return false; }
}
function _storeRawKey(raw) {
localStorage.setItem(`diora_enc_key_${window.USER_ID || 0}`, bytesToBase64(new Uint8Array(raw)));
}
function _el(tag, attrs, text) {
const el = document.createElement(tag);
for (const k in (attrs || {})) el.setAttribute(k, attrs[k]);
if (text) el.textContent = text;
return el;
}
function _openDialog(build) {
const overlay = _el('div', {class: 'modal-overlay'});
const dialog = _el('div', {class: 'modal-dialog', role: 'dialog', 'aria-modal': 'true'});
document.body.append(overlay, dialog);
const close = () => { overlay.remove(); dialog.remove(); };
build(dialog, close);
return close;
}
// Ask for the key. Resolves with the raw key (already stored), rejects with DioraLocked.
function dioraUnlock(opts) {
opts = opts || {};
if (_unlockPromise) return _unlockPromise;
if (_unlockDismissed && !opts.force) return Promise.reject(dioraLockedError());
_unlockPromise = new Promise((resolve, reject) => {
let stopped = false;
let pair = null, token = null;
const finish = (raw) => {
stopped = true; close(); _unlockPromise = null; _unlockDismissed = false;
_storeRawKey(raw);
resolve(raw);
document.dispatchEvent(new CustomEvent('diora:unlocked'));
};
const close = _openDialog((d, closeFn) => {
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Bücher entsperren'));
d.append(_el('p', {class: 'modal-message'},
'Deine Bücher sind mit deinem diora-Passwort verschlüsselt. Öffne diora auf einem Gerät, '
+ 'auf dem sie schon offen sind, und bestätige dort diesen Code:'));
const code = _el('div', {class: 'unlock-code'}, '··· ···');
const status = _el('p', {class: 'unlock-status'}, 'Frage an deine anderen Geräte …');
d.append(code, status);
d.append(_el('p', {class: 'unlock-or'}, 'Oder gib hier dein diora-Passwort ein:'));
const pw = _el('input', {type: 'password', class: 'modal-input', autocomplete: 'current-password',
placeholder: 'diora-Passwort'});
d.append(pw);
const actions = _el('div', {class: 'modal-actions'});
const later = _el('button', {class: 'btn', type: 'button'}, 'Später');
const ok = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Entsperren');
actions.append(later, ok);
d.append(actions);
const tryPassword = async () => {
if (!pw.value) { pw.focus(); return; }
ok.disabled = true;
status.textContent = 'Schlüssel wird abgeleitet …';
try {
const enc = new TextEncoder();
const username = document.querySelector('meta[name="username"]')?.content || '';
const mat = await crypto.subtle.importKey('raw', enc.encode(pw.value), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key));
if (!(await _keyFits(raw))) { status.textContent = 'Das Passwort passt nicht zu deinen Büchern.'; ok.disabled = false; return; }
finish(raw);
} catch (e) {
status.textContent = 'Fehler: ' + e.message; ok.disabled = false;
}
};
ok.addEventListener('click', tryPassword);
pw.addEventListener('keydown', (e) => { if (e.key === 'Enter') tryPassword(); });
later.addEventListener('click', () => {
stopped = true; closeFn(); _unlockPromise = null; _unlockDismissed = true;
reject(dioraLockedError());
});
// Ask the other devices.
(async () => {
try {
pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
const pub = bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)));
token = (await _ksFetch('/accounts/keyshare/', {pub, device: _deviceName()})).token;
code.textContent = await keyshareCode(pub);
status.textContent = 'Warte auf Freigabe …';
while (!stopped) {
await new Promise(r => setTimeout(r, 3000));
if (stopped) break;
let st;
try { st = await _ksFetch(`/accounts/keyshare/${token}/`); } catch (e) { continue; }
if (st.state === 'pending') continue;
if (st.state === 'declined') { status.textContent = 'Auf dem anderen Gerät abgelehnt.'; break; }
if (st.state === 'expired') { status.textContent = 'Die Anfrage ist abgelaufen. Lade die Seite neu, um es noch einmal zu versuchen.'; break; }
const aes = await _sharedAesKey(pair.privateKey, st.pub);
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: base64ToBytes(st.iv)}, aes, base64ToBytes(st.ct)));
if (raw.length !== 32 || !(await _keyFits(raw))) { status.textContent = 'Der übergebene Schlüssel passt nicht.'; break; }
finish(raw);
break;
}
} catch (e) {
if (!stopped) status.textContent = 'Übergabe nicht möglich (' + e.message + '). Das Passwort geht immer.';
}
})();
});
});
return _unlockPromise;
}
// --- The other side: an unlocked device approves new ones -------------------------------
const _seenShares = new Set();
let _approving = false;
async function _approveShare(req, rawKey) {
const codeText = await keyshareCode(req.pub);
return new Promise((resolve) => {
_openDialog((d, close) => {
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Neues Gerät entsperren?'));
d.append(_el('p', {class: 'modal-message'},
`„${req.device || 'Ein Gerät'}“ möchte deine Bücher öffnen. Gib nur frei, wenn dort derselbe Code steht:`));
d.append(_el('div', {class: 'unlock-code'}, codeText));
const status = _el('p', {class: 'unlock-status'});
d.append(status);
const actions = _el('div', {class: 'modal-actions'});
const no = _el('button', {class: 'btn', type: 'button'}, 'Ablehnen');
const yes = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Freigeben');
actions.append(no, yes);
d.append(actions);
no.addEventListener('click', async () => {
try { await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {decline: true}); } catch (e) {}
close(); resolve(false);
});
yes.addEventListener('click', async () => {
yes.disabled = no.disabled = true;
try {
const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
const aes = await _sharedAesKey(pair.privateKey, req.pub);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aes, rawKey);
await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {
pub: bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))),
iv: bytesToBase64(iv), ct: bytesToBase64(new Uint8Array(ct)),
});
close(); resolve(true);
} catch (e) {
status.textContent = 'Freigabe fehlgeschlagen: ' + e.message;
yes.disabled = no.disabled = false;
}
});
});
});
}
async function checkKeyShareRequests() {
if (_approving || !window.USER_ID || document.hidden) return;
const stored = localStorage.getItem(`diora_enc_key_${window.USER_ID}`);
if (!stored) return;
let data;
try { data = await _ksFetch('/accounts/keyshare/pending/'); } catch (e) { return; }
const req = (data.requests || []).find(r => !_seenShares.has(r.token));
if (!req) return;
_seenShares.add(req.token);
_approving = true;
try { await _approveShare(req, base64ToBytes(stored)); } finally { _approving = false; }
}
(function () {
if (!window.USER_ID || !window.crypto || !crypto.subtle) return;
setTimeout(checkKeyShareRequests, 2000);
setInterval(checkKeyShareRequests, 15000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkKeyShareRequests(); });
})();