Этап 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:
+382
-2
@@ -35,10 +35,33 @@ body {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* заставка: знак и слово, больше пока ничего */
|
||||
/* #app — колонка ровно в высоту окна: от неё считают высоту экраны,
|
||||
поэтому сайдбар с «ты: @nick» стоит на месте, а прокручивается
|
||||
только содержимое */
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* знак: четыре угла, один цвет, без скруглений */
|
||||
|
||||
.mark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* заставка: знак и слово, пока не поднялись модули */
|
||||
|
||||
.boot {
|
||||
min-height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -54,3 +77,360 @@ body {
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* формы: рамка 1 px ink, прямые углы, цель нажатия 44 px */
|
||||
|
||||
.form {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--ink);
|
||||
border-radius: 0;
|
||||
background: var(--bone);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--ink);
|
||||
border-radius: 0;
|
||||
background: var(--bone);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button[disabled] {
|
||||
border-color: var(--edge);
|
||||
color: var(--stone);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* hidden обязан прятать: display у .button перебивает таблицу браузера,
|
||||
поэтому кнопке нужно отдельное правило */
|
||||
|
||||
.button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--ink);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.row .button {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* подписи и строки состояния: акцент mark — только ошибка */
|
||||
|
||||
.hint {
|
||||
margin: -4px 0 14px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 18px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.message--error {
|
||||
color: var(--mark);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
/* вход и регистрация */
|
||||
|
||||
.auth {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth__inner {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.auth .form {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.auth__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 28px;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--mute);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.is-on {
|
||||
background: var(--ink);
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.tabs__sep {
|
||||
color: var(--stone);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* каркас: сайдбар и экран */
|
||||
|
||||
.shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 0;
|
||||
padding: 0 8px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--stone);
|
||||
}
|
||||
|
||||
.items {
|
||||
margin: 0 0 22px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: auto;
|
||||
padding: 16px 20px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: none;
|
||||
color: var(--mute);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.me i {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.back {
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
color: var(--mute);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* настройки */
|
||||
|
||||
.settings {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.block--first {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.self {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.fp {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.block .form {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 760px) {
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 224px 1fr;
|
||||
}
|
||||
|
||||
.side {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.head {
|
||||
height: 64px;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 28px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* мобильный: один экран за раз, цели нажатия не меньше 44 px */
|
||||
|
||||
@media (max-width: 759px) {
|
||||
.shell[data-screen="list"] .main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell[data-screen="screen"] .side {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab,
|
||||
.back {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -9,16 +9,19 @@
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-180.png">
|
||||
<script type="module" src="/js/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="boot">
|
||||
<svg class="mark" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="7" aria-hidden="true">
|
||||
<path d="M10 26V10h16"/>
|
||||
<path d="M38 10h16v16"/>
|
||||
<path d="M54 38v16H38"/>
|
||||
<path d="M26 54H10V38"/>
|
||||
</svg>
|
||||
<span class="word">bare</span>
|
||||
</main>
|
||||
<div id="app">
|
||||
<main class="boot">
|
||||
<svg class="mark" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="7" aria-hidden="true">
|
||||
<path d="M10 26V10h16"/>
|
||||
<path d="M38 10h16v16"/>
|
||||
<path d="M54 38v16H38"/>
|
||||
<path d="M26 54H10V38"/>
|
||||
</svg>
|
||||
<span class="word">bare</span>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Обёртки над fetch. Форма запросов и ответов — docs/protocol.md:
|
||||
// JSON в обе стороны, cookie сессии, ошибка — {error, message}.
|
||||
// SSE и ACK появятся на этапе 2.
|
||||
|
||||
// ApiError — ответ сервера с кодом из перечня docs/protocol.md.
|
||||
export class ApiError extends Error {
|
||||
constructor(code, message, status, field) {
|
||||
super(message || code);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkError — запрос не дошёл: сети нет, сервер не ответил.
|
||||
// Это состояние клиента, а не код протокола.
|
||||
export class NetworkError extends Error {
|
||||
constructor() {
|
||||
super("нет соединения");
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
// Тексты состояний и ошибок — docs/ui.md и ADR-028.
|
||||
const TEXT = {
|
||||
invalid_credentials: "неверный ник или пароль",
|
||||
nick_taken: "ник занят",
|
||||
invalid_nick: "ник: 2–32 символа, a–z, 0–9, _",
|
||||
invite_required: "нужен инвайт-код",
|
||||
invalid_invite: "инвайт-код не подходит",
|
||||
rate_limited: "слишком часто, попробуйте позже",
|
||||
};
|
||||
|
||||
export function errorText(err) {
|
||||
if (err instanceof NetworkError) {
|
||||
return "нет соединения";
|
||||
}
|
||||
if (err instanceof ApiError && TEXT[err.code]) {
|
||||
return TEXT[err.code];
|
||||
}
|
||||
return "сервер не справился, попробуйте позже";
|
||||
}
|
||||
|
||||
// expired вызывается, когда сервер сказал «нужен вход»: сессия истекла
|
||||
// или её завершили с другого устройства. IndexedDB при этом не трогается
|
||||
// (docs/ui.md, «Сеть и состояния»).
|
||||
let expired = () => {};
|
||||
|
||||
export function onSessionExpired(handler) {
|
||||
expired = handler;
|
||||
}
|
||||
|
||||
// quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где
|
||||
// «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession).
|
||||
async function request(method, path, body, { quiet = false } = {}) {
|
||||
const init = { method, credentials: "same-origin", cache: "no-store" };
|
||||
if (body !== undefined) {
|
||||
init.headers = { "Content-Type": "application/json" };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(path, init);
|
||||
} catch {
|
||||
throw new NetworkError();
|
||||
}
|
||||
|
||||
let data = null;
|
||||
if ((response.headers.get("Content-Type") ?? "").startsWith("application/json")) {
|
||||
data = await response.json().catch(() => null);
|
||||
}
|
||||
if (response.ok) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const code = typeof data?.error === "string" ? data.error : "internal";
|
||||
// Отличаем истёкшую сессию от неверного пароля: 401 invalid_credentials —
|
||||
// обычная ошибка формы входа, 401 unauthenticated — выход на экран входа.
|
||||
if (code === "unauthenticated" && !quiet) {
|
||||
expired();
|
||||
}
|
||||
throw new ApiError(code, data?.message, response.status, data?.field);
|
||||
}
|
||||
|
||||
export function config() {
|
||||
return request("GET", "/api/config");
|
||||
}
|
||||
|
||||
export function kdf(nick) {
|
||||
return request("GET", `/api/kdf?nick=${encodeURIComponent(nick)}`);
|
||||
}
|
||||
|
||||
export function register(body) {
|
||||
return request("POST", "/api/register", body);
|
||||
}
|
||||
|
||||
export function login(nick, authKey) {
|
||||
return request("POST", "/api/login", { nick, authKey });
|
||||
}
|
||||
|
||||
export function me() {
|
||||
return request("GET", "/api/me");
|
||||
}
|
||||
|
||||
// dropSession — служебный выход перед повторным входом (ADR-031). Смена
|
||||
// пароля и удаление аккаунта входят заново, а вход перезаписывает cookie:
|
||||
// прежнюю сессию закрываем сами, пока её токен ещё при нас.
|
||||
//
|
||||
// 401 unauthenticated здесь означает «сессии и так нет» — это успех, а не
|
||||
// конец сеанса: следующим шагом идёт login, он заведёт новую. Остальные
|
||||
// отказы поднимаются наверх: при живой сессии входить заново нельзя,
|
||||
// её строка осталась бы на сервере без владельца.
|
||||
export async function dropSession() {
|
||||
try {
|
||||
await request("POST", "/api/logout", undefined, { quiet: true });
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || err.code !== "unauthenticated") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function password(body) {
|
||||
return request("POST", "/api/password", body);
|
||||
}
|
||||
|
||||
export function deleteMe(authKey) {
|
||||
return request("DELETE", "/api/me", { authKey });
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Криптография клиента — всё по docs/crypto.md.
|
||||
//
|
||||
// Только WebCrypto: crypto.subtle и crypto.getRandomValues. Строки-константы
|
||||
// входят в вывод ключей, менять их нельзя. Бинарные поля — base64url без
|
||||
// паддинга, пароль нормализуется в NFC.
|
||||
//
|
||||
// Модуль не знает про DOM: его можно импортировать в node и прогнать.
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
|
||||
// Константы вывода ключей (docs/crypto.md). Буквальные строки.
|
||||
const SALT_PREFIX = "bare-v1:";
|
||||
const INFO_AUTH = "bare-auth-v1";
|
||||
const INFO_KEK = "bare-kek-v1";
|
||||
const BLOB_AAD = "bare-blob-v1|";
|
||||
|
||||
// Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM.
|
||||
export const SECRET_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
|
||||
// Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от
|
||||
// сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а
|
||||
// считает по нему клиент, поэтому проверить его может только он.
|
||||
export const MIN_ITERATIONS = 600_000;
|
||||
export const MAX_ITERATIONS = 10_000_000;
|
||||
|
||||
export function validIterations(iterations) {
|
||||
return Number.isSafeInteger(iterations)
|
||||
&& iterations >= MIN_ITERATIONS
|
||||
&& iterations <= MAX_ITERATIONS;
|
||||
}
|
||||
|
||||
// Пустая соль HKDF: «salt = пусто» из docs/crypto.md.
|
||||
const EMPTY = new Uint8Array(0);
|
||||
const ECDH_P256 = { name: "ECDH", namedCurve: "P-256" };
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export function utf8(text) {
|
||||
return encoder.encode(text);
|
||||
}
|
||||
|
||||
export function random(length) {
|
||||
const bytes = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function b64url(input) {
|
||||
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
}
|
||||
|
||||
export function unb64url(text) {
|
||||
if (typeof text !== "string" || /[^A-Za-z0-9_-]/.test(text)) {
|
||||
throw new Error("не base64url");
|
||||
}
|
||||
const padded = text.replaceAll("-", "+").replaceAll("_", "/");
|
||||
const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function hex(input) {
|
||||
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
out += bytes[i].toString(16).padStart(2, "0");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// fingerprintGroups режет отпечаток на группы по 4 символа: так его
|
||||
// показывают человеку (ADR-016).
|
||||
export function fingerprintGroups(fingerprint) {
|
||||
return fingerprint.match(/.{1,4}/g) ?? [];
|
||||
}
|
||||
|
||||
// wipe затирает сырые байты, когда они больше не нужны.
|
||||
export function wipe(bytes) {
|
||||
if (bytes instanceof Uint8Array) {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
// accountSalt — соль KDF: детерминированная, известна до входа.
|
||||
export async function accountSalt(nick) {
|
||||
return new Uint8Array(await subtle.digest("SHA-256", utf8(SALT_PREFIX + nick)));
|
||||
}
|
||||
|
||||
// deriveAccountKeys выводит мастер-ключ из пароля и два независимых ключа
|
||||
// из него: authKey уходит на сервер, kek шифрует ключевой блоб.
|
||||
// Пароль и мастер остаются в памяти и затираются здесь же.
|
||||
export async function deriveAccountKeys(nick, password, iterations) {
|
||||
// Ослабленный или неподъёмный iter не должен доходить до PBKDF2:
|
||||
// authKey, выведенный по нему, уходит на сервер (ADR-030).
|
||||
if (!validIterations(iterations)) {
|
||||
throw new Error("iter вне границ");
|
||||
}
|
||||
const salt = await accountSalt(nick);
|
||||
const secret = utf8(password.normalize("NFC"));
|
||||
const material = await subtle.importKey("raw", secret, "PBKDF2", false, ["deriveBits"]);
|
||||
wipe(secret);
|
||||
|
||||
const masterBits = await subtle.deriveBits(
|
||||
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
||||
material,
|
||||
256,
|
||||
);
|
||||
const master = await subtle.importKey("raw", masterBits, "HKDF", false, ["deriveBits", "deriveKey"]);
|
||||
wipe(new Uint8Array(masterBits));
|
||||
|
||||
const authBits = await subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: EMPTY, info: utf8(INFO_AUTH) },
|
||||
master,
|
||||
256,
|
||||
);
|
||||
const kek = await subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: EMPTY, info: utf8(INFO_KEK) },
|
||||
master,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
const authKey = b64url(authBits);
|
||||
wipe(new Uint8Array(authBits));
|
||||
return { authKey, kek };
|
||||
}
|
||||
|
||||
// generateIdentity — ключевая пара аккаунта и секрет для экспорта истории.
|
||||
// Пара extractable: приватный ключ нужно упаковать в блоб.
|
||||
export async function generateIdentity() {
|
||||
const keyPair = await subtle.generateKey(ECDH_P256, true, ["deriveBits"]);
|
||||
return { keyPair, secret: random(SECRET_LEN) };
|
||||
}
|
||||
|
||||
// publicJwk — публичный ключ ровно в той форме, в какой его хранит сервер.
|
||||
export function publicJwk(jwk) {
|
||||
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
||||
}
|
||||
|
||||
export async function exportPublicJwk(publicKey) {
|
||||
return publicJwk(await subtle.exportKey("jwk", publicKey));
|
||||
}
|
||||
|
||||
export async function exportPrivateJwk(privateKey) {
|
||||
return subtle.exportKey("jwk", privateKey);
|
||||
}
|
||||
|
||||
// importPublic — чужой или свой публичный ключ из JWK. Extractable: из него
|
||||
// считается отпечаток по сырой точке.
|
||||
export async function importPublic(jwk) {
|
||||
return subtle.importKey("jwk", publicJwk(jwk), ECDH_P256, true, []);
|
||||
}
|
||||
|
||||
// importPrivate — приватный ключ после расшифровки блоба: наружу больше
|
||||
// не выходит (docs/crypto.md, «Хранение на устройстве»).
|
||||
export async function importPrivate(jwk) {
|
||||
return subtle.importKey("jwk", jwk, ECDH_P256, false, ["deriveBits"]);
|
||||
}
|
||||
|
||||
// importSecret — секрет аккаунта как ключ HKDF, тоже non-extractable.
|
||||
export async function importSecret(bytes) {
|
||||
return subtle.importKey("raw", bytes, "HKDF", false, ["deriveKey", "deriveBits"]);
|
||||
}
|
||||
|
||||
// fingerprint — SHA-256 несжатой точки публичного ключа, 64 hex строчными.
|
||||
export async function fingerprint(publicKey) {
|
||||
const raw = await subtle.exportKey("raw", publicKey);
|
||||
return hex(await subtle.digest("SHA-256", raw));
|
||||
}
|
||||
|
||||
export async function fingerprintOf(jwk) {
|
||||
return fingerprint(await importPublic(jwk));
|
||||
}
|
||||
|
||||
// sealBlob собирает ключевой блоб: приватный ключ и секрет аккаунта под kek.
|
||||
// Ник в AAD — блоб одного аккаунта не подходит другому.
|
||||
export async function sealBlob({ nick, iterations, kek, priv, secret }) {
|
||||
const plain = utf8(JSON.stringify({ priv, secret: b64url(secret) }));
|
||||
const iv = random(IV_LEN);
|
||||
const ct = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv, additionalData: utf8(BLOB_AAD + nick) },
|
||||
kek,
|
||||
plain,
|
||||
);
|
||||
wipe(plain);
|
||||
return JSON.stringify({ v: 1, iter: iterations, iv: b64url(iv), ct: b64url(ct) });
|
||||
}
|
||||
|
||||
// parseBlob читает форму блоба, не расшифровывая: iter нужен до того,
|
||||
// как появится kek.
|
||||
export function parseBlob(text) {
|
||||
const blob = JSON.parse(text);
|
||||
if (blob === null || typeof blob !== "object") {
|
||||
throw new Error("блоб — не объект");
|
||||
}
|
||||
if (blob.v !== 1) {
|
||||
throw new Error("версия блоба не 1");
|
||||
}
|
||||
if (!validIterations(blob.iter)) {
|
||||
throw new Error("iter блоба вне границ");
|
||||
}
|
||||
if (typeof blob.iv !== "string" || typeof blob.ct !== "string") {
|
||||
throw new Error("iv или ct блоба — не строка");
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
// openBlob расшифровывает блоб и отдаёт сырые JWK приватного ключа
|
||||
// и байты секрета. Жить им — до импорта в CryptoKey.
|
||||
export async function openBlob(blob, kek, nick) {
|
||||
const iv = unb64url(blob.iv);
|
||||
const ct = unb64url(blob.ct);
|
||||
if (iv.length !== IV_LEN) {
|
||||
throw new Error("iv блоба — не 12 байт");
|
||||
}
|
||||
const plain = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv, additionalData: utf8(BLOB_AAD + nick) },
|
||||
kek,
|
||||
ct,
|
||||
);
|
||||
const bytes = new Uint8Array(plain);
|
||||
const parsed = JSON.parse(decoder.decode(bytes));
|
||||
wipe(bytes);
|
||||
if (parsed === null || typeof parsed !== "object" || parsed.priv === null || typeof parsed.priv !== "object") {
|
||||
throw new Error("в блобе нет приватного ключа");
|
||||
}
|
||||
const secret = unb64url(parsed.secret);
|
||||
if (secret.length !== SECRET_LEN) {
|
||||
throw new Error("секрет аккаунта — не 32 байта");
|
||||
}
|
||||
return { priv: parsed.priv, secret };
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// 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 = () => {};
|
||||
});
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
// Загрузка, роутинг по 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();
|
||||
@@ -0,0 +1,188 @@
|
||||
// Экран входа и регистрации — docs/ui.md, «Вход и регистрация».
|
||||
|
||||
import { 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).
|
||||
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.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.yes.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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Настройки — docs/ui.md, «Настройки». На этом этапе только разделы,
|
||||
// которые уже работают: кто ты, смена пароля, выход, удаление аккаунта.
|
||||
// Уведомления, устройства, история и установка приложения — дальше по плану.
|
||||
|
||||
import { ApiError } from "../api.js";
|
||||
import { fingerprintGroups } from "../crypto.js";
|
||||
import { button, confirmPanel, el, field, message, setError, setNote } from "./dom.js";
|
||||
|
||||
export function renderSettings(root, ctx) {
|
||||
root.append(head(ctx));
|
||||
const body = el("div", "body settings");
|
||||
body.append(identity(ctx), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx));
|
||||
root.append(body);
|
||||
}
|
||||
|
||||
function head(ctx) {
|
||||
const bar = el("div", "head");
|
||||
const back = button("назад", "back");
|
||||
back.addEventListener("click", () => ctx.go("#/"));
|
||||
bar.append(back, el("span", "title", "настройки"));
|
||||
return bar;
|
||||
}
|
||||
|
||||
// identity — «ты: @nick» и свой отпечаток группами по 4 в две строки.
|
||||
function identity(ctx) {
|
||||
const box = el("section", "block block--first");
|
||||
box.append(el("p", "self", `ты: @${ctx.me.nick}`));
|
||||
const groups = fingerprintGroups(ctx.me.fingerprint ?? "");
|
||||
box.append(el("p", "fp", groups.slice(0, 8).join(" ")));
|
||||
box.append(el("p", "fp", groups.slice(8).join(" ")));
|
||||
return box;
|
||||
}
|
||||
|
||||
function passwordBlock(ctx) {
|
||||
const box = block("сменить пароль");
|
||||
const form = el("form", "form");
|
||||
form.noValidate = true;
|
||||
|
||||
const current = field("старый", { type: "password", autocomplete: "current-password" });
|
||||
const next = field("новый", { type: "password", autocomplete: "new-password" });
|
||||
const again = field("повтор", { type: "password", autocomplete: "new-password" });
|
||||
form.append(current.wrap, next.wrap, again.wrap);
|
||||
|
||||
const check = el("label", "check");
|
||||
const others = el("input");
|
||||
others.type = "checkbox";
|
||||
// Смена пароля по желанию завершает остальные сессии (ADR-015);
|
||||
// снять галочку можно, но это осознанный выбор, а не умолчание.
|
||||
others.checked = true;
|
||||
check.append(others, el("span", null, "выйти на других устройствах"));
|
||||
form.append(check);
|
||||
|
||||
const submit = el("button", "button", "сменить пароль");
|
||||
submit.type = "submit";
|
||||
const note = message();
|
||||
form.append(submit, note);
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled) {
|
||||
return;
|
||||
}
|
||||
setNote(note, "");
|
||||
if (next.input.value !== again.input.value) {
|
||||
setError(note, "пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
if ([...next.input.value].length < ctx.minPassword) {
|
||||
setError(note, `пароль: не короче ${ctx.minPassword} символов`);
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
submit.textContent = "вычисляем ключ…";
|
||||
try {
|
||||
await ctx.changePassword(current.input.value, next.input.value, others.checked);
|
||||
for (const input of [current.input, next.input, again.input]) {
|
||||
input.value = "";
|
||||
}
|
||||
setNote(note, "пароль изменён");
|
||||
} catch (err) {
|
||||
setError(note, passwordError(ctx, err));
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = "сменить пароль";
|
||||
}
|
||||
});
|
||||
|
||||
box.append(form);
|
||||
return box;
|
||||
}
|
||||
|
||||
function exitBlock(ctx) {
|
||||
const box = block("выйти");
|
||||
const start = button("выйти");
|
||||
// Кнопки «экспортировать» пока нет: экспорт — этап 5 (docs/plan.md).
|
||||
const panel = confirmPanel("история на этом устройстве будет удалена. экспортировать сначала?", "выйти");
|
||||
|
||||
start.addEventListener("click", () => {
|
||||
start.hidden = true;
|
||||
panel.root.hidden = false;
|
||||
panel.yes.focus();
|
||||
});
|
||||
panel.no.addEventListener("click", () => {
|
||||
panel.root.hidden = true;
|
||||
start.hidden = false;
|
||||
start.focus();
|
||||
});
|
||||
panel.yes.addEventListener("click", async () => {
|
||||
panel.yes.disabled = true;
|
||||
panel.no.disabled = true;
|
||||
await ctx.signOut();
|
||||
ctx.go("#/");
|
||||
});
|
||||
|
||||
box.append(start, panel.root);
|
||||
return box;
|
||||
}
|
||||
|
||||
function deleteBlock(ctx) {
|
||||
const box = block("удалить аккаунт");
|
||||
const form = el("form", "form");
|
||||
form.noValidate = true;
|
||||
|
||||
const password = field("пароль", { type: "password", autocomplete: "current-password" });
|
||||
const submit = el("button", "button", "удалить аккаунт");
|
||||
submit.type = "submit";
|
||||
const panel = confirmPanel("аккаунт и вся история будут удалены навсегда.", "удалить");
|
||||
const note = message();
|
||||
form.append(password.wrap, submit, panel.root, note);
|
||||
|
||||
const back = () => {
|
||||
panel.root.hidden = true;
|
||||
panel.yes.disabled = false;
|
||||
panel.no.disabled = false;
|
||||
panel.yes.textContent = "удалить";
|
||||
submit.hidden = false;
|
||||
};
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
setNote(note, "");
|
||||
submit.hidden = true;
|
||||
panel.root.hidden = false;
|
||||
panel.yes.focus();
|
||||
});
|
||||
panel.no.addEventListener("click", () => {
|
||||
back();
|
||||
submit.focus();
|
||||
});
|
||||
panel.yes.addEventListener("click", async () => {
|
||||
panel.yes.disabled = true;
|
||||
panel.no.disabled = true;
|
||||
panel.yes.textContent = "вычисляем ключ…";
|
||||
try {
|
||||
await ctx.deleteAccount(password.input.value);
|
||||
ctx.go("#/");
|
||||
} catch (err) {
|
||||
back();
|
||||
setError(note, passwordError(ctx, err));
|
||||
}
|
||||
});
|
||||
|
||||
box.append(form);
|
||||
return box;
|
||||
}
|
||||
|
||||
function block(title) {
|
||||
const box = el("section", "block");
|
||||
box.append(el("h2", "section", title));
|
||||
return box;
|
||||
}
|
||||
|
||||
// passwordError: в настройках 401 означает ровно одно — не тот пароль.
|
||||
function passwordError(ctx, err) {
|
||||
if (err instanceof ApiError && err.code === "invalid_credentials") {
|
||||
return "неверный пароль";
|
||||
}
|
||||
return ctx.errorText(err);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас»
|
||||
// и «Список чатов». Чаты появятся на этапе 2, секции пока пустые.
|
||||
|
||||
import { el, mark } from "./dom.js";
|
||||
|
||||
// frame отдаёт корень и место под экран. screen — что показывать
|
||||
// на мобильном, где виден один экран за раз: "list" или "screen".
|
||||
export function frame(ctx, screen) {
|
||||
const root = el("div", "shell");
|
||||
root.dataset.screen = screen;
|
||||
const main = el("main", "main");
|
||||
root.append(side(ctx), main);
|
||||
return { root, main };
|
||||
}
|
||||
|
||||
function side(ctx) {
|
||||
const nav = el("nav", "side");
|
||||
|
||||
const brand = el("div", "brand");
|
||||
brand.append(mark(), el("span", null, "bare"));
|
||||
nav.append(brand);
|
||||
|
||||
const list = el("div", "list");
|
||||
for (const title of ["каналы", "личные"]) {
|
||||
list.append(el("h2", "section", title), el("ul", "items"));
|
||||
}
|
||||
nav.append(list);
|
||||
|
||||
const me = el("button", "me");
|
||||
me.type = "button";
|
||||
const dot = el("i");
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
me.append(dot, el("span", null, `ты: @${ctx.me.nick}`));
|
||||
me.addEventListener("click", () => ctx.go("#/settings"));
|
||||
nav.append(me);
|
||||
|
||||
return nav;
|
||||
}
|
||||
Reference in New Issue
Block a user