'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(); }); })();