跳到主要内容
Web 版 IM 聊天消息加密的三种算法实现方案 | 极客日志
JavaScript Node.js 大前端 java 算法
Web 版 IM 聊天消息加密的三种算法实现方案 Web 版即时通讯应用面临隐私泄露风险,端到端加密(E2EE)是核心解决方案。对比三种加密方案:静态非对称加密、非对称加密加数字签名、混合加密系统。前两者存在性能差或缺乏前向保密性等问题,仅适合学习或低频场景。推荐采用混合加密系统,结合对称与非对称加密优势,实现高性能与前向保密,是现代生产环境的标准做法。
HadoopMan 发布于 2026/3/29 更新于 2026/4/23 1 浏览
第一部分:引言与核心密码学概念
1.1 为什么 IM 需要端到端加密(E2EE)?
即时通讯内容通常包含个人隐私、商业机密等敏感信息。传统的安全措施(如 HTTPS)只能保证信息在传输过程中的安全,无法防止消息在服务器上被窃取或窥探(例如,数据库被攻破、服务器管理员作恶)。端到端加密(End-to-End Encryption, E2EE)旨在解决这一问题。
核心思想 :消息在发送方客户端就被加密,直到到达接收方客户端才被解密。在整个传输和存储过程中,消息始终以密文形式存在。即使是 IM 服务提供商,也无法获取消息的明文内容。
安全目标 :
保密性 (Confidentiality) :防止未授权方读取信息内容。
完整性 (Integrity) :确保信息在传输过程中未被篡改。
身份验证 (Authentication) :确保信息确实来自声称的发送者。
不可否认性 (Non-repudiation) :发送者事后无法否认自己发送过的信息。
1.2 核心密码学概念与工具
在深入方案之前,必须理解以下概念:
对称加密 (Symmetric Encryption)
描述 :加密和解密使用同一个密钥。
算法 :AES (Advanced Encryption Standard) 是当前的标准,常用密钥长度 128, 192, 256 位。
优点 :速度快,适合加密大量数据。
缺点 :密钥分发困难。如何安全地将密钥分享给通信双方是一个经典难题。
非对称加密 (Asymmetric Encryption)
描述 :使用一对密钥:公钥 (Public Key) 和 私钥 (Private Key)。公钥可以公开,用于加密;私钥必须严格保密,用于解密。用公钥加密的数据,只有对应的私钥能解密。
算法 :RSA (Rivest–Shamir–Adleman), ECC (Elliptic Curve Cryptography)。ECC 在相同安全强度下比 RSA 密钥更短、计算更快。
优点 :解决了密钥分发问题。你可以随意发布你的公钥,任何人都可以用它加密信息,但只有你能用私钥解密。
缺点 :速度非常慢(比对称加密慢几个数量级),不适合加密大量数据。
混合加密系统 (Hybrid Cryptosystem)
描述 :结合对称加密和非对称加密的优点。
流程 :
发送方随机生成一个对称密钥(称为会话密钥)。
发送方使用接收方的公钥加密这个对称密钥。
发送方使用对称密钥加密实际要发送的消息。
发送方将加密后的对称密钥和加密后的消息一起发送给接收方。
接收方使用自己的私钥解密出对称密钥。
接收方使用对称密钥解密出原始消息。
优点 :既获得了非对称加密的安全密钥分发,又获得了对称加密的高效数据加密。这是现代安全通信(如 TLS/SSL)的基础。
数字签名 (Digital Signature)
描述 :用于验证消息的来源和完整性。发送方使用自己的私钥对消息的哈希值进行加密,得到签名。接收方使用发送方的公钥对签名进行解密,并将结果与自己对消息计算的哈希值对比。如果匹配,则证明消息确实来自该发送者且未被篡改。
作用 :提供身份验证和不可否认性。密钥派生函数 (KDF - Key Derivation Function)
描述 :从一个主密钥或密码派生出一個或多个加密密钥。例如,PBKDF2, scrypt, bcrypt, HKDF。
作用 :增强弱密码的安全性,实现'密钥拉伸',并从单个输入密钥材料生成多个密钥。
前端密码学库选择
Web Crypto API :现代浏览器原生支持的 API,性能最好,但 API 较底层,某些高级功能(如 OAEP)支持可能因浏览器而异。
crypto-js :流行易用的库,但纯 JavaScript 实现,性能不如原生 API,且可能更容易受到侧信道攻击。
node-forge :功能非常强大的库,在 Node.js 和浏览器中都能工作,提供了比 Web Crypto API 更友好的抽象。
libsodium.js:是著名的 libsodium 库的 JavaScript 版本,提供了经过高度优化的、难以误用的高级 API,非常推荐用于生产环境。
后端密码学库选择
Java 标准库 (javax.crypto, java.security) 已经非常强大,足以实现所有需求。
第二部分:方案一:静态非对称加密(基础方案)
2.1 方案概述与流程 这是最直观的 E2EE 方案。每个用户拥有一对固定的长期密钥。发送者使用接收者的公钥直接加密每一条消息。
密钥生成与上传 :用户注册/登录时,前端生成 RSA 密钥对。私钥本地保存,公钥上传至服务器。
获取公钥 :A 要给 B 发消息时,A 的前端从服务器获取 B 的公钥。
加密与发送 :A 使用 B 的公钥加密消息,将密文发送给服务器。
中继与接收 :服务器将密文转发给 B。
解密与展示 :B 使用自己的私钥解密消息。
+
| 用户 A | | 前端 A | | 后端服务器 | | 前端 B |
+
| | | | | | | |
| 1. 生成密钥对 | | | |
| 2. 上传公钥 | | | |
| 3. 存储公钥 | | | |<
| | | | | | | |
| 4. 输入消息 | | | |
| 5. 获取 B 的公钥请求 | | | |
| 6. 返回 B 的公钥 | | | |<
| 7. 用 B 的公钥加密消息 | | | |
| 8. 发送密文给 B | | | |
| 9. 转发密文给 B | | | |
| 10. 用 B 的私钥解密 | | | |
| 11. 显示明文 | | | | | |
2.2 前端 Vue 实现(使用 node-forge)
1. 安装依赖
2. 核心工具类 crypto.js
import forge from 'node-forge' ;
export function generateRSAKeyPair ( ) {
return new Promise ((resolve, reject ) => {
forge.pki .rsa .generateKeyPair ({
bits : 2048 ,
workers : 2
}, (err, keypair ) => {
if (err) {
reject (err);
return ;
}
const publicKey = forge.pki .publicKeyToPem (keypair.publicKey );
const privateKey = forge.pki .privateKeyToPem (keypair.privateKey );
resolve ({ publicKey, privateKey });
});
});
}
export function encryptMessageWithPublicKey (publicKeyPem, message ) {
try {
const publicKey = forge.pki .publicKeyFromPem (publicKeyPem);
const encodedMessage = forge.util .encodeUtf8 (message);
const encrypted = publicKey.encrypt (encodedMessage, 'RSA-OAEP' , {
md : forge.md .sha256 .create ()
});
return forge.util .encode64 (encrypted);
} catch (error) {
console .error ('加密失败:' , error);
throw new Error ('消息加密失败' );
}
}
export function decryptMessageWithPrivateKey (privateKeyPem, encryptedMessageBase64 ) {
try {
const privateKey = forge.pki .privateKeyFromPem (privateKeyPem);
const encryptedData = forge.util .decode64 (encryptedMessageBase64);
const decrypted = privateKey.decrypt (encryptedData, 'RSA-OAEP' , {
md : forge.md .sha256 .create ()
});
return forge.util .decodeUtf8 (decrypted);
} catch (error) {
console .error ('解密失败:' , error);
throw new Error ('消息解密失败,可能是密钥不匹配或消息已损坏' );
}
}
export function savePrivateKeySecurely (userId, privateKey ) {
localStorage .setItem (`im_private_key_${userId} ` , privateKey);
}
export function getPrivateKey (userId ) {
return localStorage .getItem (`im_private_key_${userId} ` );
}
3. Vue 组件中使用 <!-- components/Chat.vue -->
<template>
<div>
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.sender === currentUser.id ? 'sent' : 'received']">
<p><strong>{{ msg.senderName }}:</strong> {{ msg.decryptedContent || '**加密消息**' }}</p>
<small>{{ msg.timestamp }}</small>
<button v-if="!msg.decryptedContent && msg.sender !== currentUser.id" @click="decryptMessage(msg)">解密</button>
</div>
<div>
<textarea v-model="newMessage" @keyup.enter="sendMessage" placeholder="输入消息..."></textarea>
<button @click="sendMessage" :disabled="!newMessage.trim()">发送</button>
</div>
</div>
</template>
<script>
import { encryptMessageWithPublicKey, decryptMessageWithPrivateKey, getPrivateKey } from '@/utils/crypto';
import { apiGetUserPublicKey, apiSendMessage } from '@/api/chat';
export default {
name: 'Chat',
props: ['currentUser', 'targetUser'],
data() {
return {
newMessage: '',
messages: [], // 消息格式:{ id, sender, senderName, encryptedContent, decryptedContent, timestamp }
websocket: null,
};
},
async mounted() {
this.connectWebSocket();
// ... 加载历史消息 (历史消息也是密文,需要手动解密)
},
methods: {
async sendMessage() {
if (!this.newMessage.trim()) return;
try {
// 1. 从服务器获取目标用户的公钥
const publicKeyResponse = await apiGetUserPublicKey(this.targetUser.id);
const receiverPublicKey = publicKeyResponse.data;
// 2. 加密消息
const encryptedContent = encryptMessageWithPublicKey(receiverPublicKey, this.newMessage.trim());
// 3. 构建消息对象并通过 WebSocket 或 API 发送
const messagePayload = {
receiverId: this.targetUser.id,
type: 'text',
content: encryptedContent, // 发送的是密文
isEncrypted: true, // 标记此消息已加密
timestamp: new Date().toISOString(),
};
// 4. 通过 WebSocket 发送
this.websocket.send(JSON.stringify(messagePayload));
// 5. 乐观更新 UI (显示为'已发送,加密中')
this.messages.push({
id: Date.now(), // 临时 ID
sender: this.currentUser.id,
senderName: '我',
encryptedContent: '**消息已加密发送**',
decryptedContent: null,
timestamp: '刚刚',
});
this.newMessage = '';
} catch (error) {
console.error('发送消息失败:', error);
this.$notify({ type: 'error', title: '发送失败', text: '加密或发送消息时出错' });
}
},
async decryptMessage(message) {
if (message.decryptedContent) return; // 已解密
try {
// 获取自己的私钥
const privateKey = getPrivateKey(this.currentUser.id);
if (!privateKey) {
throw new Error('未找到解密密钥,请重新登录');
}
// 解密消息
const decryptedContent = decryptMessageWithPrivateKey(privateKey, message.encryptedContent);
// 更新消息状态
message.decryptedContent = decryptedContent;
// 可选:通知后端该消息已成功解密阅读(已读回执)
} catch (error) {
console.error('解密消息失败:', error);
this.$notify({ type: 'error', title: '解密失败', text: '无法解密此消息' });
}
},
connectWebSocket() {
// ... WebSocket 连接逻辑,用于实时接收消息
this.websocket.onmessage = (event) => {
const messageData = JSON.parse(event.data);
this.handleIncomingMessage(messageData);
};
},
handleIncomingMessage(messageData) {
// 处理接收到的消息
const newMsg = {
id: messageData.id,
sender: messageData.senderId,
senderName: messageData.senderName,
encryptedContent: messageData.content, // 收到的是密文
decryptedContent: null, // 初始化为 null,等待用户点击解密
timestamp: new Date(messageData.timestamp).toLocaleTimeString(),
};
this.messages.push(newMsg);
// 可选:如果是当前会话的消息,可以提示用户有新消息
if (this.targetUser.id === messageData.senderId) {
this.$notify({ type: 'info', title: '新消息', text: `来自 ${messageData.senderName} 的加密消息` });
}
},
},
beforeUnmount() {
if (this.websocket) {
this.websocket.close();
}
},
};
</script>
<style scoped>
/* ... 聊天样式 ... */
.message.received button {
margin-left: 10px;
font-size: 0.8em;
}
</style>
2.3 后端 Java 实现(Spring Boot) 后端在此方案中角色较轻,主要负责公钥的存储查询和消息的转发。
1. 实体类
@Entity
@Table(name = "im_users")
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String nickname;
@Column(columnDefinition = "TEXT")
private String rsaPublicKey;
}
@Entity
@Table(name = "im_messages")
@Data
public class ChatMessage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long senderId;
private Long receiverId;
@Column(columnDefinition = "TEXT")
private String content;
private Boolean isEncrypted;
private String messageType;
private Instant timestamp;
}
2. Controller 层 @RestController
@RequestMapping("/api/chat")
public class ChatController {
@Autowired
private UserRepository userRepository;
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private ChatMessageRepository messageRepository;
@GetMapping("/user/{userId}/public-key")
public ResponseEntity<?> getUserPublicKey(@PathVariable Long userId) {
User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException ("User not found" ));
if (user.getRsaPublicKey() == null ) {
return ResponseEntity.badRequest().body("User does not have a public key" );
}
return ResponseEntity.ok().body(Collections.singletonMap("publicKey" , user.getRsaPublicKey()));
}
@PostMapping("/message")
public ResponseEntity<Void> sendEncryptedMessage (@RequestBody EncryptedMessageRequest request) {
ChatMessage message = new ChatMessage ();
message.setSenderId(request.getSenderId());
message.setReceiverId(request.getReceiverId());
message.setContent(request.getContent());
message.setIsEncrypted(true );
message.setMessageType(request.getType());
message.setTimestamp(Instant.now());
messageRepository.save(message);
String destination = "/queue/messages" ;
MessageDeliveryDto deliveryDto = new MessageDeliveryDto ();
deliveryDto.setId(message.getId());
deliveryDto.setSenderId(request.getSenderId());
deliveryDto.setSenderName("" );
deliveryDto.setContent(request.getContent());
deliveryDto.setEncrypted(true );
deliveryDto.setType(request.getType());
deliveryDto.setTimestamp(message.getTimestamp());
messagingTemplate.convertAndSendToUser(
request.getReceiverId().toString(),
destination,
deliveryDto
);
return ResponseEntity.ok().build();
}
public static class EncryptedMessageRequest {
private Long senderId;
private Long receiverId;
private String content;
private String type;
}
public static class MessageDeliveryDto {
private Long id;
private Long senderId;
private String senderName;
private String content;
private Boolean isEncrypted;
private String type;
private Instant timestamp;
}
}
3. WebSocket 配置 @Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints (StompEndpointRegistry registry) {
registry.addEndpoint("/ws" ).setAllowedOriginPatterns("*" ).withSockJS();
}
@Override
public void configureMessageBroker (MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app" );
registry.enableSimpleBroker("/topic" , "/queue" );
registry.setUserDestinationPrefix("/user" );
}
@Override
public void configureClientInboundChannel (ChannelRegistration registration) {
registration.interceptors(new AuthChannelInterceptor ());
}
}
2.4 密钥管理、注册与登录集成
1. 用户注册/登录时生成密钥
import { generateRSAKeyPair, savePrivateKeySecurely } from '@/utils/crypto' ;
import { apiRegister, apiLogin, apiUpdatePublicKey } from '@/api/auth' ;
const actions = {
async register ({ commit }, userData ) {
try {
const { publicKey, privateKey } = await generateRSAKeyPair ();
const registrationData = {
...userData,
publicKey : publicKey
};
const response = await apiRegister (registrationData);
if (response.data .userId ) {
savePrivateKeySecurely (response.data .userId , privateKey);
commit ('SET_USER' , response.data );
commit ('SET_PRIVATE_KEY' , privateKey);
}
return response;
} catch (error) {
console .error ('注册失败:' , error);
throw error;
}
},
async login ({ commit }, credentials ) {
try {
const response = await apiLogin (credentials);
const user = response.data ;
let privateKey = getPrivateKey (user.id );
if (!privateKey) {
this .$router .push ('/key-setup' );
return ;
}
commit ('SET_USER' , user);
commit ('SET_PRIVATE_KEY' , privateKey);
return response;
} catch (error) {
console .error ('登录失败:' , error);
throw error;
}
}
};
2. 密钥设置页面 <!-- views/KeySetup.vue -->
<template>
<div>
<h2>安全密钥设置</h2>
<p>检测到您在新设备登录,需要为您生成新的加密密钥。</p>
<p>生成后,您将无法在其他设备上解密之前的消息。</p>
<!-- 这是该方案的一个缺点 -->
<button @click="generateNewKeys">生成新密钥</button>
</div>
</template>
<script>
import { generateRSAKeyPair, savePrivateKeySecurely } from '@/utils/crypto';
import { apiUpdatePublicKey } from '@/api/auth';
export default {
methods: {
async generateNewKeys() {
try {
const { publicKey, privateKey } = await generateRSAKeyPair();
const userId = this.$store.state.auth.user.id;
// 上传新公钥到服务器
await apiUpdatePublicKey(userId, publicKey);
// 保存新私钥到本地
savePrivateKeySecurely(userId, privateKey);
this.$store.commit('auth/SET_PRIVATE_KEY', privateKey);
this.$router.go(-1); // 返回上一页
} catch (error) {
console.error('密钥生成失败:', error);
}
}
}
};
</script>
2.5 方案一优缺点总结
优点 :
概念简单 :易于理解和实现。
符合 E2EE :服务器从未接触明文。
无需状态管理 :服务器无需管理会话密钥。
缺点 :
性能极差 :RSA 加密非常慢,尤其是对长消息。频繁发送消息会导致前端界面卡顿。
无前向保密性 (Forward Secrecy) :如果用户的长期私钥将来被泄露,攻击者可以解密该用户所有过去和未来的通信记录。这是致命的缺点。
密钥管理复杂 :用户更换设备后,旧设备上的私钥无法同步,导致无法解密新消息,且旧消息也无法在新设备上解密。解决方案(如用主密码加密私钥然后同步)会引入新的复杂性。
无法认证发送者 :接收者知道消息是用自己的公钥加密的,但无法密码学上证实发送者是谁。(方案二通过数字签名解决此问题)。
结论:方案一适用于学习原理或对安全要求不高、消息频率极低的场景,不推荐用于生产环境。
第三部分:方案二:非对称加密 + 数字签名(增强身份验证)
3.1 方案概述与流程 此方案在方案一的基础上增加了数字签名,解决了发送者身份验证和消息完整性的问题。
A 生成消息明文 M。
A 使用 B 的公钥加密 M,得到密文 C。
A 使用自己的私钥对密文 C(或明文 M 的哈希)进行签名,得到签名 S。
A 将 { cipherText: C, signature: S, senderId: A } 发送给服务器。
服务器转发给 B。
B 收到后,使用 A 的公钥验证签名 S。如果验证失败,则丢弃消息。
验证通过后,B 使用自己的私钥解密 C,得到明文 M。
+
| 用户 A | | 前端 A | | 后端服务器 | | 前端 B |
+
| | | | | | | |
| 1. 输入消息 | | | |
| 2. 获取 B 的公钥 | | | |
| 3. 返回 B 的公钥 | | | |<
| 4. 用 B 的公钥加密消息 | | | |
| 5. 用 A 的私钥签名 | | | |
| 6. 发送 (密文 + 签名) | | | |
| 7. 转发给 B | | | |
| 8. 获取 A 的公钥 | | | |<
| 9. 验证签名 | | | |
| 10. 验证成功? | | | |
| | | | | | | | |
| 11. 用 B 的私钥解密 | | | |
| 12. 显示明文 | | | |
| 13. 丢弃消息,报错 | | | | | |
3.2 前端 Vue 实现增强 在原有 crypto.js 工具类中添加签名和验证功能。
export function signMessageWithPrivateKey (privateKeyPem, message ) {
try {
const privateKey = forge.pki .privateKeyFromPem (privateKeyPem);
const md = forge.md .sha256 .create ();
md.update (message, 'utf8' );
const signature = privateKey.sign (md);
return forge.util .encode64 (signature);
} catch (error) {
console .error ('签名失败:' , error);
throw new Error ('消息签名失败' );
}
}
export function verifySignatureWithPublicKey (publicKeyPem, message, signatureBase64 ) {
try {
const publicKey = forge.pki .publicKeyFromPem (publicKeyPem);
const md = forge.md .sha256 .create ();
md.update (message, 'utf8' );
const signature = forge.util .decode64 (signatureBase64);
const isVerified = publicKey.verify (md.digest ().getBytes (), signature);
return isVerified;
} catch (error) {
console .error ('验证签名失败:' , error);
return false ;
}
}
export function signPlainTextWithPrivateKey (privateKeyPem, plainText ) {
}
export function verifyPlainTextSignature (publicKeyPem, plainText, signatureBase64 ) {
}
发送消息(增加签名)
async sendMessage ( ) {
const privateKey = getPrivateKey (this .currentUser .id );
const signature = signPlainTextWithPrivateKey (privateKey, this .newMessage .trim ());
const messagePayload = {
receiverId : this .targetUser .id ,
type : 'text' ,
content : encryptedContent,
signature : signature,
isEncrypted : true ,
timestamp : new Date ().toISOString (),
};
}
接收消息(增加验证)
async decryptMessage (message ) {
try {
const senderPublicKeyResponse = await apiGetUserPublicKey (message.sender );
const senderPublicKey = senderPublicKeyResponse.data .publicKey ;
const isSignatureValid = verifyPlainTextSignature (senderPublicKey, decryptedContent, message.signature );
if (isSignatureValid) {
message.decryptedContent = decryptedContent;
message.signatureStatus = 'verified' ;
this .$notify({ type : 'success' , title : '解密成功' , text : '消息签名验证通过' });
} else {
message.decryptedContent = decryptedContent + " [警告:签名验证失败,消息可能被篡改或来源不可信]" ;
message.signatureStatus = 'invalid' ;
this .$notify({ type : 'warning' , title : '安全警告' , text : '消息解密成功,但签名验证失败' });
}
} catch (error) {
console .error ('验证签名时出错:' , error);
message.decryptedContent = decryptedContent + " [错误:签名验证过程出错]" ;
message.signatureStatus = 'error' ;
}
}
3.3 后端 Java 实现增强
1. 修改消息实体和 DTO
public class ChatMessage {
@Column(columnDefinition = "TEXT")
private String signature;
}
public static class EncryptedMessageRequest {
private Long senderId;
private Long receiverId;
private String content;
private String signature;
private String type;
}
public static class MessageDeliveryDto {
private String signature;
}
2. 后端签名验证(可选) 后端原则上不处理明文,但可以选择性地验证签名以增加一层安全防护(防止恶意客户端发送伪造签名的消息)。
@PostMapping("/message")
public ResponseEntity<Void> sendEncryptedMessage (@RequestBody EncryptedMessageRequest request) {
User sender = userRepository.findById(request.getSenderId()).orElseThrow(...);
}
3.4 方案二优缺点总结
优点 :
提供身份验证和不可否认性 :接收者可以确信消息来自特定的发送者。
提供完整性保护 :签名验证失败意味着消息或签名在传输过程中被篡改。
继承方案一的 E2EE 优点 。
缺点 :
性能进一步下降 :每次发送消息都需要进行两次昂贵的非对称加密操作(加密和签名)。
依然没有前向保密性 :长期私钥泄露的风险依然存在。
密钥管理问题依旧 。
结论:方案二解决了身份验证问题,但加剧了性能问题,且仍未解决前向保密这一核心安全缺陷。仍不推荐用于高频生产环境。
第四部分:方案三:混合加密系统(推荐生产方案)
4.1 方案概述与流程 这是现代安全通信的标准模型,完美结合了非对称加密和对称加密的优点。核心思想是:使用非对称加密安全地交换一个临时的对称密钥,然后使用这个对称密钥来加密实际的消息。
核心概念:前向保密 (Forward Secrecy)
每次会话或定期更换对称密钥(称为会话密钥)。
即使攻击者破解了用户的长期私钥,也无法解密过去的通信记录,因为过去的会话密钥早已丢弃,且是用当时的临时密钥加密的。
这是生产级 E2EE 系统的必备特性。
会话初始化 :
A 生成一个随机的对称会话密钥 SK 和初始化向量 IV。
A 获取 B 的长期公钥。
A 使用 B 的公钥加密 (SK, IV),得到密钥信封 Envelope。
A 可选地用自已的私钥对 Envelope 签名。
A 将 Envelope 和签名发送给 B。这个过程称为密钥交换。
发送消息 :
A 使用会话密钥 SK 和 IV,通过 AES 算法对消息明文进行对称加密,得到密文 C。
A 将密文 C 发送给 B。
接收消息 :
B 收到 Envelope 后,用自己的长期私钥解密,得到 SK 和 IV。
B 收到密文 C 后,使用 SK 和 IV 进行对称解密,得到明文。
+
| 用户 A | | 前端 A | | 后端服务器 | | 前端 B |
+
| | | | | | | |
| 1. 开始会话 | | | |
| 2. 生成会话密钥 SK | | | |
| 3. 获取 B 的公钥 | | | |
| 4. 返回 B 的公钥 | | | |<
| 5. 用 B 的公钥加密 SK | | | |
| 6. 发送密钥信封 | | | |
| 7. 转发给 B | | | |
| 8. 用 B 的私钥解密得 SK | | | |
| 9. 存储 SK | | | |
| ... | | | | | | | |
| | | | | | | | |
| 10. 输入消息 | | | | |
| | | | | | | | |
| 11. 用 SK 加密消息 | | | | |
| | | | | | | | |
| 12. 发送消息密文 | | | | |
| | | | | | | | |
| 13. 转发给 B | | | | |
| | | | | | | | |
| 14. 用 SK 解密消息 | | | |
| | | | | | | | |
| 15. 显示明文 | | | | | | | |
*注:密钥交换通常只在会话开始时进行一次,后续所有消息都使用高效的对称加密。
4.2 前端 Vue 实现(重大修改)
1. 扩展加密工具类 crypto.js
export function generateSymmetricKey ( ) {
const key = forge.random .getBytesSync (32 );
const iv = forge.random .getBytesSync (16 );
return {
key : forge.util .encode64 (key),
iv : forge.util .encode64 (iv)
};
}
export function encryptWithSymmetricKey (keyBase64, ivBase64, message ) {
try {
const key = forge.util .decode64 (keyBase64);
const iv = forge.util .decode64 (ivBase64);
const cipher = forge.cipher .createCipher ('AES-CBC' , key);
cipher.start ({ iv : iv });
cipher.update (forge.util .createBuffer (message, 'utf8' ));
cipher.finish ();
const encrypted = cipher.output ;
return forge.util .encode64 (encrypted.data );
} catch (error) {
console .error ('对称加密失败:' , error);
throw new Error ('消息加密失败' );
}
}
export function decryptWithSymmetricKey (keyBase64, ivBase64, encryptedMessageBase64 ) {
try {
const key = forge.util .decode64 (keyBase64);
const iv = forge.util .decode64 (ivBase64);
const encryptedData = forge.util .decode64 (encryptedMessageBase64);
const decipher = forge.cipher .createDecipher ('AES-CBC' , key);
decipher.start ({ iv : iv });
decipher.update (forge.util .createBuffer (encryptedData));
const result = decipher.finish ();
if (result) {
return decipher.output .toString ('utf8' );
} else {
throw new Error ('解密失败:可能密钥或 IV 不正确' );
}
} catch (error) {
console .error ('对称解密失败:' , error);
throw new Error ('消息解密失败' );
}
}
export function encryptSymmetricKeyWithPublicKey (publicKeyPem, symmetricKeyObj ) {
const keyDataStr = JSON .stringify (symmetricKeyObj);
return encryptMessageWithPublicKey (publicKeyPem, keyDataStr);
}
export function decryptSymmetricKeyWithPrivateKey (privateKeyPem, encryptedEnvelope ) {
const decryptedKeyStr = decryptMessageWithPrivateKey (privateKeyPem, encryptedEnvelope);
return JSON .parse (decryptedKeyStr);
}
2. 会话密钥管理 (Vuex Store) 我们需要一个地方来存储和管理与不同用户的会话密钥。
const state = {
sessionKeys : {},
};
const mutations = {
SET_SESSION_KEY (state, { userId, keyData } ) {
state.sessionKeys [userId] = {
...keyData,
timestamp : Date .now (),
};
},
CLEAR_SESSION_KEY (state, userId ) {
delete state.sessionKeys [userId];
},
CLEAR_ALL_SESSIONS (state ) {
state.sessionKeys = {};
},
};
const actions = {
async establishSession ({ commit, rootState }, targetUserId ) {
try {
const symmetricKey = generateSymmetricKey ();
const publicKeyResponse = await apiGetUserPublicKey (targetUserId);
const receiverPublicKey = publicKeyResponse.data .publicKey ;
const encryptedEnvelope = encryptSymmetricKeyWithPublicKey (receiverPublicKey, symmetricKey);
await apiSendKeyExchange ({
receiverId : targetUserId,
encryptedEnvelope : encryptedEnvelope,
});
commit ('SET_SESSION_KEY' , { userId : targetUserId, keyData : symmetricKey });
console .log (`会话密钥已建立并发送给用户 ${targetUserId} ` );
return symmetricKey;
} catch (error) {
console .error ('建立会话失败:' , error);
throw new Error ('无法建立安全会话' );
}
},
async getOrCreateSessionKey ({ state, dispatch }, targetUserId ) {
let keyData = state.sessionKeys [targetUserId];
const isExpired = keyData && (Date .now () - keyData.timestamp > 3600000 );
if (!keyData || isExpired) {
keyData = await dispatch ('establishSession' , targetUserId);
}
return keyData;
},
};
export default {
namespaced : true ,
state,
mutations,
actions,
};
3. 修改聊天组件 <!-- components/Chat.vue (重大修改) -->
<script>
import { encryptWithSymmetricKey, decryptWithSymmetricKey } from '@/utils/crypto';
import { mapActions } from 'vuex';
export default {
// ... methods: {
async sendMessage() {
if (!this.newMessage.trim()) return;
try {
// 1. 获取或创建与目标用户的会话密钥
const sessionKeyData = await this.$store.dispatch('chatSession/getOrCreateSessionKey', this.targetUser.id);
// 2. 使用对称密钥加密消息 (非常快)
const encryptedContent = encryptWithSymmetricKey(
sessionKeyData.key,
sessionKeyData.iv,
this.newMessage.trim()
);
// 3. 发送消息
const messagePayload = {
receiverId: this.targetUser.id,
type: 'text',
content: encryptedContent,
isEncrypted: true,
isSymmetric: true, // 新增字段,表明是对称加密
timestamp: new Date().toISOString(),
};
this.websocket.send(JSON.stringify(messagePayload));
// 4. 乐观更新 UI
this.messages.push({
id: Date.now(),
sender: this.currentUser.id,
senderName: '我',
encryptedContent: '**消息已加密发送**',
decryptedContent: this.newMessage.trim(), // 乐观显示明文
timestamp: '刚刚',
});
this.newMessage = '';
} catch (error) {
console.error('发送消息失败:', error);
this.$notify({ type: 'error', title: '发送失败', text: '加密或发送消息时出错' });
}
},
async handleIncomingMessage(messageData) {
// 处理接收到的消息
const newMsg = {
id: messageData.id,
sender: messageData.senderId,
senderName: messageData.senderName,
encryptedContent: messageData.content,
decryptedContent: null,
isSymmetric: messageData.isSymmetric, // 检查是否是对称加密
timestamp: new Date(messageData.timestamp).toLocaleTimeString(),
};
// 如果是对称加密消息,并且我们有会话密钥,尝试自动解密
if (newMsg.isSymmetric) {
const sessionKey = this.$store.state.chatSession.sessionKeys[newMsg.sender];
if (sessionKey) {
try {
newMsg.decryptedContent = decryptWithSymmetricKey(
sessionKey.key,
sessionKey.iv,
newMsg.encryptedContent
);
} catch (decryptError) {
console.error('自动解密失败:', decryptError);
newMsg.decryptedContent = null; // 解密失败,等待手动重试
}
}
}
this.messages.push(newMsg);
// ... 其他逻辑 ...
},
// 处理接收到的密钥交换消息(来自 B 的回复或主动发起)
async handleKeyExchangeMessage(messageData) {
if (messageData.type === 'key_exchange') {
try {
const privateKey = getPrivateKey(this.currentUser.id);
const decryptedKeyData = decryptSymmetricKeyWithPrivateKey(privateKey, messageData.encryptedEnvelope);
// 存储发送方(B)的会话密钥,用于解密他发来的消息
this.$store.commit('chatSession/SET_SESSION_KEY', {
userId: messageData.senderId,
keyData: decryptedKeyData,
});
console.log(`收到来自 ${messageData.senderId} 的会话密钥并已保存`);
// 可以发送一个确认消息
} catch (error) {
console.error('处理密钥交换消息失败:', error);
}
}
},
// ... };
};
</script>
4.3 后端 Java 实现 后端需要处理两种类型的消息:key_exchange 和普通的 text 消息。
1. 修改 Controller @PostMapping("/message")
public ResponseEntity<Void> sendMessage (@RequestBody MessageRequest request) {
ChatMessage message = new ChatMessage ();
message.setSenderId(request.getSenderId());
message.setReceiverId(request.getReceiverId());
message.setContent(request.getContent());
message.setSignature(request.getSignature());
message.setIsEncrypted(request.getIsEncrypted());
message.setMessageType(request.getType());
message.setTimestamp(Instant.now());
messageRepository.save(message);
MessageDeliveryDto deliveryDto = new MessageDeliveryDto ();
deliveryDto.setType(request.getType());
messagingTemplate.convertAndSendToUser(
request.getReceiverId().toString(),
"/queue/messages" ,
deliveryDto
);
return ResponseEntity.ok().build();
}
public static class MessageRequest {
private Long senderId;
private Long receiverId;
private String content;
private String signature;
private Boolean isEncrypted;
private String type;
}
4.4 高级特性:双工密钥协商与 Perfect Forward Secrecy 上面的实现是 A 生成密钥给 B,是单向的。更安全的做法是双方各自生成一个密钥种子,通过 Diffie-Hellman 密钥交换协议协商出一个共享的会话密钥。这可以实现完全的前向保密,即使双方的长期私钥都泄露,过去的会话也无法解密。
使用 ECDH(椭圆曲线 Diffie-Hellman)
A 和 B 各自生成临时的 ECC 密钥对 。
A 将自己的临时公钥发送给 B 。
B 将自己的临时公钥发送给 A 。
A 用自己的临时私钥和 B 的临时公钥计算共享密钥 。
B 用自己的临时私钥和 A 的临时公钥计算共享密钥 。(根据 ECC 数学原理,两者计算出的共享密钥相同)
双方用这个共享密钥派生出的对称密钥进行通信 。
会话结束后,双方立即销毁临时的 ECC 密钥对和会话密钥 。
这样,每次会话的密钥都是独立的。实现此机制复杂度较高,通常借助 libsodium.js 等库。以下是概念性代码:
import sodium from 'libsodium-wrappers' ;
await sodium.ready ;
let keyPairA = sodium.crypto_kx_keypair ();
let keyPairB = sodium.crypto_kx_keypair ();
let sharedKeyA = sodium.crypto_kx_client_session_keys (keyPairA.publicKey , keyPairA.privateKey , keyPairB.publicKey );
let sharedKeyB = sodium.crypto_kx_server_session_keys (keyPairB.publicKey , keyPairB.privateKey , keyPairA.publicKey );
4.5 方案三优缺点总结
优点 :
高性能 :消息通信使用高效的对称加密,用户体验流畅。
前向保密性 :会话密钥是临时的,定期更换可使过去的通信在长期私钥泄露后依然安全。
高安全性 :结合了非对称加密的安全密钥分发和对称加密的效率。
缺点 :
实现复杂度最高 :需要管理会话状态、密钥的生命周期、密钥交换协议等。
状态管理 :需要在前端维护会话密钥的状态,页面刷新可能导致密钥丢失(需要重新协商)。可以考虑将会话密钥安全地存储在 IndexedDB 中。
结论:方案三是唯一推荐用于生产环境的方案。它提供了最佳的性能和安全特性平衡,是现代 E2EE 应用的标准做法。
第五部分:部署、测试与安全最佳实践
5.1 密钥安全存储指南
长期私钥 :
不要明文存储 在 localStorage 中。容易被 XSS 攻击窃取。
推荐方案 :使用用户密码通过 PBKDF2 等 KDF 派生出一个密钥,用这个密钥对长期私钥进行加密后再存储。解密时要求用户输入密码。
替代方案 :使用浏览器的 window.crypto.subtle API 生成非提取式密钥,并存储在 IndexedDB 中。
会话密钥 :
可以存储在 Vuex 内存中,页面刷新即丢失(需要重新协商,增强了前向保密性)。
如果希望刷新后保持会话,可以加密后存入 sessionStorage 或 IndexedDB。
5.2 传输安全
必须使用 HTTPS :所有 API 和 WebSocket 连接都必须通过 TLS 加密,防止中间人攻击窃取公钥或密文。
安全的 WebSocket :使用 wss:// 协议。
5.3 后端安全考虑
身份验证 :所有 API 调用都必须有严格的身份验证(如 JWT),确保用户不能冒充他人发送消息或获取他人的公钥。
权限检查 :在转发消息前,验证发送者 senderId 确实属于当前登录用户。
速率限制 :对密钥交换和消息发送接口实施速率限制,防止滥用。
5.4 测试策略
单元测试 :测试每个加密/解密函数,确保其正确性。
集成测试 :
测试两个客户端能否成功完成密钥交换。
测试加密消息能否被正确解密。
测试签名验证能否正确识别有效和无效签名。
负载测试 :模拟大量用户同时发送加密消息,测试系统的性能表现。
5.5 处理常见问题
'无法解密'错误 :引导用户检查密码(如果用了加密存储)或尝试重新建立会话。
'签名无效'警告 :明确告知用户消息可能不可信,并建议通过其他渠道验证消息内容。
新设备登录 :设计清晰的流程引导用户生成新密钥对,并告知其对旧消息的影响。
第六部分:总结与方案选择 特性 方案一:静态非对称 方案二:静态 + 签名 方案三:混合加密 安全性 低 中 高 性能 极差 非常差 优秀 前向保密 无 无 有 身份验证/完整性 无 有 有 实现复杂度 低 中 高 密钥管理 复杂 复杂 中等 推荐场景 学习原型 需要身份验证的低频场景 生产环境、所有 IM 应用
对于任何严肃的、面向用户的 Web 版 IM 应用,请务必选择方案三(混合加密系统)。它虽然是实现起来最复杂的方案,但它是唯一能同时满足安全性(E2EE、前向保密)、性能和用户体验要求的方案。方案一和方案二仅适用于理解概念或极其特殊的低频场景。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online