Сервер: миграция 001 со всей схемой storage.md, store на modernc.org/sqlite (WAL, foreign_keys, один писатель), фоновая чистка раз в час, argon2id с параметрами ADR-021 и сверкой constant-time, сессии по SHA-256 токена, cookie bare_session, глобальная проверка Origin, девять эндпоинтов аккаунта. Ник в журнал не попадает: для /api/ пишется шаблон маршрута. Клиент: crypto.js по crypto.md построчно — мастер из пароля, два независимых ключа из мастера, ключевой блоб с ником в AAD, отпечаток от сырой точки; db.js со всеми хранилищами версии 1; экран входа и регистрации, настройки со сменой пароля, выходом и удалением аккаунта. Пароль не покидает клиент: проверено на боевом сервере — ни пароля, ни priv.d ни в одном теле запроса, вход на втором устройстве даёт тот же отпечаток. ADR-027: код internal для 500, причина только в журнале. ADR-028: тексты состояний клиента сведены в ui.md. ADR-029: вход под другим ником стирает историю только после подтверждения. ADR-030: верхняя граница итераций KDF, проверка границ на обеих сторонах. ADR-031: служебный выход перед повторным входом не заканчивает сеанс. ADR-032: каталог состояния 0700, файлы базы 0600. Прямые зависимости: modernc.org/sqlite, golang.org/x/crypto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
115 lines
4.2 KiB
JavaScript
115 lines
4.2 KiB
JavaScript
// IndexedDB клиента — схема из docs/storage.md, «Клиент — IndexedDB».
|
|
//
|
|
// База `bare`, версия 1, все хранилища заводятся сразу: одна версия — одна
|
|
// схема, даже если часть хранилищ наполняется на следующих этапах.
|
|
// CryptoKey кладётся объектом: structured clone умеет их хранить,
|
|
// и приватный ключ остаётся non-extractable.
|
|
|
|
const NAME = "bare";
|
|
const VERSION = 1;
|
|
|
|
let opening = null;
|
|
|
|
export function open() {
|
|
if (!opening) {
|
|
opening = new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(NAME, VERSION);
|
|
request.onupgradeneeded = () => create(request.result);
|
|
request.onsuccess = () => {
|
|
const db = request.result;
|
|
// Соседняя вкладка стирает базу при выходе из аккаунта — отпускаем
|
|
// соединение, иначе удаление зависнет заблокированным.
|
|
db.onversionchange = () => {
|
|
db.close();
|
|
opening = null;
|
|
};
|
|
resolve(db);
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
request.onblocked = () => reject(new Error("база занята другой вкладкой"));
|
|
});
|
|
opening.catch(() => {
|
|
opening = null;
|
|
});
|
|
}
|
|
return opening;
|
|
}
|
|
|
|
function create(db) {
|
|
db.createObjectStore("meta"); // ключ — строка снаружи значения
|
|
db.createObjectStore("chats", { keyPath: "id" });
|
|
const messages = db.createObjectStore("messages", { keyPath: "id" });
|
|
messages.createIndex("chat", ["chatId", "id"]);
|
|
db.createObjectStore("roomKeys", { keyPath: ["roomId", "keyId"] });
|
|
db.createObjectStore("peers", { keyPath: "nick" });
|
|
}
|
|
|
|
function done(tx) {
|
|
return new Promise((resolve, reject) => {
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
tx.onabort = () => reject(tx.error ?? new Error("транзакция отменена"));
|
|
});
|
|
}
|
|
|
|
function value(request) {
|
|
return new Promise((resolve, reject) => {
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// meta читает несколько ключей одной транзакцией.
|
|
export async function meta(keys) {
|
|
const db = await open();
|
|
const store = db.transaction("meta", "readonly").objectStore("meta");
|
|
const out = {};
|
|
await Promise.all(keys.map(async (key) => {
|
|
out[key] = await value(store.get(key));
|
|
}));
|
|
return out;
|
|
}
|
|
|
|
// putMeta пишет пары «ключ — значение» одной транзакцией.
|
|
export async function putMeta(entries) {
|
|
const db = await open();
|
|
const tx = db.transaction("meta", "readwrite");
|
|
const store = tx.objectStore("meta");
|
|
for (const [key, item] of Object.entries(entries)) {
|
|
store.put(item, key);
|
|
}
|
|
await done(tx);
|
|
}
|
|
|
|
// persist просит браузер не вычищать базу: история на устройстве —
|
|
// единственная копия (docs/storage.md).
|
|
export async function persist() {
|
|
if (!navigator.storage?.persist) {
|
|
return false;
|
|
}
|
|
try {
|
|
return await navigator.storage.persist();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// destroy стирает базу целиком: выход из аккаунта уносит историю
|
|
// (docs/storage.md, один аккаунт на браузерный профиль).
|
|
export async function destroy() {
|
|
const db = await open().catch(() => null);
|
|
if (db) {
|
|
db.close();
|
|
}
|
|
opening = null;
|
|
await new Promise((resolve, reject) => {
|
|
const request = indexedDB.deleteDatabase(NAME);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
// Блокировка — не ответ: соседние вкладки закрывают соединение
|
|
// по versionchange, после чего удаление доходит до success.
|
|
// Сказать «история удалена», не удалив её, нельзя.
|
|
request.onblocked = () => {};
|
|
});
|
|
}
|