209 lines
7.3 KiB
JavaScript
209 lines
7.3 KiB
JavaScript
|
|
/*
|
||
|
|
* ════════════════════════════════════════════════════════════════
|
||
|
|
* Vault · AES-256-GCM 文件落盘加密
|
||
|
|
* Sovereign: TCS-0002∞ · ICE-GL∞ · 国作登字-2026-A-00037559
|
||
|
|
* 守护: 铸渊 · ICE-GL-ZY001
|
||
|
|
* ════════════════════════════════════════════════════════════════
|
||
|
|
*
|
||
|
|
* 设计 (cc-004 系统自管 + cc-003 不让冰朔记):
|
||
|
|
* 主密钥 .master:
|
||
|
|
* - 首次启动随机生成 32 字节 (256 位)
|
||
|
|
* - 落到 VAULT_DIR/.master, chmod 600, 仅 portal 用户可读
|
||
|
|
* - **不上 git, 不上 COS, 不传冰朔**
|
||
|
|
* - 真要换机器: 先 rsync 这个文件再起 vault, 否则 vault.enc 解不开
|
||
|
|
*
|
||
|
|
* vault.enc 文件格式 (单文件全量重写 · 2C2G 上 secrets 量级 < 1KB):
|
||
|
|
* {
|
||
|
|
* "_sovereign": "...",
|
||
|
|
* "version": 1,
|
||
|
|
* "algo": "aes-256-gcm",
|
||
|
|
* "iv": "<24 hex chars (12 bytes)>",
|
||
|
|
* "tag": "<32 hex chars (16 bytes)>",
|
||
|
|
* "ciphertext": "<base64 plaintext JSON>"
|
||
|
|
* }
|
||
|
|
*
|
||
|
|
* plaintext JSON:
|
||
|
|
* { "secrets": { "ZY_AUTODL_HOST": "...", ... }, "updated_at": "..." }
|
||
|
|
*/
|
||
|
|
"use strict";
|
||
|
|
|
||
|
|
const fs = require("fs");
|
||
|
|
const path = require("path");
|
||
|
|
const crypto = require("crypto");
|
||
|
|
|
||
|
|
const VAULT_VERSION = 1;
|
||
|
|
const ALGO = "aes-256-gcm";
|
||
|
|
const KEY_LEN = 32; // 256 位
|
||
|
|
const IV_LEN = 12; // 96 位 (GCM 推荐)
|
||
|
|
const TAG_LEN = 16;
|
||
|
|
|
||
|
|
class Vault {
|
||
|
|
constructor(vaultDir) {
|
||
|
|
if (!vaultDir) throw new Error("vault: vaultDir 必填");
|
||
|
|
this.vaultDir = vaultDir;
|
||
|
|
this.masterPath = path.join(vaultDir, ".master");
|
||
|
|
this.encPath = path.join(vaultDir, "vault.enc");
|
||
|
|
this._key = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 主密钥 ─────────────────────────────────────────────────
|
||
|
|
loadOrCreateMaster() {
|
||
|
|
fs.mkdirSync(this.vaultDir, { recursive: true, mode: 0o700 });
|
||
|
|
if (fs.existsSync(this.masterPath)) {
|
||
|
|
const buf = fs.readFileSync(this.masterPath);
|
||
|
|
if (buf.length !== KEY_LEN) {
|
||
|
|
throw new Error(
|
||
|
|
`vault: .master 长度异常 ${buf.length} (期望 ${KEY_LEN}). 请人工介入.`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const st = fs.statSync(this.masterPath);
|
||
|
|
if ((st.mode & 0o077) !== 0) fs.chmodSync(this.masterPath, 0o600);
|
||
|
|
} catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
this._key = buf;
|
||
|
|
return { created: false };
|
||
|
|
}
|
||
|
|
const key = crypto.randomBytes(KEY_LEN);
|
||
|
|
const tmp = this.masterPath + ".tmp." + process.pid;
|
||
|
|
fs.writeFileSync(tmp, key, { mode: 0o600 });
|
||
|
|
try {
|
||
|
|
const fd = fs.openSync(tmp, "r+");
|
||
|
|
fs.fsyncSync(fd);
|
||
|
|
fs.closeSync(fd);
|
||
|
|
} catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
fs.renameSync(tmp, this.masterPath);
|
||
|
|
fs.chmodSync(this.masterPath, 0o600);
|
||
|
|
this._key = key;
|
||
|
|
return { created: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 加密 / 解密 ────────────────────────────────────────────
|
||
|
|
_encrypt(plaintextStr) {
|
||
|
|
if (!this._key) throw new Error("vault: 主密钥未加载");
|
||
|
|
const iv = crypto.randomBytes(IV_LEN);
|
||
|
|
const cipher = crypto.createCipheriv(ALGO, this._key, iv);
|
||
|
|
const ct = Buffer.concat([
|
||
|
|
cipher.update(plaintextStr, "utf8"),
|
||
|
|
cipher.final()
|
||
|
|
]);
|
||
|
|
const tag = cipher.getAuthTag();
|
||
|
|
return {
|
||
|
|
_sovereign: "TCS-0002∞ · 国作登字-2026-A-00037559",
|
||
|
|
version: VAULT_VERSION,
|
||
|
|
algo: ALGO,
|
||
|
|
iv: iv.toString("hex"),
|
||
|
|
tag: tag.toString("hex"),
|
||
|
|
ciphertext: ct.toString("base64")
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_decrypt(payload) {
|
||
|
|
if (!this._key) throw new Error("vault: 主密钥未加载");
|
||
|
|
if (!payload || payload.algo !== ALGO) {
|
||
|
|
throw new Error("vault: 加密载荷格式不识别 (可能版本不匹配或主密钥换过了)");
|
||
|
|
}
|
||
|
|
if (typeof payload.iv !== "string" || typeof payload.tag !== "string") {
|
||
|
|
throw new Error("vault: 加密载荷缺 iv/tag");
|
||
|
|
}
|
||
|
|
const iv = Buffer.from(payload.iv, "hex");
|
||
|
|
const tag = Buffer.from(payload.tag, "hex");
|
||
|
|
if (iv.length !== IV_LEN || tag.length !== TAG_LEN) {
|
||
|
|
throw new Error("vault: iv/tag 长度异常");
|
||
|
|
}
|
||
|
|
const ct = Buffer.from(payload.ciphertext, "base64");
|
||
|
|
const decipher = crypto.createDecipheriv(ALGO, this._key, iv);
|
||
|
|
decipher.setAuthTag(tag);
|
||
|
|
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
|
||
|
|
return pt.toString("utf8");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 读 / 写整库 ────────────────────────────────────────────
|
||
|
|
loadAll() {
|
||
|
|
if (!fs.existsSync(this.encPath)) {
|
||
|
|
return { secrets: {}, updated_at: null };
|
||
|
|
}
|
||
|
|
let raw;
|
||
|
|
try {
|
||
|
|
raw = JSON.parse(fs.readFileSync(this.encPath, "utf8"));
|
||
|
|
} catch (e) {
|
||
|
|
throw new Error("vault: vault.enc 不是合法 JSON: " + e.message);
|
||
|
|
}
|
||
|
|
const ptStr = this._decrypt(raw);
|
||
|
|
let pt;
|
||
|
|
try {
|
||
|
|
pt = JSON.parse(ptStr);
|
||
|
|
} catch (e) {
|
||
|
|
throw new Error("vault: 解密后明文不是合法 JSON: " + e.message);
|
||
|
|
}
|
||
|
|
if (!pt || typeof pt !== "object" || typeof pt.secrets !== "object") {
|
||
|
|
return { secrets: {}, updated_at: pt && pt.updated_at };
|
||
|
|
}
|
||
|
|
return { secrets: pt.secrets || {}, updated_at: pt.updated_at || null };
|
||
|
|
}
|
||
|
|
|
||
|
|
saveAll(state) {
|
||
|
|
const safe = {
|
||
|
|
secrets: (state && state.secrets) || {},
|
||
|
|
updated_at: new Date().toISOString()
|
||
|
|
};
|
||
|
|
const payload = this._encrypt(JSON.stringify(safe));
|
||
|
|
const text = JSON.stringify(payload, null, 2);
|
||
|
|
const tmp = this.encPath + ".tmp." + process.pid;
|
||
|
|
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
||
|
|
try {
|
||
|
|
const fd = fs.openSync(tmp, "r+");
|
||
|
|
fs.fsyncSync(fd);
|
||
|
|
fs.closeSync(fd);
|
||
|
|
} catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
fs.renameSync(tmp, this.encPath);
|
||
|
|
fs.chmodSync(this.encPath, 0o600);
|
||
|
|
return safe.updated_at;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 单条操作 ───────────────────────────────────────────────
|
||
|
|
get(key) {
|
||
|
|
const all = this.loadAll();
|
||
|
|
return Object.prototype.hasOwnProperty.call(all.secrets, key)
|
||
|
|
? all.secrets[key]
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
set(key, value) {
|
||
|
|
if (typeof key !== "string" || !key) throw new Error("vault: key 必须是非空字符串");
|
||
|
|
if (typeof value !== "string") throw new Error("vault: value 必须是字符串");
|
||
|
|
const all = this.loadAll();
|
||
|
|
all.secrets[key] = value;
|
||
|
|
return this.saveAll(all);
|
||
|
|
}
|
||
|
|
|
||
|
|
delete(key) {
|
||
|
|
const all = this.loadAll();
|
||
|
|
if (!(key in all.secrets)) return false;
|
||
|
|
delete all.secrets[key];
|
||
|
|
this.saveAll(all);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
list() {
|
||
|
|
return Object.keys(this.loadAll().secrets);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 遮罩 (返回前端用 · 永不返回明文) ──────────────────────────
|
||
|
|
function maskValue(v) {
|
||
|
|
if (v == null) return null;
|
||
|
|
const s = String(v);
|
||
|
|
if (s.length === 0) return "";
|
||
|
|
if (s.length <= 4) return "••••";
|
||
|
|
if (s.length <= 12) return s.slice(0, 2) + "••••" + s.slice(-2);
|
||
|
|
return s.slice(0, 4) + "••••••" + s.slice(-4);
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { Vault, maskValue };
|