## 本次版本更新内容(v2.0.0 — Gem-Alpha 重大里程碑版本)
本次发布为 CloudSSH **v2.0.0** 重大版本更新(代号
**Gem-Alpha**),包含后端与前端大文件深层次架构解耦瘦身、核心交互体验升级、SFTP
路径面包屑与三向排列表头、命令片段动态占位符与模糊搜索、多标签页内联重命名与上下文操作、以及 AI Agent 助手响应式窗口与移动端深度适配。
---
### 一、核心更新要点
#### 1. 架构深度解耦瘦身与代码健康治理
- **分享审计持久化管道解耦(`src/worker/share-audit-writer.ts`)**:抽离
`ShareAuditWriter`,统一封装审计记录排空、防抖刷新与排他写入锁;
- **RFC 4256 交互式键盘认证状态机解耦(`src/worker/ssh-interactive-auth.ts`)**:抽离
`KeyboardInteractiveAuthHandler`,独立管理多轮挑战 ID、超时看门狗及响应组包;
- **弱网断线保持与重连缓冲解耦(`src/worker/ssh-detached-buffer.ts`)**:抽离
`DetachedSessionBuffer`,独立负责 128KB 有界环形缓冲队列与重连补偿逻辑;
- **远端操作系统探测执行器解耦(`src/worker/os-detect.ts`)**:抽离
`detectAndPersistRemoteOS`,封装探测执行、解析与 UserDB 持久化;
- **SFTP 前端大文件解耦**:
- 抽离
`SFTPEditorCoordinator`(`frontend/src/sftp-editor-session.ts`):独立管理编辑器挂载、编码探测、冲突检测与覆盖保存;
- 抽离 `sftp-dialogs.ts`:管理文件/目录创建、重命名与删除交互对话框及路径合法性校验;
- 抽离 `sftp-helpers.ts` 与 `sftp-transfer.ts`:封装面包屑解析、多维排序算法与异步传输状态模型;
- **代码健康规范治理**:全局显式补充 `parseInt` 10 进制基数,清理冗余构造函数与导入,收敛严格类型断言与类型告警。
#### 2. SFTP 与核心交互体验优化
- **SFTP
路径面包屑与可排列表头**:支持可点击直达的面包屑导航,保留文本框快速跳转与失焦自动恢复;表头支持文件名、大小、修改时间三向排序,目录智能置顶;
- **SFTP 工具栏新建文件**:点击工具栏「新建文件」按钮弹出命名弹窗,通过 0 字节探测上传后无缝调起在线编辑器;
- **命令片段模糊搜索与一键复制**:片段库新增实时搜索过滤,支持名称与命令模糊匹配;列表项增加一键复制到剪贴板按钮;
- **终端快捷键增强**:macOS 新增 `Cmd+F` 调出搜索栏,`Cmd+K` / `Ctrl+Shift+K`
清除终端屏幕缓冲区(`terminal.clear()`),并完好保留 Linux/Bash 默认的 `Ctrl+K` 剪切行尾逻辑。
#### 3. 易用性增强与多标签/Agent 体验
- **服务器卡片一键克隆**:提供一键克隆操作,完整复用主机、端口、用户名、跳板机关系与标签,安全清空敏感凭据,自动附加 `(Copy)`
后缀;
- **片段动态参数占位符**:支持 `{{variable}}` 语法,插入或执行前自动解析变量并弹出输入弹窗,支持默认值与输入记忆;
- **多标签页双击内联重命名与上下文菜单**:双击标签页标题直接进行内联编辑(`Enter` 保存,`Esc`
取消,空值/纯空白自动恢复原名);右键标签弹出操作菜单(重命名、克隆会话、关闭其他、关闭当前);
- **AI Agent 助手体验全面升级**:
- **动态 Clamp 响应式窗口**:参考 SFTP 采用 `min(clamp(420px, 40vw, 600px), 100%)`
弹性自适应宽度,顶栏对齐 `h-12` 规范并内联 `smart_toy` 助手图标;
- **桌面端严格限制在终端区域**:分栏高度严格限制在 `#terminal-area`
内部,绝不遮挡顶部状态栏右上方的双端延迟展示(`#term-info`)与连接状态;
- **移动端全屏覆盖与软键盘联动**:移动端保持全屏覆盖(`position: fixed; top:
48px;`)保证小屏下充裕的对话阅读空间;打开 Agent
时联动隐藏底部软键盘终端快捷工具栏,彻底解决输入框和发送按钮被遮挡问题;退出返回终端后自动恢复工具栏;
- **诊断 Prompt 预设与触控热区强化**:提供 4 组一键场景诊断 Prompt 胶囊(分析报错、系统负载、端口网络、Docker
状态),移动端全面优化 Prompt
胶囊(`32px`)、发送按钮(`40px`)、代码块操作(`30px`)和高危命令确认弹窗按钮触控热区;支持按 `Esc` 快速退出
Agent 返回终端。
---
### 二、提交列表
- `70042c2` feat: 提交 Gem-Alpha 版本(SFTP增强/片段占位符/标签菜单/大文件解耦瘦身) (#120)
- `78dca39` feat: Gem-Alpha 增量迭代(AI Agent 窗口响应式与移动端优化 / 标签重命名缺陷修复 /
文档同步) (#122)
- `0b04480` fix: 恢复移动端 AI Agent 全屏覆盖布局以保障小屏交互空间
- `0c19ac2` fix: 移动端打开 AI Agent 时自动隐藏底部终端快捷键工具栏
- `50171c7` release: 发布 v2.0.0 Gem-Alpha 架构解耦与交互体验升级版本
---
### 三、关联 Issue / PR
- 关联 PR:#120, #122
---
### 四、验证结果
- ✅ `pnpm run typecheck`:通过(Worker + Frontend 0 错误)
- ✅ `pnpm test`:通过(66 个测试文件,755 项测试全数通过)
- ✅ `pnpm run build:frontend`:通过(单 bundle 确定性构建并内联至
`src/worker/html.ts`)
- ✅ `pnpm run test:e2e`:通过(75 项 Playwright 端到端及 axe 无障碍测试全部通过,1
项按平台条件跳过)
- ✅ `pnpm run verify`:完整本地质量门禁验证全绿通过
---------
Co-authored-by: TanXin <tanxin@example.com>
669 lines
28 KiB
TypeScript
669 lines
28 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
base64UrlEncodeUnsigned,
|
||
convertSSHECDSASig,
|
||
REKEY_THRESHOLD,
|
||
SSHAESCTRCipher,
|
||
SSHAESGCMCipher,
|
||
SSHHMAC,
|
||
shouldRekey,
|
||
} from '../../src/ssh/crypto';
|
||
|
||
// =====================================================================
|
||
// crypto.test.ts
|
||
// ---------------------------------------------------------------
|
||
// SSH 密码学层测试,使用 Node 原生 WebCrypto(不 mock 加密原语)。
|
||
// 重点不是重测 WebCrypto 本身,而是固化项目代码对它的使用约束:
|
||
// - GCM/CTR 的 IV/counter 在每次加密后必须按 RFC 自增
|
||
// - commit=false(peek 解密)不应修改内部状态
|
||
// - AES-CTR 的 16 字节 IV 长度检查
|
||
// - HMAC 三种算法 + length 字段 + 不支持算法的构造时拒绝
|
||
// - shouldRekey 的阈值边界
|
||
// 这些约束错位都是 SSH 协议里"看起来能工作但安全 silent fail"
|
||
// 的常见藏身处:nonce 复用、AAD 漏传、IV 没自增会导致密钥轮换
|
||
// 我从来不触发、长期暴露同一密钥——表面安全但前向保密失效。
|
||
// =====================================================================
|
||
|
||
// --- 共用测试工具 ---
|
||
const ENCODER = new TextEncoder();
|
||
|
||
function randomBytes(n: number): Uint8Array {
|
||
const b = new Uint8Array(n);
|
||
crypto.getRandomValues(b);
|
||
return b;
|
||
}
|
||
|
||
// 项目里 GCM 的 IV 长度是 12 字节(看 algorithms.ts 的 ivLength=12)
|
||
const GCM_IV_LENGTH = 12;
|
||
// CTR 的 IV 长度是 16 字节(AES block size)
|
||
const CTR_IV_LENGTH = 16;
|
||
|
||
// GCM 密钥长度支持 16/32 字节(AES-128/AES-256)
|
||
const AES_128_KEY = randomBytes(16);
|
||
const AES_256_KEY = randomBytes(32);
|
||
|
||
// =====================================================================
|
||
// shouldRekey 与 REKEY_THRESHOLD
|
||
// =====================================================================
|
||
describe('crypto — shouldRekey 边界', () => {
|
||
it('REKEY_THRESHOLD = 1 << 30 = 1073741824', () => {
|
||
expect(REKEY_THRESHOLD).toBe(1 << 30);
|
||
expect(REKEY_THRESHOLD).toBe(1073741824);
|
||
});
|
||
|
||
it('seqNum = 0 应小于阈值,不需 rekey', () => {
|
||
expect(shouldRekey(0)).toBe(false);
|
||
});
|
||
|
||
it('seqNum = 阈值 - 1 应小于阈值,不需 rekey', () => {
|
||
expect(shouldRekey(REKEY_THRESHOLD - 1)).toBe(false);
|
||
});
|
||
|
||
it('seqNum = 阈值正好等于阈值,应触发 rekey', () => {
|
||
expect(shouldRekey(REKEY_THRESHOLD)).toBe(true);
|
||
});
|
||
|
||
it('seqNum = 阈值 + 1 应触发 rekey', () => {
|
||
expect(shouldRekey(REKEY_THRESHOLD + 1)).toBe(true);
|
||
});
|
||
|
||
it('seqNum 远大于阈值应触发 rekey', () => {
|
||
expect(shouldRekey(Number.MAX_SAFE_INTEGER)).toBe(true);
|
||
});
|
||
|
||
it('负 seqNum 不触发 rekey(边界,虽然实际不会出现)', () => {
|
||
expect(shouldRekey(-1)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// =====================================================================
|
||
// SSHAESGCMCipher
|
||
// =====================================================================
|
||
describe('crypto — SSHAESGCMCipher', () => {
|
||
describe('初始化', () => {
|
||
it('init 之前调用 encrypt 抛错', async () => {
|
||
const cipher = new SSHAESGCMCipher(AES_256_KEY, randomBytes(GCM_IV_LENGTH));
|
||
await expect(cipher.encrypt(ENCODER.encode('hello'))).rejects.toThrow(/not initialized/);
|
||
});
|
||
it('init 之前调用 decrypt 抛错', async () => {
|
||
const cipher = new SSHAESGCMCipher(AES_256_KEY, randomBytes(GCM_IV_LENGTH));
|
||
await expect(cipher.decrypt(randomBytes(16))).rejects.toThrow(/not initialized/);
|
||
});
|
||
it('init 之后不抛错', async () => {
|
||
const cipher = new SSHAESGCMCipher(AES_256_KEY, randomBytes(GCM_IV_LENGTH));
|
||
await expect(cipher.init()).resolves.toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('加解密往返', () => {
|
||
// SSH 协议中加密和解密是两个独立方向(c2s / s2c),两端各自维护自己的 IV。
|
||
// 一个 cipher 实例自加密又自解密的做法在 GCM/CTR 里是错的——
|
||
// encrypt 后 IV 自增,decrypt 时 IV 已经不在密文生成的位置上。
|
||
// 正确模式:用两个 cipher 实例从同一 (key, iv) 起步,一个 enc 一个 dec。
|
||
async function makePair(key: Uint8Array) {
|
||
const iv = randomBytes(GCM_IV_LENGTH);
|
||
const enc = new SSHAESGCMCipher(key, iv);
|
||
const dec = new SSHAESGCMCipher(new Uint8Array(key), iv);
|
||
await enc.init();
|
||
await dec.init();
|
||
return { enc, dec };
|
||
}
|
||
|
||
it('短文本解密后应还原原文(AES-256-GCM)', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('hello ssh world');
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
// GCM 密文尾部带 16 字节 tag
|
||
expect(ciphertext.length).toBe(plaintext.length + 16);
|
||
const decrypted = await dec.decrypt(ciphertext);
|
||
expect(decrypted).not.toBeNull();
|
||
expect(decrypted!).toEqual(plaintext);
|
||
});
|
||
|
||
it('空 plaintext 加解密往返', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = new Uint8Array(0);
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
// 即使明文为空,GCM 仍输出 16 字节 tag
|
||
expect(ciphertext.length).toBe(16);
|
||
const decrypted = await dec.decrypt(ciphertext);
|
||
expect(decrypted).not.toBeNull();
|
||
expect(decrypted!.length).toBe(0);
|
||
});
|
||
|
||
it('AES-128-GCM 同样可往返', async () => {
|
||
const { enc, dec } = await makePair(AES_128_KEY);
|
||
const plaintext = ENCODER.encode('aes-128 test');
|
||
const decrypted = await dec.decrypt(await enc.encrypt(plaintext));
|
||
expect(decrypted).toEqual(plaintext);
|
||
});
|
||
|
||
it('AAD 参与校验:解密时传相同 AAD 能还原', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('packet payload');
|
||
const aad = ENCODER.encode('length-field');
|
||
const ciphertext = await enc.encrypt(plaintext, undefined, aad);
|
||
const decrypted = await dec.decrypt(ciphertext, undefined, aad);
|
||
expect(decrypted).toEqual(plaintext);
|
||
});
|
||
|
||
it('AAD 参与校验:解密时传不同 AAD 应失败(返回 null)', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('packet payload');
|
||
const aad = ENCODER.encode('correct-length');
|
||
const wrongAad = ENCODER.encode('tampered-length');
|
||
const ciphertext = await enc.encrypt(plaintext, undefined, aad);
|
||
const decrypted = await dec.decrypt(ciphertext, undefined, wrongAad);
|
||
expect(decrypted).toBeNull();
|
||
});
|
||
|
||
it('加密时无 AAD、解密时有 AAD,应失败(AAD 不匹配)', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('payload');
|
||
const ciphertext = await enc.encrypt(plaintext); // 不传 AAD
|
||
const aad = ENCODER.encode('late-aad');
|
||
const decrypted = await dec.decrypt(ciphertext, undefined, aad);
|
||
// 加密时没 AAD,解密时传了 AAD,WebCrypto 视为 AAD 不匹配
|
||
expect(decrypted).toBeNull();
|
||
});
|
||
|
||
it('密文被篡改 1 字节应解密失败', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('sensitive-data');
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
ciphertext[0] ^= 0xff; // 翻转第一字节
|
||
const decrypted = await dec.decrypt(ciphertext);
|
||
expect(decrypted).toBeNull();
|
||
});
|
||
|
||
it('tag 被篡改 1 字节应解密失败(完整性校验生效)', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('sensitive-data');
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
// tag 在末尾 16 字节
|
||
ciphertext[ciphertext.length - 1] ^= 0x01;
|
||
const decrypted = await dec.decrypt(ciphertext);
|
||
expect(decrypted).toBeNull();
|
||
});
|
||
|
||
it('两个独立 cipher 实例同步加密解密连续多条消息往返', async () => {
|
||
// 模拟真实 SSH 流:发送方连续加密多条,接收方按顺序解密,
|
||
// 两端各自维护递增 IV,必须对齐才能解出。
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const messages = ['msg-one', 'msg-two', 'msg-three', 'msg-four'];
|
||
const ciphertexts: Uint8Array[] = [];
|
||
for (const m of messages) {
|
||
ciphertexts.push(await enc.encrypt(ENCODER.encode(m)));
|
||
}
|
||
for (let i = 0; i < messages.length; i++) {
|
||
const decrypted = await dec.decrypt(ciphertexts[i]);
|
||
expect(decrypted).toEqual(ENCODER.encode(messages[i]));
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('IV 自增(RFC 5647 §7.1)', () => {
|
||
it('相邻两次加密应使用不同 IV(nonce 不可复用)', async () => {
|
||
// 通过观察密文间接验证:同样明文 + 同一 cipher 实例下,两次加密必须不同
|
||
const cipher = new SSHAESGCMCipher(AES_256_KEY, randomBytes(GCM_IV_LENGTH));
|
||
await cipher.init();
|
||
const plaintext = ENCODER.encode('same input');
|
||
const c1 = await cipher.encrypt(plaintext);
|
||
const c2 = await cipher.encrypt(plaintext);
|
||
// 如果 IV 没自增,WebCrypto 会用同样的 (key, iv, plaintext) 两次加密得到同样结果,
|
||
// 这在 GCM 下是 catastrophic failure。这里断言两次密文不同。
|
||
let eq = true;
|
||
for (let i = 0; i < c1.length; i++) {
|
||
if (c1[i] !== c2[i]) {
|
||
eq = false;
|
||
break;
|
||
}
|
||
}
|
||
expect(eq).toBe(false);
|
||
});
|
||
|
||
it('解密侧也自增 IV', async () => {
|
||
// 验证方式:两个 cipher 实例从同一 (key, iv) 起步,连续往返多条消息。
|
||
// 加密方自增 IV、解密方也自增 IV,两边按相同方式自增才能逐条解出。
|
||
// 这正面证明 decrypt 后 IV 也按 RFC 5647 §7.1 自增(与 encrypt 对称)。
|
||
const iv = randomBytes(GCM_IV_LENGTH);
|
||
const enc = new SSHAESGCMCipher(AES_256_KEY, iv);
|
||
const dec = new SSHAESGCMCipher(AES_256_KEY, iv);
|
||
await enc.init();
|
||
await dec.init();
|
||
const plaintexts = ['alpha', 'beta', 'gamma', 'delta', 'epsilon'];
|
||
const ciphertexts: Uint8Array[] = [];
|
||
for (const p of plaintexts) {
|
||
ciphertexts.push(await enc.encrypt(ENCODER.encode(p)));
|
||
}
|
||
const decrypted: string[] = [];
|
||
for (const c of ciphertexts) {
|
||
const d = await dec.decrypt(c);
|
||
expect(d).not.toBeNull();
|
||
decrypted.push(new TextDecoder().decode(d!));
|
||
}
|
||
expect(decrypted).toEqual(plaintexts);
|
||
});
|
||
|
||
it('IV 末字节 0xFF 时进位到上一字节(carry 分支)', async () => {
|
||
// 构造 IV 使 byte[11]=0xFF:incIV 后 byte[11] 回绕到 0x00,
|
||
// 不 break,继续进位 byte[10] —— 覆盖 incIV 的 carry 路径
|
||
const iv = new Uint8Array(GCM_IV_LENGTH);
|
||
iv[11] = 0xff;
|
||
const enc = new SSHAESGCMCipher(AES_256_KEY, iv);
|
||
await enc.init();
|
||
// 加密一条 → incIV 触发 carry
|
||
const plaintext = ENCODER.encode('carry-test');
|
||
const c1 = await enc.encrypt(plaintext);
|
||
// 再加密同样明文 → IV 已变(carry 后 byte[10] 加了 1)→ 密文不同
|
||
const c2 = enc.encrypt ? await enc.encrypt(plaintext) : plaintext;
|
||
let eq = true;
|
||
for (let i = 0; i < c1.length; i++) {
|
||
if (c1[i] !== c2[i]) {
|
||
eq = false;
|
||
break;
|
||
}
|
||
}
|
||
expect(eq).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('IV 起始值被构造时复制(不持有引用)', () => {
|
||
it('构造时传入的 IV 数组被复制,外部修改不影响 cipher 状态', async () => {
|
||
const iv = randomBytes(GCM_IV_LENGTH);
|
||
const ivCopy = new Uint8Array(iv);
|
||
const enc = new SSHAESGCMCipher(AES_256_KEY, iv);
|
||
await enc.init();
|
||
// 外部修改原 iv 数组
|
||
iv[0] = 0xff;
|
||
iv[1] = 0xee;
|
||
// 加密仍用构造时复制的内部副本,不受外部修改影响
|
||
const plaintext = ENCODER.encode('test');
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
|
||
// 验证外部 iv 修改没有 patch 进 cipher:用 ivCopy(iv 的原貌)构造一个新 enc,
|
||
// 加密同样明文得到的密文应与原 cipher 第一条密文相同 → 证明 cipher 复制了 iv
|
||
const enc2 = new SSHAESGCMCipher(AES_256_KEY, ivCopy);
|
||
await enc2.init();
|
||
const c2 = await enc2.encrypt(plaintext);
|
||
expect(c2).toEqual(ciphertext);
|
||
});
|
||
});
|
||
|
||
describe('decrypt 异常处理', () => {
|
||
it('crypto.subtle.decrypt 抛非 Error 对象时 catch 仍返回 null(String(e) 分支)', async () => {
|
||
const cipher = new SSHAESGCMCipher(AES_256_KEY, randomBytes(GCM_IV_LENGTH));
|
||
await cipher.init();
|
||
// mock 使 decrypt 抛出非 Error 值(如字符串),覆盖 e instanceof Error 的 false 分支
|
||
const spy = vi.spyOn(crypto.subtle, 'decrypt').mockRejectedValueOnce('not-an-error');
|
||
const result = await cipher.decrypt(randomBytes(32));
|
||
expect(result).toBeNull();
|
||
spy.mockRestore();
|
||
});
|
||
});
|
||
});
|
||
|
||
// =====================================================================
|
||
// SSHAESCTRCipher
|
||
// =====================================================================
|
||
describe('crypto — SSHAESCTRCipher', () => {
|
||
describe('构造与初始化', () => {
|
||
it('IV 长度不是 16 字节应在构造时抛错', () => {
|
||
expect(() => new SSHAESCTRCipher(AES_256_KEY, randomBytes(12))).toThrow(/16-byte IV/);
|
||
expect(() => new SSHAESCTRCipher(AES_256_KEY, randomBytes(15))).toThrow(/16-byte IV/);
|
||
expect(() => new SSHAESCTRCipher(AES_256_KEY, randomBytes(17))).toThrow(/16-byte IV/);
|
||
expect(() => new SSHAESCTRCipher(AES_256_KEY, randomBytes(0))).toThrow(/16-byte IV/);
|
||
});
|
||
it('IV 正好 16 字节不抛错', () => {
|
||
expect(() => new SSHAESCTRCipher(AES_256_KEY, randomBytes(16))).not.toThrow();
|
||
});
|
||
it('init 之前调用 encrypt 抛错', async () => {
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await expect(cipher.encrypt(ENCODER.encode('x'))).rejects.toThrow(/not initialized/);
|
||
});
|
||
it('init 之前调用 decrypt 抛错', async () => {
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await expect(cipher.decrypt(randomBytes(16))).rejects.toThrow(/not initialized/);
|
||
});
|
||
});
|
||
|
||
describe('加解密往返(CTR 无 tag)', () => {
|
||
// 同 GCM 测试一样,加密解密是两个独立方向,各管自己的 counter。
|
||
async function makePair(key: Uint8Array) {
|
||
const iv = randomBytes(CTR_IV_LENGTH);
|
||
const enc = new SSHAESCTRCipher(key, iv);
|
||
const dec = new SSHAESCTRCipher(new Uint8Array(key), iv);
|
||
await enc.init();
|
||
await dec.init();
|
||
return { enc, dec };
|
||
}
|
||
|
||
it('短文本解密后应还原原文(AES-256-CTR)', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('hello ctr world');
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
// CTR 模式无 tag,密文长度 = 明文长度
|
||
expect(ciphertext.length).toBe(plaintext.length);
|
||
const decrypted = await dec.decrypt(ciphertext);
|
||
expect(decrypted).toEqual(plaintext);
|
||
});
|
||
|
||
it('空 plaintext 加解密往返', async () => {
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = new Uint8Array(0);
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
expect(ciphertext.length).toBe(0);
|
||
const decrypted = await dec.decrypt(ciphertext);
|
||
expect(decrypted).toEqual(plaintext);
|
||
});
|
||
|
||
it('AES-128-CTR 同样可往返', async () => {
|
||
const { enc, dec } = await makePair(AES_128_KEY);
|
||
const plaintext = ENCODER.encode('aes-128-ctr');
|
||
const decrypted = await dec.decrypt(await enc.encrypt(plaintext));
|
||
expect(decrypted).toEqual(plaintext);
|
||
});
|
||
|
||
it('CTR 模式无完整性校验:篡改密文 1 字节仍能"解密"但不报错', async () => {
|
||
// 这是 CTR 的设计特性(不是 bug),固化"CTR 无 MAC"的语义:
|
||
// 篡改密文不会让 decrypt 返回 null,只会得到被篡改后的明文
|
||
// 与 GCM 不同 —— GCM tag 一旦不匹配 decrypt 返 null,
|
||
// CTR 没有 tag → 任何密文都能"解密"成 something
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const plaintext = ENCODER.encode('ctr-no-mac');
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
ciphertext[0] ^= 0xff;
|
||
const decrypted = await dec.decrypt(ciphertext);
|
||
expect(decrypted).not.toBeNull();
|
||
expect(decrypted!).not.toEqual(plaintext);
|
||
});
|
||
|
||
it('两个独立 cipher 实例同步连续多条消息往返', async () => {
|
||
// 同 GCM 多消息测试,验证 CTR 两端 counter 按相同规则自增同步
|
||
const { enc, dec } = await makePair(AES_256_KEY);
|
||
const messages = ['one', 'two', 'three', 'four'];
|
||
const ciphertexts: Uint8Array[] = [];
|
||
for (const m of messages) {
|
||
ciphertexts.push(await enc.encrypt(ENCODER.encode(m)));
|
||
}
|
||
const obtained: string[] = [];
|
||
for (const c of ciphertexts) {
|
||
const d = await dec.decrypt(c);
|
||
expect(d).not.toBeNull();
|
||
obtained.push(new TextDecoder().decode(d!));
|
||
}
|
||
expect(obtained).toEqual(messages);
|
||
});
|
||
});
|
||
|
||
describe('counter 状态自增与 commit 语义', () => {
|
||
it('commit=true(默认)应自增 counter:同明文两次加密密文不同', async () => {
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await cipher.init();
|
||
const plaintext = ENCODER.encode('same input');
|
||
const c1 = await cipher.encrypt(plaintext);
|
||
const c2 = await cipher.encrypt(plaintext);
|
||
// counter 自增后两次密文必须不同
|
||
let eq = true;
|
||
for (let i = 0; i < c1.length; i++) {
|
||
if (c1[i] !== c2[i]) {
|
||
eq = false;
|
||
break;
|
||
}
|
||
}
|
||
expect(eq).toBe(false);
|
||
});
|
||
|
||
it('commit=false 不应修改 counter:连续两次 peek 解密用同样 counter', async () => {
|
||
// peek 解密典型场景:解析时先 peek 长度字段,再 commit 全包
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await cipher.init();
|
||
// 构造一条已知密文:加密一次 plaintext,再用另一个 cipher 提取该 counter
|
||
const enc = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await enc.init();
|
||
const plaintext = ENCODER.encode('peek-test-payload');
|
||
const ciphertext = await enc.encrypt(plaintext);
|
||
|
||
// 两次 peek 解密(commit=false)应得到完全相同结果,且 counter 不动
|
||
const peek1 = await cipher.decrypt(ciphertext, undefined, undefined, false);
|
||
const peek2 = await cipher.decrypt(ciphertext, undefined, undefined, false);
|
||
expect(peek1).not.toBeNull();
|
||
expect(peek2).not.toBeNull();
|
||
// 两次 peek 用同样 counter,得到同样明文
|
||
// 注意:peek 出来的明文不一定是原文(counter 需要对齐 enc 那一边的自增后状态),
|
||
// 但两次 peek 之间必然相等 —— 这才是核心断言:commit=false 不改 counter
|
||
expect(peek1).toEqual(peek2);
|
||
});
|
||
|
||
it('commit=false 加密不应自增 counter(同样明文两次 commit=false 加密得到同样密文)', async () => {
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await cipher.init();
|
||
const plaintext = ENCODER.encode('commit-false');
|
||
const c1 = await cipher.encrypt(plaintext, undefined, undefined, false);
|
||
const c2 = await cipher.encrypt(plaintext, undefined, undefined, false);
|
||
// commit=false 不改 counter → 两次加密用同样 (key, counter) → 同样密文
|
||
expect(c1).toEqual(c2);
|
||
});
|
||
|
||
it('构造时传入的 IV 被复制,外部修改不影响 cipher', async () => {
|
||
const iv = randomBytes(CTR_IV_LENGTH);
|
||
const ivCopy = new Uint8Array(iv);
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, iv);
|
||
await cipher.init();
|
||
iv[0] = 0xff;
|
||
iv[15] = 0xee;
|
||
const plaintext = ENCODER.encode('isolation');
|
||
const ciphertext = await cipher.encrypt(plaintext);
|
||
// 与"用 iv 的原貌重新构造一个 cipher"加密同样明文得到的密文一致 →
|
||
// 证明 cipher 在构造时复制了 iv,外部修改没 patch 进去
|
||
const cipher2 = new SSHAESCTRCipher(AES_256_KEY, ivCopy);
|
||
await cipher2.init();
|
||
const ciphertext2 = await cipher2.encrypt(plaintext);
|
||
expect(ciphertext).toEqual(ciphertext2);
|
||
});
|
||
});
|
||
|
||
describe('CTR counter 64位+ 自增溢出处理', () => {
|
||
it('counter=0xFF..FF 时 incCounter 1 块应正确进位溢出', async () => {
|
||
// 构造 counter 全 0xFF,加密后 counter 自增应正确处理 carry 溢出
|
||
const iv = new Uint8Array(16).fill(0xff);
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, iv);
|
||
await cipher.init();
|
||
// 加密 16 字节(恰好 1 块)触发自增
|
||
const plaintext = randomBytes(16);
|
||
const ciphertext = await cipher.encrypt(plaintext);
|
||
const decrypted = await cipher.encrypt(ciphertext); // 再加密一次
|
||
// 这里不直接断言 counter 值(私有),但确保 encrypt/decrypt 往返不抛错即可
|
||
expect(decrypted.length).toBe(ciphertext.length);
|
||
});
|
||
});
|
||
|
||
describe('decrypt 异常处理', () => {
|
||
it('decrypt 传入非法数据应进入 catch 返回 null', async () => {
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await cipher.init();
|
||
// CTR 无 tag 校验,正常输入不会失败;传入非 BufferSource 触发 catch
|
||
const result = await cipher.decrypt(null as unknown as Uint8Array);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('crypto.subtle.decrypt 抛非 Error 对象时 catch 仍返回 null(String(e) 分支)', async () => {
|
||
const cipher = new SSHAESCTRCipher(AES_256_KEY, randomBytes(CTR_IV_LENGTH));
|
||
await cipher.init();
|
||
const spy = vi.spyOn(crypto.subtle, 'decrypt').mockRejectedValueOnce('not-an-error');
|
||
const result = await cipher.decrypt(randomBytes(16));
|
||
expect(result).toBeNull();
|
||
spy.mockRestore();
|
||
});
|
||
});
|
||
});
|
||
|
||
// =====================================================================
|
||
// SSHHMAC
|
||
// =====================================================================
|
||
describe('crypto — SSHHMAC', () => {
|
||
describe('构造与算法映射', () => {
|
||
it('hmac-sha1 → SHA-1,length=20', () => {
|
||
const m = new SSHHMAC('hmac-sha1', randomBytes(20));
|
||
expect(m.length).toBe(20);
|
||
});
|
||
it('hmac-sha2-256 → SHA-256,length=32', () => {
|
||
const m = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
expect(m.length).toBe(32);
|
||
});
|
||
it('hmac-sha2-512 → SHA-512,length=64', () => {
|
||
const m = new SSHHMAC('hmac-sha2-512', randomBytes(64));
|
||
expect(m.length).toBe(64);
|
||
});
|
||
it('不支持算法构造时抛错且消息含算法名', () => {
|
||
expect(() => new SSHHMAC('hmac-md5', randomBytes(16))).toThrow(/Unsupported MAC.*hmac-md5/);
|
||
expect(() => new SSHHMAC('', randomBytes(16))).toThrow(/Unsupported MAC/);
|
||
});
|
||
it('length 是 readonly 字段(不被 sign 修改)', async () => {
|
||
const m = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await m.init();
|
||
const before = m.length;
|
||
await m.sign(randomBytes(10), 1);
|
||
expect(m.length).toBe(before);
|
||
});
|
||
});
|
||
|
||
describe('init 前置检查', () => {
|
||
it('init 之前调用 sign 抛错', async () => {
|
||
const m = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await expect(m.sign(randomBytes(10), 1)).rejects.toThrow(/not initialized/);
|
||
});
|
||
it('init 之前调用 verify 抛错', async () => {
|
||
const m = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await expect(m.verify(randomBytes(10), 1, randomBytes(32))).rejects.toThrow(
|
||
/not initialized/
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('签名与验证往返', () => {
|
||
it('sign 然后 verify 同样 (packet, seqNum) 应通过', async () => {
|
||
const mac = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await mac.init();
|
||
const packet = ENCODER.encode('some-ssh-packet');
|
||
const tag = await mac.sign(packet, 42);
|
||
expect(tag.length).toBe(32); // sha2-256 输出长度
|
||
const ok = await mac.verify(packet, 42, tag);
|
||
expect(ok).toBe(true);
|
||
});
|
||
|
||
it('hmac-sha1 输出 20 字节', async () => {
|
||
const mac = new SSHHMAC('hmac-sha1', randomBytes(20));
|
||
await mac.init();
|
||
const tag = await mac.sign(ENCODER.encode('x'), 0);
|
||
expect(tag.length).toBe(20);
|
||
});
|
||
|
||
it('hmac-sha2-512 输出 64 字节', async () => {
|
||
const mac = new SSHHMAC('hmac-sha2-512', randomBytes(64));
|
||
await mac.init();
|
||
const tag = await mac.sign(ENCODER.encode('x'), 0);
|
||
expect(tag.length).toBe(64);
|
||
});
|
||
|
||
it('seqNum 不同应验签失败(seqNum 参与签名)', async () => {
|
||
const mac = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await mac.init();
|
||
const packet = ENCODER.encode('packet');
|
||
const tag = await mac.sign(packet, 100);
|
||
const ok = await mac.verify(packet, 101, tag);
|
||
expect(ok).toBe(false);
|
||
});
|
||
|
||
it('packet 不同应验签失败', async () => {
|
||
const mac = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await mac.init();
|
||
const packet = ENCODER.encode('real-packet');
|
||
const tag = await mac.sign(packet, 7);
|
||
const tampered = ENCODER.encode('fake-packet');
|
||
const ok = await mac.verify(tampered, 7, tag);
|
||
expect(ok).toBe(false);
|
||
});
|
||
|
||
it('tag 被篡改 1 字节应验签失败', async () => {
|
||
const mac = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await mac.init();
|
||
const packet = ENCODER.encode('packet');
|
||
const tag = await mac.sign(packet, 7);
|
||
tag[0] ^= 0xff;
|
||
const ok = await mac.verify(packet, 7, tag);
|
||
expect(ok).toBe(false);
|
||
});
|
||
|
||
it('不同密钥签名互相验签失败', async () => {
|
||
const macA = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
const macB = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await macA.init();
|
||
await macB.init();
|
||
const packet = ENCODER.encode('shared-packet');
|
||
const tagA = await macA.sign(packet, 5);
|
||
const ok = await macB.verify(packet, 5, tagA);
|
||
expect(ok).toBe(false);
|
||
});
|
||
|
||
it('seqNum=0 也能正常签名验签', async () => {
|
||
const mac = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await mac.init();
|
||
const packet = ENCODER.encode('first-packet');
|
||
const tag = await mac.sign(packet, 0);
|
||
expect(await mac.verify(packet, 0, tag)).toBe(true);
|
||
});
|
||
|
||
it('大 packet(4KB)往返', async () => {
|
||
const mac = new SSHHMAC('hmac-sha2-256', randomBytes(32));
|
||
await mac.init();
|
||
const packet = randomBytes(4096);
|
||
const tag = await mac.sign(packet, 999);
|
||
expect(await mac.verify(packet, 999, tag)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('base64UrlEncodeUnsigned', () => {
|
||
it('剥离前导零字节并进行 URL 安全的 Base64 编码', () => {
|
||
const bytesWithLeadingZero = new Uint8Array([0x00, 0x00, 0x01, 0x02]);
|
||
const encoded = base64UrlEncodeUnsigned(bytesWithLeadingZero);
|
||
expect(encoded).toBe('AQI');
|
||
});
|
||
|
||
it('不包含 URL 不安全字符 + / =', () => {
|
||
const bytes = new Uint8Array([0xfb, 0xff, 0xfe]);
|
||
const encoded = base64UrlEncodeUnsigned(bytes);
|
||
expect(encoded).not.toContain('+');
|
||
expect(encoded).not.toContain('/');
|
||
expect(encoded).not.toContain('=');
|
||
});
|
||
});
|
||
|
||
describe('convertSSHECDSASig', () => {
|
||
it('解析 SSH ECDSA 签名的 r 和 s 并填充为指定坐标字节宽度的原始格式', () => {
|
||
const r = new Uint8Array(32).fill(0x11);
|
||
const s = new Uint8Array(32).fill(0x22);
|
||
const sshSig = new Uint8Array(4 + 32 + 4 + 32);
|
||
let offset = 0;
|
||
// rLen
|
||
sshSig[offset++] = 0;
|
||
sshSig[offset++] = 0;
|
||
sshSig[offset++] = 0;
|
||
sshSig[offset++] = 32;
|
||
sshSig.set(r, offset);
|
||
offset += 32;
|
||
// sLen
|
||
sshSig[offset++] = 0;
|
||
sshSig[offset++] = 0;
|
||
sshSig[offset++] = 0;
|
||
sshSig[offset++] = 32;
|
||
sshSig.set(s, offset);
|
||
|
||
const raw = convertSSHECDSASig(sshSig, 32);
|
||
expect(raw.length).toBe(64);
|
||
expect(raw.subarray(0, 32)).toEqual(r);
|
||
expect(raw.subarray(32, 64)).toEqual(s);
|
||
});
|
||
});
|
||
});
|