Этап 5: история — экспорт и импорт .bare, устройства, место, пагинация

Экспорт: ключ архива из секрета аккаунта через 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
This commit is contained in:
2026-08-23 02:57:51 +03:00
co-authored by Claude Opus 5
parent 8f67f4aa4d
commit 0c878477d2
21 changed files with 1274 additions and 53 deletions
+342
View File
@@ -0,0 +1,342 @@
// Архив `.bare` — экспорт и импорт истории.
//
// История живёт только на устройстве (ADR-009), и архив — единственный
// способ перенести её на другое (ADR-010). Файл привязан к аккаунту
// криптографически: ключ выводится из секрета аккаунта, и у чужого клиента
// его нет (ADR-014). Формат — docs/crypto.md, «Экспорт .bare», состав
// полезной нагрузки — docs/storage.md.
//
// Модуль работает и без сессии: и история, и секрет аккаунта лежат
// на устройстве. Это и есть смысл кнопки «экспортировать» в подтверждении
// выхода и в подтверждении входа под другим ником (docs/ui.md).
import * as db from "./db.js";
import * as sync from "./sync.js";
import {
fingerprintBytes,
fingerprintOf,
importPublic,
openArchive,
parseArchive,
publicJwk,
sameBytes,
sealArchive,
utf8,
wipe,
} from "./crypto.js";
import { validUlid } from "./ulid.js";
// Версия полезной нагрузки — поле v внутри шифротекста (docs/crypto.md).
// Версия самого файла живёт в заголовке и считается отдельно.
const PAYLOAD_VERSION = 1;
// Тексты отказа — docs/ui.md, «Настройки», раздел «история».
const BROKEN = "файл повреждён";
const FOREIGN = "архив создан другим аккаунтом";
// Файл отдаётся как двоичный: своего типа у .bare нет и заводить его
// незачем.
const MIME = "application/octet-stream";
// ARCHIVE_EXT — расширение файла (docs/storage.md). Оно же уходит в accept
// выбора файла: предлагать человеку всё подряд незачем.
export const ARCHIVE_EXT = ".bare";
// Временный адрес живёт до конца скачивания: браузер читает Blob по нему
// уже после click. Минута — с запасом на медленный диск.
const REVOKE_AFTER = 60_000;
const decoder = new TextDecoder();
// ArchiveError — отказ импорта. Сообщение уже пригодно для показа
// человеку (ADR-028): причин у отказа ровно две, и обе — в docs/ui.md.
export class ArchiveError extends Error {
constructor(text) {
super(text);
this.name = "ArchiveError";
}
}
// --- экспорт ------------------------------------------------------------
// exportHistory собирает архив и отдаёт его браузеру на скачивание.
export async function exportHistory() {
const { nick, publicKey, accountSecret } = await db.meta(["nick", "publicKey", "accountSecret"]);
if (!nick || !publicKey || !accountSecret) {
throw new Error("на устройстве нет ключей аккаунта");
}
const fingerprint = await fingerprintBytes(await importPublic(publicKey));
const payload = utf8(JSON.stringify(await collect()));
let file;
try {
file = await sealArchive(accountSecret, fingerprint, payload);
} finally {
// Плейнтекст истории в памяти дальше не нужен.
wipe(payload);
}
save(file, fileName(nick));
}
// collect — полезная нагрузка (docs/storage.md, «Экспорт .bare»).
async function collect() {
const [chats, messages, peers] = await Promise.all([
// Скрытые чаты — тоже история: «убрать из списка» не удаление (ADR-019).
db.chats({ hidden: true }),
db.allMessages(),
db.allPeers(),
]);
return {
v: PAYLOAD_VERSION,
exportedAt: Date.now(),
chats: chats.map(chatRecord),
messages: messages.filter(archivable).map(messageRecord),
peers: peers.map(peerRecord),
};
}
// archivable — что из ленты попадает в архив: только отправленное.
// Нерасшифрованное не уносится: без текста в архиве от него остался бы один
// заголовок, а raw — служебное поле. Незаконченная и отвергнутая попытки
// не уносятся тоже: pending и failed привязаны к устройству и к своему
// ULID (ADR-036) — на другом устройстве «повторить» отправило бы то же
// сообщение вторым, а запись, ушедшая после повтора под свежим id,
// вернулась бы из архива дублем (ADR-050).
function archivable(record) {
return typeof record?.text === "string" && record.status === "sent";
}
// fileName — bare-<nick>-<YYYY-MM-DD>.bare (docs/storage.md). Дата местная:
// это день человека, а не UTC.
function fileName(nick) {
const now = new Date();
const day = [
String(now.getFullYear()).padStart(4, "0"),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("-");
return `bare-${nick}-${day}${ARCHIVE_EXT}`;
}
// save отдаёт файл браузеру: Blob, временный адрес и <a download>. Ни
// inline-скриптов, ни атрибутов-обработчиков это не требует, а CSP
// default-src 'self' скачиванию не мешает: сохранение файла — не подгрузка
// ресурса страницы (ADR-021).
function save(bytes, name) {
const url = URL.createObjectURL(new Blob([bytes], { type: MIME }));
const link = document.createElement("a");
link.href = url;
link.download = name;
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), REVOKE_AFTER);
}
// --- импорт -------------------------------------------------------------
// importHistory разбирает выбранный файл и вливает его в базу. Порядок —
// docs/crypto.md: магия и версия, потом отпечаток владельца, и только потом
// ключ. Чужой архив не расшифровывается вовсе: сверка отпечатка — вежливость,
// настоящая защита в том, что секрета аккаунта у чужого клиента нет (ADR-014).
//
// Отдаёт число добавленных сообщений.
export async function importHistory(file) {
const { nick, publicKey, accountSecret } = await db.meta(["nick", "publicKey", "accountSecret"]);
if (!nick || !publicKey || !accountSecret) {
throw new Error("на устройстве нет ключей аккаунта");
}
let bytes;
try {
bytes = new Uint8Array(await file.arrayBuffer());
} catch {
// Файл не прочитался: для человека это то же самое, что порча.
throw new ArchiveError(BROKEN);
}
const archive = parseArchive(bytes);
if (archive === null) {
throw new ArchiveError(BROKEN);
}
const mine = await fingerprintBytes(await importPublic(publicKey));
if (!sameBytes(archive.fingerprint, mine)) {
throw new ArchiveError(FOREIGN);
}
const payload = await unpack(accountSecret, archive);
const { added, chats } = await db.mergeArchive({
chats: list(payload.chats).filter(usableChat).map(chatRecord),
messages: list(payload.messages).filter((record) => usableMessage(record, nick)).map(messageRecord),
peers: await peersOf(payload.peers),
});
if (chats.length > 0) {
sync.imported(chats);
}
return added;
}
// unpack расшифровывает и разбирает нагрузку. Порча заголовка, порча
// шифротекста и мусор внутри — одно и то же для человека: файл повреждён.
async function unpack(secret, archive) {
let payload;
try {
payload = JSON.parse(decoder.decode(await openArchive(secret, archive)));
} catch {
throw new ArchiveError(BROKEN);
}
if (payload === null || typeof payload !== "object" || payload.v !== PAYLOAD_VERSION) {
throw new ArchiveError(BROKEN);
}
return payload;
}
function list(value) {
return Array.isArray(value) ? value : [];
}
// --- записи -------------------------------------------------------------
//
// Один и тот же отбор полей работает в обе стороны: что уходит в архив,
// то и приходит из него. Всё, чего в этих функциях нет, до базы не доходит.
//
// Место чата в списке, счётчик непрочитанных, граница «новых» и «убрано
// из списка» — показания устройства, а не история: lastId, unread,
// lastReadId и hidden в архив не пишутся (ADR-050). У lastId причина
// вторая: он указывает на последнюю строку чата, а ею бывает и та,
// которой в архиве нет, — неотправленная или нерасшифрованная. Устройство,
// принявшее архив, считает его само — по тому, что действительно добавило.
function chatRecord(chat) {
const record = {
id: chat.id,
type: chat.type,
title: chat.title,
};
if (chat.type === "dm") {
record.peer = chat.peer;
} else {
record.roomId = chat.roomId;
}
// Владелец и состав есть только у комнаты и приходят от сервера: пока
// комната не перечитана, их может не быть вовсе.
if (typeof chat.owner === "string") {
record.owner = chat.owner;
}
if (Array.isArray(chat.members)) {
record.members = chat.members.filter((nick) => typeof nick === "string");
}
return record;
}
function messageRecord(record) {
return {
id: record.id,
chatId: record.chatId,
from: record.from,
text: record.text,
ts: record.ts,
status: record.status,
};
}
function peerRecord(record) {
return {
nick: record.nick,
publicKey: publicJwk(record.publicKey),
fingerprint: record.fingerprint,
firstSeen: record.firstSeen,
};
}
// peersOf — записи TOFU из архива. Отпечаток считается заново из ключа:
// человек сверяет голосом именно его, и брать его на веру из файла рядом
// с ключом нельзя (ADR-016). Ключ, из которого отпечаток не считается, —
// не ключ, такая запись пропускается.
async function peersOf(peers) {
const out = [];
for (const record of list(peers)) {
if (!usablePeer(record)) {
continue;
}
try {
out.push({
...peerRecord(record),
fingerprint: await fingerprintOf(record.publicKey),
// Ждущий подтверждения ключ в архив не пишется (docs/storage.md).
pending: null,
});
} catch {
// Не ключ.
}
}
return out;
}
// --- разбор архива ------------------------------------------------------
//
// Архив собрал владелец аккаунта — чужой его не соберёт (ADR-014), — но
// разбирается он на устройстве и ложится в базу рядом с настоящей историей.
// На сетевом пути форму держит сервер (internal/api/valid.go), поэтому
// sync.js обходится проверкой типа; у файла с диска такой опоры нет, и
// форму проверяет клиент, до записи (ADR-054). Запись, которую потом
// нельзя ни открыть, ни убрать, лежала бы в базе навсегда.
// Ник — форма ADR-019; roomId — 16 случайных байт base64url, 22 символа
// (docs/crypto.md, «Идентификаторы»).
const NICK = /^[a-z0-9_]{2,32}$/;
const ROOM_ID = /^[A-Za-z0-9_-]{22}$/;
// MAX_TS — предел Date: дальше `new Date(ts)` не дата вовсе, а лента
// падает на такой строке целиком (ADR-054).
const MAX_TS = 8.64e15;
// known — ключ чата по форме docs/storage.md: «dm:<ник>» или «room:<id>».
function known(chatId) {
if (typeof chatId !== "string") {
return false;
}
const peer = db.peerOf(chatId);
if (peer !== null) {
return NICK.test(peer);
}
const roomId = db.roomIdOf(chatId);
return roomId !== null && ROOM_ID.test(roomId);
}
function usableChat(chat) {
if (chat === null || typeof chat !== "object" || !known(chat.id)) {
return false;
}
const peer = db.peerOf(chat.id);
// Вид чата задаёт его ключ: «dm:<ник>» или «room:<id>» (docs/storage.md).
if (chat.type !== (peer !== null ? "dm" : "room")) {
return false;
}
if (peer !== null ? chat.peer !== peer : chat.roomId !== db.roomIdOf(chat.id)) {
return false;
}
return typeof chat.title === "string";
}
// usableMessage — строка истории. Автор в личном чате — свой ник или ник
// собеседника: третьего в переписке двоих не бывает. В комнате автором
// бывает и вышедший участник, поэтому там сверяется только форма ника.
function usableMessage(record, me) {
if (record === null || typeof record !== "object" || !known(record.chatId)) {
return false;
}
const peer = db.peerOf(record.chatId);
if (peer !== null && record.from !== peer && record.from !== me) {
return false;
}
return typeof record.id === "string" && validUlid(record.id)
&& typeof record.from === "string" && NICK.test(record.from)
&& typeof record.text === "string"
&& Number.isSafeInteger(record.ts) && record.ts >= 0 && record.ts <= MAX_TS
&& record.status === "sent";
}
function usablePeer(record) {
return record !== null && typeof record === "object"
&& typeof record.nick === "string" && NICK.test(record.nick)
&& record.publicKey !== null && typeof record.publicKey === "object"
&& Number.isFinite(record.firstSeen);
}