Files
TanXinandTanXin 4eeb050576 release: 发布 v1.10.0 自定义命令片段库版本 (#93)
合并 `test` 的 v1.10.0 提交到 `main`。

**包含提交**
- `28c9433` feat: 添加自定义命令片段库
- `9aa2e80` release: 发布 v1.10.0 自定义命令片段库版本

**关联**
- Related to #90

> 本 PR 仅关联 #90,不自动关闭该 Issue。

**验证**
- `pnpm run typecheck` 0 错
- `pnpm test` 47/47 603/603 通过
- `pnpm run build:frontend` 成功

---------

Co-authored-by: TanXin <tanxin@example.com>
2026-08-20 11:42:28 +08:00

931 lines
37 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 } from 'vitest';
import { nextSequenceNumber, SSHPacketBuilder, SSHPacketParser } from '../../src/ssh/packet';
import { readUint32 } from '../../src/ssh/utils';
import type { SSHPacket } from '../../src/types';
// =====================================================================
// packet.test.ts
// ---------------------------------------------------------------
// SSH 报文序列化/反序列化测试,验证 SSHPacketBuilder 和 SSHPacketParser
// 的核心不变性:
// - 明文 build → identity decrypt 往返能还原 payload
// - padding 对齐 blockSize(GCM vs CTR 两种对齐策略)
// - padding 随机但 padding_length 正确
// - 跨分片 feed 正确组装
// - MAX_PACKET_SIZE 溢出检测
// - MAC 校验集成
// - seqNum 递增语义
// 加密原语不做 mock:使用 identity decrypt(原样返回)模拟无加密链路。
// =====================================================================
// --- identity decrypt:原样返回,模拟无加密链路 ---
function identityDecrypt(
data: Uint8Array,
_seq: number,
_aad?: Uint8Array,
_commit?: boolean
): Uint8Array {
return data;
}
// --- 工具:生成随机 payload ---
function randomPayload(len: number): Uint8Array {
const buf = new Uint8Array(len);
crypto.getRandomValues(buf);
return buf;
}
// --- 工具:比较两个 Uint8Array 内容是否相同 ---
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
// =====================================================================
// SSHPacketBuilder — 明文构建
// =====================================================================
describe('packet — SSHPacketBuilder.build(明文,无加密)', () => {
describe('包格式基本验证', () => {
it('空 payload 构建后应有正确的 length 字段', async () => {
const payload = new Uint8Array(0);
const packet = await SSHPacketBuilder.build(payload, 8, null, 0);
// length 字段 = 1(padding_length字段) + payload.length + paddingLength
const lengthField = readUint32(packet, 0);
const paddingLength = packet[4];
expect(lengthField).toBe(1 + 0 + paddingLength);
// 整包长度 = 4(length) + 1(padding_length) + payload + padding
expect(packet.length).toBe(4 + 1 + 0 + paddingLength);
});
it('非空 payload 构建后 length 字段 = 1 + payload + padding', async () => {
const payload = randomPayload(32);
const packet = await SSHPacketBuilder.build(payload, 16, null, 0);
const lengthField = readUint32(packet, 0);
const paddingLength = packet[4];
expect(lengthField).toBe(1 + 32 + paddingLength);
expect(packet.length).toBe(4 + lengthField);
});
it('payload 内容应出现在 packet[5..5+length)', async () => {
const payload = randomPayload(20);
const packet = await SSHPacketBuilder.build(payload, 16, null, 0);
const extracted = packet.subarray(5, 5 + payload.length);
expect(arraysEqual(extracted, payload)).toBe(true);
});
it('packet[4] 字段应等于 paddingLength', async () => {
const payload = randomPayload(10);
const packet = await SSHPacketBuilder.build(payload, 8, null, 0);
const paddingLength = packet[4];
// 验证 padding 区域确实存在且长度正确
const paddingStart = 5 + payload.length;
const paddingEnd = packet.length;
expect(paddingEnd - paddingStart).toBe(paddingLength);
});
});
describe('padding 对齐规则(CTR 模式,hasAuthTag=false)', () => {
it('padding 应使 4+packetLength 对齐到 blockSize 整数倍', async () => {
const blockSize = 16;
for (const pl of [1, 5, 15, 16, 50, 100, 255]) {
const payload = randomPayload(pl);
const packet = await SSHPacketBuilder.build(payload, blockSize, null, 0);
expect(packet.length % blockSize).toBe(0);
}
});
it('padding 最少 4 字节(RFC 4253 §6 规定最小 padding 4)', async () => {
// 用 blockSize=8 使 alignBase 恰好为 0 → paddingNeeded = 8-8 = 0 → paddingLength = 0+8 = 8 ≥ 4 ✓
// 用 blockSize=16 + payload 恰好对齐 → paddingNeeded=0 → paddingLength=16 ≥ 4 ✓
const payload = randomPayload(11); // 4 + 1 + 11 = 16,对齐 blockSize=16 → 需要 padding 16
const packet = await SSHPacketBuilder.build(payload, 16, null, 0);
const paddingLength = packet[4];
expect(paddingLength).toBeGreaterThanOrEqual(4);
});
it('不同 blockSize 下都能正确对齐', async () => {
for (const blockSize of [8, 16, 32]) {
for (const pl of [0, 1, blockSize - 5, blockSize, blockSize * 2 - 1]) {
const payload = randomPayload(pl);
const packet = await SSHPacketBuilder.build(payload, blockSize, null, 0);
expect(packet.length % blockSize).toBe(0);
expect(packet[4]).toBeGreaterThanOrEqual(4);
}
}
});
});
describe('padding 对齐规则(GCM 模式,hasAuthTag=true)', () => {
it('padding 应使 1+payload+padding 对齐到 blockSize,4字节 length 不参与对齐', async () => {
const blockSize = 16;
// GCM: alignBase = (1 + payloadLength) % blockSize
// paddingNeeded = blockSize - (alignBase || blockSize)
// 对齐目标是 [4..end] 部分 = 1 + payload + padding
for (const pl of [0, 1, 10, 15, 16, 31, 32, 100]) {
const payload = randomPayload(pl);
const packet = await SSHPacketBuilder.build(payload, blockSize, null, 0, true);
// 加密后部分长度 = packet.length - 4 = 1 + payload + padding
const encryptedPortion = packet.length - 4;
expect(encryptedPortion % blockSize).toBe(0);
}
});
});
describe('padding 随机性', () => {
it('同样 payload 和参数两次构造 padding 内容应不同', async () => {
const payload = randomPayload(20);
// 明文模式不加密,padding 区域直接可见
const p1 = await SSHPacketBuilder.build(payload, 16, null, 0);
const p2 = await SSHPacketBuilder.build(payload, 16, null, 0);
// length 和 paddingLength 相同
expect(p1.length).toBe(p2.length);
expect(p1[4]).toBe(p2[4]);
// padding 内容不同(概率上,random 非确定性)
const padStart = 5 + payload.length;
const padEnd = p1.length;
let differ = false;
for (let i = padStart; i < padEnd; i++) {
if (p1[i] !== p2[i]) {
differ = true;
break;
}
}
// 极端情况 padding 只有 4 字节,全不同的概率极低
if (padEnd - padStart > 4) {
expect(differ).toBe(true);
}
});
});
});
// =====================================================================
// SSHPacketBuilder.buildWithPayloadWriter
// =====================================================================
describe('packet — SSHPacketBuilder.buildWithPayloadWriter', () => {
it('效果应与 build(payload) 完全相同', async () => {
const payload = randomPayload(30);
const direct = await SSHPacketBuilder.build(payload, 16, null, 0);
// 用 buildWithPayloadWriter 复制 payload 到 buffer 中
const withWriter = await SSHPacketBuilder.buildWithPayloadWriter(
payload.length,
(packet, offset) => packet.set(payload, offset),
16,
null,
0
);
// length 字段和 paddingLength 应相同
expect(readUint32(withWriter, 0)).toBe(readUint32(direct, 0));
expect(withWriter[4]).toBe(direct[4]);
// payload 区域相同
const directPayload = direct.subarray(5, 5 + payload.length);
const writerPayload = withWriter.subarray(5, 5 + payload.length);
expect(arraysEqual(writerPayload, directPayload)).toBe(true);
expect(arraysEqual(writerPayload, payload)).toBe(true);
});
it('payloadLength=0 时 writePayload 不被需要,仍正确生成包', async () => {
const packet = await SSHPacketBuilder.buildWithPayloadWriter(
0,
() => {}, // no-op writer
16,
null,
0
);
expect(readUint32(packet, 0)).toBe(1 + (packet.length - 5)); // 1 + padding
expect(packet[4]).toBeGreaterThanOrEqual(4);
expect(packet.length % 16).toBe(0);
});
});
// =====================================================================
// 序列化/反序列化往返(核心)
// =====================================================================
describe('packet — build → parse 往返(无加密)', () => {
describe('CTR 模式往返(hasAuthTag=false)', () => {
it('标准 payload:build 后 feed+nextPacket 能还原 payload', async () => {
const payload = randomPayload(32);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
expect(pkt!.length).toBe(readUint32(built, 0));
expect(pkt!.paddingLength).toBe(built[4]);
});
it('空 payload 往返', async () => {
const payload = new Uint8Array(0);
const blockSize = 8;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(pkt!.payload.length).toBe(0);
});
it('单字节 payload 往返', async () => {
const payload = new Uint8Array([0x55]);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(pkt!.payload).toEqual(payload);
});
it('大 payload 往返(接近 MAX_PACKET_SIZE)', async () => {
const payload = randomPayload(1024);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
it('多种 blockSize 往返', async () => {
for (const blockSize of [8, 16, 32]) {
for (const pl of [0, 1, blockSize - 1, blockSize, blockSize * 3]) {
const payload = randomPayload(pl);
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
}
}
});
});
describe('GCM 模式往返(hasAuthTag=true)', () => {
it('标准 payload build 后 nextPacket 能还原', async () => {
const payload = randomPayload(32);
const blockSize = 16;
// GCM 模式下 build 不加密会生成 [4-byte length][padding_length + payload + padding]
// 但 nextPacket GCM 模式期望 [4-byte length][encrypted data: padding_length+payload+padding + 16-byte tag]
// 明文测试时 we need to handle this: built output has no tag
// → GCM 明文往返需要特殊处理
// 实际上 build(hasAuthTag=true, encrypt=null) 生成的是 plain [length][padding_length+payload+padding]
// 而 nextPacket(hasAuthTag=true) 期望 expectedSize = 4 + packetLength + 16
// 这两者不匹配 → 明文 GCM 测试需要 mock 加密添加 fake tag
// 这里暂时跳过 GCM 往返,放到 GCM 专项测试中
// 跳过:用 build(encrypt=null) 测试 GCM 对齐
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0, true);
// 验证 GCM 对齐:[4..end] 对齐 blockSize
expect((built.length - 4) % blockSize).toBe(0);
});
});
describe('连续多个包往返', () => {
it('一次 feed 多个包,连续 nextPacket 能逐个还原', async () => {
const blockSize = 16;
const payloads = [randomPayload(10), randomPayload(50), randomPayload(0), randomPayload(200)];
const parser = new SSHPacketParser();
// 先 build 所有包,再全部 feed
for (const p of payloads) {
const built = await SSHPacketBuilder.build(p, blockSize, null, 0);
parser.feed(built);
}
// 逐个解析
for (const expectedPayload of payloads) {
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, expectedPayload)).toBe(true);
}
});
it('seqNum 应在某会话内每包递增', async () => {
const blockSize = 16;
const parser = new SSHPacketParser();
const seqBefore = parser.getSeqNum();
expect(seqBefore).toBe(0);
for (let i = 0; i < 3; i++) {
const payload = randomPayload(10);
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
parser.feed(built);
await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(parser.getSeqNum()).toBe(i + 1);
}
});
});
});
// =====================================================================
// SSHPacketParser — 跨分片 feed
// =====================================================================
describe('packet — 跨分片 feed(模拟 TCP 分包)', () => {
it('一个包分成 2 片 feed 仍能正确解析', async () => {
const payload = randomPayload(50);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
// 等分两片
const split = Math.floor(built.length / 2);
const parser = new SSHPacketParser();
parser.feed(built.subarray(0, split));
// 数据不完整,应该返回 null
let pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).toBeNull();
// 喂剩余部分
parser.feed(built.subarray(split));
pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
it('一个包分成多片(每片 1 字节)仍能正确解析', async () => {
const payload = randomPayload(10);
const blockSize = 8;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
// 逐字节 feed
for (let i = 0; i < built.length; i++) {
parser.feed(built.subarray(i, i + 1));
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
if (i < built.length - 1) {
// 大部分情况下数据不完整
// 但不强制 null(某些极短包可能刚好完整)
} else {
// 最后一片后必定完整
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
}
}
});
it('切片边界恰好在 blockSize 处的边界对齐情况', async () => {
// CTR 模式 nextPacket 先 peek blockSize 字节(first block)解密
// 如果第一块没到齐,应该返回 null
const payload = randomPayload(20);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
// 只喂 blockSize-1 字节(不够第一个 block)
const parser = new SSHPacketParser();
parser.feed(built.subarray(0, blockSize - 1));
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).toBeNull();
// 喂满第一个 block
parser.feed(built.subarray(blockSize - 1));
const pkt2 = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt2).not.toBeNull();
expect(arraysEqual(pkt2!.payload, payload)).toBe(true);
});
});
// =====================================================================
// SSHPacketParser — 部分包等待
// =====================================================================
describe('packet — 部分数据返回 null,等待更多数据', () => {
it('空 feed 后 nextPacket 返回 null', async () => {
const parser = new SSHPacketParser();
const pkt = await parser.nextPacket(16, identityDecrypt, false, 0);
expect(pkt).toBeNull();
});
it('feed 空 Uint8Array 不影响后续解析', async () => {
const payload = randomPayload(20);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(new Uint8Array(0)); // 空 feed 应被忽略
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
it('恰好够一个包的数据量,nextPacket 正好返回非 null', async () => {
const payload = randomPayload(10);
const blockSize = 8;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
// built.length 恰好是 packet 的全部数据
const parser = new SSHPacketParser();
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
// 再次调用应该返回 null(无多余数据)
const pkt2 = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt2).toBeNull();
});
it('多余数据保留在 buffer 中供下个包使用', async () => {
const blockSize = 16;
const p1 = randomPayload(20);
const p2 = randomPayload(30);
const built1 = await SSHPacketBuilder.build(p1, blockSize, null, 0);
const built2 = await SSHPacketBuilder.build(p2, blockSize, null, 0);
const parser = new SSHPacketParser();
// 一次性 feed 两个包
parser.feed(built1);
parser.feed(built2);
const pkt1 = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt1).not.toBeNull();
expect(arraysEqual(pkt1!.payload, p1)).toBe(true);
const pkt2 = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt2).not.toBeNull();
expect(arraysEqual(pkt2!.payload, p2)).toBe(true);
});
});
// =====================================================================
// SSHPacketParser — MAX_PACKET_SIZE 限制
// =====================================================================
describe('packet — MAX_PACKET_SIZE 溢出检测', () => {
it('CTR 模式:packet length 超过 256KB 应抛错', async () => {
const blockSize = 16;
// 手工构造一个长度字段超限的 fake 包
// CTR: first blockSize bytes 解密后头 4 字节 = packetLength
// 手工构造 [length=0x00040001(超过 256KB), ...padding]
const fakeFirstBlock = new Uint8Array(blockSize);
fakeFirstBlock[0] = 0x00;
fakeFirstBlock[1] = 0x10; // 0x00100000 = 1048576 > 256KB
fakeFirstBlock[2] = 0x00;
fakeFirstBlock[3] = 0x00;
const parser = new SSHPacketParser();
parser.feed(fakeFirstBlock);
await expect(parser.nextPacket(blockSize, identityDecrypt, false, 0)).rejects.toThrow(
/exceeds maximum/
);
});
it('GCM 模式:packet length 超过 256KB 应抛错', async () => {
const blockSize = 16;
// GCM: peek 4 bytes = packetLength (before decryption)
const fakeLength = new Uint8Array(4);
fakeLength[0] = 0x00;
fakeLength[1] = 0x10; // > 256KB
fakeLength[2] = 0x00;
fakeLength[3] = 0x00;
const parser = new SSHPacketParser();
parser.feed(fakeLength);
await expect(parser.nextPacket(blockSize, identityDecrypt, true, 0)).rejects.toThrow(
/exceeds maximum/
);
});
});
// =====================================================================
// GCM 模式完整往返(hasAuthTag=true)
// -------------------------------------------------------------------
// GCM build(encrypt=null) 生成 [4-byte length][plaintext: padLen+payload+padding]
// GCM nextPacket 期望 [4-byte length][encrypted: padLen+payload+padding + 16-byte tag]
// → 用 mock encrypt 在 build 端附 16 字节 fake tag
// → parse 端 identity decrypt 时 strip 掉 16 字节 tag
// =====================================================================
describe('packet — GCM 模式完整往返', () => {
// fake encrypt:原样返回 data + 16 字节 fake tag
async function fakeGcmEncrypt(
data: Uint8Array,
_seq: number,
_aad?: Uint8Array
): Promise<Uint8Array> {
const result = new Uint8Array(data.length + 16);
result.set(data, 0);
// fake tag = 16 个 0xAA
for (let i = data.length; i < data.length + 16; i++) result[i] = 0xaa;
return result;
}
// fake decrypt:data 末尾有 16 字节 fake tag,去掉后返回前缀
async function fakeGcmDecrypt(
data: Uint8Array,
_seq: number,
_aad?: Uint8Array,
_commit?: boolean
): Promise<Uint8Array> {
return data.subarray(0, data.length - 16);
}
it('标准 payload GCM build → parse 完整还原', async () => {
const payload = randomPayload(40);
const blockSize = 16;
// build 时 encrypt=null(明文),手工添加 fake tag 模拟 GCM 格式
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0, true);
// built 结构:[4-byte length][padLen+payload+padding]
// 期望 wire 格式:[4-byte length][encrypted(padLen+payload+padding)+16-byte tag]
// → 手工追加 16 字节 fake tag
const wire = new Uint8Array(built.length + 16);
wire.set(built, 0);
for (let i = built.length; i < built.length + 16; i++) wire[i] = 0xaa;
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, fakeGcmDecrypt, true, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
it('用 fakeGcmEncrypt 直接走 build 加密路径往返', async () => {
const payload = randomPayload(25);
const blockSize = 16;
// build 用 fakeGcmEncrypt 模拟 GCM 加密链路
const wire = await SSHPacketBuilder.build(payload, blockSize, fakeGcmEncrypt, 0, true);
// wire 结构:[4-byte length][encrypted(padLen+payload+padding)+16-byte tag]
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, fakeGcmDecrypt, true, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
it('GCM 模式 payload=0 往返', async () => {
const payload = new Uint8Array(0);
const blockSize = 16;
const wire = await SSHPacketBuilder.build(payload, blockSize, fakeGcmEncrypt, 0, true);
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, fakeGcmDecrypt, true, 0);
expect(pkt).not.toBeNull();
expect(pkt!.payload.length).toBe(0);
});
it('GCM 模式数据不完整时返回 null(等待更多数据)', async () => {
const payload = randomPayload(20);
const blockSize = 16;
const wire = await SSHPacketBuilder.build(payload, blockSize, fakeGcmEncrypt, 0, true);
// 只喂前半段
const parser = new SSHPacketParser();
parser.feed(wire.subarray(0, Math.floor(wire.length / 2)));
const pkt = await parser.nextPacket(blockSize, fakeGcmDecrypt, true, 0);
expect(pkt).toBeNull();
});
it('GCM 模式 decrypt 返回 null 时 nextPacket 返回 null', async () => {
const payload = randomPayload(20);
const blockSize = 16;
const wire = await SSHPacketBuilder.build(payload, blockSize, fakeGcmEncrypt, 0, true);
const parser = new SSHPacketParser();
parser.feed(wire);
// decrypt 返回 null 模拟解密失败
const pkt = await parser.nextPacket(blockSize, () => null, true, 0);
expect(pkt).toBeNull();
});
it('GCM 模式 MAC 字段(raw.subarray(4+packetLength))应为 16 字节 tag', async () => {
const payload = randomPayload(10);
const blockSize = 16;
const wire = await SSHPacketBuilder.build(payload, blockSize, fakeGcmEncrypt, 0, true);
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, fakeGcmDecrypt, true, 0);
expect(pkt).not.toBeNull();
expect(pkt!.mac).toBeDefined();
expect(pkt!.mac!.length).toBe(16);
// tag 内容应全是 0xAA
for (const b of pkt!.mac!) expect(b).toBe(0xaa);
});
});
// =====================================================================
// CTR 模式 MAC 校验
// =====================================================================
describe('packet — CTR 模式 MAC 校验', () => {
it('verifyMac 返回 true 时正常解析', async () => {
const payload = randomPayload(20);
const blockSize = 16;
const macLen = 32;
const fakeMac = randomPayload(macLen);
// build 带加密链路 + mac:用 mock encrypt + mock mac
async function mockEnc(data: Uint8Array): Promise<Uint8Array> {
return data;
}
async function mockMac(data: Uint8Array): Promise<Uint8Array> {
const r = new Uint8Array(macLen);
r.set(fakeMac.subarray(0, Math.min(fakeMac.length, macLen)));
return r;
}
const wire = await SSHPacketBuilder.build(payload, blockSize, mockEnc, 0, false, mockMac);
// wire = encrypted packet + mac(长度由 payload 对齐决定,不硬编码)
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(
blockSize,
identityDecrypt,
false,
macLen,
(_pkt, _mac, _seq) => true // verifyMac = true
);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
it('verifyMac 返回 false 时抛 MAC 校验错误', async () => {
const payload = randomPayload(20);
const blockSize = 16;
const macLen = 32;
async function mockEnc(data: Uint8Array): Promise<Uint8Array> {
return data;
}
async function mockMac(_data: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(macLen);
}
const wire = await SSHPacketBuilder.build(payload, blockSize, mockEnc, 0, false, mockMac);
const parser = new SSHPacketParser();
parser.feed(wire);
await expect(
parser.nextPacket(
blockSize,
identityDecrypt,
false,
macLen,
() => false // verifyMac 失败
)
).rejects.toThrow(/Invalid packet MAC/);
});
it('macLength > 0 但未提供 verifyMac 时不校验,直接返回包', async () => {
const payload = randomPayload(15);
const blockSize = 16;
const macLen = 20;
async function mockEnc(d: Uint8Array): Promise<Uint8Array> {
return d;
}
async function mockMac(_d: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(macLen);
}
const wire = await SSHPacketBuilder.build(payload, blockSize, mockEnc, 0, false, mockMac);
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, macLen);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
// mac 字段应提取出 macLen 字节
expect(pkt!.mac!.length).toBe(macLen);
});
it('CTR decrypt 返回 null 时 nextPacket 返回 null(first block 解密失败)', async () => {
const payload = randomPayload(20);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(built);
const pkt = await parser.nextPacket(blockSize, () => null, false, 0);
expect(pkt).toBeNull();
});
it('CTR 数据不够 first block 时返回 null(不调用 decrypt)', async () => {
const blockSize = 16;
// 只喂 < blockSize 字节
const parser = new SSHPacketParser();
parser.feed(randomPayload(blockSize - 1));
const pkt = await parser.nextPacket(
blockSize,
() => {
throw new Error('should not call decrypt');
},
false,
0
);
expect(pkt).toBeNull();
});
it('CTR 数据够 first block 但不够 totalSize+mac 时返回 null', async () => {
const payload = randomPayload(50);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
// 只喂前 blockSize+1 字节(够 first block 但不够完整)
const parser = new SSHPacketParser();
parser.feed(built.subarray(0, blockSize + 1));
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).toBeNull();
});
});
// =====================================================================
// compactChunks — 高 chunk 索引回收
// =====================================================================
describe('packet — compactChunks 回收(33+ chunk 边界)', () => {
it('跨 33+ 片 feed 后仍能正确解析(触发 chunkIndex > 32 回收)', async () => {
const payload = randomPayload(100);
const blockSize = 8;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
// 逐字节 feed,制造 100+ chunk
const parser = new SSHPacketParser();
for (let i = 0; i < built.length; i++) {
parser.feed(built.subarray(i, i + 1));
}
// 调用 nextPacket 触发 chunk 消耗,chunkIndex 应到达 > 32 的区间
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
// 解析完后继续用同一个 parser,验证 chunk 回收后仍能正常工作
const payload2 = randomPayload(20);
const built2 = await SSHPacketBuilder.build(payload2, blockSize, null, 0);
parser.feed(built2);
const pkt2 = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt2).not.toBeNull();
expect(arraysEqual(pkt2!.payload, payload2)).toBe(true);
});
it('bufferedLength 归零时 compactChunks 应清空 chunks 数组', async () => {
// 解析完一个包后 buffer 应为空
const payload = randomPayload(20);
const blockSize = 16;
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
parser.feed(built);
await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(parser.getBufferLength()).toBe(0);
// 再次调用应返回 null
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).toBeNull();
});
it('chunkIndex > 32 且 chunkIndex*2 >= chunks.length 时触发 chunk 回收', async () => {
// 构造大包逐字节喂 70+ chunk,最后再喂一个下包的少量字节
// 使 nextPacket 消费完后 bufferedLength > 0 但 chunkIndex 已 > 32
// → compactChunks 走 L104-109 回收分支(非归零分支)
const blockSize = 8;
const payload = randomPayload(200);
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
for (let i = 0; i < built.length; i++) {
parser.feed(built.subarray(i, i + 1));
}
// 追加下一包的部分字节(trigger 不完整 → bufferedLength > 0 但 chunkIndex > 32)
const payload2 = randomPayload(20);
const built2 = await SSHPacketBuilder.build(payload2, blockSize, null, 0);
parser.feed(built2.subarray(0, 5)); // 只喂 5 字节,不够下个包
// 现在状态:chunkIndex=0,但 chunks.length 很大、bufferedLength = built.length + 5
// nextPacket 解析完第一个包后 consumeBytes(built.length)
// → chunkIndex 跳到 built.length(约 210),bufferedLength = 5 > 0
// → compactChunks: chunkIndex>32 && chunkIndex*2 >= chunks.length → 触发回收
const pkt = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
// buffer 应剩 5 字节(下包的前 5 字节)
expect(parser.getBufferLength()).toBe(5);
// 回收后状态正确,喂剩余数据继续解析下个包
parser.feed(built2.subarray(5));
const pkt2 = await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(pkt2).not.toBeNull();
expect(arraysEqual(pkt2!.payload, payload2)).toBe(true);
});
});
// =====================================================================
// seqNum / buffer 查询方法
// =====================================================================
describe('packet — seqNum 与 buffer 查询方法', () => {
it('序列号按 uint32 递增并在上限回绕', () => {
expect(nextSequenceNumber(0)).toBe(1);
expect(nextSequenceNumber(0xfffffffe)).toBe(0xffffffff);
expect(nextSequenceNumber(0xffffffff)).toBe(0);
});
it('getSeqNum 初始应为 0', () => {
const parser = new SSHPacketParser();
expect(parser.getSeqNum()).toBe(0);
});
it('getBufferLength 反映当前 buffered 字节数', async () => {
const blockSize = 16;
const payload = randomPayload(20);
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
const parser = new SSHPacketParser();
expect(parser.getBufferLength()).toBe(0);
parser.feed(built);
expect(parser.getBufferLength()).toBe(built.length);
await parser.nextPacket(blockSize, identityDecrypt, false, 0);
expect(parser.getBufferLength()).toBe(0);
});
});
// =====================================================================
// SSHPacketBuilder — 加密路径
// =====================================================================
describe('packet — SSHPacketBuilder 加密路径', () => {
it('CTR 模式 encrypt 只加密不附 MAC:返回纯密文', async () => {
const payload = randomPayload(30);
const blockSize = 16;
// mock encrypt:每个字节 +1
async function mockEnc(data: Uint8Array): Promise<Uint8Array> {
const r = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) r[i] = (data[i] + 1) & 0xff;
return r;
}
const wire = await SSHPacketBuilder.build(payload, blockSize, mockEnc, 0, false);
// 无 MAC:length 应等于明文包长度
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
expect(wire.length).toBe(built.length);
// 解密侧:每个字节 -1 还原
async function mockDec(data: Uint8Array): Promise<Uint8Array> {
const r = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) r[i] = (data[i] - 1) & 0xff;
return r;
}
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, mockDec, false, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
it('CTR 模式 encrypt + mac:返回密文 + MAC', async () => {
const payload = randomPayload(20);
const blockSize = 16;
const macLen = 10;
async function mockEnc(data: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(data); // identity 加密,便于验证
}
async function mockMac(_data: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(macLen).fill(0xbb);
}
const wire = await SSHPacketBuilder.build(payload, blockSize, mockEnc, 0, false, mockMac);
const built = await SSHPacketBuilder.build(payload, blockSize, null, 0);
expect(wire.length).toBe(built.length + macLen);
// 末尾 macLen 字节应全为 0xBB
for (let i = wire.length - macLen; i < wire.length; i++) {
expect(wire[i]).toBe(0xbb);
}
// parse 端验证
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, mockEnc, false, macLen);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
expect(pkt!.mac!.length).toBe(macLen);
for (const b of pkt!.mac!) expect(b).toBe(0xbb);
});
it('GCM 模式 buildWithPayloadWriter 加密路径', async () => {
const payload = randomPayload(30);
const blockSize = 16;
async function gcmEnc(data: Uint8Array, _seq: number, _aad?: Uint8Array): Promise<Uint8Array> {
const r = new Uint8Array(data.length + 16);
r.set(data, 0);
for (let i = data.length; i < data.length + 16; i++) r[i] = 0xcc;
return r;
}
const wire = await SSHPacketBuilder.buildWithPayloadWriter(
payload.length,
(pkt, offset) => pkt.set(payload, offset),
blockSize,
gcmEnc,
0,
true
);
// wire: [4-byte length][encrypted + 16-byte tag]
expect(wire instanceof Uint8Array).toBe(true);
expect(wire.length - 4 - 16).toBeGreaterThan(0); // 加密部分有数据
// 末 16 字节 tag 全 0xCC
for (let i = wire.length - 16; i < wire.length; i++) {
expect(wire[i]).toBe(0xcc);
}
// parse 端
async function gcmDec(data: Uint8Array): Promise<Uint8Array> {
return data.subarray(0, data.length - 16);
}
const parser = new SSHPacketParser();
parser.feed(wire);
const pkt = await parser.nextPacket(blockSize, gcmDec, true, 0);
expect(pkt).not.toBeNull();
expect(arraysEqual(pkt!.payload, payload)).toBe(true);
});
});