Files
TanXinandTanXin 1ce345d61b release: 发布 v2.0.0 Gem-Alpha 架构解耦与交互体验升级版本 (#123)
## 本次版本更新内容(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>
2026-09-04 14:14:08 +08:00

669 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
});