Files
bare/web/js/main.js
T
mayatnikovandClaude Opus 5 597c55301c Этап 1: аккаунты — argon2id, сессии, ключевой блоб, вход и регистрация
Сервер: миграция 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
2026-08-22 14:06:07 +03:00

339 lines
12 KiB
JavaScript

// Загрузка, роутинг по hash и состояние аккаунта.
//
// Экраны из js/ui/ занимаются только разметкой: всё, что меняет состояние —
// вход, регистрация, смена пароля, выход, удаление аккаунта — живёт здесь
// и отдаётся экранам через ctx.
import * as api from "./api.js";
import * as db from "./db.js";
import {
deriveAccountKeys,
exportPrivateJwk,
exportPublicJwk,
fingerprintOf,
generateIdentity,
importPrivate,
importSecret,
openBlob,
parseBlob,
publicJwk,
sealBlob,
validIterations,
wipe,
} from "./crypto.js";
import { clear } from "./ui/dom.js";
import { renderAuth } from "./ui/auth.js";
import { renderSettings } from "./ui/settings.js";
import { frame } from "./ui/shell.js";
// Минимальная длина пароля — ADR-013.
const MIN_PASSWORD = 12;
// Ник — ADR-019. Клиент проверяет ту же форму, что и сервер.
const NICK = /^[a-z0-9_]{2,32}$/;
const state = { config: null, me: null };
// AccountError — то, что случилось с ключевым материалом, а не с сетью.
// Сообщение уже пригодно для показа человеку (ADR-028).
class AccountError extends Error {
constructor(text) {
super(text);
this.name = "AccountError";
}
}
const ctx = {
minPassword: MIN_PASSWORD,
validNick: (nick) => NICK.test(nick),
storedNick,
get config() {
return state.config;
},
get me() {
return state.me;
},
errorText,
ensureConfig,
signUp,
signIn,
changePassword,
deleteAccount,
signOut,
go,
};
// --- роутинг -----------------------------------------------------------
// render рисует экран под текущий hash. Маршруты — docs/ui.md, «Каркас»;
// на этом этапе есть только список и настройки, остальные ведут в пустой
// список: чатов, контактов и комнат ещё нет.
function render() {
const app = document.getElementById("app");
clear(app);
if (!state.me) {
renderAuth(app, ctx);
return;
}
const settings = (location.hash || "#/") === "#/settings";
const { root, main } = frame(ctx, settings ? "screen" : "list");
if (settings) {
renderSettings(main, ctx);
}
app.append(root);
}
function go(hash) {
if (location.hash === hash) {
render();
} else {
location.hash = hash;
}
}
// --- состояние ---------------------------------------------------------
async function ensureConfig() {
if (!state.config) {
state.config = await api.config();
}
return state.config;
}
// restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи
// на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт.
async function restore() {
let who;
try {
who = await api.me();
} catch {
return null;
}
let meta;
try {
meta = await db.meta(["nick", "publicKey", "fingerprint", "privateKey"]);
} catch {
return null;
}
if (!meta.privateKey || meta.nick !== who.nick) {
return null;
}
return { nick: meta.nick, publicKey: meta.publicKey, fingerprint: meta.fingerprint };
}
// storedNick — чьи ключи лежат на устройстве. Вход под другим ником стирает
// базу, поэтому экран входа сначала спрашивает (ADR-029).
async function storedNick() {
try {
const meta = await db.meta(["nick"]);
return meta.nick ?? null;
} catch {
return null;
}
}
// --- аккаунт -----------------------------------------------------------
// derive — вывод ключей по числу итераций, пришедшему от сервера. Границы
// проверяются до PBKDF2: authKey уходит на сервер сразу после вычисления,
// а слишком большой iter не заканчивается вовсе (ADR-030).
async function derive(nick, password, iterations) {
if (!validIterations(iterations)) {
throw new AccountError("параметры ключа не совпали");
}
return deriveAccountKeys(nick, password, iterations);
}
async function signUp(nick, password, invite) {
const config = await ensureConfig();
const iterations = config.kdfIterations;
const { authKey, kek } = await derive(nick, password, iterations);
const { keyPair, secret } = await generateIdentity();
try {
const priv = await exportPrivateJwk(keyPair.privateKey);
const body = {
nick,
authKey,
publicKey: await exportPublicJwk(keyPair.publicKey),
blob: await sealBlob({ nick, iterations, kek, priv, secret }),
};
if (invite) {
body.invite = invite;
}
await api.register(body);
await adopt(nick, priv, secret);
} finally {
wipe(secret);
}
}
async function signIn(nick, password) {
const config = await ensureConfig();
const { authKey, iterations, priv, secret } = await unlock(nick, password);
try {
await adopt(nick, priv, secret);
if (iterations < config.kdfIterations) {
await raise(nick, password, authKey, priv, secret, config.kdfIterations);
}
} finally {
wipe(secret);
}
}
// unlock скачивает ключевой блоб и расшифровывает его. Блоб отдаёт только
// вход — другого источника в протоколе нет, поэтому смена пароля тоже
// проходит через login (docs/crypto.md, «Повышение итераций и смена пароля»).
async function unlock(nick, password) {
const { iterations } = await api.kdf(nick);
const { authKey, kek } = await derive(nick, password, iterations);
const account = await api.login(nick, authKey);
let blob;
try {
blob = parseBlob(account.blob);
} catch {
throw new AccountError("ключ аккаунта повреждён");
}
// Расшифровка идёт по iter из блоба; расхождение с ответом /api/kdf
// означает несогласованность данных (docs/crypto.md, «Ключевой блоб»).
if (blob.iter !== iterations) {
throw new AccountError("параметры ключа не совпали");
}
let opened;
try {
opened = await openBlob(blob, kek, nick);
} catch {
throw new AccountError("ключ аккаунта повреждён");
}
// Публичный ключ, который сервер раздаёт собеседникам, обязан
// соответствовать приватному из блоба.
if (opened.priv.x !== account.publicKey?.x || opened.priv.y !== account.publicKey?.y) {
wipe(opened.secret);
throw new AccountError("ключ аккаунта повреждён");
}
return { authKey, iterations, priv: opened.priv, secret: opened.secret };
}
// adopt кладёт ключи на устройство. Сырые байты дальше не идут:
// в IndexedDB попадают только non-extractable CryptoKey (docs/crypto.md).
async function adopt(nick, priv, secret) {
const previous = await db.meta(["nick"]);
if (previous.nick && previous.nick !== nick) {
// На устройстве история другого аккаунта: один аккаунт на браузерный
// профиль (docs/storage.md). Согласие на стирание спрашивает экран
// входа до вычисления ключа (ADR-029) — здесь оно уже получено.
await db.destroy();
}
const publicKey = publicJwk(priv);
const fingerprint = await fingerprintOf(publicKey);
await db.putMeta({
nick,
publicKey,
fingerprint,
privateKey: await importPrivate(priv),
accountSecret: await importSecret(secret),
});
state.me = { nick, publicKey, fingerprint };
}
// raise — автоматическое повышение итераций сразу после входа, молча
// и без завершения чужих сессий (docs/crypto.md).
async function raise(nick, password, authKey, priv, secret, iterations) {
try {
const next = await derive(nick, password, iterations);
await api.password({
authKey,
newAuthKey: next.authKey,
blob: await sealBlob({ nick, iterations, kek: next.kek, priv, secret }),
logoutOthers: false,
});
} catch {
// Не вышло — вход уже состоялся, повторим при следующем.
}
}
async function changePassword(current, next, logoutOthers) {
const config = await ensureConfig();
const nick = state.me.nick;
// unlock входит заново — иначе не добыть блоб, — а вход заводит новую
// сессию и перезаписывает cookie. Прежнюю закрываем сами и до входа:
// иначе её строка осталась бы жить на сервере, а токена от неё нет уже
// ни у кого. Сорвавшийся unlock после этого оставляет клиент без сессии,
// но не на экране входа: следующая попытка начинается с того же
// служебного выхода и проходит целиком (ADR-031).
await api.dropSession();
const { authKey, priv, secret } = await unlock(nick, current);
try {
const iterations = config.kdfIterations;
const derived = await derive(nick, next, iterations);
await api.password({
authKey,
newAuthKey: derived.authKey,
blob: await sealBlob({ nick, iterations, kek: derived.kek, priv, secret }),
logoutOthers,
});
} finally {
wipe(secret);
}
}
// deleteAccount входит заново тем же порядком, что и смена пароля: сессии
// может уже не быть — её закрывает сорвавшаяся смена пароля, — а DELETE
// /api/me без сессии не проходит. Блоб здесь не нужен, поэтому вход прямой,
// без unlock: аккаунт с испорченным блобом обязан удаляться (ADR-031).
async function deleteAccount(password) {
const nick = state.me.nick;
const { iterations } = await api.kdf(nick);
const { authKey } = await derive(nick, password, iterations);
await api.dropSession();
await api.login(nick, authKey);
await api.deleteMe(authKey);
await forget();
}
async function signOut() {
try {
await api.dropSession();
} catch {
// Сети нет или сервер не справился; база стирается в любом случае.
}
await forget();
}
// forget уносит историю: она на этом устройстве единственная копия
// (docs/storage.md, docs/ui.md).
async function forget() {
await db.destroy();
state.me = null;
}
function errorText(err) {
if (err instanceof AccountError) {
return err.message;
}
return api.errorText(err);
}
// --- старт -------------------------------------------------------------
async function boot() {
db.persist();
try {
await ensureConfig();
} catch {
// Ошибку покажем на первой попытке входа: экран рисуется и без конфигурации.
}
state.me = await restore();
render();
addEventListener("hashchange", render);
// 401 unauthenticated на любом запросе — на экран входа, IndexedDB цела.
api.onSessionExpired(() => {
if (state.me) {
state.me = null;
render();
}
});
}
boot();