chore(repo): initialize WebDrop monorepo
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
commit
53768a195d
39 files changed
+9063
No files matched your search
@@ -0,0 +1,144 @@
|
||||
import { AAD_FRAME, Flags, HEADER_SIZE, ID_SIZE, MAX_FRAME_DATA, MAX_FRAME_PLAINTEXT, TAG_SIZE, TIERS } from './constants.js';
|
||||
import { concat, readU32, utf8, writeU32, type Bytes } from './bytes.js';
|
||||
import { IntegrityError } from './errors.js';
|
||||
|
||||
// ───────────────────────── padding(protocol §6) ─────────────────────────
|
||||
|
||||
/** 不小于 total 的最小档位。 */
|
||||
export function tierFor(total: number): number {
|
||||
for (const t of TIERS) if (t >= total) return t;
|
||||
throw new RangeError('超出单帧上限');
|
||||
}
|
||||
|
||||
/** 明文 = u32 data_len ‖ data ‖ 零填充,总长为档位。 */
|
||||
export function padPlaintext(data: Uint8Array): Bytes {
|
||||
if (data.length > MAX_FRAME_DATA) throw new RangeError('单帧 data 最多 1,048,572 字节');
|
||||
const tier = tierFor(4 + data.length);
|
||||
const out = new Uint8Array(tier);
|
||||
out.set(writeU32(data.length), 0);
|
||||
out.set(data, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 接收方校验:长度字段不越界,总长是档位,填充全为零。 */
|
||||
export function unpadPlaintext(plain: Uint8Array): Bytes {
|
||||
if (plain.length < 4 || !(TIERS as readonly number[]).includes(plain.length)) throw new IntegrityError('bad_padding');
|
||||
const len = readU32(plain, 0);
|
||||
if (len > plain.length - 4) throw new IntegrityError('bad_padding');
|
||||
for (let i = 4 + len; i < plain.length; i++) {
|
||||
if (plain[i] !== 0) throw new IntegrityError('bad_padding');
|
||||
}
|
||||
return plain.slice(4, 4 + len);
|
||||
}
|
||||
|
||||
// ───────────────────────── 封装(protocol §4) ─────────────────────────
|
||||
|
||||
export interface Envelope {
|
||||
flags: number;
|
||||
itemId: Bytes;
|
||||
frameIndex: number;
|
||||
payloadLength: number;
|
||||
/** 前 26 字节。 */
|
||||
header: Bytes;
|
||||
payload: Bytes;
|
||||
/** 完整封装。 */
|
||||
bytes: Bytes;
|
||||
}
|
||||
|
||||
/** 严格解析:版本、保留位、长度必须全部合法。与服务端 Envelope.TryParse 一致。 */
|
||||
export function parseEnvelope(data: Uint8Array): Envelope {
|
||||
if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope');
|
||||
const flags = data[1] as number;
|
||||
if ((flags & 0xf0) !== 0) throw new IntegrityError('bad_envelope');
|
||||
const payloadLength = readU32(data, 22);
|
||||
if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope');
|
||||
|
||||
const bytes = data.slice();
|
||||
return {
|
||||
flags,
|
||||
itemId: bytes.slice(2, 2 + ID_SIZE),
|
||||
frameIndex: readU32(bytes, 18),
|
||||
payloadLength,
|
||||
header: bytes.slice(0, HEADER_SIZE),
|
||||
payload: bytes.slice(HEADER_SIZE),
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeEnvelope(flags: number, itemId: Uint8Array, frameIndex: number, payload: Uint8Array): Bytes {
|
||||
if (itemId.length !== ID_SIZE) throw new RangeError('item_id 必须是 16 字节');
|
||||
const header = concat(new Uint8Array([1, flags]), itemId, writeU32(frameIndex), writeU32(payload.length));
|
||||
return concat(header, payload);
|
||||
}
|
||||
|
||||
/** 流式解析时用:从缓冲区起点读出下一个封装的总长度,数据不够返回 null。 */
|
||||
export function peekEnvelopeLength(buf: Uint8Array, maxPayload: number): number | null {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
if (buf[0] !== 1) throw new IntegrityError('bad_envelope');
|
||||
const payloadLength = readU32(buf, 22);
|
||||
if (payloadLength > maxPayload) throw new IntegrityError('bad_envelope');
|
||||
return HEADER_SIZE + payloadLength;
|
||||
}
|
||||
|
||||
export const MAX_ENC_PAYLOAD = MAX_FRAME_PLAINTEXT + TAG_SIZE;
|
||||
|
||||
// ───────────────────────── 加解密(protocol §5) ─────────────────────────
|
||||
|
||||
/** IV = 8 字节零 ‖ frame_index(u32)。每个条目有独立的密钥,所以 frame_index 唯一即保证 IV 唯一。 */
|
||||
export function frameIv(frameIndex: number): Bytes {
|
||||
const iv = new Uint8Array(12);
|
||||
iv.set(writeU32(frameIndex), 8);
|
||||
return iv;
|
||||
}
|
||||
|
||||
/** AAD = "WD1" ‖ channel_id ‖ envelope[0..22),绑定频道、条目、序号和全部标志位。 */
|
||||
export function frameAad(channelId: Uint8Array, header: Uint8Array): Bytes {
|
||||
return concat(utf8(AAD_FRAME), channelId, header.subarray(0, 22));
|
||||
}
|
||||
|
||||
export interface SealParams {
|
||||
flags: number;
|
||||
itemId: Uint8Array;
|
||||
index: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密一帧并返回完整封装。
|
||||
* 注意:同一 (itemId, index) 只能加密一份固定明文,否则会造成 IV 重用。重试必须重发相同字节。
|
||||
*/
|
||||
export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise<Bytes> {
|
||||
if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志');
|
||||
if ((p.flags & 0xf0) !== 0) throw new RangeError('保留标志位必须为 0');
|
||||
|
||||
const plaintext = padPlaintext(p.data);
|
||||
const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE));
|
||||
|
||||
const cipher = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: frameIv(p.index), additionalData: frameAad(channelId, header), tagLength: 128 },
|
||||
itemKey,
|
||||
plaintext,
|
||||
);
|
||||
return concat(header, new Uint8Array(cipher));
|
||||
}
|
||||
|
||||
/** 认证并解密一帧,返回去掉 padding 的 data。任何失败都抛 IntegrityError。 */
|
||||
export async function openFrame(itemKey: CryptoKey, channelId: Uint8Array, env: Envelope): Promise<Bytes> {
|
||||
if ((env.flags & Flags.Enc) === 0) throw new IntegrityError('wrong_mode');
|
||||
if (env.payloadLength < TAG_SIZE) throw new IntegrityError('bad_envelope');
|
||||
|
||||
let plain: Bytes;
|
||||
try {
|
||||
plain = new Uint8Array(
|
||||
await globalThis.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: frameIv(env.frameIndex), additionalData: frameAad(channelId, env.header), tagLength: 128 },
|
||||
itemKey,
|
||||
env.payload,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
throw new IntegrityError('auth_failed');
|
||||
}
|
||||
return unpadPlaintext(plain);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user