合并 `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>
476 lines
17 KiB
TypeScript
476 lines
17 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
concat,
|
|
encodePrefixedString,
|
|
encodeString,
|
|
encodeUint32,
|
|
extractRawECDHPoint,
|
|
readUint32,
|
|
toSSHMPInt,
|
|
writeUint32,
|
|
} from '../../src/ssh/utils';
|
|
|
|
describe('SSH Utils', () => {
|
|
describe('concat', () => {
|
|
it('should concatenate multiple Uint8Arrays', () => {
|
|
const a = new Uint8Array([1, 2, 3]);
|
|
const b = new Uint8Array([4, 5]);
|
|
const c = new Uint8Array([6]);
|
|
|
|
const result = concat(a, b, c);
|
|
|
|
expect(result.length).toBe(6);
|
|
expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6]));
|
|
});
|
|
|
|
it('should handle empty arrays', () => {
|
|
const a = new Uint8Array([]);
|
|
const b = new Uint8Array([1, 2]);
|
|
|
|
const result = concat(a, b);
|
|
|
|
expect(result.length).toBe(2);
|
|
expect(result).toEqual(new Uint8Array([1, 2]));
|
|
});
|
|
});
|
|
|
|
describe('readUint32', () => {
|
|
it('should read big-endian uint32', () => {
|
|
const data = new Uint8Array([0x00, 0x00, 0x01, 0x00]);
|
|
const result = readUint32(data, 0);
|
|
|
|
expect(result).toBe(256);
|
|
});
|
|
|
|
it('should read uint32 at offset', () => {
|
|
const data = new Uint8Array([0xff, 0x00, 0x00, 0x01, 0x00]);
|
|
const result = readUint32(data, 1);
|
|
|
|
expect(result).toBe(256);
|
|
});
|
|
|
|
it('should read max uint32', () => {
|
|
const data = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
|
const result = readUint32(data, 0);
|
|
|
|
expect(result).toBe(0xffffffff);
|
|
});
|
|
});
|
|
|
|
describe('writeUint32', () => {
|
|
it('should write big-endian uint32', () => {
|
|
const data = new Uint8Array(4);
|
|
writeUint32(data, 0, 256);
|
|
|
|
expect(data).toEqual(new Uint8Array([0x00, 0x00, 0x01, 0x00]));
|
|
});
|
|
});
|
|
|
|
describe('encodeUint32', () => {
|
|
it('should encode uint32 as 4-byte array', () => {
|
|
const result = encodeUint32(256);
|
|
|
|
expect(result.length).toBe(4);
|
|
expect(result).toEqual(new Uint8Array([0x00, 0x00, 0x01, 0x00]));
|
|
});
|
|
});
|
|
|
|
describe('encodeString', () => {
|
|
it('should encode string with length prefix', () => {
|
|
const result = encodeString('test');
|
|
|
|
expect(result.length).toBe(8);
|
|
|
|
const length = readUint32(result, 0);
|
|
expect(length).toBe(4);
|
|
|
|
const str = new TextDecoder().decode(result.slice(4));
|
|
expect(str).toBe('test');
|
|
});
|
|
|
|
it('should encode Uint8Array with length prefix', () => {
|
|
const data = new Uint8Array([1, 2, 3]);
|
|
const result = encodeString(data);
|
|
|
|
expect(result.length).toBe(7);
|
|
|
|
const length = readUint32(result, 0);
|
|
expect(length).toBe(3);
|
|
|
|
expect(result.slice(4)).toEqual(data);
|
|
});
|
|
|
|
it('should handle empty string', () => {
|
|
const result = encodeString('');
|
|
|
|
expect(result.length).toBe(4);
|
|
expect(readUint32(result, 0)).toBe(0);
|
|
});
|
|
|
|
it('should encode multi-byte UTF-8 string (Chinese)', () => {
|
|
const result = encodeString('你好');
|
|
// 你 = 3 bytes, 好 = 3 bytes → 6 bytes + 4 length prefix
|
|
expect(result.length).toBe(10);
|
|
expect(readUint32(result, 0)).toBe(6);
|
|
const decoded = new TextDecoder().decode(result.subarray(4));
|
|
expect(decoded).toBe('你好');
|
|
});
|
|
|
|
it('should encode string with embedded null bytes', () => {
|
|
const result = encodeString('a\x00b');
|
|
expect(result.length).toBe(7);
|
|
expect(readUint32(result, 0)).toBe(3);
|
|
expect(result[4]).toBe(0x61);
|
|
expect(result[5]).toBe(0x00);
|
|
expect(result[6]).toBe(0x62);
|
|
});
|
|
});
|
|
|
|
// ===================================================================
|
|
// concat — 边界补充
|
|
// ===================================================================
|
|
describe('concat — 边界', () => {
|
|
it('zero arguments returns empty Uint8Array', () => {
|
|
const result = concat();
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
expect(result.length).toBe(0);
|
|
});
|
|
|
|
it('single argument returns a copy', () => {
|
|
const orig = new Uint8Array([1, 2, 3]);
|
|
const result = concat(orig);
|
|
expect(result).toEqual(orig);
|
|
expect(result).not.toBe(orig); // should be a new array
|
|
});
|
|
|
|
it('all empty arguments returns empty', () => {
|
|
const result = concat(new Uint8Array(0), new Uint8Array(0));
|
|
expect(result.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ===================================================================
|
|
// readUint32 / writeUint32 / encodeUint32 — 边界值
|
|
// ===================================================================
|
|
describe('readUint32 / writeUint32 — 边界值', () => {
|
|
it('readUint32 of all zeros is 0', () => {
|
|
expect(readUint32(new Uint8Array([0, 0, 0, 0]), 0)).toBe(0);
|
|
});
|
|
|
|
it('readUint32 of 0x80000000 does not overflow to negative', () => {
|
|
// 0x80000000 in JS bit ops would be negative without >>> 0
|
|
const data = new Uint8Array([0x80, 0x00, 0x00, 0x00]);
|
|
expect(readUint32(data, 0)).toBe(0x80000000);
|
|
});
|
|
|
|
it('writeUint32(0) writes four zero bytes', () => {
|
|
const buf = new Uint8Array(4);
|
|
writeUint32(buf, 0, 0);
|
|
expect(buf).toEqual(new Uint8Array([0, 0, 0, 0]));
|
|
});
|
|
|
|
it('writeUint32(0xFFFFFFFF) writes all-FF', () => {
|
|
const buf = new Uint8Array(4);
|
|
writeUint32(buf, 0, 0xffffffff);
|
|
expect(buf).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
|
|
});
|
|
|
|
it('writeUint32(0x80000000) writes correct bytes', () => {
|
|
const buf = new Uint8Array(4);
|
|
writeUint32(buf, 0, 0x80000000);
|
|
expect(buf).toEqual(new Uint8Array([0x80, 0x00, 0x00, 0x00]));
|
|
});
|
|
|
|
it('writeUint32 then readUint32 roundtrip for various values', () => {
|
|
const values = [0, 1, 127, 128, 255, 256, 65535, 65536, 0x7fffffff, 0x80000000, 0xffffffff];
|
|
for (const v of values) {
|
|
const buf = new Uint8Array(4);
|
|
writeUint32(buf, 0, v);
|
|
expect(readUint32(buf, 0)).toBe(v);
|
|
}
|
|
});
|
|
|
|
it('encodeUint32(0) returns 4 zero bytes', () => {
|
|
expect(encodeUint32(0)).toEqual(new Uint8Array([0, 0, 0, 0]));
|
|
});
|
|
|
|
it('encodeUint32(0xFFFFFFFF) returns all-FF', () => {
|
|
expect(encodeUint32(0xffffffff)).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
|
|
});
|
|
});
|
|
});
|
|
|
|
// =====================================================================
|
|
// toSSHMPInt — RFC 4251 多精度整数编码
|
|
// ---------------------------------------------------------------
|
|
// mpint 格式: [4-byte length][integer bytes]
|
|
// 规则:
|
|
// - 去掉前导零(但保留至少 1 字节)
|
|
// - 如果最高字节的最高位是 1,需要补一个前导零字节
|
|
// (因为 SSH mpint 用两个补码表示,最高位 1 会被当作负数)
|
|
// - length 字段 = significant.length + (needsLeadingZero ? 1 : 0)
|
|
// =====================================================================
|
|
describe('SSH Utils — toSSHMPInt', () => {
|
|
describe('正数(最高位 bit=0,不补前导零)', () => {
|
|
it('0x01 → [0,0,0,1, 0x01]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x01]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x01]));
|
|
});
|
|
|
|
it('0x7F → [0,0,0,1, 0x7F] (最高位 0,不补零)', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x7f]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x7f]));
|
|
});
|
|
|
|
it('0x007F → 去前导零后 [0,0,0,1, 0x7F]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x00, 0x7f]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x7f]));
|
|
});
|
|
|
|
it('0x7FFF → [0,0,0,2, 0x7F,0xFF]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x7f, 0xff]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 2, 0x7f, 0xff]));
|
|
});
|
|
|
|
it('0x0100 → [0,0,0,2, 0x01,0x00]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x01, 0x00]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 2, 0x01, 0x00]));
|
|
});
|
|
});
|
|
|
|
describe('正数(最高位 bit=1,需补前导零)', () => {
|
|
it('0x80 → [0,0,0,2, 0x00,0x80] (补前导零避免被当作负数)', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x80]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 2, 0x00, 0x80]));
|
|
});
|
|
|
|
it('0xFF → [0,0,0,2, 0x00,0xFF]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0xff]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 2, 0x00, 0xff]));
|
|
});
|
|
|
|
it('0x00FF → 去前导零后 0xFF,最高位 1 → 补零 → [0,0,0,2, 0x00,0xFF]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x00, 0xff]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 2, 0x00, 0xff]));
|
|
});
|
|
|
|
it('0x8000 → [0,0,0,3, 0x00,0x80,0x00]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x80, 0x00]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 3, 0x00, 0x80, 0x00]));
|
|
});
|
|
|
|
it('0xFFFF → [0,0,0,3, 0x00,0xFF,0xFF]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0xff, 0xff]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 3, 0x00, 0xff, 0xff]));
|
|
});
|
|
});
|
|
|
|
describe('前导零去除', () => {
|
|
it('0x00000001 → 去掉 3 个前导零 → [0,0,0,1, 0x01]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x00, 0x00, 0x00, 0x01]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x01]));
|
|
});
|
|
|
|
it('0x0080 → 去前导零后 0x80,最高位 1 → 补零 → [0,0,0,2, 0x00,0x80]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x00, 0x80]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 2, 0x00, 0x80]));
|
|
});
|
|
|
|
it('保留至少 1 字节:0x000000 → 不全部去掉 → [0,0,0,1, 0x00]', () => {
|
|
// while 条件: start < bytes.length - 1,所以最后一个 0 不会被跳过
|
|
const result = toSSHMPInt(new Uint8Array([0x00, 0x00, 0x00]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x00]));
|
|
});
|
|
|
|
it('0x0100FF → 不去中间零(只去前导)→ [0,0,0,3, 0x01,0x00,0xFF]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x01, 0x00, 0xff]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 3, 0x01, 0x00, 0xff]));
|
|
});
|
|
|
|
it('0x00FF80 → 去前导 0x00 → 0xFF80,最高位 1 → 补零 → [0,0,0,3, 0x00,0xFF,0x80]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x00, 0xff, 0x80]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 3, 0x00, 0xff, 0x80]));
|
|
});
|
|
});
|
|
|
|
describe('单调字节 / 单元素数组', () => {
|
|
it('single byte 0x00 → [0,0,0,1, 0x00]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x00]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x00]));
|
|
});
|
|
|
|
it('single byte 0x01 → [0,0,0,1, 0x01]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0x01]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x01]));
|
|
});
|
|
|
|
it('single byte 0xFF → [0,0,0,2, 0x00,0xFF]', () => {
|
|
const result = toSSHMPInt(new Uint8Array([0xff]));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 2, 0x00, 0xff]));
|
|
});
|
|
});
|
|
|
|
describe('length 字段验证', () => {
|
|
it('length field always equals significant.length + leadingZero(0 or 1)', () => {
|
|
const cases = [
|
|
new Uint8Array([0x01]),
|
|
new Uint8Array([0x80]),
|
|
new Uint8Array([0x00, 0x01]),
|
|
new Uint8Array([0x00, 0xff]),
|
|
new Uint8Array([0x7f, 0xff]),
|
|
new Uint8Array([0xff, 0xff]),
|
|
new Uint8Array([0x00, 0x00, 0x7f]),
|
|
new Uint8Array([0x01, 0x02, 0x03, 0x04]),
|
|
];
|
|
for (const input of cases) {
|
|
const result = toSSHMPInt(input);
|
|
const lengthField = readUint32(result, 0);
|
|
// length = result.length - 4
|
|
expect(lengthField).toBe(result.length - 4);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('大整数(模拟 ECDH 共享密钥)', () => {
|
|
it('32-byte Curve25519 共享密钥:结果 length 字段应为 32 或 33(取决最高位)', () => {
|
|
// 随机 32 字节模拟 shared secret
|
|
const secret = new Uint8Array(32);
|
|
crypto.getRandomValues(secret);
|
|
const result = toSSHMPInt(secret);
|
|
const lengthField = readUint32(result, 0);
|
|
const needsLeadingZero = (secret[0] & 0x80) !== 0;
|
|
expect(lengthField).toBe(32 + (needsLeadingZero ? 1 : 0));
|
|
expect(result.length).toBe(4 + lengthField);
|
|
});
|
|
|
|
it('32-byte 全 0:去前导零后保留最后 1 个 0 字节 → [0,0,0,1, 0x00]', () => {
|
|
const result = toSSHMPInt(new Uint8Array(32));
|
|
expect(result).toEqual(new Uint8Array([0, 0, 0, 1, 0x00]));
|
|
});
|
|
|
|
it('32-byte 最高位 1:补 1 字节前导零,总长度 4+33=37', () => {
|
|
const secret = new Uint8Array(32);
|
|
secret[0] = 0xff; // 最高位 1
|
|
const result = toSSHMPInt(secret);
|
|
expect(result.length).toBe(4 + 33);
|
|
expect(result[4]).toBe(0x00); // 补的前导零
|
|
expect(result[5]).toBe(0xff);
|
|
});
|
|
|
|
it('32-byte 最高位 0:不补零,总长度 4+32=36', () => {
|
|
const secret = new Uint8Array(32);
|
|
secret[0] = 0x7f; // 最高位 0
|
|
const result = toSSHMPInt(secret);
|
|
expect(result.length).toBe(4 + 32);
|
|
expect(result[4]).toBe(0x7f); // 无前导零
|
|
});
|
|
});
|
|
});
|
|
|
|
// =====================================================================
|
|
// extractRawECDHPoint — 从 SSH EC 公钥 blob 提取原始点
|
|
// ---------------------------------------------------------------
|
|
// blob 格式: [4-byte keyTypeLen][keyType]
|
|
// [4-byte curveLen][curve]
|
|
// [4-byte pointLen][point]
|
|
// 返回 point 部分(不含长度前缀)
|
|
// =====================================================================
|
|
describe('SSH Utils — extractRawECDHPoint', () => {
|
|
// 构造一个标准 ECDH 公钥 blob 的辅助函数
|
|
function buildEcdhBlob(keyType: string, curve: string, point: Uint8Array): Uint8Array {
|
|
const keyTypeBytes = new TextEncoder().encode(keyType);
|
|
const curveBytes = new TextEncoder().encode(curve);
|
|
const result = new Uint8Array(
|
|
4 + keyTypeBytes.length + 4 + curveBytes.length + 4 + point.length
|
|
);
|
|
let offset = 0;
|
|
writeUint32(result, offset, keyTypeBytes.length);
|
|
offset += 4;
|
|
result.set(keyTypeBytes, offset);
|
|
offset += keyTypeBytes.length;
|
|
writeUint32(result, offset, curveBytes.length);
|
|
offset += 4;
|
|
result.set(curveBytes, offset);
|
|
offset += curveBytes.length;
|
|
writeUint32(result, offset, point.length);
|
|
offset += 4;
|
|
result.set(point, offset);
|
|
return result;
|
|
}
|
|
|
|
it('标准 nistp256 公钥点提取', () => {
|
|
// 真实格式: keyType="ecdsa-sha2-nistp256", curve="nistp256", point=65 bytes
|
|
const point = new Uint8Array(65);
|
|
point[0] = 0x04; // uncompressed point prefix
|
|
crypto.getRandomValues(point.subarray(1));
|
|
const blob = buildEcdhBlob('ecdsa-sha2-nistp256', 'nistp256', point);
|
|
|
|
const extracted = extractRawECDHPoint(blob);
|
|
expect(extracted).toEqual(point);
|
|
});
|
|
|
|
it('Curve25519 公钥点(32 字节)提取', () => {
|
|
const point = new Uint8Array(32);
|
|
crypto.getRandomValues(point);
|
|
const blob = buildEcdhBlob('curve25519-sha256', 'curve25519', point);
|
|
|
|
const extracted = extractRawECDHPoint(blob);
|
|
expect(extracted).toEqual(point);
|
|
});
|
|
|
|
it('空 point 的 blob', () => {
|
|
const blob = buildEcdhBlob('ecdsa-sha2-nistp256', 'nistp256', new Uint8Array(0));
|
|
const extracted = extractRawECDHPoint(blob);
|
|
expect(extracted.length).toBe(0);
|
|
});
|
|
|
|
it('单字节 point', () => {
|
|
const blob = buildEcdhBlob('kex', 'c', new Uint8Array([0x42]));
|
|
const extracted = extractRawECDHPoint(blob);
|
|
expect(extracted).toEqual(new Uint8Array([0x42]));
|
|
});
|
|
|
|
it('keyType 和 curve 长度不同时偏移仍正确', () => {
|
|
const point = new Uint8Array([1, 2, 3, 4, 5]);
|
|
// keyType 很长, curve 很短
|
|
const blob = buildEcdhBlob('very-long-keytype-name', 'x', point);
|
|
const extracted = extractRawECDHPoint(blob);
|
|
expect(extracted).toEqual(point);
|
|
});
|
|
|
|
it('提取的 subarray 是 blob 的视图(无复制)', () => {
|
|
const point = new Uint8Array([0x04, 0xaa, 0xbb]);
|
|
const blob = buildEcdhBlob('kt', 'cv', point);
|
|
const extracted = extractRawECDHPoint(blob);
|
|
// subarray 返回的是视图,修改原 blob 会反映到 extracted
|
|
blob[blob.length - 1] = 0xff;
|
|
expect(extracted[extracted.length - 1]).toBe(0xff);
|
|
});
|
|
});
|
|
|
|
// =====================================================================
|
|
// encodePrefixedString
|
|
// =====================================================================
|
|
describe('SSH Utils — encodePrefixedString', () => {
|
|
it('与 encodeString 结果完全相同', () => {
|
|
const s = 'test-string';
|
|
const a = encodePrefixedString(s);
|
|
const b = encodeString(s);
|
|
expect(a).toEqual(b);
|
|
});
|
|
|
|
it('空字符串返回 4 字节零长度前缀', () => {
|
|
const result = encodePrefixedString('');
|
|
expect(result.length).toBe(4);
|
|
expect(readUint32(result, 0)).toBe(0);
|
|
});
|
|
|
|
it('Uint8Array 输入也走 encodeString 路径', () => {
|
|
const data = new Uint8Array([0x10, 0x20, 0x30]);
|
|
const result = encodePrefixedString(data);
|
|
expect(result.length).toBe(7);
|
|
expect(readUint32(result, 0)).toBe(3);
|
|
expect(result.subarray(4)).toEqual(data);
|
|
});
|
|
});
|