Files
dsh_ai1net_server/src/net/relay/content/crypto.ts
T
admin 09ce76f3af feat(overlay): 内容块级寻址 + 实例逐步拉起 + 骨干选路 + 组密钥加密(序24–㉛ 累积同步)
代码
- 内容分发块级寻址:新增 src/net/relay/content/{chunker,store,runtime,source,peer,crypto}.ts
- 组密钥(C 档)确定性加密:AES-256-GCM,块 id β′ = sha256(密文) 前 32 hex;双 epoch 过渡窗口
- 实例生命周期:三处 teardown() 不再杀实例(local/remote/leased-spawner);启动认领 + TCP 探活判孤儿
- 骨干选路:jitter 选路 + endpoint-target;relay client/server/wire/identity/directory/rendezvous/switcher 调整
- 工作台 src/web/server.ts、src/worker/relay-tunnel.ts 装配与候选链观测

脚本与测试
- scripts/overlay-{probe,keyring,jitter}.cjs 更新
- 探针新增 OBS-21(每连接候选数)/ OBS-22(teardown 静态守卫 + 认领面)/ OBS-23(组密钥加密)
- 新增 test/{orchestrator-teardown,orchestrator-rehydrate,overlay-content,overlay-jitter}.test.mjs;relay 两例更新

文档
- 新增交接单:覆盖网络-序24-内容分发块级寻址 / 序25-实例逐步拉起 / 序26-骨干稳定选路与加密
- INDEX.md、交接单/README.md、skills/dsh-auto-handoff-chain/SKILL.md 同步

验收(零回归,2026-09-18 08:0x 复核)
- npm test           201 tests / 200 pass / 0 fail / 1 skipped
- overlay-failover-drill --scene all --table   12 PASS / 0 SKIP / 0 FAIL
- overlay-probe --table                        23 PASS / 0 SKIP / 0 FAIL (rc=0)
2026-09-18 08:08:51 +08:00

586 lines
24 KiB
TypeScript
Raw 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.
/**
* 内容面**组密钥加密**(覆盖网络线 序㉘ · 单 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 }
}