626 lines
18 KiB
JavaScript
626 lines
18 KiB
JavaScript
// NodeCrypt core cryptographic client for secure chat
|
|
// NodeCrypt 安全聊天的核心加密客户端
|
|
|
|
import {
|
|
sha256
|
|
} from 'js-sha256';
|
|
import {
|
|
ec as elliptic
|
|
} from 'elliptic';
|
|
import {
|
|
ModeOfOperation
|
|
} from 'aes-js';
|
|
import chacha from 'js-chacha20';
|
|
import {
|
|
Buffer
|
|
} from 'buffer';
|
|
window.Buffer = Buffer;
|
|
|
|
// Main NodeCrypt class for secure communication
|
|
// 用于安全通信的 NodeCrypt 主类
|
|
class NodeCrypt {
|
|
// Initialize NodeCrypt instance
|
|
// 初始化 NodeCrypt 实例
|
|
constructor(config = {}, callbacks = {}) {
|
|
this.config = {
|
|
rsaPublic: config.rsaPublic || '',
|
|
wsAddress: config.wsAddress || '',
|
|
reconnectDelay: config.reconnectDelay || 3000,
|
|
pingInterval: config.pingInterval || 20000,
|
|
debug: config.debug || false,
|
|
};
|
|
this.callbacks = {
|
|
onServerClosed: callbacks.onServerClosed || null,
|
|
onServerSecured: callbacks.onServerSecured || null,
|
|
onClientSecured: callbacks.onClientSecured || null,
|
|
onClientList: callbacks.onClientList || null,
|
|
onClientMessage: callbacks.onClientMessage || null,
|
|
};
|
|
this.SERVER_KEY_STORAGE = 'nodecrypt_server_key';
|
|
try {
|
|
this.clientEc = new elliptic('curve25519')
|
|
} catch (error) {
|
|
this.logEvent('constructor', error, 'error')
|
|
}
|
|
this.serverKeys = null;
|
|
this.serverShared = null;
|
|
this.credentials = null;
|
|
this.connection = null;
|
|
this.reconnect = null;
|
|
this.ping = null;
|
|
this.channel = {};
|
|
this.setCredentials = this.setCredentials.bind(this);
|
|
this.connect = this.connect.bind(this);
|
|
this.destruct = this.destruct.bind(this);
|
|
this.onOpen = this.onOpen.bind(this);
|
|
this.onMessage = this.onMessage.bind(this);
|
|
this.onError = this.onError.bind(this);
|
|
this.onClose = this.onClose.bind(this);
|
|
this.logEvent = this.logEvent.bind(this);
|
|
this.isOpen = this.isOpen.bind(this);
|
|
this.isClosed = this.isClosed.bind(this);
|
|
this.startReconnect = this.startReconnect.bind(this);
|
|
this.stopReconnect = this.stopReconnect.bind(this);
|
|
this.startPing = this.startPing.bind(this);
|
|
this.stopPing = this.stopPing.bind(this);
|
|
this.disconnect = this.disconnect.bind(this);
|
|
this.sendMessage = this.sendMessage.bind(this);
|
|
this.sendChannelMessage = this.sendChannelMessage.bind(this);
|
|
this.encryptServerMessage = this.encryptServerMessage.bind(this);
|
|
this.decryptServerMessage = this.decryptServerMessage.bind(this);
|
|
this.encryptClientMessage = this.encryptClientMessage.bind(this);
|
|
this.decryptClientMessage = this.decryptClientMessage.bind(this)
|
|
}
|
|
|
|
// Set user credentials (username, channel, password)
|
|
// 设置用户凭证(用户名、频道、密码)
|
|
setCredentials(username, channel, password) {
|
|
this.logEvent('setCredentials');
|
|
try {
|
|
this.credentials = {
|
|
username: username,
|
|
channel: sha256(channel),
|
|
password: sha256(password)
|
|
}
|
|
} catch (error) {
|
|
this.logEvent('setCredentials', error, 'error');
|
|
return (false)
|
|
}
|
|
return (true)
|
|
}
|
|
|
|
// Connect to the server
|
|
// 连接到服务器
|
|
connect() {
|
|
if (!this.credentials) {
|
|
return (false)
|
|
}
|
|
this.logEvent('connect', this.config.wsAddress);
|
|
this.stopReconnect();
|
|
this.stopPing();
|
|
this.serverKeys = null;
|
|
this.serverShared = null;
|
|
this.channel = {};
|
|
try {
|
|
this.connection = new WebSocket(this.config.wsAddress);
|
|
this.connection.onopen = this.onOpen;
|
|
this.connection.onmessage = this.onMessage;
|
|
this.connection.onerror = this.onError;
|
|
this.connection.onclose = this.onClose
|
|
} catch (error) {
|
|
this.logEvent('connect', error, 'error');
|
|
return (false)
|
|
}
|
|
return (true)
|
|
}
|
|
|
|
// Clean up and disconnect
|
|
// 清理并断开连接
|
|
destruct() {
|
|
this.logEvent('destruct');
|
|
this.stopReconnect();
|
|
this.stopPing();
|
|
this.reconnect = null;
|
|
this.ping = null;
|
|
this.config = {
|
|
rsaPublic: '',
|
|
wsAddress: '',
|
|
reconnectDelay: 3000,
|
|
pingInterval: 15000,
|
|
debug: false,
|
|
};
|
|
this.callbacks.onServerClosed = null;
|
|
this.callbacks.onServerSecured = null;
|
|
this.callbacks.onClientSecured = null;
|
|
this.callbacks.onClientList = null;
|
|
this.callbacks.onClientMessage = null;
|
|
this.clientEc = null;
|
|
this.serverKeys = null;
|
|
this.serverShared = null;
|
|
this.credentials = null;
|
|
this.connection.onopen = null;
|
|
this.connection.onmessage = null;
|
|
this.connection.onerror = null;
|
|
this.connection.onclose = null;
|
|
try {
|
|
this.connection.removeAllListeners()
|
|
} catch (error) {
|
|
this.logEvent('destruct', error, 'error')
|
|
}
|
|
try {
|
|
this.connection.close()
|
|
} catch (error) {
|
|
this.logEvent('destruct', error, 'error')
|
|
}
|
|
this.connection = null;
|
|
this.channel = {};
|
|
return (true)
|
|
}
|
|
|
|
// WebSocket open event handler
|
|
// WebSocket 连接打开事件处理
|
|
async onOpen() {
|
|
this.logEvent('onOpen');
|
|
this.startPing();
|
|
try {
|
|
this.serverKeys = await crypto.subtle.generateKey({
|
|
name: 'ECDH',
|
|
namedCurve: 'P-384'
|
|
}, false, ['deriveKey', 'deriveBits']);
|
|
this.serverShared = null;
|
|
this.sendMessage(Buffer.from(await crypto.subtle.exportKey('raw', this.serverKeys.publicKey)).toString('hex'))
|
|
} catch (error) {
|
|
this.logEvent('onOpen', error, 'error')
|
|
}
|
|
}
|
|
|
|
// WebSocket message event handler
|
|
// WebSocket 消息事件处理
|
|
async onMessage(event) {
|
|
if (!event || !this.isString(event.data)) {
|
|
return
|
|
}
|
|
if (event.data === 'pong') {
|
|
return
|
|
}
|
|
this.logEvent('onMessage', event.data);
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'server-key') {
|
|
const result = await this.handleServerKey(data.key);
|
|
if (!result) {
|
|
return
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
if (!this.serverShared) {
|
|
const parts = event.data.split('|');
|
|
if (!parts[0] || !parts[1]) {
|
|
return
|
|
}
|
|
try {
|
|
if (await crypto.subtle.verify({
|
|
name: 'RSASSA-PKCS1-v1_5'
|
|
}, await crypto.subtle.importKey('spki', Buffer.from(this.config.rsaPublic, 'base64'), {
|
|
name: 'RSASSA-PKCS1-v1_5',
|
|
hash: {
|
|
name: 'SHA-256'
|
|
}
|
|
}, false, ['verify']), Buffer.from(parts[1], 'base64'), Buffer.from(parts[0], 'hex')) === true) {
|
|
this.serverShared = Buffer.from(await crypto.subtle.deriveBits({
|
|
name: 'ECDH',
|
|
namedCurve: 'P-384',
|
|
public: await crypto.subtle.importKey('raw', Buffer.from(parts[0], 'hex'), {
|
|
name: 'ECDH',
|
|
namedCurve: 'P-384'
|
|
}, true, [])
|
|
}, this.serverKeys.privateKey, 384)).slice(8, 40);
|
|
this.sendMessage(this.encryptServerMessage({
|
|
a: 'j',
|
|
p: this.credentials.channel
|
|
}, this.serverShared));
|
|
if (this.callbacks.onServerSecured) {
|
|
try {
|
|
this.callbacks.onServerSecured()
|
|
} catch (error) {
|
|
this.logEvent('onMessage-server-secured-callback', error, 'error')
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logEvent('onMessage', error, 'error')
|
|
}
|
|
return
|
|
}
|
|
const serverDecrypted = this.decryptServerMessage(event.data, this.serverShared);
|
|
this.logEvent('onMessage-server-decrypted', serverDecrypted);
|
|
if (!this.isObject(serverDecrypted) || !this.isString(serverDecrypted.a)) {
|
|
return
|
|
}
|
|
if (serverDecrypted.a === 'l' && this.isArray(serverDecrypted.p)) {
|
|
try {
|
|
for (const clientId in this.channel) {
|
|
if (serverDecrypted.p.indexOf(clientId) < 0) {
|
|
delete(this.channel[clientId])
|
|
}
|
|
}
|
|
let payloads = {};
|
|
for (const clientId of serverDecrypted.p) {
|
|
if (!this.channel[clientId]) {
|
|
this.channel[clientId] = {
|
|
username: null,
|
|
keys: this.clientEc.genKeyPair(),
|
|
shared: null,
|
|
};
|
|
payloads[clientId] = this.channel[clientId].keys.getPublic('hex')
|
|
}
|
|
}
|
|
if (Object.keys(payloads).length > 0) {
|
|
this.sendMessage(this.encryptServerMessage({
|
|
a: 'w',
|
|
p: payloads,
|
|
}, this.serverShared))
|
|
}
|
|
} catch (error) {
|
|
this.logEvent('onMessage-list', error, 'error')
|
|
}
|
|
if (this.callbacks.onClientList) {
|
|
let clients = [];
|
|
for (const clientId in this.channel) {
|
|
if (this.channel[clientId].shared && this.channel[clientId].username) {
|
|
clients.push({
|
|
clientId: clientId,
|
|
username: this.channel[clientId].username
|
|
})
|
|
}
|
|
}
|
|
try {
|
|
this.callbacks.onClientList(clients)
|
|
} catch (error) {
|
|
this.logEvent('onMessage-client-list-callback', error, 'error')
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if (!this.isString(serverDecrypted.p) || !this.isString(serverDecrypted.c)) {
|
|
return
|
|
}
|
|
if (serverDecrypted.a === 'c' && (!this.channel[serverDecrypted.c] || !this.channel[serverDecrypted.c].shared)) {
|
|
try {
|
|
if (!this.channel[serverDecrypted.c]) {
|
|
this.channel[serverDecrypted.c] = {
|
|
username: null,
|
|
keys: this.clientEc.genKeyPair(),
|
|
shared: null,
|
|
};
|
|
this.sendMessage(this.encryptServerMessage({
|
|
a: 'c',
|
|
p: this.channel[serverDecrypted.c].keys.getPublic('hex'),
|
|
c: serverDecrypted.c
|
|
}, this.serverShared))
|
|
}
|
|
this.channel[serverDecrypted.c].shared = Buffer.from(this.xorHex(this.channel[serverDecrypted.c].keys.derive(this.clientEc.keyFromPublic(serverDecrypted.p, 'hex').getPublic()).toString('hex').padEnd(64, '8').substr(0, 64), this.credentials.password), 'hex');
|
|
this.sendMessage(this.encryptServerMessage({
|
|
a: 'c',
|
|
p: this.encryptClientMessage({
|
|
a: 'u',
|
|
p: this.credentials.username
|
|
}, this.channel[serverDecrypted.c].shared),
|
|
c: serverDecrypted.c
|
|
}, this.serverShared))
|
|
} catch (error) {
|
|
this.logEvent('onMessage-client', error, 'error')
|
|
}
|
|
return
|
|
}
|
|
if (serverDecrypted.a === 'c' && this.channel[serverDecrypted.c] && this.channel[serverDecrypted.c].shared) {
|
|
const clientDecrypted = this.decryptClientMessage(serverDecrypted.p, this.channel[serverDecrypted.c].shared);
|
|
this.logEvent('onMessage-client-decrypted', clientDecrypted);
|
|
if (!this.isObject(clientDecrypted) || !this.isString(clientDecrypted.a)) {
|
|
return
|
|
}
|
|
if (clientDecrypted.a === 'u' && this.isString(clientDecrypted.p) && clientDecrypted.p.match(/\S+/) && !this.channel[serverDecrypted.c].username) {
|
|
this.channel[serverDecrypted.c].username = clientDecrypted.p.replace(/^\s+/, '').replace(/\s+$/, '');
|
|
if (this.callbacks.onClientSecured) {
|
|
try {
|
|
this.callbacks.onClientSecured({
|
|
clientId: serverDecrypted.c,
|
|
username: this.channel[serverDecrypted.c].username
|
|
})
|
|
} catch (error) {
|
|
this.logEvent('onMessage-client-secured-callback', error, 'error')
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if (!this.channel[serverDecrypted.c].username) {
|
|
return
|
|
}
|
|
if (clientDecrypted.a === 'm' && this.isString(clientDecrypted.t) && this.isString(clientDecrypted.d)) {
|
|
if (this.callbacks.onClientMessage) {
|
|
try {
|
|
this.callbacks.onClientMessage({
|
|
clientId: serverDecrypted.c,
|
|
username: this.channel[serverDecrypted.c].username,
|
|
type: clientDecrypted.t,
|
|
data: clientDecrypted.d
|
|
})
|
|
} catch (error) {
|
|
this.logEvent('onMessage-client-message-callback', error, 'error')
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// WebSocket error event handler
|
|
// WebSocket 错误事件处理
|
|
async onError(event) {
|
|
this.logEvent('onError', event, 'error');
|
|
this.disconnect();
|
|
if (this.credentials) {
|
|
this.startReconnect()
|
|
}
|
|
if (this.callbacks.onServerClosed) {
|
|
try {
|
|
this.callbacks.onServerClosed()
|
|
} catch (error) {
|
|
this.logEvent('onError-server-closed-callback', error, 'error')
|
|
}
|
|
}
|
|
}
|
|
|
|
// WebSocket close event handler
|
|
// WebSocket 关闭事件处理
|
|
async onClose(event) {
|
|
this.logEvent('onClose', event);
|
|
this.disconnect();
|
|
if (this.credentials) {
|
|
this.startReconnect()
|
|
}
|
|
if (this.callbacks.onServerClosed) {
|
|
try {
|
|
this.callbacks.onServerClosed()
|
|
} catch (error) {
|
|
this.logEvent('onClose-server-closed-callback', error, 'error')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log events for debugging
|
|
// 记录事件日志用于调试
|
|
logEvent(source, message, level) {
|
|
if (this.config.debug) {
|
|
const date = new Date(),
|
|
dateString = date.getFullYear() + '-' + ('0' + (date.getMonth() + 1)).slice(-2) + '-' + ('0' + date.getDate()).slice(-2) + ' ' + ('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2) + ':' + ('0' + date.getSeconds()).slice(-2);
|
|
console.log('[' + dateString + ']', (level ? level.toUpperCase() : 'INFO'), source + (message ? ':' : ''), (message ? message : ''))
|
|
}
|
|
}
|
|
|
|
// Check if connection is open
|
|
// 检查连接是否已打开
|
|
isOpen() {
|
|
return (this.connection && this.connection.readyState && this.connection.readyState === WebSocket.OPEN ? true : false)
|
|
}
|
|
|
|
// Check if connection is closed
|
|
// 检查连接是否已关闭
|
|
isClosed() {
|
|
return (!this.connection || !this.connection.readyState || this.connection.readyState === WebSocket.CLOSED ? true : false)
|
|
}
|
|
|
|
// Start reconnect timer
|
|
// 启动重连定时器
|
|
startReconnect() {
|
|
this.stopReconnect();
|
|
this.logEvent('startReconnect');
|
|
this.reconnect = setTimeout(() => {
|
|
this.reconnect = null;
|
|
this.connect()
|
|
}, this.config.reconnectDelay)
|
|
}
|
|
|
|
// Stop reconnect timer
|
|
// 停止重连定时器
|
|
stopReconnect() {
|
|
if (this.reconnect) {
|
|
this.logEvent('stopReconnect');
|
|
clearTimeout(this.reconnect);
|
|
this.reconnect = null
|
|
}
|
|
}
|
|
|
|
// Start ping timer
|
|
// 启动心跳定时器
|
|
startPing() {
|
|
this.stopPing();
|
|
this.logEvent('startPing');
|
|
this.ping = setInterval(() => {
|
|
this.sendMessage('ping')
|
|
}, this.config.pingInterval)
|
|
}
|
|
|
|
// Stop ping timer
|
|
// 停止心跳定时器
|
|
stopPing() {
|
|
if (this.ping) {
|
|
this.logEvent('stopPing');
|
|
clearInterval(this.ping);
|
|
this.ping = null
|
|
}
|
|
}
|
|
|
|
// Disconnect from server
|
|
// 从服务器断开连接
|
|
disconnect() {
|
|
this.stopReconnect();
|
|
this.stopPing();
|
|
if (!this.isClosed()) {
|
|
try {
|
|
this.logEvent('disconnect');
|
|
this.connection.close()
|
|
} catch (error) {
|
|
this.logEvent('disconnect', error, 'error')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send a message to the server
|
|
// 向服务器发送消息
|
|
sendMessage(message) {
|
|
try {
|
|
if (this.isOpen()) {
|
|
this.connection.send(message);
|
|
return (true)
|
|
}
|
|
} catch (error) {
|
|
this.logEvent('sendMessage', error, 'error')
|
|
}
|
|
return (false)
|
|
}
|
|
|
|
// Send a message to all channels
|
|
// 向所有频道发送消息
|
|
sendChannelMessage(type, data) {
|
|
if (this.serverShared) {
|
|
try {
|
|
let payloads = {};
|
|
for (const clientId in this.channel) {
|
|
if (this.channel[clientId].shared && this.channel[clientId].username) {
|
|
payloads[clientId] = this.encryptClientMessage({
|
|
a: 'm',
|
|
t: type,
|
|
d: data
|
|
}, this.channel[clientId].shared);
|
|
if (payloads[clientId].length === 0) {
|
|
return (false)
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(payloads).length > 0) {
|
|
const payload = this.encryptServerMessage({
|
|
a: 'w',
|
|
p: payloads,
|
|
}, this.serverShared);
|
|
if (!this.isOpen() || payload.length === 0 || payload.length > (8 * 1024 * 1024)) {
|
|
return (false)
|
|
}
|
|
this.connection.send(payload)
|
|
}
|
|
return (true)
|
|
} catch (error) {
|
|
this.logEvent('sendChannelMessage', error, 'error')
|
|
}
|
|
}
|
|
return (false)
|
|
}
|
|
|
|
// Encrypt a message for the server
|
|
// 加密发送给服务器的消息
|
|
encryptServerMessage(message, key) {
|
|
let encrypted = '';
|
|
try {
|
|
message = Buffer.from(JSON.stringify(message), 'utf8');
|
|
if ((message.length % 16) !== 0) {
|
|
message = Buffer.from([...message, ...Buffer.alloc(16 - (message.length % 16))])
|
|
}
|
|
const iv = Buffer.from(crypto.getRandomValues(new Uint8Array(16)));
|
|
const cipher = new ModeOfOperation.cbc(key, iv);
|
|
encrypted = iv.toString('base64') + '|' + Buffer.from(cipher.encrypt(message)).toString('base64')
|
|
} catch (error) {
|
|
this.logEvent('encryptServerMessage', error, 'error')
|
|
}
|
|
return (encrypted)
|
|
}
|
|
|
|
// Decrypt a message from the server
|
|
// 解密来自服务器的消息
|
|
decryptServerMessage(message, key) {
|
|
let decrypted = {};
|
|
try {
|
|
const parts = message.split('|');
|
|
const decipher = new ModeOfOperation.cbc(key, Buffer.from(parts[0], 'base64'));
|
|
decrypted = JSON.parse(Buffer.from(decipher.decrypt(Buffer.from(parts[1], 'base64'))).toString('utf8').replace(/\0+$/, ''))
|
|
} catch (error) {
|
|
this.logEvent('decryptServerMessage', error, 'error')
|
|
}
|
|
return (decrypted)
|
|
}
|
|
|
|
// Encrypt a message for a client
|
|
// 加密发送给客户端的消息
|
|
encryptClientMessage(message, key) {
|
|
let encrypted = '';
|
|
try {
|
|
message = Buffer.from(JSON.stringify(message), 'utf8');
|
|
if ((message.length % 16) !== 0) {
|
|
message = Buffer.from([...message, ...Buffer.alloc(16 - (message.length % 16))])
|
|
}
|
|
const iv = Buffer.from(crypto.getRandomValues(new Uint8Array(12)));
|
|
const counter = Buffer.from(crypto.getRandomValues(new Uint8Array(4)));
|
|
const cipher = new chacha(key, iv, counter.reduce((a, b) => a * b));
|
|
encrypted = iv.toString('base64') + '|' + counter.toString('base64') + '|' + Buffer.from(cipher.encrypt(message)).toString('base64')
|
|
} catch (error) {
|
|
this.logEvent('encryptClientMessage', error, 'error')
|
|
}
|
|
return (encrypted)
|
|
}
|
|
|
|
// Decrypt a message from a client
|
|
// 解密来自客户端的消息
|
|
decryptClientMessage(message, key) {
|
|
let decrypted = {};
|
|
try {
|
|
const parts = message.split('|');
|
|
const decipher = new chacha(key, Buffer.from(parts[0], 'base64'), Buffer.from(parts[1], 'base64').reduce((a, b) => a * b));
|
|
decrypted = JSON.parse(Buffer.from(decipher.decrypt(Buffer.from(parts[2], 'base64'))).toString('utf8').replace(/\0+$/, ''))
|
|
} catch (error) {
|
|
this.logEvent('decryptClientMessage', error, 'error')
|
|
}
|
|
return (decrypted)
|
|
}
|
|
|
|
// XOR two hex strings
|
|
// 对两个十六进制字符串进行异或
|
|
xorHex(a, b) {
|
|
let result = '',
|
|
hexLength = Math.min(a.length, b.length);
|
|
for (let i = 0; i < hexLength; ++i) {
|
|
result += (parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16)).toString(16)
|
|
}
|
|
return (result)
|
|
}
|
|
|
|
// Check if value is a string
|
|
// 检查值是否为字符串
|
|
isString(value) {
|
|
return (value && Object.prototype.toString.call(value) === '[object String]' ? true : false)
|
|
}
|
|
|
|
// Check if value is an array
|
|
// 检查值是否为数组
|
|
isArray(value) {
|
|
return (value && Object.prototype.toString.call(value) === '[object Array]' ? true : false)
|
|
}
|
|
|
|
// Check if value is an object
|
|
// 检查值是否为对象
|
|
isObject(value) {
|
|
return (value && Object.prototype.toString.call(value) === '[object Object]' ? true : false)
|
|
}
|
|
|
|
// Handle server public key
|
|
// 处理服务器公钥
|
|
async handleServerKey(serverKey) {
|
|
this.logEvent('handleServerKey', 'Received server key');
|
|
localStorage.removeItem(this.SERVER_KEY_STORAGE);
|
|
localStorage.setItem(this.SERVER_KEY_STORAGE, serverKey);
|
|
this.config.rsaPublic = serverKey;
|
|
return true
|
|
}
|
|
};
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.NodeCrypt = NodeCrypt
|
|
} |