import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js'; import { fromBase64Url, toBase64Url, type Bytes } from './bytes.js'; import { LinkError } from './errors.js'; /** * 频道链接(protocol §2): * enc 所有者 /c/{id}#k=&a= 成员 /c/{id}#k= * raw 所有者 /c/{id}#a=[&w=] 成员 /c/{id}[#w=] * * 密钥只放在 fragment 里,浏览器不会把它发给服务器。降级防御:带 k 就是 enc,否则是 raw。 */ export interface ParsedLink { /** 频道 id 的 base64url 文本。 */ id: string; idBytes: Bytes; /** 带 k 即 enc,否则 raw。客户端必须据此核对服务端报告的模式。 */ mode: ChannelMode; role: 'owner' | 'member'; /** 频道密钥,仅 enc。 */ k?: Bytes; /** 管理密钥,仅所有者链接。 */ a?: Bytes; /** raw 模式成员的写密钥。 */ w?: Bytes; } const PATH = /(?:^|\/)c\/([A-Za-z0-9_-]{22})\/?$/; export function parseLink(input: string | URL): ParsedLink { let url: URL; try { url = typeof input === 'string' ? new URL(input) : input; } catch { throw new LinkError('不是合法的链接'); } const match = PATH.exec(url.pathname); if (!match) throw new LinkError('链接里没有频道 id'); const id = match[1] as string; const idBytes = fromBase64Url(id, ID_SIZE); if (!idBytes) throw new LinkError('频道 id 不合法'); // 密钥放在查询串里会随请求发给服务器,等于已经泄露,必须拒绝而不是悄悄当成 raw 链接 for (const name of ['k', 'a', 'w']) { if (url.searchParams.has(name)) throw new LinkError('密钥必须放在 # 后面,放在 ? 后面会被发送给服务器'); } const params = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash); const key = (name: string): Bytes | undefined => { const v = params.get(name); if (v === null) return undefined; const bytes = fromBase64Url(v, KEY_SIZE); if (!bytes) throw new LinkError(`链接里的 ${name} 不合法`); return bytes; }; const k = key('k'); const a = key('a'); const w = key('w'); // enc 频道的写令牌由 K 派生,出现 w 说明链接被拼错或被篡改 if (k && w) throw new LinkError('enc 链接不应包含 w'); const link: ParsedLink = { id, idBytes, mode: k ? 'enc' : 'raw', role: a ? 'owner' : 'member', }; if (k) link.k = k; if (a) link.a = a; if (w) link.w = w; return link; } export interface LinkParts { id: string; k?: Uint8Array; a?: Uint8Array; w?: Uint8Array; } /** 生成链接。origin 如 "https://app.example.com",路径固定为 /c/{id}。 */ export function buildLink(origin: string, parts: LinkParts): string { const params: string[] = []; if (parts.k) params.push(`k=${toBase64Url(parts.k)}`); if (parts.a) params.push(`a=${toBase64Url(parts.a)}`); if (parts.w) params.push(`w=${toBase64Url(parts.w)}`); const base = `${origin.replace(/\/+$/, '')}/c/${parts.id}`; return params.length > 0 ? `${base}#${params.join('&')}` : base; }