Этап 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
This commit is contained in:
2026-08-22 14:06:07 +03:00
co-authored by Claude Opus 5
parent 32717cb7dd
commit 597c55301c
39 changed files with 4220 additions and 96 deletions
+94
View File
@@ -0,0 +1,94 @@
// Сборка узлов. innerHTML не используется нигде: сообщения и ники —
// пользовательские данные (docs/ui.md, CSP из ADR-021).
const SVG = "http://www.w3.org/2000/svg";
export function el(tag, className, text) {
const node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined) {
node.textContent = text;
}
return node;
}
export function clear(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
// mark — знак «скобы»: четыре угла рамки (docs/identity/brief.md).
export function mark() {
const svg = document.createElementNS(SVG, "svg");
svg.setAttribute("class", "mark");
svg.setAttribute("viewBox", "0 0 64 64");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "7");
svg.setAttribute("aria-hidden", "true");
for (const d of ["M10 26V10h16", "M38 10h16v16", "M54 38v16H38", "M26 54H10V38"]) {
const path = document.createElementNS(SVG, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
// field — подпись и строка ввода. Отдаёт и то, и другое: подпись идёт
// в форму, ввод нужен обработчику.
export function field(label, { type = "text", name, autocomplete } = {}) {
const wrap = el("label", "field");
wrap.append(el("span", null, label));
const input = el("input");
input.type = type;
if (name) {
input.name = name;
}
if (autocomplete) {
input.autocomplete = autocomplete;
}
input.autocapitalize = "off";
input.spellcheck = false;
wrap.append(input);
return { wrap, input };
}
export function button(text, className = "button") {
const node = el("button", className, text);
node.type = "button";
return node;
}
// confirmPanel — вопрос и две кнопки; спрятан, пока не спросили.
// Вопрос отдаётся наружу: его текст бывает известен только к моменту показа.
export function confirmPanel(question, yesLabel) {
const root = el("div");
root.hidden = true;
const text = el("p", "confirm", question);
const yes = button(yesLabel);
const no = button("отмена");
const row = el("div", "row");
row.append(yes, no);
root.append(text, row);
return { root, text, yes, no };
}
// message — строка состояния под формой: ошибка цветом mark, ответ — mute.
export function message() {
const node = el("p", "message");
node.setAttribute("aria-live", "polite");
return node;
}
export function setError(node, text) {
node.className = "message message--error";
node.textContent = text;
}
export function setNote(node, text) {
node.className = "message";
node.textContent = text;
}