Этап 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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user