Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -13,11 +13,21 @@ const SALT_PREFIX = "bare-v1:";
|
||||
const INFO_AUTH = "bare-auth-v1";
|
||||
const INFO_KEK = "bare-kek-v1";
|
||||
const BLOB_AAD = "bare-blob-v1|";
|
||||
const DM_SALT = "bare-dm-v1";
|
||||
const MSG_AAD = "bare-msg-v1|";
|
||||
|
||||
// DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного
|
||||
// идентификатора у него нет (docs/crypto.md, «Сообщение»).
|
||||
export const DM_KEY_ID = "dm";
|
||||
|
||||
// Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM.
|
||||
export const SECRET_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
|
||||
// ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных
|
||||
// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»).
|
||||
const ID_LEN = 16;
|
||||
|
||||
// Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от
|
||||
// сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а
|
||||
// считает по нему клиент, поэтому проверить его может только он.
|
||||
@@ -47,6 +57,11 @@ export function random(length) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// newId — идентификатор устройства, ключа комнаты или комнаты.
|
||||
export function newId() {
|
||||
return b64url(random(ID_LEN));
|
||||
}
|
||||
|
||||
export function b64url(input) {
|
||||
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
let binary = "";
|
||||
@@ -240,3 +255,82 @@ export async function openBlob(blob, kek, nick) {
|
||||
}
|
||||
return { priv: parsed.priv, secret };
|
||||
}
|
||||
|
||||
// --- чат 1:1 -----------------------------------------------------------
|
||||
|
||||
// order — ники пары по возрастанию. Сравниваются кодовые единицы, а не
|
||||
// буквы языка: ник — это [a-z0-9_], и порядок обязан совпасть у обеих
|
||||
// сторон побайтно (docs/crypto.md, «Чат 1:1»).
|
||||
export function order(a, b) {
|
||||
return a < b ? [a, b] : [b, a];
|
||||
}
|
||||
|
||||
// dmLabel — метка чата для AAD сообщения: "dm:" + a + ":" + b.
|
||||
// Это не ключ хранилища chats: там чат зовётся "dm:<собеседник>".
|
||||
export function dmLabel(a, b) {
|
||||
const [first, second] = order(a, b);
|
||||
return `dm:${first}:${second}`;
|
||||
}
|
||||
|
||||
// dmKey выводит ключ личного чата (docs/crypto.md, «Чат 1:1»).
|
||||
// Ключ симметричен для обеих сторон и всех их устройств; в IndexedDB
|
||||
// не пишется — выводится заново из peers.
|
||||
export async function dmKey(privateKey, peerPublicJwk, me, peer) {
|
||||
const [a, b] = order(me, peer);
|
||||
const publicKey = await importPublic(peerPublicJwk);
|
||||
const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256);
|
||||
const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
|
||||
wipe(new Uint8Array(shared));
|
||||
return subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: utf8(DM_SALT), info: utf8(`${a}\0${b}`) },
|
||||
material,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
// --- сообщение ---------------------------------------------------------
|
||||
|
||||
// messageAad привязывает открытые поля конверта к шифротексту: подмена
|
||||
// любого из них ломает расшифровку (docs/crypto.md, «Сообщение»).
|
||||
function messageAad({ id, chat, from, keyId }) {
|
||||
return utf8(`${MSG_AAD}${id}|${chat}|${from}|${keyId}`);
|
||||
}
|
||||
|
||||
// sealMessage шифрует текст сообщения. plain — JSON {"t": текст};
|
||||
// ничего кроме текста внутрь не кладётся.
|
||||
export async function sealMessage(key, { id, chat, from, keyId, text }) {
|
||||
const iv = random(IV_LEN);
|
||||
const plain = utf8(JSON.stringify({ t: text }));
|
||||
const ct = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv, additionalData: messageAad({ id, chat, from, keyId }) },
|
||||
key,
|
||||
plain,
|
||||
);
|
||||
wipe(plain);
|
||||
return { iv: b64url(iv), ct: b64url(ct) };
|
||||
}
|
||||
|
||||
// openMessage расшифровывает конверт и отдаёт текст. Бросает при любой
|
||||
// порче: не тот ключ, изменившиеся открытые поля, битый base64url.
|
||||
// Для вызывающего это не фатально — сообщение сохраняется нерасшифрованным
|
||||
// с кодом причины (docs/storage.md).
|
||||
export async function openMessage(key, { id, chat, from, keyId, iv, ct }) {
|
||||
const nonce = unb64url(iv);
|
||||
if (nonce.length !== IV_LEN) {
|
||||
throw new Error("iv — не 12 байт");
|
||||
}
|
||||
const plain = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce, additionalData: messageAad({ id, chat, from, keyId }) },
|
||||
key,
|
||||
unb64url(ct),
|
||||
);
|
||||
const bytes = new Uint8Array(plain);
|
||||
const parsed = JSON.parse(decoder.decode(bytes));
|
||||
wipe(bytes);
|
||||
if (parsed === null || typeof parsed !== "object" || typeof parsed.t !== "string") {
|
||||
throw new Error("в сообщении нет текста");
|
||||
}
|
||||
return parsed.t;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user