Экспорт: ключ архива из секрета аккаунта через HKDF, заголовок ровно 65 байт (magic, версия, соль, 32 сырых байта отпечатка владельца, iv) и он же целиком AAD шифротекста. Ника владельца в файле нет. Импорт сверяет отпечаток до расшифровки, сливает идемпотентно по id, а записи peers берёт только для ников, которых в локальном TOFU ещё нет: архивом доверие к ключу не перебить. Настройки: устройства с датой и пометкой «это устройство», «занято N МБ», кнопка «экспортировать» в подтверждении выхода и в подтверждении входа под другим ником — долг этапа 1 и обещание ADR-029 закрыты. Лента: страницы по 50 с подгрузкой вверх без прыжка прокрутки; новая страница вставляется, а не пересобирает ленту. ADR-050: импорт не перезаписывает лежащую запись — у своей есть состояние отправки, которого в архиве нет. ADR-054: архив — недоверенный ввод. Ревью собрало архивы с ts вне диапазона Date, мусорным lastId, ником с bidi-переопределением и roomId с обходом пути: каждый из них навсегда ломал ленту или счётчик. Теперь форма ника, roomId, id, ts и автора проверяется, а lastId из файла не читается вовсе. ADR-051, 052, 053: тексты и кнопки, устройства и место, страницы ленты без виртуализации. Приёмка: формат сверен побайтно на модулях, скачанных с боевого сервера, — смещения заголовка, отпечаток сырыми байтами, новые соль и iv на каждый экспорт, подмена любого байта заголовка ломает расшифровку, чужой секрет не открывает, семь видов битых файлов отвергнуты. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
210 lines
6.6 KiB
JavaScript
210 lines
6.6 KiB
JavaScript
// Экран входа и регистрации — docs/ui.md, «Вход и регистрация».
|
||
|
||
import { exportHistory } from "../export.js";
|
||
import { EXPORT_FAILED, clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js";
|
||
|
||
const HINT = "пароль — это ключ шифрования, а не запись в базе. восстановления нет. "
|
||
+ "не короче 12 символов; лучше — фраза из нескольких слов.";
|
||
|
||
const MODES = [
|
||
["in", "вход"],
|
||
["up", "регистрация"],
|
||
];
|
||
|
||
export function renderAuth(root, ctx) {
|
||
// Состояние экрана переживает перерисовку: режим, введённый ник и ошибка.
|
||
const view = { mode: "in", nick: "", error: "" };
|
||
const paint = () => {
|
||
clear(root);
|
||
root.append(screen(ctx, view, paint));
|
||
};
|
||
paint();
|
||
}
|
||
|
||
function screen(ctx, view, paint) {
|
||
const main = el("main", "auth");
|
||
const inner = el("div", "auth__inner");
|
||
main.append(inner);
|
||
|
||
const brand = el("div", "auth__brand");
|
||
brand.append(mark(), el("span", null, "bare"));
|
||
inner.append(brand);
|
||
inner.append(tabs(view, paint));
|
||
|
||
const form = el("form", "form");
|
||
form.noValidate = true;
|
||
|
||
const register = view.mode === "up";
|
||
const nick = field("ник", { name: "nick", autocomplete: "username" });
|
||
nick.input.value = view.nick;
|
||
const password = field("пароль", {
|
||
type: "password",
|
||
name: "password",
|
||
autocomplete: register ? "new-password" : "current-password",
|
||
});
|
||
form.append(nick.wrap, password.wrap);
|
||
|
||
let invite = null;
|
||
if (register) {
|
||
form.append(el("p", "hint", HINT));
|
||
if (ctx.config?.inviteRequired) {
|
||
invite = field("инвайт-код", { name: "invite", autocomplete: "off" });
|
||
form.append(invite.wrap);
|
||
}
|
||
}
|
||
|
||
const label = register ? "регистрация" : "вход";
|
||
const submit = el("button", "button", label);
|
||
submit.type = "submit";
|
||
form.append(submit);
|
||
|
||
// На устройстве могут лежать ключи другого ника: вход под этим сотрёт
|
||
// историю прежнего, поэтому сначала подтверждение (ADR-029). История
|
||
// на устройстве — единственная копия, и подтверждение предлагает сначала
|
||
// сохранить её: секрет прежнего аккаунта ещё здесь, экспорту сессия
|
||
// не нужна (ADR-014).
|
||
const wipe = confirmPanel("", "удалить", "экспортировать");
|
||
form.append(wipe.root);
|
||
|
||
const note = message();
|
||
form.append(note);
|
||
if (view.error) {
|
||
setError(note, view.error);
|
||
}
|
||
|
||
const fail = (text) => {
|
||
view.error = text;
|
||
if (text) {
|
||
setError(note, text);
|
||
} else {
|
||
setNote(note, "");
|
||
}
|
||
};
|
||
|
||
// run — то, ради чего экран: PBKDF2 и запрос к серверу. Вызывается либо
|
||
// сразу, либо после подтверждения стирания.
|
||
const run = async () => {
|
||
submit.disabled = true;
|
||
submit.textContent = "вычисляем ключ…";
|
||
const pass = password.input.value;
|
||
try {
|
||
if (register) {
|
||
await ctx.signUp(view.nick, pass, invite ? invite.input.value.trim() : "");
|
||
} else {
|
||
await ctx.signIn(view.nick, pass);
|
||
}
|
||
ctx.go("#/");
|
||
} catch (err) {
|
||
submit.disabled = false;
|
||
submit.textContent = label;
|
||
fail(ctx.errorText(err));
|
||
}
|
||
};
|
||
|
||
const hideWipe = () => {
|
||
wipe.root.hidden = true;
|
||
submit.hidden = false;
|
||
};
|
||
// Экспорт подтверждение не закрывает: архив скачался, а входить или нет —
|
||
// отдельное решение.
|
||
wipe.extra.addEventListener("click", async () => {
|
||
if (wipe.extra.disabled) {
|
||
return;
|
||
}
|
||
wipe.extra.disabled = true;
|
||
fail("");
|
||
try {
|
||
await exportHistory();
|
||
} catch {
|
||
fail(EXPORT_FAILED);
|
||
} finally {
|
||
wipe.extra.disabled = false;
|
||
}
|
||
});
|
||
wipe.no.addEventListener("click", () => {
|
||
hideWipe();
|
||
submit.focus();
|
||
});
|
||
wipe.yes.addEventListener("click", () => {
|
||
hideWipe();
|
||
run();
|
||
});
|
||
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (submit.disabled) {
|
||
return;
|
||
}
|
||
view.nick = nick.input.value.trim().toLowerCase();
|
||
nick.input.value = view.nick;
|
||
const pass = password.input.value;
|
||
fail("");
|
||
hideWipe();
|
||
|
||
if (!ctx.validNick(view.nick)) {
|
||
fail("ник: 2–32 символа, a–z, 0–9, _");
|
||
nick.input.focus();
|
||
return;
|
||
}
|
||
// Длина пароля проверяется в обоих режимах: короче минимума пароля
|
||
// нет ни у одного аккаунта, считать по нему PBKDF2 незачем (docs/ui.md).
|
||
if ([...pass].length < ctx.minPassword) {
|
||
fail(`пароль: не короче ${ctx.minPassword} символов`);
|
||
password.input.focus();
|
||
return;
|
||
}
|
||
try {
|
||
await ctx.ensureConfig();
|
||
} catch (err) {
|
||
fail(ctx.errorText(err));
|
||
return;
|
||
}
|
||
// Поле инвайта могло не появиться: конфигурация приехала только что.
|
||
if (register && ctx.config.inviteRequired && invite === null) {
|
||
view.error = "нужен инвайт-код";
|
||
paint();
|
||
return;
|
||
}
|
||
|
||
const other = await ctx.storedNick();
|
||
if (other && other !== view.nick) {
|
||
wipe.text.textContent = `на этом устройстве история @${other}. вход под другим ником удалит её.`;
|
||
wipe.root.hidden = false;
|
||
submit.hidden = true;
|
||
// Фокус — на «экспортировать»: с него безопасно начинать.
|
||
wipe.extra.focus();
|
||
return;
|
||
}
|
||
await run();
|
||
});
|
||
|
||
inner.append(form);
|
||
return main;
|
||
}
|
||
|
||
// tabs — переключатель «вход / регистрация».
|
||
function tabs(view, paint) {
|
||
const wrap = el("div", "tabs");
|
||
MODES.forEach(([mode, text], index) => {
|
||
if (index > 0) {
|
||
wrap.append(el("span", "tabs__sep", "/"));
|
||
}
|
||
const tab = el("button", "tab", text);
|
||
tab.type = "button";
|
||
const on = view.mode === mode;
|
||
if (on) {
|
||
tab.classList.add("is-on");
|
||
}
|
||
tab.setAttribute("aria-pressed", String(on));
|
||
tab.addEventListener("click", () => {
|
||
if (view.mode !== mode) {
|
||
view.mode = mode;
|
||
view.error = "";
|
||
paint();
|
||
}
|
||
});
|
||
wrap.append(tab);
|
||
});
|
||
return wrap;
|
||
}
|