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>
254 lines
12 KiB
JavaScript
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(); });
|
|
})();
|