585 lines
24 KiB
TypeScript
585 lines
24 KiB
TypeScript
/**
|
||||
|
|
* 内容面**组密钥加密**(覆盖网络线 序㉘ · 单 B)—— 让"中继看不到明文载荷"与
|
|||
|
|
* "按哈希共享块"**同时成立**。
|
|||
|
|
*
|
|||
|
|
* ## 一句话说清它是什么
|
|||
|
|
* 同一个「内容组」`(network, group)` 共享**一把对称密钥**(AES-256-GCM,与 `src/crypto.ts` 同源);
|
|||
|
|
* 加密是**确定性**的 ⇒ **同组 + 同明文 ⇒ 密文逐字节相同**(`md5` 相等)
|
|||
|
|
* ⇒ 序㉔ 的"块 id = 内容哈希"照旧成立(**组内共享不退化**),而中继只拿到不透明字节。
|
|||
|
|
*
|
|||
|
|
* ## 为什么必须"确定性"(⛔ 不是随便挑的加密模式)
|
|||
|
|
* 普通 GCM 每次随机 `iv` ⇒ 同明文两次密文不同 ⇒ **块 id 每次都变** ⇒ 去重与 peer 命中**全废**
|
|||
|
|
* (这正是序㉔ 主判据 `E1` 从 1.00× 退回 4.00× 的路径)。所以:
|
|||
|
|
* - `iv = HMAC(key, "iv"‖明文) 前 12 字节` —— **由明文决定,不留随机数**;
|
|||
|
|
* - `k = HMAC(key, "k"‖iv)` —— 与 `iv` 一一对应 ⇒ **解密侧能复算**(解密时只有密文,没有明文);
|
|||
|
|
* - `AAD = "<network>|<group>|<epoch>"` —— 把组与 epoch **绑进认证** ⇒ 跨组 / 跨 epoch 的密文
|
|||
|
|
* **在认证阶段就被拒**(⛔ 不靠"解出来是乱码"来判)。
|
|||
|
|
*
|
|||
|
|
* 🔑 **唯一的解密实现是本文件的 `decodeBlock`**。它有两个调用点、**同一批字节只过其中一处**:
|
|||
|
|
* ① `source.ts` 优先级链的统一返回点(**生产路径**:五档一律在这里解密 ⇒ ⛔ 不存在
|
|||
|
|
* "local 档不解密、peer 档解密"那种双口径);
|
|||
|
|
* ② `chunker.ts#reassemble` 的重组位(**夹具 / 工具路径**)。二者**不叠加**。
|
|||
|
|
*
|
|||
|
|
* ## 三条纪律(每条都对应本线踩过的病)
|
|||
|
|
* 1. **失败关闭且具名**:密钥文件缺失 / 权限不对 / 组名不配 / 密钥形状不对 / epoch 超窗口 ——
|
|||
|
|
* 五种情形各有**独立原因码**并落计数器。⛔ 绝不出现"以为加密了其实没加"。
|
|||
|
|
* 2. **计数即判据**:所有判别器都是可断言的数字(⛔ 不许只写日志 —— 探针 `OBS-23` 逐键断言)。
|
|||
|
|
* 3. **明文不出现**:`plainScans` / `plainLeaks` 是**字节级**自证 ——
|
|||
|
|
* 对自己刚加密出的字节扫明文标记,命中数必须 **0**;⛔ 记录 / 日志里也不许出现明文标记。
|
|||
|
|
*
|
|||
|
|
* ## ⛔ 本模块**不做**的事(故意)
|
|||
|
|
* - 不做网络、不做 IO 传输(密钥**只从 `0600` 文件读**,⛔ 不经网络、不经 relay —— 见 §7.2);
|
|||
|
|
* - 不引第二套算法栈(⛔ 无 ChaCha / 无 RSA 包裹);
|
|||
|
|
* - 不新根、不新签名链:组密钥凭据由**既有签名者**签发,验签走**既有** `identity.ts` 实现。
|
|||
|
|
*
|
|||
|
|
* @module dshs/net/relay/content/crypto
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { createCipheriv, createDecipheriv, createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|||
|
|
import { readFileSync, statSync } from 'node:fs'
|
|||
|
|
import { verifySignedPayload } from '../identity.js'
|
|||
|
|
import type { IdentityReason } from '../identity.js'
|
|||
|
|
|
|||
|
|
/** 组密钥凭据的载荷标签(规范拼接用;⛔ 不用 `JSON.stringify`)。 */
|
|||
|
|
export const GROUP_KEY_TAG = 'dshs-overlay-groupkey/v1'
|
|||
|
|
|
|||
|
|
/** 组密钥文件的缺省落点(与节点密钥同处 `/etc/dshs/`,同样 `0600`)。 */
|
|||
|
|
export const DEFAULT_GROUP_KEY_FILE = '/etc/dshs/content-group-key.json'
|
|||
|
|
|
|||
|
|
/** 加密格式版本(进 AAD ⇒ 换代即全量失效,⛔ 不该随手改)。 */
|
|||
|
|
export const CONTENT_CIPHER_VERSION = 1
|
|||
|
|
|
|||
|
|
/** GCM 的 iv 长度(字节)。 */
|
|||
|
|
export const IV_LEN = 12
|
|||
|
|
/** GCM 认证标签长度(字节)。 */
|
|||
|
|
export const TAG_LEN = 16
|
|||
|
|
/** 密文块的最小长度(`iv|tag` 各一段,再加至少 0 字节明文)。 */
|
|||
|
|
export const MIN_BLOB_LEN = IV_LEN + TAG_LEN
|
|||
|
|
/** 密钥长度(AES-256 ⇒ 32 字节)。 */
|
|||
|
|
export const KEY_LEN = 32
|
|||
|
|
|
|||
|
|
/** 组密钥的**可断言**判别器(探针 `OBS-23` 逐键断言 "存在且是 number")。 */
|
|||
|
|
export const CONTENT_CRYPTO_COUNTER_KEYS = [
|
|||
|
|
'encrypts',
|
|||
|
|
'decrypts',
|
|||
|
|
'decryptRejected',
|
|||
|
|
'epochs',
|
|||
|
|
'epoch',
|
|||
|
|
'epochExpired',
|
|||
|
|
'detChecks',
|
|||
|
|
'detMismatches',
|
|||
|
|
'plainScans',
|
|||
|
|
'plainLeaks',
|
|||
|
|
] as const
|
|||
|
|
|
|||
|
|
export type ContentCryptoCounterKey = (typeof CONTENT_CRYPTO_COUNTER_KEYS)[number]
|
|||
|
|
|
|||
|
|
export type ContentCryptoCounters = Record<ContentCryptoCounterKey, number>
|
|||
|
|
|
|||
|
|
/** 装载密钥文件失败的原因(**具名** —— ⛔ 不许合并成一句"不可用")。 */
|
|||
|
|
export type GroupKeyLoadReason =
|
|||
|
|
| 'no-file'
|
|||
|
|
| 'bad-perms'
|
|||
|
|
| 'parse-error'
|
|||
|
|
| 'group-mismatch'
|
|||
|
|
| 'bad-key'
|
|||
|
|
| 'bad-epoch'
|
|||
|
|
|
|||
|
|
/** 装载结果:要么拿到密钥,要么拿到**具名原因**。 */
|
|||
|
|
export type GroupKeyLoadResult =
|
|||
|
|
| { ok: true; group: string; epoch: number; epochs: number; keyId: string; permsChecked: boolean }
|
|||
|
|
| { ok: false; reason: GroupKeyLoadReason; detail: string }
|
|||
|
|
|
|||
|
|
/** 组密钥文件里的一条 epoch 记录。 */
|
|||
|
|
export interface GroupKeyEpochEntry {
|
|||
|
|
epoch: number
|
|||
|
|
/** base64(32 字节)。 */
|
|||
|
|
key: string
|
|||
|
|
/** 该 epoch 的**停止服役时刻**(ISO)。给了才参与"过渡窗口上界"判定。 */
|
|||
|
|
retiredAt?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 组密钥文件形状(`0600`)。
|
|||
|
|
*
|
|||
|
|
* ```json
|
|||
|
|
* { "version": 1, "group": "relay", "epoch": 2, "key": "<base64 32B>",
|
|||
|
|
* "previous": [{ "epoch": 1, "key": "<base64 32B>", "retiredAt": "2026-09-18T00:00:00.000Z" }] }
|
|||
|
|
* ```
|
|||
|
|
*
|
|||
|
|
* ⚠️ `previous` 只**解不写**(S5 的双 epoch 过渡态);`epoch` = 当前**写入**用 epoch。
|
|||
|
|
*/
|
|||
|
|
export interface GroupKeyFile {
|
|||
|
|
version?: number
|
|||
|
|
/** **组名**(与运行时的 `group` 逐字比对)。 */
|
|||
|
|
group: string
|
|||
|
|
/** 可选的网名:给了就必须与运行时一致(防"同名不同网")。 */
|
|||
|
|
network?: string
|
|||
|
|
/** 当前写入 epoch。 */
|
|||
|
|
epoch: number
|
|||
|
|
/** 当前写入密钥(base64)。 */
|
|||
|
|
key: string
|
|||
|
|
/** 仅解不写的历史 epoch。 */
|
|||
|
|
previous?: GroupKeyEpochEntry[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 组密钥凭据(签名者签发;**载荷内不含密钥本体**)。 */
|
|||
|
|
export interface GroupKeyCredential {
|
|||
|
|
version?: number
|
|||
|
|
network: string
|
|||
|
|
group: string
|
|||
|
|
epoch: number
|
|||
|
|
/** 密钥指纹 = `sha256(key)` 前 16 hex(⛔ 不是密钥本身)。 */
|
|||
|
|
keyId: string
|
|||
|
|
issuedAt: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 密钥指纹(可公开)—— 组密钥文件与凭据用它对齐"是不是同一把"。 */
|
|||
|
|
export function keyIdOf(key: Buffer): string {
|
|||
|
|
return createHash('sha256').update(key).digest('hex').slice(0, 16)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 凭据:规范载荷 + 解析 + 验签(**复用既有验签实现**,⛔ 不新写第二份) ──────────
|
|||
|
|
|
|||
|
|
/** 规范拼接(字段顺序**写死**;⛔ 改顺序 = 令所有既有签名失效)。 */
|
|||
|
|
export function groupKeyCredentialPayload(doc: GroupKeyCredential): string {
|
|||
|
|
return [
|
|||
|
|
GROUP_KEY_TAG,
|
|||
|
|
`version=${String(doc.version ?? CONTENT_CIPHER_VERSION)}`,
|
|||
|
|
`network=${doc.network}`,
|
|||
|
|
`group=${doc.group}`,
|
|||
|
|
`epoch=${String(doc.epoch)}`,
|
|||
|
|
`keyId=${doc.keyId}`,
|
|||
|
|
`issuedAt=${doc.issuedAt}`,
|
|||
|
|
].join('\n')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 解析(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */
|
|||
|
|
export function parseGroupKeyCredential(raw: unknown): GroupKeyCredential | undefined {
|
|||
|
|
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
|
|||
|
|
const r = raw as Record<string, unknown>
|
|||
|
|
const network = typeof r.network === 'string' ? r.network.trim() : ''
|
|||
|
|
const group = typeof r.group === 'string' ? r.group.trim() : ''
|
|||
|
|
const keyId = typeof r.keyId === 'string' ? r.keyId.trim() : ''
|
|||
|
|
const issuedAt = typeof r.issuedAt === 'string' ? r.issuedAt.trim() : ''
|
|||
|
|
const epoch = typeof r.epoch === 'number' ? r.epoch : NaN
|
|||
|
|
if (network === '' || group === '' || keyId === '' || issuedAt === '') return undefined
|
|||
|
|
if (!Number.isInteger(epoch) || epoch <= 0) return undefined
|
|||
|
|
const version = typeof r.version === 'number' ? r.version : CONTENT_CIPHER_VERSION
|
|||
|
|
return { version, network, group, epoch, keyId, issuedAt }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 用**既有受信签名者**验一份组密钥凭据。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 与 `identity.ts` 的四条判据同源:**不可验 = 不接受**(受信签名者为空 ⇒ 拒绝)。
|
|||
|
|
*/
|
|||
|
|
export function verifyGroupKeyCredential(
|
|||
|
|
doc: unknown,
|
|||
|
|
sig: unknown,
|
|||
|
|
trustedSigners: readonly string[],
|
|||
|
|
): { ok: true; doc: GroupKeyCredential } | { ok: false; reason: IdentityReason | 'bad-payload' } {
|
|||
|
|
const parsed = parseGroupKeyCredential(doc)
|
|||
|
|
if (parsed === undefined) return { ok: false, reason: 'bad-payload' }
|
|||
|
|
const verdict = verifySignedPayload(groupKeyCredentialPayload(parsed), sig, trustedSigners)
|
|||
|
|
if (verdict !== 'ok') return { ok: false, reason: verdict }
|
|||
|
|
return { ok: true, doc: parsed }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 密钥文件装载 ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/** base64 → 32 字节(形状不对 ⇒ `undefined`)。 */
|
|||
|
|
function decodeKey(b64: unknown): Buffer | undefined {
|
|||
|
|
if (typeof b64 !== 'string' || b64.trim() === '') return undefined
|
|||
|
|
let buf: Buffer
|
|||
|
|
try {
|
|||
|
|
buf = Buffer.from(b64.trim(), 'base64')
|
|||
|
|
} catch {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
return buf.length === KEY_LEN ? buf : undefined
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 装载选项。 */
|
|||
|
|
export interface LoadGroupKeyOptions {
|
|||
|
|
/** 密钥文件路径。 */
|
|||
|
|
file: string
|
|||
|
|
/** 运行时组名(必须与文件里的 `group` 一致)。 */
|
|||
|
|
group: string
|
|||
|
|
/** 运行时网名(文件里给了 `network` 时必须一致)。 */
|
|||
|
|
network?: string
|
|||
|
|
/**
|
|||
|
|
* 是否强制 `0600` 判定。缺省 = **`process.platform !== 'win32'`**。
|
|||
|
|
*
|
|||
|
|
* 🔴 **为什么必须按平台分**(本地实测):Windows **没有 POSIX 权限位** ——
|
|||
|
|
* `writeFileSync(p, x, {mode: 0o600})` 之后 `statSync(p).mode & 0o777` 恒为 `666`
|
|||
|
|
* ⇒ 无条件判会**把每个文件都判成 `bad-perms`**、加密**永远开不起来**。
|
|||
|
|
* 而生产(47 / 106)是 Linux ⇒ 判据在那里**必须**成立。
|
|||
|
|
* ⚠️ 本选项同时是"判据有牙"的证明位(单测用它在本机复现 `bad-perms`)。
|
|||
|
|
*/
|
|||
|
|
enforcePerms?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从 `0600` 文件装载组密钥。**任一不符 ⇒ 具名失败**(⛔ 静默降级成明文)。
|
|||
|
|
*
|
|||
|
|
* 顺序刻意如此:**先看文件在不在 → 再看权限 → 再解析 → 再比对组 / 网 → 再验密钥形状**。
|
|||
|
|
* 这样"没装"与"装了但配错"在日志里是**两种**原因(本线反复要求的可区分性)。
|
|||
|
|
*/
|
|||
|
|
export function loadGroupKeyFile(opts: LoadGroupKeyOptions): GroupKeyLoadResult {
|
|||
|
|
let st
|
|||
|
|
try {
|
|||
|
|
st = statSync(opts.file)
|
|||
|
|
} catch {
|
|||
|
|
return { ok: false, reason: 'no-file', detail: `${opts.file} 不存在或读不到` }
|
|||
|
|
}
|
|||
|
|
// 0600 口径:只要 group/other 有任一权限位 ⇒ 判权限错(⛔ 不"自动 chmod"——那会掩盖部署缺陷)
|
|||
|
|
// ⚠️ POSIX-only:Windows 的 mode 恒 666(无权限位语义)⇒ 默认在那里跳过判定(`permsChecked=false`)
|
|||
|
|
const enforcePerms = opts.enforcePerms ?? process.platform !== 'win32'
|
|||
|
|
if (enforcePerms && (st.mode & 0o077) !== 0) {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
reason: 'bad-perms',
|
|||
|
|
detail: `${opts.file} 权限 ${(st.mode & 0o777).toString(8)}(必须 0600)`,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let parsed: GroupKeyFile
|
|||
|
|
try {
|
|||
|
|
parsed = JSON.parse(readFileSync(opts.file, 'utf8')) as GroupKeyFile
|
|||
|
|
} catch (err) {
|
|||
|
|
return { ok: false, reason: 'parse-error', detail: String(err) }
|
|||
|
|
}
|
|||
|
|
if (parsed === null || typeof parsed !== 'object') {
|
|||
|
|
return { ok: false, reason: 'parse-error', detail: '顶层不是对象' }
|
|||
|
|
}
|
|||
|
|
if (parsed.group !== opts.group) {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
reason: 'group-mismatch',
|
|||
|
|
detail: `文件 group=${String(parsed.group)} ≠ 运行时 group=${opts.group}`,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (parsed.network !== undefined && opts.network !== undefined && parsed.network !== opts.network) {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
reason: 'group-mismatch',
|
|||
|
|
detail: `文件 network=${String(parsed.network)} ≠ 运行时 network=${opts.network}`,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const write = decodeKey(parsed.key)
|
|||
|
|
if (write === undefined) {
|
|||
|
|
return { ok: false, reason: 'bad-key', detail: 'key 不是 base64 的 32 字节' }
|
|||
|
|
}
|
|||
|
|
if (!Number.isInteger(parsed.epoch) || parsed.epoch <= 0) {
|
|||
|
|
return { ok: false, reason: 'bad-epoch', detail: `epoch=${String(parsed.epoch)}(必须是正整数)` }
|
|||
|
|
}
|
|||
|
|
let extra = 0
|
|||
|
|
for (const p of parsed.previous ?? []) {
|
|||
|
|
if (decodeKey(p?.key) === undefined) {
|
|||
|
|
return { ok: false, reason: 'bad-key', detail: `previous epoch=${String(p?.epoch)} 的 key 形状不对` }
|
|||
|
|
}
|
|||
|
|
if (!Number.isInteger(p.epoch) || p.epoch <= 0) {
|
|||
|
|
return { ok: false, reason: 'bad-epoch', detail: `previous epoch=${String(p?.epoch)} 必须是正整数` }
|
|||
|
|
}
|
|||
|
|
extra += 1
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
ok: true,
|
|||
|
|
group: parsed.group,
|
|||
|
|
epoch: parsed.epoch,
|
|||
|
|
epochs: 1 + extra,
|
|||
|
|
keyId: keyIdOf(write),
|
|||
|
|
permsChecked: enforcePerms,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 装载后的可读回确认(供装配点打一行**不含密钥**的判别器日志)。 */
|
|||
|
|
export function describeGroupKey(file: string, r: GroupKeyLoadResult): string {
|
|||
|
|
return r.ok
|
|||
|
|
? `[content-crypto] 组密钥已装载 file=${file} group=${r.group} epoch=${r.epoch} epochs=${r.epochs} keyId=${r.keyId}` +
|
|||
|
|
(r.permsChecked ? ' perms=0600 ✓' : ' perms=未判定(Windows 无 POSIX 权限位)')
|
|||
|
|
: `[content-crypto] ⛔ 不启用加密:${r.reason} —— ${r.detail}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 加解密 ────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/** `ContentCipher` 构造选项。 */
|
|||
|
|
export interface ContentCipherOptions {
|
|||
|
|
/** 组键(`` `${network}|${group}` ``)—— 进 AAD,⛔ 不许跨组复用同一把钥。 */
|
|||
|
|
groupKey: string
|
|||
|
|
/** 当前**写入** epoch 与它的密钥。 */
|
|||
|
|
epoch: number
|
|||
|
|
key: Buffer
|
|||
|
|
/** 仅解不写的历史 epoch(S5 双 epoch 过渡态)。 */
|
|||
|
|
previous?: readonly { epoch: number; key: Buffer; retiredAt?: string }[]
|
|||
|
|
/**
|
|||
|
|
* 过渡窗口上界(毫秒)。`retiredAt + graceMs < now` ⇒ 该历史 epoch **不再解**
|
|||
|
|
* (⛔ `decryptRejected` + `epochExpired` 各 +1,并**点名** epoch)。
|
|||
|
|
* 缺省 **24 h**(`CONTENT_EPOCH_GRACE_MS`)。
|
|||
|
|
*/
|
|||
|
|
graceMs?: number
|
|||
|
|
/** 日志函数(⛔ 不许把明文塞进来)。 */
|
|||
|
|
log?: (line: string) => void
|
|||
|
|
/** 时钟注入(单测用)。 */
|
|||
|
|
now?: () => number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 缺省过渡窗口:24 h(参数表 `CONTENT_EPOCH_GRACE_MS`)。 */
|
|||
|
|
export const DEFAULT_EPOCH_GRACE_MS = 24 * 60 * 60 * 1000
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 组密钥加解密器。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 生命周期:无 IO、无网络、无监听 —— 构造与使用都只碰内存 ⇒ 不触 R5。
|
|||
|
|
*/
|
|||
|
|
export class ContentCipher {
|
|||
|
|
/** 组键(含网名 —— "同名不同网"必须不同组)。 */
|
|||
|
|
readonly groupKey: string
|
|||
|
|
/** 当前写入 epoch。 */
|
|||
|
|
readonly epoch: number
|
|||
|
|
|
|||
|
|
private readonly key: Buffer
|
|||
|
|
private readonly previous: { epoch: number; key: Buffer; retiredAt?: string }[]
|
|||
|
|
private readonly graceMs: number
|
|||
|
|
private readonly log: (line: string) => void
|
|||
|
|
private readonly now: () => number
|
|||
|
|
private readonly c: ContentCryptoCounters = {
|
|||
|
|
encrypts: 0,
|
|||
|
|
decrypts: 0,
|
|||
|
|
decryptRejected: 0,
|
|||
|
|
epochs: 0,
|
|||
|
|
epoch: 0,
|
|||
|
|
epochExpired: 0,
|
|||
|
|
detChecks: 0,
|
|||
|
|
detMismatches: 0,
|
|||
|
|
plainScans: 0,
|
|||
|
|
plainLeaks: 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
constructor(opts: ContentCipherOptions) {
|
|||
|
|
if (opts.key.length !== KEY_LEN) {
|
|||
|
|
throw new Error(`content-crypto: 密钥必须是 ${KEY_LEN} 字节,收到 ${opts.key.length}`)
|
|||
|
|
}
|
|||
|
|
this.key = Buffer.from(opts.key)
|
|||
|
|
this.epoch = opts.epoch
|
|||
|
|
this.groupKey = opts.groupKey
|
|||
|
|
this.previous = (opts.previous ?? []).map((p) => ({
|
|||
|
|
epoch: p.epoch,
|
|||
|
|
key: Buffer.from(p.key),
|
|||
|
|
...(p.retiredAt === undefined ? {} : { retiredAt: p.retiredAt }),
|
|||
|
|
}))
|
|||
|
|
this.graceMs = opts.graceMs ?? DEFAULT_EPOCH_GRACE_MS
|
|||
|
|
this.log = opts.log ?? (() => {})
|
|||
|
|
this.now = opts.now ?? (() => Date.now())
|
|||
|
|
this.c.epochs = 1 + this.previous.length
|
|||
|
|
this.c.epoch = opts.epoch
|
|||
|
|
// 双 epoch ⇒ 明确留一行(轮换是有代价的动作,⛔ 不许静默发生)
|
|||
|
|
if (this.previous.length > 0) {
|
|||
|
|
this.log(
|
|||
|
|
`[content-crypto] 过渡态:写入 epoch=${this.epoch},仅解 epoch=[${this.previous.map((p) => p.epoch).join(',')}]` +
|
|||
|
|
`(窗口 ${this.graceMs} ms)`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 判别器快照(**拷贝**)。 */
|
|||
|
|
counters(): ContentCryptoCounters {
|
|||
|
|
return { ...this.c }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 当前可解的 epoch 列表(写入在前)。 */
|
|||
|
|
epochs(): number[] {
|
|||
|
|
return [this.epoch, ...this.previous.map((p) => p.epoch)]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 组密钥指纹(可公开比对;⛔ 不是密钥)。 */
|
|||
|
|
keyId(): string {
|
|||
|
|
return keyIdOf(this.key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** AAD = `<groupKey>|<epoch>` —— 把"组"与"代"绑进认证。 */
|
|||
|
|
private aadOf(epoch: number): Buffer {
|
|||
|
|
return Buffer.from(`${this.groupKey}|${epoch}`, 'utf8')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** `iv = HMAC(key,"iv"‖plain)` 前 12 字节(**确定性**:同明文同 iv)。 */
|
|||
|
|
private ivOf(key: Buffer, plain: Buffer): Buffer {
|
|||
|
|
return createHmac('sha256', key).update('iv').update(plain).digest().subarray(0, IV_LEN)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** `k = HMAC(key,"k"‖iv)` —— 解密侧只有 iv,故 k **必须**只由 iv 决定。 */
|
|||
|
|
private keyOf(key: Buffer, iv: Buffer): Buffer {
|
|||
|
|
return createHmac('sha256', key).update('k').update(iv).digest()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* **确定性**加密一个块。形状 = `iv ‖ tag ‖ 密文`。
|
|||
|
|
*
|
|||
|
|
* 三条不变量(单测锁住):
|
|||
|
|
* 1. 同组 + 同明文 **两次** ⇒ 返回**逐字节相同**(⇒ 块 id 稳定 ⇒ 共享不退化);
|
|||
|
|
* 2. 换 epoch ⇒ 密文**不同**(AAD 与密钥双变);
|
|||
|
|
* 3. 换组密钥 ⇒ 密文**不同**。
|
|||
|
|
*/
|
|||
|
|
encryptBlock(plain: Buffer): Buffer {
|
|||
|
|
const iv = this.ivOf(this.key, plain)
|
|||
|
|
const k = this.keyOf(this.key, iv)
|
|||
|
|
const c = createCipheriv('aes-256-gcm', k, iv)
|
|||
|
|
c.setAAD(this.aadOf(this.epoch))
|
|||
|
|
const ct = Buffer.concat([c.update(plain), c.final()])
|
|||
|
|
const tag = c.getAuthTag()
|
|||
|
|
this.c.encrypts += 1
|
|||
|
|
return Buffer.concat([iv, tag, ct])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 解密一个块。**唯一的解密实现**(调用点见文件头)。
|
|||
|
|
*
|
|||
|
|
* 判定顺序(每一步失败都有**独立**线索,⛔ 不合并):
|
|||
|
|
* ① 形状(太短 ⇒ `too-short`);② 逐 epoch 试认证(写 epoch 优先);
|
|||
|
|
* ③ 解出来的明文**复算 iv 必须等于原 iv**(确定性口径自证 + 非规范输入拦截)。
|
|||
|
|
*
|
|||
|
|
* @returns 明文;失败 ⇒ `undefined`(并 `decryptRejected` +1,原因落日志)
|
|||
|
|
*/
|
|||
|
|
decodeBlock(blob: Buffer): Buffer | undefined {
|
|||
|
|
if (blob.length < MIN_BLOB_LEN) {
|
|||
|
|
this.reject('too-short', `长度 ${blob.length} < ${MIN_BLOB_LEN}`)
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
const iv = blob.subarray(0, IV_LEN)
|
|||
|
|
const tag = blob.subarray(IV_LEN, MIN_BLOB_LEN)
|
|||
|
|
const ct = blob.subarray(MIN_BLOB_LEN)
|
|||
|
|
const tried: number[] = []
|
|||
|
|
for (const cand of this.candidates()) {
|
|||
|
|
tried.push(cand.epoch)
|
|||
|
|
let plain: Buffer
|
|||
|
|
try {
|
|||
|
|
const d = createDecipheriv('aes-256-gcm', this.keyOf(cand.key, iv), iv)
|
|||
|
|
d.setAAD(this.aadOf(cand.epoch))
|
|||
|
|
d.setAuthTag(tag)
|
|||
|
|
plain = Buffer.concat([d.update(ct), d.final()])
|
|||
|
|
} catch {
|
|||
|
|
continue // 认证失败(也可能是 epoch 不对)⇒ 试下一个
|
|||
|
|
}
|
|||
|
|
const again = this.ivOf(cand.key, plain)
|
|||
|
|
if (!timingSafeEqual(again, iv)) {
|
|||
|
|
this.reject('non-canonical', `epoch=${cand.epoch} 复算 iv 不符(非本实现产出?)`)
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
this.c.decrypts += 1
|
|||
|
|
return plain
|
|||
|
|
}
|
|||
|
|
this.reject('auth-failed', `认证失败(试过 epoch=[${tried.join(',')}])`)
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 按"过期窗口"过筛后的候选(写入 epoch 恒在;历史 epoch 超窗口即剔除并计数)。 */
|
|||
|
|
private candidates(): { epoch: number; key: Buffer }[] {
|
|||
|
|
const out: { epoch: number; key: Buffer }[] = [{ epoch: this.epoch, key: this.key }]
|
|||
|
|
const now = this.now()
|
|||
|
|
for (const p of this.previous) {
|
|||
|
|
if (p.retiredAt !== undefined) {
|
|||
|
|
const retired = Date.parse(p.retiredAt)
|
|||
|
|
if (Number.isFinite(retired) && retired + this.graceMs < now) {
|
|||
|
|
this.c.epochExpired += 1
|
|||
|
|
this.log(
|
|||
|
|
`[content-crypto] ⛔ epoch=${p.epoch} 超出过渡窗口(retiredAt=${p.retiredAt} + ${this.graceMs} ms < now)⇒ 不再解`,
|
|||
|
|
)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out.push({ epoch: p.epoch, key: p.key })
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private reject(reason: string, detail: string): void {
|
|||
|
|
this.c.decryptRejected += 1
|
|||
|
|
this.log(`[content-crypto] ⛔ 解密被拒:${reason} —— ${detail}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* **字节级明文外泄自证**(`OBS-23` 判据③的落点)。
|
|||
|
|
*
|
|||
|
|
* 对给定的"已落地字节"扫一段**已知明文标记**:命中数必须为 **0**。
|
|||
|
|
* ⛔ 本函数**只报计数、不打印字节**(打印就等于把明文写进日志 —— 自证变自毁)。
|
|||
|
|
*/
|
|||
|
|
scanForPlaintext(bytes: Buffer, marker: string): boolean {
|
|||
|
|
this.c.plainScans += 1
|
|||
|
|
if (marker === '') return false
|
|||
|
|
const hit = bytes.includes(Buffer.from(marker, 'utf8'))
|
|||
|
|
if (hit) {
|
|||
|
|
this.c.plainLeaks += 1
|
|||
|
|
this.log('[content-crypto] ⛔ 明文标记出现在已落地字节里(长度 ' + String(bytes.length) + ' B)')
|
|||
|
|
}
|
|||
|
|
return hit
|
|||
|
|
}
|
|||
|
|
/**
|
|||
|
|
* **启动自证**(防"装了但一次都没走过"):
|
|||
|
|
* ① 同一明文加密两次 ⇒ 比 `md5`(`detChecks` / `detMismatches`);
|
|||
|
|
* ② 加密 → 落存储 → 取回 → 解密 ⇒ 与明文逐字节相同(`decrypts` 兜底);
|
|||
|
|
* ③ 对密文做明文标记扫描(`plainScans` / `plainLeaks`)。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 判据全落**计数器**(探针读得到);返回值只给调用方决定要不要打一行日志。
|
|||
|
|
*/
|
|||
|
|
selfProbe(marker: string, sink: { put: (blob: Buffer) => void; get: () => Buffer | undefined }): boolean {
|
|||
|
|
const plain = Buffer.from(marker, 'utf8')
|
|||
|
|
const a = this.encryptBlock(plain)
|
|||
|
|
const b = this.encryptBlock(plain)
|
|||
|
|
this.c.detChecks += 1
|
|||
|
|
const same = a.equals(b)
|
|||
|
|
if (!same) this.c.detMismatches += 1
|
|||
|
|
this.scanForPlaintext(a, marker)
|
|||
|
|
sink.put(a)
|
|||
|
|
const back = sink.get()
|
|||
|
|
const roundtrip = back !== undefined && this.decodeBlock(back)?.equals(plain) === true
|
|||
|
|
if (!same) this.log('[content-crypto] ⛔ 确定性自证失败:同明文两次密文不同(共享会退化)')
|
|||
|
|
if (!roundtrip) this.log('[content-crypto] ⛔ 取回-解密自证失败(存储或解密链路有问题)')
|
|||
|
|
return same && roundtrip
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 一步到位:读文件 → 造 cipher(供装配点用)。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 刻意**不吞**具名原因:调用方拿到 `{ cipher: undefined, reason }` 后**必须**打一行
|
|||
|
|
* 判别器日志(⛔ 静默"不启用"= 用户以为加密了)。
|
|||
|
|
*/
|
|||
|
|
export function openContentCipher(opts: {
|
|||
|
|
file: string
|
|||
|
|
group: string
|
|||
|
|
network: string
|
|||
|
|
graceMs?: number
|
|||
|
|
log?: (line: string) => void
|
|||
|
|
now?: () => number
|
|||
|
|
previous?: readonly { epoch: number; key: Buffer; retiredAt?: string }[]
|
|||
|
|
}): { cipher?: ContentCipher; load: GroupKeyLoadResult } {
|
|||
|
|
const load = loadGroupKeyFile({ file: opts.file, group: opts.group, network: opts.network })
|
|||
|
|
if (!load.ok) {
|
|||
|
|
opts.log?.(describeGroupKey(opts.file, load))
|
|||
|
|
return { load }
|
|||
|
|
}
|
|||
|
|
// 密钥本体**只从文件读**(⛔ 不经网络):这里重新读一次以拿到 base64 → Buffer。
|
|||
|
|
const parsed = JSON.parse(readFileSync(opts.file, 'utf8')) as GroupKeyFile
|
|||
|
|
const key = decodeKey(parsed.key)
|
|||
|
|
if (key === undefined) {
|
|||
|
|
const bad: GroupKeyLoadResult = { ok: false, reason: 'bad-key', detail: 'key 形状不对(二次读取)' }
|
|||
|
|
opts.log?.(describeGroupKey(opts.file, bad))
|
|||
|
|
return { load: bad }
|
|||
|
|
}
|
|||
|
|
const previous = (parsed.previous ?? []).map((p) => ({
|
|||
|
|
epoch: p.epoch,
|
|||
|
|
key: decodeKey(p.key) as Buffer,
|
|||
|
|
...(p.retiredAt === undefined ? {} : { retiredAt: p.retiredAt }),
|
|||
|
|
}))
|
|||
|
|
const cipher = new ContentCipher({
|
|||
|
|
groupKey: `${opts.network}|${opts.group}`,
|
|||
|
|
epoch: load.epoch,
|
|||
|
|
key,
|
|||
|
|
previous,
|
|||
|
|
...(opts.graceMs === undefined ? {} : { graceMs: opts.graceMs }),
|
|||
|
|
...(opts.log === undefined ? {} : { log: opts.log }),
|
|||
|
|
...(opts.now === undefined ? {} : { now: opts.now }),
|
|||
|
|
})
|
|||
|
|
opts.log?.(describeGroupKey(opts.file, load))
|
|||
|
|
return { cipher, load }
|
|||
|
|
}
|