Реализовать SHiNE_DM v1 с E2EE и tombstone

This commit is contained in:
AidarKC
2026-07-07 18:07:10 +04:00
parent 9c588bd9b5
commit 1b7a2a8f7c
33 changed files with 1636 additions and 445 deletions
+43 -4
View File
@@ -27,6 +27,8 @@ import {
state,
terminateCurrentSession,
addSignedMessageToChat,
deleteConversationMessagesBefore,
deleteSignedMessageByBaseKey,
markIncomingReadByBaseKey,
markOutgoingReadByBaseKey,
normalizeDmChatId,
@@ -1018,10 +1020,29 @@ async function init() {
const fromLogin = parsed.fromLogin || '';
const toLogin = parsed.toLogin || '';
const messageType = Number(parsed.messageType || 0);
const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
const text = (messageType === 1 || messageType === 2)
? String(parsed.text || '')
: '';
const currentLogin = String(state.session?.login || '').trim().toLowerCase();
const chatPeerLogin = currentLogin && currentLogin === String(fromLogin || '').trim().toLowerCase()
? toLogin
: fromLogin;
const chatId = normalizeDmChatId(chatPeerLogin);
let text = '';
if (messageType === 1 || messageType === 2) {
try {
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session?.login || '',
storagePwd: state.session?.storagePwdInMemory || '',
});
text = String(decrypted?.text || '');
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'signed-dm',
message: 'Не удалось расшифровать DM',
details: { messageKey, baseKey: parsed.baseKey, error: error?.message || 'unknown' },
});
}
}
let shouldRefreshToolbarUnread = false;
@@ -1084,6 +1105,24 @@ async function init() {
message: 'Получено подтверждение прочтения',
details: { messageKey, baseKey: parsed.baseKey, messageType },
});
} else if (messageType === 5 || messageType === 6) {
const changed = deleteSignedMessageByBaseKey(chatId, parsed.baseKey);
if (changed) shouldRefreshToolbarUnread = true;
addAppLogEntry({
level: 'info',
source: 'signed-dm',
message: 'Получено удаление сообщения',
details: { messageKey, baseKey: parsed.baseKey, messageType, changed },
});
} else if (messageType === 7 || messageType === 8) {
const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0));
if (removed > 0) shouldRefreshToolbarUnread = true;
addAppLogEntry({
level: 'info',
source: 'signed-dm',
message: 'Получено удаление истории переписки',
details: { messageKey, baseKey: parsed.baseKey, messageType, removed, boundaryTimeMs: Number(parsed.timeMs || 0) },
});
}
try {
+18 -8
View File
@@ -594,16 +594,25 @@ export function render({ navigate, route }) {
}
};
const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
if (!localOutgoingBlobB64) return;
try {
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
let text = '';
if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) {
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
text = String(decrypted?.text || '');
}
addSignedMessageToChat({
chatId,
messageKey: fallbackMessageKey || parsed?.messageKey || '',
baseKey: fallbackBaseKey || parsed?.baseKey || '',
from: 'out',
text: parsed?.text || '',
text,
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: localOutgoingBlobB64,
@@ -625,11 +634,13 @@ export function render({ navigate, route }) {
timeMs: base.timeMs,
nonce: base.nonce,
revisionTimeMs: Date.now(),
deleteByRecipient: msg?.from === 'in',
});
applyLocalRevision({
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
addSignedMessageToChat({
chatId,
messageKey: String(msg?.messageKey || ''),
baseKey: String(msg?.baseKey || ''),
deleted: true,
});
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
cancelEditMode({ restoreDraft: true });
@@ -670,7 +681,7 @@ export function render({ navigate, route }) {
});
}
applyLocalRevision({
void applyLocalRevision({
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
@@ -857,7 +868,6 @@ async function sendReadReceiptsForVisible(chatId) {
refFromLogin: ref.fromLogin,
refTimeMs: ref.timeMs,
refNonce: ref.nonce,
refType: 1,
});
if (row.baseKey) {
markReadReceiptSentByBaseKey(row.baseKey);
+273 -165
View File
@@ -4,16 +4,26 @@ import {
bytesToBase64,
deriveEd25519FromMasterSecret,
deriveMasterSecretFromPassword,
ed25519PublicToX25519Public,
ed25519SeedToX25519Private,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
extractEd25519SeedFromPkcs8B64,
hkdfSha256,
importPkcs8Ed25519,
publicKeyB64FromPkcs8Ed25519,
randomBase64,
randomBytes,
sha256Bytes,
signBytes,
signBase64,
utf8Bytes,
x25519PublicFromPrivate,
x25519RandomPrivateKey,
x25519SharedSecret,
encryptBytesAesGcm,
decryptBytesAesGcm,
} from './crypto-utils.js';
import {
channelNameErrorText,
@@ -215,14 +225,20 @@ function uint8Bytes(value) {
}
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
const DM2_TYPE_INCOMING = 1;
const DM2_TYPE_OUTGOING_COPY = 2;
const DM2_TYPE_READ_INCOMING = 3;
const DM2_TYPE_READ_OUTGOING_COPY = 4;
const DM_TYPE_INCOMING = 1;
const DM_TYPE_OUTGOING_COPY = 2;
const DM_TYPE_READ_INCOMING = 3;
const DM_TYPE_READ_OUTGOING_COPY = 4;
const DM_TYPE_MESSAGE_DELETED_BY_SENDER = 5;
const DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6;
const DM_TYPE_CONVERSATION_DELETED_BY_SENDER = 7;
const DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8;
const DM_FORMAT_VERSION_MAJOR = 1;
const DM_FORMAT_VERSION_MINOR = 0;
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
const DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1;
const DM_CRYPTO_VERSION_1_0 = 0;
const DM_HKDF_INFO = utf8Bytes('SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM');
function ensureAsciiBytes(value, field, min = 1, max = 60) {
const text = String(value || '').trim();
@@ -237,15 +253,15 @@ function ensureAsciiBytes(value, field, min = 1, max = 60) {
return bytes;
}
function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) {
function dmBaseKey({ toLogin, fromLogin, timeMs, nonce }) {
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
}
function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
function dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
return `${dmBaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
}
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) {
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }) {
const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
return concatBytes(
@@ -253,10 +269,67 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref
uint8Bytes(fromBytes.length), fromBytes,
uint64Bytes(refTimeMs),
uint32Bytes(refNonce),
uint16Bytes(refType),
);
}
function buildDmEncryptedBodyBytes({ plainBytes, recipientClientKeyB64 }) {
return buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 });
}
async function buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }) {
const recipientEd25519Pub = base64ToBytes(String(recipientClientKeyB64 || '').trim());
if (recipientEd25519Pub.length !== 32) {
throw new Error('Некорректный clientKey получателя');
}
const recipientX25519Pub = ed25519PublicToX25519Public(recipientEd25519Pub);
const ephemeralPriv = x25519RandomPrivateKey();
const ephemeralPub = x25519PublicFromPrivate(ephemeralPriv);
const sharedSecret = x25519SharedSecret(ephemeralPriv, recipientX25519Pub);
const salt = concatBytes(ephemeralPub, recipientX25519Pub);
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
const iv = randomBytes(12);
const cipherText = await encryptBytesAesGcm(plainBytes, aesKeyBytes, iv);
return concatBytes(
uint8Bytes(DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM),
uint8Bytes(DM_CRYPTO_VERSION_1_0),
uint8Bytes(ephemeralPub.length),
ephemeralPub,
uint8Bytes(iv.length),
iv,
uint32Bytes(cipherText.length),
cipherText,
);
}
function parseEncryptedDmBodyBytes(payloadBytes) {
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_ENCRYPTED_BODY');
let o = 0;
const read = (n) => {
if (o + n > payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
const out = payloadBytes.slice(o, o + n);
o += n;
return out;
};
const readU8 = () => read(1)[0];
const cryptoMethod = readU8();
const cryptoVersion = readU8();
const ephemeralPubKeyLen = readU8();
const ephemeralPubKey = read(ephemeralPubKeyLen);
const ivLen = readU8();
const iv = read(ivLen);
const cipherTextLenBytes = read(4);
const cipherTextLen = new DataView(cipherTextLenBytes.buffer, cipherTextLenBytes.byteOffset, 4).getUint32(0, false);
const cipherText = read(cipherTextLen);
if (o !== payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
return {
cryptoMethod,
cryptoVersion,
ephemeralPubKey,
iv,
cipherText,
};
}
function parseSignedMessageBlockBytes(bytes) {
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
let o = 0;
@@ -267,11 +340,6 @@ function parseSignedMessageBlockBytes(bytes) {
return out;
};
const readU8 = () => read(1)[0];
const readU16 = () => {
const part = read(2);
const view = new DataView(part.buffer, part.byteOffset, 2);
return view.getUint16(0, false);
};
const readU32 = () => {
const part = read(4);
const view = new DataView(part.buffer, part.byteOffset, 4);
@@ -301,87 +369,59 @@ function parseSignedMessageBlockBytes(bytes) {
return true;
};
if (startsWith(DM_PREFIX_V1)) {
read(DM_PREFIX_V1.length);
const formatVersionMajor = readU8();
const formatVersionMinor = readU8();
const toLogin = readAscii();
const fromLogin = readAscii();
const timeMs = readU64();
const nonce = readU32();
const messageType = readU16();
const revisionTimeMs = readU64();
const attachmentsCount = readU8();
if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED');
const encryptedBodyLen = readU32();
const encryptedBodyBytes = read(encryptedBodyLen);
const signatureBytes = read(64);
if (o !== bytes.length) throw new Error('BAD_LEN');
const signedBody = bytes.slice(0, bytes.length - 64);
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
const bodyText = new TextDecoder().decode(encryptedBodyBytes);
return {
toLogin,
fromLogin,
timeMs,
nonce,
messageType,
revisionTimeMs,
formatVersionMajor,
formatVersionMinor,
encryptedBodyBytes,
encryptedBodyText: bodyText,
text: bodyText,
bodyAttachments: [],
payloadBytes: encryptedBodyBytes,
signatureBytes,
signedBody,
rawBytes: bytes,
baseKey,
messageKey,
legacyFormat: false,
deleted: attachmentsCount === 0 && encryptedBodyLen === 0,
};
}
const prefix = read(DM2_PREFIX.length);
for (let i = 0; i < DM2_PREFIX.length; i += 1) {
if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
}
if (!startsWith(DM_PREFIX_V1)) throw new Error('BAD_PREFIX');
read(DM_PREFIX_V1.length);
const formatVersionMajor = readU8();
const formatVersionMinor = readU8();
const toLogin = readAscii();
const fromLogin = readAscii();
const timeMs = readU64();
const nonce = readU32();
const messageType = readU16();
const payloadLen = readU16();
const payloadBytes = read(payloadLen);
const messageType = readU8();
const revisionTimeMs = readU64();
const reencryptedAtMs = readU64();
const bodyLen = readU32();
const bodyBytes = read(bodyLen);
const signatureBytes = read(64);
if (o !== bytes.length) throw new Error('BAD_LEN');
const signedBody = bytes.slice(0, bytes.length - 64);
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
const baseKey = dmBaseKey({ toLogin, fromLogin, timeMs, nonce });
const messageKey = dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
let encryptedBodyPayload = null;
let readReceiptPayload = null;
if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) {
encryptedBodyPayload = parseEncryptedDmBodyBytes(bodyBytes);
} else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) {
readReceiptPayload = null;
}
return {
toLogin,
fromLogin,
timeMs,
nonce,
messageType,
revisionTimeMs: 0,
encryptedBodyBytes: payloadBytes,
encryptedBodyText: new TextDecoder().decode(payloadBytes),
text: new TextDecoder().decode(payloadBytes),
revisionTimeMs,
reencryptedAtMs,
formatVersionMajor,
formatVersionMinor,
encryptedBodyBytes: bodyBytes,
encryptedBodyPayload,
text: '',
bodyAttachments: [],
payloadBytes,
payloadBytes: bodyBytes,
signatureBytes,
signedBody,
rawBytes: bytes,
baseKey,
messageKey,
legacyFormat: true,
deleted: false,
legacyFormat: false,
deleted: messageType >= DM_TYPE_MESSAGE_DELETED_BY_SENDER,
isMessageDelete: messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT,
isConversationDelete: messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT,
readReceiptPayload,
};
}
@@ -2186,64 +2226,28 @@ export class AuthService {
return response.payload || {};
}
async buildSignedDm2Block({
login,
toLogin,
storagePwd,
timeMs,
nonce,
messageType,
payloadBytes,
}) {
const cleanFromLogin = String(login || '').trim();
const cleanToLogin = String(toLogin || '').trim();
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
const clientPriv = secrets?.clientKey || secrets?.clientKey;
if (!clientPriv) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPriv);
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) {
throw new Error('payload должен быть 1..4096 байт');
}
const preimage = concatBytes(
DM2_PREFIX,
uint8Bytes(toBytes.length), toBytes,
uint8Bytes(fromBytes.length), fromBytes,
uint64Bytes(timeMs),
uint32Bytes(nonce),
uint16Bytes(messageType),
uint16Bytes(payloadBytes.length),
payloadBytes,
);
const signature = await signBytes(privateKey, preimage);
return concatBytes(preimage, signature);
}
async buildSignedDmV1Block({
login,
async buildSignedDmBlock({
signerLogin,
fromLogin,
toLogin,
storagePwd,
timeMs,
nonce,
messageType,
revisionTimeMs = 0,
encryptedBodyBytes = new Uint8Array(0),
reencryptedAtMs = 0,
bodyBytes = new Uint8Array(0),
}) {
const cleanFromLogin = String(login || '').trim();
const cleanSignerLogin = String(signerLogin || '').trim();
const cleanFromLogin = String(fromLogin || '').trim();
const cleanToLogin = String(toLogin || '').trim();
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
throw new Error(`body должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
}
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
const secrets = await loadEncryptedUserSecrets(cleanSignerLogin, storagePwd);
const clientPriv = secrets?.clientKey || secrets?.clientKey;
if (!clientPriv) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPriv);
@@ -2258,11 +2262,11 @@ export class AuthService {
uint8Bytes(fromBytes.length), fromBytes,
uint64Bytes(timeMs),
uint32Bytes(nonce),
uint16Bytes(messageType),
uint8Bytes(messageType),
uint64Bytes(revisionTimeMs),
uint8Bytes(0),
uint32Bytes(encryptedBodyBytes.length),
encryptedBodyBytes,
uint64Bytes(reencryptedAtMs),
uint32Bytes(bodyBytes.length),
bodyBytes,
);
const signature = await signBytes(privateKey, preimage);
return concatBytes(preimage, signature);
@@ -2283,10 +2287,6 @@ export class AuthService {
return out;
};
const readU8 = () => read(1)[0];
const readU16 = () => {
const part = read(2);
return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false);
};
const readU32 = () => {
const part = read(4);
return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
@@ -2304,9 +2304,40 @@ export class AuthService {
const refFromLogin = readAscii();
const refTimeMs = readU64();
const refNonce = readU32();
const refType = readU16();
if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
return { refToLogin, refFromLogin, refTimeMs, refNonce, refType };
return { refToLogin, refFromLogin, refTimeMs, refNonce };
}
async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) {
const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64);
const messageType = Number(parsedBlock?.messageType || 0);
if (messageType !== DM_TYPE_INCOMING && messageType !== DM_TYPE_OUTGOING_COPY) {
return { text: '', plainBytes: new Uint8Array(0) };
}
if (!storagePwd) throw new Error('Не передан storagePwd для расшифровки');
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Не передан login для расшифровки');
const payload = parsedBlock.encryptedBodyPayload || parseEncryptedDmBodyBytes(parsedBlock.payloadBytes);
if (payload.cryptoMethod !== DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM || payload.cryptoVersion !== DM_CRYPTO_VERSION_1_0) {
throw new Error('Неподдерживаемый метод шифрования DM');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
const recipientSeed = extractEd25519SeedFromPkcs8B64(clientPrivPkcs8);
const recipientX25519Priv = ed25519SeedToX25519Private(recipientSeed);
const recipientEd25519PubB64 = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
const recipientX25519Pub = ed25519PublicToX25519Public(base64ToBytes(recipientEd25519PubB64));
const sharedSecret = x25519SharedSecret(recipientX25519Priv, payload.ephemeralPubKey);
const salt = concatBytes(payload.ephemeralPubKey, recipientX25519Pub);
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
const plainBytes = await decryptBytesAesGcm(payload.cipherText, aesKeyBytes, payload.iv);
return {
text: new TextDecoder().decode(plainBytes),
plainBytes,
};
}
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
@@ -2330,6 +2361,7 @@ export class AuthService {
timeMs,
nonce,
revisionTimeMs = 0,
reencryptedAtMs = 0,
}) {
const cleanFromLogin = String(login || '').trim();
const cleanToLogin = String(toLogin || '').trim();
@@ -2338,30 +2370,54 @@ export class AuthService {
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
const normalizedReencryptedAtMs = Number(reencryptedAtMs || 0);
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs');
const encryptedBodyBytes = utf8Bytes(cleanText);
if (!Number.isFinite(normalizedReencryptedAtMs) || normalizedReencryptedAtMs < 0) throw new Error('Некорректный reencryptedAtMs');
const incomingBlock = await this.buildSignedDmV1Block({
login: cleanFromLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: DM2_TYPE_INCOMING,
revisionTimeMs: normalizedRevisionTimeMs,
encryptedBodyBytes,
const plainBytes = utf8Bytes(cleanText);
const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists || !String(targetUser?.clientKey || '').trim()) {
throw new Error('Не найден clientKey получателя');
}
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
const senderPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!senderPrivPkcs8) throw new Error('Не найден приватный clientKey отправителя');
const senderPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(senderPrivPkcs8);
const incomingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
plainBytes,
recipientClientKeyB64: String(targetUser.clientKey || '').trim(),
});
const outgoingBlock = await this.buildSignedDmV1Block({
login: cleanFromLogin,
const outgoingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
plainBytes,
recipientClientKeyB64: senderPublicKeyB64,
});
const incomingBlock = await this.buildSignedDmBlock({
signerLogin: cleanFromLogin,
fromLogin: cleanFromLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: DM2_TYPE_OUTGOING_COPY,
messageType: DM_TYPE_INCOMING,
revisionTimeMs: normalizedRevisionTimeMs,
encryptedBodyBytes,
reencryptedAtMs: normalizedReencryptedAtMs,
bodyBytes: incomingEncryptedBodyBytes,
});
const outgoingBlock = await this.buildSignedDmBlock({
signerLogin: cleanFromLogin,
fromLogin: cleanFromLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: DM_TYPE_OUTGOING_COPY,
revisionTimeMs: normalizedRevisionTimeMs,
reencryptedAtMs: normalizedReencryptedAtMs,
bodyBytes: outgoingEncryptedBodyBytes,
});
const payload = await this.sendMessagePair({
@@ -2372,7 +2428,7 @@ export class AuthService {
...payload,
localIncomingBlobB64: bytesToBase64(incomingBlock),
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
localBaseKey: dmBaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
};
}
@@ -2390,40 +2446,62 @@ export class AuthService {
});
}
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs }) {
return this.sendDirectMessageRevision({
login,
toLogin,
text: '',
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
const cleanLogin = String(login || '').trim();
const cleanPeerLogin = String(toLogin || '').trim();
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs <= 0) throw new Error('Некорректный revisionTimeMs');
const block = await this.buildSignedDmBlock({
signerLogin: cleanLogin,
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
storagePwd,
timeMs,
nonce,
revisionTimeMs,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: deleteByRecipient ? DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT : DM_TYPE_MESSAGE_DELETED_BY_SENDER,
revisionTimeMs: normalizedRevisionTimeMs,
reencryptedAtMs: 0,
bodyBytes: new Uint8Array(0),
});
const blobB64 = bytesToBase64(block);
const response = await this.ws.request('DeleteMessage', { blobB64 });
if (response.status !== 200) throw opError('DeleteMessage', response);
return {
...(response.payload || {}),
localBlobB64: blobB64,
};
}
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000);
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType });
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce });
const type3 = await this.buildSignedDm2Block({
login,
const type3 = await this.buildSignedDmBlock({
signerLogin: login,
fromLogin: login,
toLogin,
storagePwd,
timeMs,
nonce,
messageType: DM2_TYPE_READ_INCOMING,
payloadBytes: payload,
messageType: DM_TYPE_READ_INCOMING,
bodyBytes: payload,
});
const type4 = await this.buildSignedDm2Block({
login,
const type4 = await this.buildSignedDmBlock({
signerLogin: login,
fromLogin: login,
toLogin,
storagePwd,
timeMs,
nonce,
messageType: DM2_TYPE_READ_OUTGOING_COPY,
payloadBytes: payload,
messageType: DM_TYPE_READ_OUTGOING_COPY,
bodyBytes: payload,
});
return this.sendMessagePair({
incomingBlobB64: bytesToBase64(type3),
@@ -2431,6 +2509,36 @@ export class AuthService {
});
}
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
const cleanLogin = String(login || '').trim();
const cleanPeerLogin = String(toLogin || '').trim();
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
const block = await this.buildSignedDmBlock({
signerLogin: cleanLogin,
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
storagePwd,
timeMs: normalizedTimeMs,
nonce: normalizedNonce,
messageType: deleteByRecipient ? DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT : DM_TYPE_CONVERSATION_DELETED_BY_SENDER,
revisionTimeMs: 0,
reencryptedAtMs: 0,
bodyBytes: new Uint8Array(0),
});
const blobB64 = bytesToBase64(block);
const response = await this.ws.request('DeleteConversation', { blobB64 });
if (response.status !== 200) throw opError('DeleteConversation', response);
return {
...(response.payload || {}),
localBlobB64: blobB64,
};
}
async ackSessionDelivery(messageKey) {
const response = await this.ws.request('AckSessionDelivery', { messageKey });
if (response.status !== 200) throw opError('AckSessionDelivery', response);
+54
View File
@@ -1,6 +1,7 @@
const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519';
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
function getCryptoApi() {
@@ -340,3 +341,56 @@ export async function signBytes(privateKey, bytes) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
return new Uint8Array(signature);
}
export function extractEd25519SeedFromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key');
return bytes.slice(bytes.length - 32);
}
export function ed25519SeedToX25519Private(seed32) {
const seed = seed32 instanceof Uint8Array ? seed32 : new Uint8Array(seed32 || []);
if (seed.length !== 32) throw new Error('Seed Ed25519 должен быть 32 байта');
return new Uint8Array(edwardsToMontgomeryPriv(seed));
}
export function ed25519PublicToX25519Public(ed25519Pub32) {
const pub = ed25519Pub32 instanceof Uint8Array ? ed25519Pub32 : new Uint8Array(ed25519Pub32 || []);
if (pub.length !== 32) throw new Error('Публичный Ed25519 ключ должен быть 32 байта');
return new Uint8Array(edwardsToMontgomeryPub(pub));
}
export function x25519RandomPrivateKey() {
return new Uint8Array(x25519.utils.randomPrivateKey());
}
export function x25519PublicFromPrivate(privateKey32) {
const key = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
if (key.length !== 32) throw new Error('Приватный X25519 ключ должен быть 32 байта');
return new Uint8Array(x25519.getPublicKey(key));
}
export function x25519SharedSecret(privateKey32, publicKey32) {
const priv = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
const pub = publicKey32 instanceof Uint8Array ? publicKey32 : new Uint8Array(publicKey32 || []);
if (priv.length !== 32 || pub.length !== 32) {
throw new Error('X25519 ключи должны быть по 32 байта');
}
return new Uint8Array(x25519.getSharedSecret(priv, pub));
}
export async function hkdfSha256(ikmBytes, saltBytes, infoBytes, outLen) {
const subtle = getSubtleApi();
const baseKey = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveBits']);
const bits = await subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: saltBytes instanceof Uint8Array ? saltBytes : new Uint8Array(saltBytes || []),
info: infoBytes instanceof Uint8Array ? infoBytes : new Uint8Array(infoBytes || []),
},
baseKey,
Number(outLen) * 8,
);
return new Uint8Array(bits);
}
+39
View File
@@ -660,6 +660,45 @@ export function addSignedMessageToChat({
return true;
}
export function deleteSignedMessageByBaseKey(chatId, baseKey) {
const normalizedChatId = normalizeDmChatId(chatId);
const normalizedBaseKey = String(baseKey || '').trim();
if (!normalizedChatId || !normalizedBaseKey) return false;
const list = getChatMessages(normalizedChatId);
let changed = false;
for (let i = list.length - 1; i >= 0; i -= 1) {
const row = list[i];
if (String(row?.baseKey || '').trim() !== normalizedBaseKey) continue;
changed = true;
removeStoredMessageRecord(String(row?.messageKey || '').trim());
list.splice(i, 1);
}
if (changed) {
sortChatMessagesInPlace(normalizedChatId);
}
return changed;
}
export function deleteConversationMessagesBefore(chatId, boundaryTimeMs) {
const normalizedChatId = normalizeDmChatId(chatId);
const boundary = Number(boundaryTimeMs || 0);
if (!normalizedChatId || !Number.isFinite(boundary) || boundary <= 0) return 0;
const list = getChatMessages(normalizedChatId);
let removed = 0;
for (let i = list.length - 1; i >= 0; i -= 1) {
const row = list[i];
const rowTime = resolveChatMessageTimeMs(row);
if (!Number.isFinite(rowTime) || rowTime >= boundary) continue;
removed += 1;
removeStoredMessageRecord(String(row?.messageKey || '').trim());
list.splice(i, 1);
}
if (removed > 0) {
sortChatMessagesInPlace(normalizedChatId);
}
return removed;
}
export function markChatRead(chatId) {
const normalizedChatId = normalizeDmChatId(chatId);
const list = getChatMessages(normalizedChatId);