SHA256
Реализовать SHiNE_DM v1 с E2EE и tombstone
This commit is contained in:
+43
-4
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user