Files
bare/web/js/export.js
T
mayatnikovandClaude Opus 5 0c878477d2 Этап 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
2026-08-23 02:57:51 +03:00

343 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Архив `.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);
}