diff --git a/.gitignore b/.gitignore
index 7af96f4b..a3138f71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
/sdk/KeyToneSetting.json
/sdk/log.jsonl
/sdk/temporaryDebug
+/sdk/printconfig
# Private Keys
private_keys.env
diff --git a/BUILD_COMPATIBILITY.md b/BUILD_COMPATIBILITY.md
index 8a8295a7..317e419c 100644
--- a/BUILD_COMPATIBILITY.md
+++ b/BUILD_COMPATIBILITY.md
@@ -13,6 +13,23 @@ Certain features rely on **build-time injected parameters** (such as signing or
This difference is intentional and limited to identity-related behavior.
+## Build-Time Injected Keys
+
+The project supports **build-time key injection** (via Go `-ldflags -X`), so private build identities can override default open-source keys **without changing source code**.
+
+This mechanism is used by:
+
+* Signature authorization flow keys (F/K/Y/N)
+* Signature management encryption keys (A/B)
+* Album export encryption keys (versioned XOR keys: v1/v2)
+* Album config encryption seed (FixedSecret used for key derivation)
+* Album `signature` field inner encryption key
+
+For local/private builds, see:
+
+* `sdk/private_keys.template.env`
+* `sdk/setup_build_env.sh`
+
---
# Encrypted Output Compatibility
diff --git a/BUILD_COMPATIBILITY.zh-CN.md b/BUILD_COMPATIBILITY.zh-CN.md
index b78b7f21..1e3fcf75 100644
--- a/BUILD_COMPATIBILITY.zh-CN.md
+++ b/BUILD_COMPATIBILITY.zh-CN.md
@@ -13,6 +13,24 @@
这种差异是**有意设计的**,且仅限于与身份相关的行为。
+## 构建时注入密钥(Build-Time Injected Keys)
+
+本项目支持通过 Go 的 `-ldflags -X` 进行**构建时密钥注入**:
+私有构建可以在不修改源码的前提下覆盖开源默认密钥(注入值为 XOR 混淆后的 hex,运行时自动解混淆)。
+
+该机制覆盖:
+
+- 授权流密钥(F/K/Y/N)
+- 签名管理对称密钥(A/B)
+- 专辑导出文件对称密钥(版本化 XOR:v1/v2)
+- 专辑配置加密派生 seed(FixedSecret,用于派生 AES key)
+- 专辑配置 `signature` 字段内层加密密钥
+
+本地/私有构建入口:
+
+- `sdk/private_keys.template.env`
+- `sdk/setup_build_env.sh`
+
---
# 加密产物的兼容性
diff --git a/openspec/changes/update-build-injected-symmetric-keys/design.md b/openspec/changes/update-build-injected-symmetric-keys/design.md
new file mode 100644
index 00000000..8600d0a8
--- /dev/null
+++ b/openspec/changes/update-build-injected-symmetric-keys/design.md
@@ -0,0 +1,121 @@
+# Design: 构建注入式对称密钥适配
+
+## Overview
+
+本设计将历史遗留的对称密钥/secret 统一到与授权流相同的构建注入体系:
+
+- 默认值保留在源码中(开源构建无需私钥文件即可工作)
+- 私有构建通过 `-ldflags -X` 覆盖变量值
+- 覆盖值为 XOR 混淆后的 hex 字符串,运行时自动解混淆为明文
+
+## Injection Format
+
+### Build-Time
+
+- 通过 Go 链接器 `-X 'package.path.VarName=VALUE'` 注入
+- `VALUE` 推荐为:`tools/key-obfuscator` 输出的 hex(对任意长度字符串可用;长度非 32 时会提示 warning)
+
+#### tool stdout/stderr contract(跨平台关键约束)
+
+`tools/key-obfuscator` 的输出必须满足:
+
+- `stdout`:仅输出混淆后的 hex 字符串(机器可消费,允许被脚本 `$(...)` 捕获并直接用于 `-ldflags -X`)。
+- `stderr`:输出任何 warning/info/error 文本(例如“长度不是 32”的提示)。
+
+原因:CI(如 GitHub Actions)常将注入值复制为 secrets;若 warning 混入 stdout,会污染 `EXTRA_LDFLAGS`,导致构建失败或运行期走 fallback 造成兼容性破坏。
+
+### Runtime
+
+- 若变量值等于默认常量,则直接按默认明文使用
+- 否则按以下逻辑尝试解混淆:
+ 1. `hex.DecodeString(value)`
+ 2. 对每个字节执行 `b ^ xorMask[i%len(xorMask)]`
+ 3. 转换为 `string` 或(对 32-byte key 场景)截断/补齐到 32
+- 若注入值并非 hex(用户误注入明文),则回退为“直接使用该字符串”
+
+## Key Inventory and Usage
+
+### Signature Keys
+
+- KeyA (`KeyToneSignatureEncryptionKeyA`)
+ - 用途:加密签名 ID、派生动态密钥(PBKDF2)
+ - 注入点:`KeyTone/signature.KeyToneSignatureEncryptionKeyA`
+
+- KeyB (`KeyToneSignatureEncryptionKeyB`)
+ - 用途:`.ktsign` 导入/导出对称加密
+ - 注入点:`KeyTone/signature.KeyToneSignatureEncryptionKeyB`
+
+### Album Export Keys (XOR)
+
+- v1/v2:用于 `.ktalbum` 文件体(zip 数据)的 XOR 加/解密
+- 文件头 `Version` 指定密钥版本;解密时校验失败会回退尝试 v1
+- 注入点:
+ - `KeyTone/server.KeytoneEncryptKeyV1`
+ - `KeyTone/server.KeytoneEncryptKeyV2`
+
+### Album Config Seed (FixedSecret)
+
+- 用途:派生 AES key:`SHA256(secret + last6(sha1(albumUUID)))`
+- 注入点:`KeyTone/audioPackage/enc.FixedSecret`
+- 说明:该 secret 非固定 32 字节,因此采用“可变长度解混淆”路径(hex->xor->string)
+
+### Album Signature Field Inner Key
+
+- 用途:专辑配置 `signature` 字段内层 AES-GCM(外层仍由 albumUUID 派生 key 保护)
+- 注入点:`KeyTone/signature.KeyToneAlbumSignatureEncryptionKey`
+
+## Build Script Integration
+
+- `sdk/private_keys.template.env` 增加对应 KEY_* 项
+- `sdk/setup_build_env.sh` 增加 `KEYS_TO_PROCESS` 映射,使本地私有构建可以自动生成 `EXTRA_LDFLAGS`
+
+### Compatibility Behavior (重要)
+
+为保证历史 `private_keys.env`(未包含新增 KEY_*)以及“仅开源构建”场景可正常运行:
+
+- `sdk/setup_build_env.sh` 采用 best-effort 策略:
+ - 若未找到 `private_keys.env`,脚本不会报错退出,而是设置 `EXTRA_LDFLAGS=""` 并继续(等价于不注入)。
+ - 若某个 `KEY_*` 缺失,或值仍为模板占位符(如 `PLACEHOLDER_*` / `REPLACE_ME`),脚本会跳过该 key 的注入,让运行时回退到源码默认值。
+
+该行为的目标是:在不要求用户立刻补齐新增环境变量的前提下,依旧保持与适配前版本的兼容性。
+
+### Shell Robustness Notes
+
+为了保证在 `dev.sh` 的严格模式(`set -euo pipefail`)下也稳定:
+
+- 当脚本需要在“缺失私钥文件”场景下提前结束时,必须在脚本顶层直接使用 `return ... || exit ...`,而不是在函数内封装 `return`(函数内 `return` 只会返回函数,无法中止被 `source` 的脚本继续执行)。
+- 在包含中文标点的输出字符串中,变量展开建议统一使用 `${VAR}` 形式,避免某些 locale/编码情况下 `set -u` 将紧邻的非 ASCII 字节误判为变量名的一部分。
+
+### ktalbum-tools(本地调试工具)
+
+ktalbum-tools 的目标是便于本地查看/调试 `.ktalbum` 文件,因此它需要在“一个二进制”中尽量兼容两类产物:
+
+- 开源默认密钥产物
+- 私有构建注入密钥产物
+
+实现方式:
+
+- 构建注入脚本:`tools/ktalbum-tools/setup_build_env.sh`(默认复用 `sdk/private_keys.env`,也可使用本地 `tools/ktalbum-tools/private_keys.env`)
+- 一键构建:`tools/ktalbum-tools/build.sh` 会自动合并并应用 `EXTRA_LDFLAGS`
+- 运行时解密:按顺序尝试“注入密钥 → 默认密钥”,并在校验失败时按 SDK 策略回退尝试 v1
+
+### printconfig(SDK 内部调试工具)
+
+`sdk/audioPackage/cmd/printconfig` 是用于解密查看键音专辑配置的内部工具。
+
+- 位于 SDK 模块内,构建时自动继承 `EXTRA_LDFLAGS` 注入的 `FixedSecret`
+- 无需单独配置注入脚本
+- 私有构建后可解密私有产物的加密配置
+
+使用方式补充:
+
+- 可直接 `go run ./audioPackage/cmd/printconfig --path ...`(开源默认密钥)
+- 若要解密私有构建产物,需要 `go run -ldflags "$EXTRA_LDFLAGS" ./audioPackage/cmd/printconfig --path ...`
+- 注入发生在编译/链接阶段,因此同一次运行无法同时兼容两套密钥(需分别运行)
+
+## Compatibility Notes
+
+- 未注入:行为与当前开源版本完全一致
+- 注入后:
+ - 官方/私有构建与社区构建的加密产物可能不互通(符合 BUILD_COMPATIBILITY 设计)
+ - 若选择覆盖 v1 key,将导致旧 v1 产物在该私有构建中不可解密(预期行为,需在 proposal 中显式告知)
diff --git a/openspec/changes/update-build-injected-symmetric-keys/proposal.md b/openspec/changes/update-build-injected-symmetric-keys/proposal.md
new file mode 100644
index 00000000..c742d9b6
--- /dev/null
+++ b/openspec/changes/update-build-injected-symmetric-keys/proposal.md
@@ -0,0 +1,89 @@
+# Change: 旧有对称加密密钥接入构建注入体系
+
+## Why
+
+项目已在“授权流密钥”中采用构建时注入(`-ldflags -X` + XOR+hex 混淆)将私钥注入到构建结果中,并保持开源构建可用。
+
+但项目中仍存在更早实现的对称加密能力,其密钥/secret 直接硬编码在源码中,导致无法与“构建身份”机制统一,且私有构建难以做到与开源构建的加密产物隔离。
+
+## What Changes
+
+- 将以下对称密钥/secret 从 `const` 改为可注入 `var`,默认值保持原硬编码字符串不变:
+ - 签名管理 KeyA / KeyB
+ - 专辑配置 `signature` 字段内层加密密钥
+ - 专辑导出文件 XOR 密钥(v1/v2)
+ - 专辑配置加密派生 secret(FixedSecret)
+- 统一注入方式:注入值为 XOR 混淆后的 hex 字符串;运行时自动解混淆。
+- 更新构建脚本与模板:`sdk/setup_build_env.sh` 与 `sdk/private_keys.template.env` 增加新 key 项。
+- 为本地调试工具 ktalbum-tools 补齐注入脚本:`tools/ktalbum-tools/setup_build_env.sh`(默认复用 `sdk/private_keys.env`)。
+- ktalbum-tools 构建脚本会自动应用 `EXTRA_LDFLAGS`:`tools/ktalbum-tools/build.sh`。
+- 更新文档:`BUILD_COMPATIBILITY.md` 补充“Build-Time Injected Keys”列表。
+
+## Non-Goals
+
+- 不改变默认开源构建行为:未提供私钥注入时,仍使用源码默认值。
+- 不更换加密算法(仅调整密钥来源/注入方式)。
+
+## Impact
+
+- Affected code:
+ - `sdk/signature/encryption.go`
+ - `sdk/signature/album.go`
+ - `sdk/audioPackage/enc/enc.go`
+ - `sdk/server/server.go`
+ - `sdk/setup_build_env.sh`
+ - `sdk/private_keys.template.env`
+ - `tools/ktalbum-tools/utils/header.go`
+ - `tools/ktalbum-tools/commands/*.go`
+ - `tools/ktalbum-tools/setup_build_env.sh`
+ - `tools/ktalbum-tools/build.sh`
+ - `tools/ktalbum-tools/private_keys.template.env`
+ - `BUILD_COMPATIBILITY.md`
+ - `BUILD_COMPATIBILITY.zh-CN.md`
+
+- Affected specs:
+ - New capability: `openspec/specs/encrypted-outputs/spec.md`
+
+## Review Notes / Audit Trail
+
+## 2026-01-04 Compatibility Fixes (Audit)
+
+为满足“未使用新增 KEY_* 变量也能运行/构建成功,并与适配前兼容”的预期,补充以下实现约束:
+
+- `sdk/setup_build_env.sh` 必须在 `set -euo pipefail` 环境下稳定运行(不允许因 `grep` / pipeline 失败而静默退出)。
+- 未提供 `sdk/private_keys.env` 时,脚本不得失败,必须设置 `EXTRA_LDFLAGS=""`(等价于不注入,回退源码默认值)。
+- `private_keys.env` 存在但缺少新增条目时(历史文件),脚本不得失败;缺失项应跳过注入。
+- 若 `private_keys.env` 中仍是模板占位符(如 `PLACEHOLDER_*` / `REPLACE_ME`),必须视为“未配置”并跳过注入,避免误覆盖开源默认密钥导致兼容性破坏。
+
+补充(本次修复中实际发现的两个具体故障形态):
+
+- `set -u` 下出现 `KEYS_FILE�: unbound variable`:根因是输出字符串中使用 `$KEYS_FILE,`(紧邻非 ASCII 标点),在某些编码/locale 下会被解析成“带异常字节的变量名”。修复:统一改用 `${KEYS_FILE}` 形式。
+- “无私钥文件”分支虽然设置了 `EXTRA_LDFLAGS=""`,但仍继续执行后续混淆流程:根因是把 `return/exit` 封装进函数,函数内 `return` 只返回函数,无法中止被 `source` 的脚本。修复:在脚本顶层直接 `return ... || exit ...`。
+
+补充(CI 注入相关):
+
+- `tools/key-obfuscator` 在 key 长度不为 32 时会打印 `Warning: ...`,但历史实现将 warning 打到 `stdout`,导致 `sdk/setup_build_env.sh` 捕获 `$(go run ...)` 时把 warning 一并拼进 `-ldflags -X` 的值,进而污染 GitHub Actions secrets / 破坏构建与运行兼容性。
+- 修复策略:规定并实现 `stdout` 仅输出混淆后的 hex;任何 warning/info/error 必须写入 `stderr`。
+
+| Key/Secret | Default(源码) | 注入变量(Go -ldflags -X) | 用途摘要 |
+| ----------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- |
+| 签名 KeyA | `KeyTone2024Signature_KeyA_SecureEncryptionKeyForIDEncryption` | `KeyTone/signature.KeyToneSignatureEncryptionKeyA` | 加密签名ID、派生动态密钥(PBKDF2) |
+| 签名 KeyB | `KeyTone2024Signature_KeyB_SuperSecureEncryptionKeyForExportImportOperation` | `KeyTone/signature.KeyToneSignatureEncryptionKeyB` | `.ktsign` 导入/导出加密 |
+| 专辑 signature 字段密钥 | `KeyTone2024Album_Signature_Field_EncryptionKey_32Bytes` | `KeyTone/signature.KeyToneAlbumSignatureEncryptionKey` | 专辑配置中 `signature` 字段内层 AES-GCM |
+| 专辑导出 XOR v1 | `KeyTone2024SecretKey` | `KeyTone/server.KeytoneEncryptKeyV1` | `.ktalbum` v1 加/解密(兼容) |
+| 专辑导出 XOR v2 | `KeyTone2025AlbumSecureEncryptionKeyV2` | `KeyTone/server.KeytoneEncryptKeyV2` | `.ktalbum` v2 加/解密(当前) |
+| 专辑配置派生 secret | `LuSrackhall_KeyTone_2024_Signature_66688868686688` | `KeyTone/audioPackage/enc.FixedSecret` | 派生 AES key:`SHA256(secret + last6(sha1(albumUUID)))` |
+
+注:工具链 `tools/ktalbum-tools` 也提供同等注入点(模块路径不同)。
+
+另外:ktalbum-tools 为本地查看/调试用途,解密 `.ktalbum` 时会按顺序尝试“注入密钥 → 开源默认密钥”,并在校验失败时回退尝试 v1,以便同一构建可同时兼容开源与私有两类产物。
+
+## 扫描验证结论
+
+2025-12-31 再次全量扫描主项目(sdk + frontend):
+
+- SDK 共发现 **10 个**对称加密密钥/secret,全部已被 `setup_build_env.sh` 覆盖
+- 前端无任何硬编码对称密钥参与加密操作
+- SDK 内部调试工具 `sdk/audioPackage/cmd/printconfig` 依赖 `FixedSecret`,因在 SDK 模块内,构建时会自动继承注入
+- `printconfig` 可通过 `go run` 直接使用;私有密钥需要 `go run -ldflags "$EXTRA_LDFLAGS" ...`,且同一次运行无法同时兼容两套密钥(需分别运行)
+- **结论:无遗漏**
\ No newline at end of file
diff --git a/openspec/changes/update-build-injected-symmetric-keys/specs/encrypted-outputs/spec.md b/openspec/changes/update-build-injected-symmetric-keys/specs/encrypted-outputs/spec.md
new file mode 100644
index 00000000..3b7555a9
--- /dev/null
+++ b/openspec/changes/update-build-injected-symmetric-keys/specs/encrypted-outputs/spec.md
@@ -0,0 +1,43 @@
+# Encrypted Outputs(增量)
+
+## MODIFIED Requirements
+
+### Requirement: 构建身份与对称密钥来源
+
+Normative: The system SHALL keep default symmetric keys/secrets hardcoded in source for open-source builds, and SHALL allow overriding them at build-time via Go `-ldflags -X` using XOR-obfuscated hex values; at runtime it MUST deobfuscate injected values before use.
+
+Note: The internal debug utility `sdk/audioPackage/cmd/printconfig` depends on `FixedSecret` and MAY be executed via `go run`; private builds must use the same build-time injection (e.g. `go run -ldflags "$EXTRA_LDFLAGS" ...`).
+
+#### Scenario: 未注入私钥时保持原行为
+
+- **GIVEN** 构建过程中未提供任何 `-ldflags -X` 覆盖值
+- **WHEN** 系统进行签名管理加解密、专辑导出/导入、专辑配置加解密
+- **THEN** 系统使用源码默认密钥/secret,行为与当前开源版本一致
+
+Note: For local development, the helper script `sdk/setup_build_env.sh` SHOULD treat missing `private_keys.env` or missing/placeholder `KEY_*` entries as “not injected” (skip `-ldflags -X` for that key) to preserve backward compatibility.
+
+Note: The obfuscation tool used by the scripts (`tools/key-obfuscator`) MUST output only the obfuscated hex value to `stdout`. Any warning/info text (including non-32-byte length warnings) MUST go to `stderr` so `EXTRA_LDFLAGS` and CI secrets remain valid.
+
+#### Scenario: 注入密钥后自动解混淆
+
+- **GIVEN** 构建过程中通过 `-ldflags -X` 注入了 XOR 混淆后的 hex
+- **WHEN** 系统在运行时读取该变量并用于加解密
+- **THEN** 系统先执行 `hex -> xorMask -> plaintext` 解混淆,再进行加解密
+
+### Requirement: 专辑导出版本化密钥选择
+
+Normative: The system SHALL encrypt `.ktalbum` file body using the current version key (v2), store `header.Version=2`, and SHALL select decryption key by `header.Version` with a v1 fallback on checksum mismatch.
+
+Note: For local debugging, ktalbum-tools MAY additionally attempt both injected and default open-source keys for the same version (in that order) before declaring failure. This is tooling behavior and does not change the core app compatibility boundary.
+
+#### Scenario: 导出使用 v2
+
+- **GIVEN** 用户导出专辑
+- **WHEN** 系统生成 `.ktalbum`
+- **THEN** `header.Version` MUST be `2` and the file body MUST be encrypted with the v2 key
+
+#### Scenario: 导入按版本解密并回退
+
+- **GIVEN** 用户导入 `.ktalbum`
+- **WHEN** 系统解密 zip body
+- **THEN** 系统按 `header.Version` 选择密钥;若校验失败且版本不是 v1,则 MUST 尝试 v1 回退
diff --git a/openspec/changes/update-build-injected-symmetric-keys/tasks.md b/openspec/changes/update-build-injected-symmetric-keys/tasks.md
new file mode 100644
index 00000000..d03002b8
--- /dev/null
+++ b/openspec/changes/update-build-injected-symmetric-keys/tasks.md
@@ -0,0 +1,63 @@
+# 旧有对称加密密钥接入构建注入体系 - 任务清单
+
+## 状态
+
+**当前状态**: ✅ 已实现(代码与文档已同步)
+
+## 1. 发现与清点(留痕)
+
+- [x] 在 `sdk/**/*.go` 中定位所有包含硬编码对称密钥/secret 的位置
+- [x] 在 `tools/**/*.go` 中定位所有包含硬编码对称密钥/secret 的位置
+- [x] 逐一标注用途、调用链、数据格式(32-byte key / 可变长度 secret / XOR key)
+
+## 2. SDK:密钥注入适配
+
+- [x] `sdk/signature/encryption.go`:KeyA/KeyB 改为可注入 `var`,默认保持原值
+- [x] `sdk/signature/album.go`:专辑 signature 字段密钥改为可注入 `var`
+- [x] `sdk/audioPackage/enc/enc.go`:FixedSecret 改为可注入 `var` 并支持可变长度解混淆
+- [x] `sdk/server/server.go`:专辑导出 XOR v1/v2 key 改为可注入 `var`,使用时解混淆
+- [x] `sdk/server/server.go`:移除/替换不必要的硬编码签名清理密钥,改用 `signature.GetKeyA()`
+
+## 3. 工具链:ktalbum-tools 适配
+
+- [x] `tools/ktalbum-tools/utils/header.go`:增加 v1/v2 key 与注入点、提供 `GetEncryptKeyByVersion`
+- [x] `tools/ktalbum-tools/utils/header.go`:增加 `GetDecryptKeyCandidatesByVersion`(注入→默认双候选)以兼容开源/私有两类产物
+- [x] `tools/ktalbum-tools/commands/extract.go`:按版本循环尝试候选密钥 + 校验失败回退 v1 候选
+- [x] `tools/ktalbum-tools/commands/info.go`:按版本循环尝试候选密钥 + 校验失败回退 v1 候选
+- [x] `tools/ktalbum-tools/setup_build_env.sh`:复用 sdk 私钥文件生成注入用 `EXTRA_LDFLAGS`
+- [x] `tools/ktalbum-tools/build.sh`:自动应用 `EXTRA_LDFLAGS`(一键构建时支持注入)
+- [x] `tools/ktalbum-tools/private_keys.template.env`:可选的独立私钥模板(不提交 private_keys.env)
+
+## 4. 构建入口与模板
+
+- [x] `sdk/setup_build_env.sh`:追加 KEY_A/KEY_B/KEY_ALBUM_* 映射
+- [x] `sdk/private_keys.template.env`:追加新增 KEY_* 项
+
+## 5. 文档与规格同步
+
+- [x] `BUILD_COMPATIBILITY.md`:补充 Build-Time Injected Keys
+- [x] `BUILD_COMPATIBILITY.zh-CN.md`:同步补充 Build-Time Injected Keys
+- [x] 新增能力规格:`openspec/specs/encrypted-outputs/spec.md`
+- [x] 变更增量规格:`openspec/changes/update-build-injected-symmetric-keys/specs/encrypted-outputs/spec.md`
+
+## 6. 验证
+
+- [x] `go build ./...`(sdk)
+- [x] 再次全量扫描主项目(sdk + frontend),确认所有对称密钥均已被构建脚本覆盖(共 10 个,无遗漏)
+- [x] 确认 SDK 内部工具 `sdk/audioPackage/cmd/printconfig` 依赖 `FixedSecret`,因在 SDK 模块内自动继承注入
+- [x] 为 `printconfig` 添加文件头部使用说明(含密钥/构建说明)
+- [x] 为 `printconfig` 补充 `go run` 使用说明(含开源/私有注入与兼容性边界)
+- [ ] 可选:在本地提供 `sdk/private_keys.env` 并运行 `source sdk/setup_build_env.sh` 后构建,验证注入 keys 生效
+- [ ] 可选:为 `tools/ktalbum-tools` 构建时传入 `-ldflags -X`,验证能解密对应构建身份产物
+
+## 7. 兼容性修复(2026-01-04)
+
+- [x] `sdk/setup_build_env.sh`:在 `set -euo pipefail` 下不因 grep/pipeline 失败而静默退出
+- [x] `sdk/setup_build_env.sh`:缺失 `private_keys.env` 时不失败,设置 `EXTRA_LDFLAGS=""` 并回退默认行为
+- [x] `sdk/setup_build_env.sh`:缺失新增 `KEY_*` 时跳过注入(兼容历史 private_keys.env)
+- [x] `sdk/setup_build_env.sh`:模板占位符(`PLACEHOLDER_*`/`REPLACE_ME`)视为未配置并跳过,避免误覆盖默认密钥
+- [x] `tools/ktalbum-tools/setup_build_env.sh`:同等容错(缺失/占位符跳过)
+- [x] `sdk/setup_build_env.sh`:修复 `set -u` 下 `KEYS_FILE�: unbound variable`(统一使用 `${VAR}` 展开)
+- [x] `sdk/setup_build_env.sh`:修复“无私钥文件分支仍继续执行”的 source 早退问题(顶层 `return ... || exit ...`)
+- [x] `tools/ktalbum-tools/setup_build_env.sh`:修复 source 场景下 `exit 0` 会退出当前 shell 的问题(改为 `return 0 || exit 0`)
+- [x] `tools/key-obfuscator/main.go`:stdout 仅输出混淆后的 hex;Warning/错误输出到 stderr,避免污染 `EXTRA_LDFLAGS` 与 CI secrets
diff --git "a/openspec/changes/update-build-injected-symmetric-keys/\344\270\264\346\227\266\350\257\264\346\230\216.md" "b/openspec/changes/update-build-injected-symmetric-keys/\344\270\264\346\227\266\350\257\264\346\230\216.md"
new file mode 100644
index 00000000..45ef3c40
--- /dev/null
+++ "b/openspec/changes/update-build-injected-symmetric-keys/\344\270\264\346\227\266\350\257\264\346\230\216.md"
@@ -0,0 +1,62 @@
+# **已完成:把“旧有对称加密密钥”接入与授权流一致的构建注入机制,并同步代码+规格+文档(含 proposal/design/tasks/spec)。**
+
+sdk 与 ktalbum-tools 均已 `go build safe.` 通过。
+
+**1) 我在仓库里定位到的“硬编码对称密钥/secret”与用途(留痕清单)**
+- encryption.go
+ - `KeyToneSignatureEncryptionKeyA`(KeyA):用于加密签名 ID、派生动态密钥(PBKDF2)
+ - `KeyToneSignatureEncryptionKeyB`(KeyB):用于 `.ktsign` 导入/导出加密
+- album.go
+ - `KeyToneAlbumSignatureEncryptionKey`:用于专辑配置 `signature` 字段的“内层 AES-GCM”加密
+- enc.go
+ - `FixedSecret`:用于派生专辑配置 AES key:`SHA256(secret + last6(sha1(albumUUID)))`
+- server.go
+ - `KeytoneEncryptKeyV1` / `KeytoneEncryptKeyV2`:用于 `.ktalbum` zip body 的版本化 XOR 加/解密
+ - 另有一处硬编码字符串用于签名图片清理(实际逻辑并不依赖该字符串),已改为使用 `signature.GetKeyA()`,避免继续引入新的“写死密钥源”
+- header.go
+ - 原 `KeytoneEncryptKey = "KeyTone2024SecretKey"`:用于工具解包/读信息时 XOR 解密 `.ktalbum`
+
+**2) 适配方式(与授权流一致)**
+- 默认值仍然保留在源码中(未注入时保持开源构建行为完全不变)
+- 支持通过 Go `-ldflags -X` 注入“XOR 混淆后的 hex”
+- 运行时对注入值执行 `hex -> xorMask -> plaintext` 解混淆后再参与加/解密
+ - 若误注入明文(非 hex),会回退为“直接使用该字符串”,保证不把构建卡死
+
+**3) 实际改动位置(代码)**
+- SDK(注入点 + 解混淆)
+ - encryption.go:KeyA/KeyB 从 `const` → `var`,并复用授权流的解混淆策略
+ - album.go:专辑 signature 字段密钥从 `const` → `var`
+ - enc.go:`FixedSecret` 从 `const` → `var`(可变长度解混淆)
+ - server.go:`KeytoneEncryptKeyV1/V2` 从 `const` → `var`;导出时用 `getEncryptKeyByVersion(2)`(明文)而不是直接用变量值
+- 工具链(同步支持 v1/v2 + 注入)
+ - header.go:新增 `KeytoneEncryptKeyV1/V2` 与 `GetEncryptKeyByVersion`
+ - extract.go、info.go:按 header.Version 选 key,校验失败回退尝试 v1(与 SDK 行为一致)
+- 构建注入入口
+ - setup_build_env.sh:新增 KEY_A/KEY_B/KEY_ALBUM_* 映射
+ - private_keys.template.env:新增对应 KEY_* 模板项
+
+**4) 文档与规格同步(你要求的 proposal/design/tasks/spec 全部覆盖)**
+- 文档: BUILD_COMPATIBILITY.md 增加 “Build-Time Injected Keys” 列表
+- OpenSpec(新能力规格 + 变更留痕)
+ - spec.md(新 capability:Encrypted Outputs)
+ - proposal.md
+ - design.md
+ - tasks.md
+ - spec.md
+- 既有规格补充: spec.md 明确 KeyB “可构建注入”
+
+**5) 你审查时最关键的“注入变量名”对照**
+- SDK(由 setup_build_env.sh 自动拼 EXTRA_LDFLAGS)
+ - `KeyTone/signature.KeyToneSignatureEncryptionKeyA` ← `KEY_A`
+ - `KeyTone/signature.KeyToneSignatureEncryptionKeyB` ← `KEY_B`
+ - `KeyTone/signature.KeyToneAlbumSignatureEncryptionKey` ← `KEY_ALBUM_SIGNATURE_FIELD`
+ - `KeyTone/server.KeytoneEncryptKeyV1` ← `KEY_ALBUM_EXPORT_V1`
+ - `KeyTone/server.KeytoneEncryptKeyV2` ← `KEY_ALBUM_EXPORT_V2`
+ - `KeyTone/audioPackage/enc.FixedSecret` ← `KEY_ALBUM_CONFIG_SECRET`
+- ktalbum-tools(模块路径不同,需要你在 tools 的构建命令里自行 `-ldflags -X`)
+ - `ktalbum-tools/utils.KeytoneEncryptKeyV1`
+ - `ktalbum-tools/utils.KeytoneEncryptKeyV2`
+
+如果你希望我再把“ktalbum-tools 的构建脚本/说明”也做成和 SDK 一样的 `setup_build_env.sh + private_keys.template.env`(避免你手写 `-ldflags -X`),我可以继续补齐,但会新增文件与文档说明(你确认后我再做)。
+
+已进行更改。
\ No newline at end of file
diff --git a/openspec/specs/encrypted-outputs/spec.md b/openspec/specs/encrypted-outputs/spec.md
new file mode 100644
index 00000000..609a7f79
--- /dev/null
+++ b/openspec/specs/encrypted-outputs/spec.md
@@ -0,0 +1,86 @@
+# Encrypted Outputs
+
+## Purpose
+
+定义 KeyTone 在“加密产物”上的兼容性边界与构建身份(build identity)的关联。
+
+本能力覆盖:
+
+- 专辑导出文件(`.ktalbum`)加密
+- 专辑配置加密(AES-GCM,基于 albumUUID 派生 key)
+- 签名导出文件(`.ktsign`)加密
+- 构建时密钥注入(不改源码、开源构建默认可用)
+
+## Requirements
+
+### Requirement: 构建时密钥注入
+
+Normative: The system SHALL support overriding selected symmetric keys/secrets at build-time via Go `-ldflags -X` using XOR-obfuscated hex values; when not injected, it MUST fall back to the default hardcoded values so open-source builds remain functional.
+
+Note: The recommended helper script `sdk/setup_build_env.sh` is intentionally best-effort for compatibility. If `sdk/private_keys.env` is missing, or if individual `KEY_*` entries are missing / still template placeholders, the script SHOULD skip those injections and leave `EXTRA_LDFLAGS` empty or partial, so builds remain compatible with the default open-source behavior.
+
+Note: The obfuscation helper (`tools/key-obfuscator`) MUST keep `stdout` machine-consumable. `stdout` SHALL contain only the obfuscated hex string (no warnings, no extra text). Any warnings (e.g. non-32-byte key length) or errors MUST be printed to `stderr` so CI systems (GitHub Actions secrets) and scripts capturing `$(...)` are not polluted.
+
+#### Scenario: 开源构建未注入
+
+- **GIVEN** 用户从公开源码直接构建且未提供私钥文件/注入参数
+- **WHEN** 系统进行涉及加密的功能
+- **THEN** 使用源码默认密钥/secret,并保持可用
+
+#### Scenario: 私有构建注入
+
+- **GIVEN** 用户提供私钥(例如通过 `sdk/setup_build_env.sh` 生成 `EXTRA_LDFLAGS`)
+- **WHEN** 使用 `-ldflags -X` 注入混淆值构建
+- **THEN** 构建产物的加密身份发生变化,加密产物可与未注入构建不兼容(预期)
+
+### Requirement: `.ktalbum` 版本化 XOR 加密
+
+Normative: The system SHALL encrypt the `.ktalbum` zip body using a versioned XOR key; it MUST store the key version in the file header and MUST select the decryption key by header version, with a v1 fallback on checksum mismatch.
+
+Note: A local debug tool (ktalbum-tools) MAY additionally try both the injected key and the default open-source key for the same version to improve inspection compatibility. This does not change the main application's compatibility boundary.
+
+#### Scenario: 导出写入 v2
+
+- **GIVEN** 用户导出专辑
+- **WHEN** 系统生成 `.ktalbum`
+- **THEN** MUST set `header.Version=2` and encrypt with v2 key
+
+#### Scenario: 导入按版本选择密钥
+
+- **GIVEN** 用户导入 `.ktalbum`
+- **WHEN** 系统解析文件头
+- **THEN** MUST decrypt using the key corresponding to `header.Version`
+
+### Requirement: 专辑配置 AES-GCM 派生密钥
+
+Normative: The system SHALL derive a 32-byte AES key using `SHA256(secret + last6(sha1(albumUUID)))`, where `secret` is configurable via build-time injection; the encrypted config bytes MUST be stored as `nonce + ciphertext`.
+
+Note: The internal debug utility `sdk/audioPackage/cmd/printconfig` can be used to inspect decrypted config; for private builds it must be run/built with the same `-ldflags -X` injection (e.g. via `EXTRA_LDFLAGS`).
+
+#### Scenario: 使用默认 secret 派生 key
+
+- **GIVEN** 未注入自定义 secret
+- **WHEN** 系统派生专辑配置 AES key
+- **THEN** 使用默认 secret,派生结果与当前版本一致
+
+#### Scenario: 注入 secret 改变派生 key
+
+- **GIVEN** 构建时注入了自定义 secret
+- **WHEN** 系统派生专辑配置 AES key
+- **THEN** 派生结果与默认构建不同,导致加密配置不可跨构建身份通用(预期)
+
+### Requirement: 签名导出 KeyB
+
+Normative: The system SHALL encrypt `.ktsign` using KeyB, where KeyB SHALL be build-injectable; when not injected, it MUST use the default hardcoded value.
+
+#### Scenario: KeyB 未注入
+
+- **GIVEN** 未注入 KeyB
+- **WHEN** 导出签名
+- **THEN** 使用默认 KeyB 加密 `.ktsign`
+
+#### Scenario: KeyB 注入
+
+- **GIVEN** 注入了 KeyB
+- **WHEN** 导出签名
+- **THEN** `.ktsign` MUST be encrypted with the injected KeyB
diff --git a/openspec/specs/signature-management/spec.md b/openspec/specs/signature-management/spec.md
index f6380286..ed1bca8e 100644
--- a/openspec/specs/signature-management/spec.md
+++ b/openspec/specs/signature-management/spec.md
@@ -78,7 +78,7 @@ Normative: The system SHALL 在删除前显示确认对话框;客户端 MUST
### Requirement: 导出签名
-Normative: The client SHALL 通过 `POST /signature/export` 请求导出;后端 MUST 返回经 KeyB 加密并以十六进制编码的 `.ktsign` 内容,客户端 MUST 在用户确认保存后才提示成功。
+Normative: The client SHALL 通过 `POST /signature/export` 请求导出;后端 MUST 返回经 KeyB(可构建注入;未注入时使用源码默认值)加密并以十六进制编码的 `.ktsign` 内容,客户端 MUST 在用户确认保存后才提示成功。
#### Scenario: 导出成功
@@ -96,7 +96,7 @@ Normative: The client SHALL 通过 `POST /signature/export` 请求导出;后
### Requirement: 导入签名
-Normative: The client SHALL 上传 `.ktsign` 文件到 `POST /signature/import`;后端 MUST 使用 KeyB 解密并校验字段,当签名已存在时返回 `409` 且 `conflict: true`;覆盖流程 SHALL 通过 `POST /signature/import-confirm` 携带原始加密字符串和 `overwrite` 标识完成导入。
+Normative: The client SHALL 上传 `.ktsign` 文件到 `POST /signature/import`;后端 MUST 使用 KeyB(可构建注入;未注入时使用源码默认值)解密并校验字段,当签名已存在时返回 `409` 且 `conflict: true`;覆盖流程 SHALL 通过 `POST /signature/import-confirm` 携带原始加密字符串和 `overwrite` 标识完成导入。
#### Scenario: 导入成功
diff --git a/sdk/audioPackage/cmd/printconfig/main.go b/sdk/audioPackage/cmd/printconfig/main.go
index ba257d74..28e6a3f2 100644
--- a/sdk/audioPackage/cmd/printconfig/main.go
+++ b/sdk/audioPackage/cmd/printconfig/main.go
@@ -1,3 +1,75 @@
+/**
+ * This file is part of the KeyTone project.
+ *
+ * Copyright (C) 2024 LuSrackhall
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+/*
+printconfig - KeyTone 专辑配置解密查看工具
+
+
+密钥说明:
+ 解密使用 FixedSecret 派生 AES key(SHA256(secret + last6(sha1(albumUUID))))。
+ FixedSecret 支持构建时注入(-ldflags -X KeyTone/audioPackage/enc.FixedSecret=...)。
+ - 开源构建:使用默认 FixedSecret,只能解密开源构建产物
+ - 私有构建:注入私有 FixedSecret 后,可解密对应私有构建产物
+
+构建方式:
+ 假设在 sdk/ 目录下, 执行该命令, 会在 sdk/ 目录下生成可执行文件 printconfig, 此cli工具可用于解密开源构建产物, 方便开发调试。
+ > 目前已将默认产物添加到.gitignore中避免误提交。
+
+ # 开源构建(使用默认密钥)
+ go build ./audioPackage/cmd/printconfig
+
+ # 私有构建(需先加载注入参数)
+ source ./setup_build_env.sh
+ go build -ldflags "$EXTRA_LDFLAGS" ./audioPackage/cmd/printconfig
+
+直接运行(不落地构建产物):
+
+ # 开源默认密钥(仅能解密开源构建产物)
+ go run ./audioPackage/cmd/printconfig --path /path/to/album/uuid
+
+ # 私有注入密钥(可解密对应私有构建产物)
+ source ./setup_build_env.sh
+ go run -ldflags "$EXTRA_LDFLAGS" ./audioPackage/cmd/printconfig --path /path/to/album/uuid
+
+兼容性说明:
+ - FixedSecret 的覆盖发生在编译/链接阶段(-ldflags -X),不是运行时参数。
+ - 因此:同一次运行(同一个 go run/go build 产物)无法同时兼容两套密钥。
+ 若要分别查看开源/私有产物,请使用不同的 ldflags 分别运行/构建。
+
+用途:
+ 解密并打印键音专辑的 package.json 配置文件内容。
+ 当用户选择"需要签名"导出专辑时,配置文件会被 AES-GCM 加密,
+ 此工具用于在终端中查看加密后的真实配置内容,便于调试。
+
+使用方法:
+ printconfig --path [--raw]
+
+参数:
+ --path 专辑目录路径(包含 package.json 或 stub + core 文件)
+ --raw 输出原始密文 hex(不解密)
+
+示例:
+ # 解密并查看配置
+ printconfig --path /path/to/album/uuid
+
+ # 仅输出密文 hex(不解密)
+ printconfig --path /path/to/album/uuid --raw
+*/
package main
import (
diff --git a/sdk/audioPackage/enc/enc.go b/sdk/audioPackage/enc/enc.go
index c4b9682d..5f958a47 100644
--- a/sdk/audioPackage/enc/enc.go
+++ b/sdk/audioPackage/enc/enc.go
@@ -10,8 +10,41 @@ import (
"KeyTone/signature"
)
-// FixedSecret is the fixed secret prefix for album config enc/dec per spec.
-const FixedSecret = "LuSrackhall_KeyTone_2024_Signature_66688868686688"
+// ==============================
+// 对称加密种子(用于派生专辑配置 AES 密钥)
+// 注意:该 secret 由源码提供默认值(开源构建保持原行为),也可在构建时通过 -ldflags 注入
+// 注入的值应为经过 XOR 混淆后的 Hex 字符串(与授权流一致)
+// ==============================
+
+// xorMask 用于混淆密钥的掩码,必须与授权流一致
+var xorMask = []byte{0x55, 0xAA, 0x33, 0xCC, 0x99, 0x66, 0x11, 0xEE, 0x77, 0xBB, 0x22, 0xDD, 0x88, 0x44, 0xFF, 0x00}
+
+// DefaultFixedSecret is the fixed secret prefix for album config enc/dec per spec.
+const DefaultFixedSecret = "LuSrackhall_KeyTone_2024_Signature_66688868686688"
+
+// FixedSecret is the build-injectable secret seed (default: DefaultFixedSecret).
+// 若被注入,则值应为 XOR 混淆后的 Hex 字符串。
+var FixedSecret = DefaultFixedSecret
+
+func deobfuscateString(obfuscatedHex string) string {
+ obfuscated, err := hex.DecodeString(obfuscatedHex)
+ if err != nil {
+ // 非 hex(可能是默认明文,或用户错误注入了明文)
+ return obfuscatedHex
+ }
+ realBytes := make([]byte, len(obfuscated))
+ for i, b := range obfuscated {
+ realBytes[i] = b ^ xorMask[i%len(xorMask)]
+ }
+ return string(realBytes)
+}
+
+func getFixedSecret() string {
+ if FixedSecret == DefaultFixedSecret {
+ return DefaultFixedSecret
+ }
+ return deobfuscateString(FixedSecret)
+}
// DeriveKey derives a 32-byte AES key using SHA256(FixedSecret + last6(sha1(albumUUID))).
// Assumption: albumUUID is the directory name of the album folder unless specified otherwise.
@@ -26,7 +59,7 @@ func DeriveKey(albumUUID string) []byte {
hexStr = pad + hexStr
}
suffix := hexStr[len(hexStr)-6:]
- seed := FixedSecret + suffix
+ seed := getFixedSecret() + suffix
sum := sha256.Sum256([]byte(seed))
key := make([]byte, 32)
copy(key, sum[:])
diff --git a/sdk/private_keys.template.env b/sdk/private_keys.template.env
index 77e7552b..e4eaa715 100644
--- a/sdk/private_keys.template.env
+++ b/sdk/private_keys.template.env
@@ -22,4 +22,26 @@ KEY_K="PLACEHOLDER_KEY_K_REPLACE_ME_32B"
KEY_Y="PLACEHOLDER_KEY_Y_REPLACE_ME_32B"
# 密钥N:用于授权文件的最终加密 (建议 32 bytes)
-KEY_N="PLACEHOLDER_KEY_N_REPLACE_ME_32B"
\ No newline at end of file
+KEY_N="PLACEHOLDER_KEY_N_REPLACE_ME_32B"
+
+# ==============================
+# 旧有对称加密能力(现已支持构建时注入)
+# ==============================
+
+# 密钥A:用于加密签名ID和生成动态密钥 (建议 32 bytes)
+KEY_A="PLACEHOLDER_KEY_A_REPLACE_ME_32B"
+
+# 密钥B:用于导出/导入签名文件加密 (建议 32 bytes)
+KEY_B="PLACEHOLDER_KEY_B_REPLACE_ME_32B"
+
+# 专辑 signature 字段内层加密密钥 (建议 32 bytes)
+KEY_ALBUM_SIGNATURE_FIELD="PLACEHOLDER_ALBUM_SIGNATURE_FIELD_KEY_32B"
+
+# 专辑导出文件 XOR 密钥 v1(旧版本,用于向后兼容)
+KEY_ALBUM_EXPORT_V1="PLACEHOLDER_ALBUM_EXPORT_KEY_V1"
+
+# 专辑导出文件 XOR 密钥 v2(当前版本)
+KEY_ALBUM_EXPORT_V2="PLACEHOLDER_ALBUM_EXPORT_KEY_V2"
+
+# 专辑配置加密派生 secret(可变长度;用于派生 AES key,不要求 32 bytes)
+KEY_ALBUM_CONFIG_SECRET="PLACEHOLDER_ALBUM_CONFIG_SECRET"
\ No newline at end of file
diff --git a/sdk/server/server.go b/sdk/server/server.go
index c254daa8..e64629aa 100644
--- a/sdk/server/server.go
+++ b/sdk/server/server.go
@@ -33,6 +33,7 @@ import (
"crypto"
"crypto/sha256"
"encoding/binary"
+ "encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -53,14 +54,52 @@ const (
KeytoneVersion = "1.0.0" // 当前版本号
KeytoneFileSignature = "KTALBUM" // 文件签名
KeytoneFileVersion = 1 // 文件版本(已废弃,仅用于向后兼容)
+)
+
+// ==============================
+// 专辑导出文件对称密钥(版本化)
+// 注意:这些变量不再是 const,而是 var,以便在编译时通过 -ldflags 进行注入
+// 注入的值应为经过 XOR 混淆后的 Hex 字符串(与授权流一致)
+// ==============================
- // 版本化加密密钥
- KeytoneEncryptKeyV1 = "KeyTone2024SecretKey" // v1 密钥(旧版本,用于向后兼容)
- KeytoneEncryptKeyV2 = "KeyTone2025AlbumSecureEncryptionKeyV2" // v2 密钥(当前版本)
- KeytoneEncryptKeyCurrent = KeytoneEncryptKeyV2 // 当前使用的密钥版本
- KeytoneEncryptKey = KeytoneEncryptKeyV1 // 已废弃:向后兼容,请使用 KeytoneEncryptKeyV1
+// xorMask 用于混淆密钥的掩码,必须与授权流一致
+var xorMask = []byte{0x55, 0xAA, 0x33, 0xCC, 0x99, 0x66, 0x11, 0xEE, 0x77, 0xBB, 0x22, 0xDD, 0x88, 0x44, 0xFF, 0x00}
+
+// 默认开源密钥常量(明文)
+const (
+ DefaultKeytoneEncryptKeyV1 = "KeyTone2024SecretKey" // v1 密钥(旧版本,用于向后兼容)
+ DefaultKeytoneEncryptKeyV2 = "KeyTone2025AlbumSecureEncryptionKeyV2" // v2 密钥(当前版本)
+ DefaultKeytoneEncryptKeyCurrent = DefaultKeytoneEncryptKeyV2
)
+// 版本化加密密钥(可注入)
+var (
+ KeytoneEncryptKeyV1 = DefaultKeytoneEncryptKeyV1
+ KeytoneEncryptKeyV2 = DefaultKeytoneEncryptKeyV2
+ KeytoneEncryptKeyCurrent = DefaultKeytoneEncryptKeyCurrent
+ KeytoneEncryptKey = KeytoneEncryptKeyV1 // 已废弃:向后兼容,请使用 KeytoneEncryptKeyV1
+)
+
+func deobfuscateString(obfuscatedHex string) string {
+ obfuscated, err := hex.DecodeString(obfuscatedHex)
+ if err != nil {
+ // 非 hex(可能是默认明文,或用户错误注入了明文)
+ return obfuscatedHex
+ }
+ realBytes := make([]byte, len(obfuscated))
+ for i, b := range obfuscated {
+ realBytes[i] = b ^ xorMask[i%len(xorMask)]
+ }
+ return string(realBytes)
+}
+
+func getPlainEncryptKey(value string, defaultValue string) string {
+ if value == defaultValue {
+ return defaultValue
+ }
+ return deobfuscateString(value)
+}
+
// KeytoneAlbumMeta 用于存储专辑元数据
type KeytoneAlbumMeta struct {
MagicNumber string `json:"magicNumber"`
@@ -339,13 +378,13 @@ func xorCrypt(data []byte, key string) []byte {
func getEncryptKeyByVersion(version uint8) string {
switch version {
case 1:
- return KeytoneEncryptKeyV1
+ return getPlainEncryptKey(KeytoneEncryptKeyV1, DefaultKeytoneEncryptKeyV1)
case 2:
- return KeytoneEncryptKeyV2
+ return getPlainEncryptKey(KeytoneEncryptKeyV2, DefaultKeytoneEncryptKeyV2)
default:
// 未知版本,返回当前密钥
logger.Warn("未知的文件版本号,使用当前密钥", "version", version)
- return KeytoneEncryptKeyCurrent
+ return getPlainEncryptKey(KeytoneEncryptKeyCurrent, DefaultKeytoneEncryptKeyCurrent)
}
}
@@ -372,7 +411,7 @@ func decryptAlbumData(encryptedData []byte, header KeytoneFileHeader) ([]byte, u
// 如果校验失败且版本不是v1,尝试使用v1密钥回退
if header.Version != 1 {
logger.Warn("使用版本密钥解密失败,尝试v1密钥回退", "version", header.Version)
- zipData = xorCrypt(encryptedData, KeytoneEncryptKeyV1)
+ zipData = xorCrypt(encryptedData, getEncryptKeyByVersion(1))
checksum = sha256.Sum256(zipData)
if checksum == header.Checksum {
logger.Info("使用v1密钥成功解密", "file_version", header.Version)
@@ -388,8 +427,8 @@ func ServerRun() {
// 启动签名名片图片清理任务(在SDK启动5秒后执行一次)
go func() {
time.Sleep(5 * time.Second)
- encryptionKey := []byte("KeyTone2024SignatureEncryptionKey"[:32]) // 截取前32字节
- if err := signature.CleanupOrphanCardImages(encryptionKey); err != nil {
+ // CleanupOrphanCardImages 的参数为历史兼容保留;实际解密逻辑使用 KeyA(支持构建注入)
+ if err := signature.CleanupOrphanCardImages(signature.GetKeyA()); err != nil {
logger.Error("签名名片图片清理任务执行失败", "error", err.Error())
}
}()
@@ -1525,7 +1564,7 @@ func keytonePkgRouters(r *gin.Engine) {
checksum := sha256.Sum256(zipData)
// 加密 zip 数据(使用当前版本密钥 v2)
- encryptedData := xorCrypt(zipData, KeytoneEncryptKeyCurrent)
+ encryptedData := xorCrypt(zipData, getEncryptKeyByVersion(2))
// 创建文件头(使用版本号 2)
header := KeytoneFileHeader{
diff --git a/sdk/setup_build_env.sh b/sdk/setup_build_env.sh
index a09573aa..94dd1a5b 100755
--- a/sdk/setup_build_env.sh
+++ b/sdk/setup_build_env.sh
@@ -40,30 +40,83 @@ OBFUSCATOR_TOOL="../tools/key-obfuscator/main.go"
# 格式说明:
# KEY_NAME : 在 private_keys.env 文件中的键名 (如 KEY_F)
# GO_VAR : 在 Go 代码中接收注入的变量全路径 (如 KeyTone/signature.KeyToneAuthRequestEncryptionKeyF)
+#
+# 说明:
+# - 推荐 32 字节密钥(AES-256 标准)
+# - 部分“种子/口令”可能不是 32 字节(例如专辑配置派生 secret),混淆工具会给出长度警告但仍可工作
KEYS_TO_PROCESS=(
"KEY_F:KeyTone/signature.KeyToneAuthRequestEncryptionKeyF"
"KEY_K:KeyTone/signature.KeyToneAuthRequestEncryptionKeyK"
"KEY_Y:KeyTone/signature.KeyToneAuthGrantEncryptionKeyY"
"KEY_N:KeyTone/signature.KeyToneAuthGrantEncryptionKeyN"
+
+ # 旧有对称加密密钥(本次补齐到构建注入体系)
+ "KEY_A:KeyTone/signature.KeyToneSignatureEncryptionKeyA"
+ "KEY_B:KeyTone/signature.KeyToneSignatureEncryptionKeyB"
+ "KEY_ALBUM_SIGNATURE_FIELD:KeyTone/signature.KeyToneAlbumSignatureEncryptionKey"
+ "KEY_ALBUM_EXPORT_V1:KeyTone/server.KeytoneEncryptKeyV1"
+ "KEY_ALBUM_EXPORT_V2:KeyTone/server.KeytoneEncryptKeyV2"
+ "KEY_ALBUM_CONFIG_SECRET:KeyTone/audioPackage/enc.FixedSecret"
# 示例:新增密钥时,取消注释并修改下行
# "KEY_NEW:KeyTone/signature.KeyToneNewKey"
)
# =================逻辑区域=================
+is_placeholder_value() {
+ local v="$1"
+ [[ -z "$v" ]] && return 0
+ [[ "$v" == PLACEHOLDER_* ]] && return 0
+ [[ "$v" == *REPLACE_ME* ]] && return 0
+ return 1
+}
+
+read_env_value_from_file() {
+ local key_name="$1"
+ local file_path="$2"
+
+ # 注意:dev.sh 使用了 `set -euo pipefail`,因此这里必须避免 grep/pipeline 失败导致脚本静默退出。
+ local line
+ line=$(grep -m 1 "^${key_name}=" "$file_path" 2>/dev/null || true)
+ if [ -z "$line" ]; then
+ echo ""
+ return 0
+ fi
+
+ local value
+ value="${line#*=}"
+ # 去掉可能的 CRLF
+ value="${value%$'\r'}"
+
+ # 支持 KEY="value" / KEY='value' / KEY=value 三种写法
+ if [[ "$value" == '"'*'"' ]]; then
+ value="${value#\"}"
+ value="${value%\"}"
+ elif [[ "$value" == "'"*"'" ]]; then
+ value="${value#\'}"
+ value="${value%\'}"
+ fi
+
+ echo "$value"
+}
+
# 检查私钥文件是否存在
+# 兼容性策略:
+# - 未提供私钥文件时,开源构建依然应当可运行(使用源码默认密钥);因此这里不再视为错误。
+# - 若仅需要部分注入(例如只注入授权流密钥),也允许其它 key 缺失并自动跳过。
if [ ! -f "$KEYS_FILE" ]; then
- # >&2 表示将输出重定向到标准错误(stderr),避免干扰正常的标准输出(stdout)
- echo "错误: 未找到私钥文件 $KEYS_FILE" >&2
- echo "请复制 private_keys.template.env 为 $KEYS_FILE 并填入您的密钥。" >&2
- # return 1 用于在 source 执行时退出脚本但不退出终端
- # exit 1 用于在直接执行时退出脚本
- return 1 2>/dev/null || exit 1
+ echo "提示: 未找到私钥文件 ${KEYS_FILE},将不设置 EXTRA_LDFLAGS(使用源码默认密钥/secret)。" >&2
+ export EXTRA_LDFLAGS=""
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ echo "export EXTRA_LDFLAGS=\"\""
+ fi
+ # 注意:这里必须直接 return/exit 以中止被 source 的脚本继续执行
+ return 0 2>/dev/null || exit 0
fi
# 检查混淆工具是否存在
if [ ! -f "$OBFUSCATOR_TOOL" ]; then
- echo "错误: 未找到混淆工具源码 $OBFUSCATOR_TOOL" >&2
+ echo "错误: 未找到混淆工具源码 ${OBFUSCATOR_TOOL}" >&2
return 1 2>/dev/null || exit 1
fi
@@ -80,15 +133,15 @@ for entry in "${KEYS_TO_PROCESS[@]}"; do
KEY_NAME=$(echo "$entry" | cut -d':' -f1)
GO_VAR=$(echo "$entry" | cut -d':' -f2)
- # 从文件中读取明文密钥
- # grep "^$KEY_NAME=" : 查找以 KEY_NAME= 开头的行
- # cut -d'"' -f2 : 以双引号为分隔符,提取中间的内容(即密钥值)
- PLAINTEXT_KEY=$(grep "^$KEY_NAME=" "$KEYS_FILE" | cut -d'"' -f2)
+ # 从文件中读取明文密钥(兼容 set -euo pipefail)
+ PLAINTEXT_KEY=$(read_env_value_from_file "${KEY_NAME}" "${KEYS_FILE}")
- # 检查是否成功读取到密钥
- if [ -z "$PLAINTEXT_KEY" ]; then
- echo "错误: 在 $KEYS_FILE 中未找到 $KEY_NAME" >&2
- return 1 2>/dev/null || exit 1
+ # 兼容性:
+ # - 若缺失该 KEY_*,则跳过注入,让 Go 侧回退到源码默认值(保持与旧版/开源版兼容)
+ # - 若仍是模板占位符,则视为“未配置”,同样跳过注入
+ if is_placeholder_value "$PLAINTEXT_KEY"; then
+ echo "提示: 跳过 ${KEY_NAME}(未配置或仍为模板占位符),将使用源码默认密钥/secret。" >&2
+ continue
fi
# 调用 Go 工具生成混淆后的 Hex 字符串
diff --git a/sdk/signature/album.go b/sdk/signature/album.go
index ddc1338f..590814a3 100644
--- a/sdk/signature/album.go
+++ b/sdk/signature/album.go
@@ -25,6 +25,15 @@ import (
"fmt"
)
+// ==============================
+// 专辑签名字段对称密钥变量定义
+// 注意:该密钥由源码提供默认值(开源构建保持原行为),也可在构建时通过 -ldflags 注入
+// 注入的值应为经过 XOR 混淆后的 Hex 字符串(与授权流一致)
+// ==============================
+
+// 默认开源密钥常量(明文)
+const DefaultAlbumSignatureFieldKey = "KeyTone2024Album_Signature_Field_EncryptionKey_32Bytes"
+
// KeyToneAlbumSignatureEncryptionKey 专辑签名字段专用加密密钥
//
// 用途:加密专辑配置中的signature字段内容
@@ -35,7 +44,7 @@ import (
// - 此密钥独立于签名管理的KeyA/KeyB,职责分离
// - 专辑配置本身已有基于albumUUID的派生密钥保护(外层加密)
// - 此密钥用于signature字段的内层加密,双重保护
-const KeyToneAlbumSignatureEncryptionKey = "KeyTone2024Album_Signature_Field_EncryptionKey_32Bytes"
+var KeyToneAlbumSignatureEncryptionKey = DefaultAlbumSignatureFieldKey
// GetAlbumSignatureKey 获取专辑签名加密密钥(32字节)
//
@@ -46,14 +55,18 @@ const KeyToneAlbumSignatureEncryptionKey = "KeyTone2024Album_Signature_Field_Enc
// - 确保密钥长度符合AES-256要求
// - 如密钥字符串不足32字节,自动填充0
func GetAlbumSignatureKey() []byte {
- key := []byte(KeyToneAlbumSignatureEncryptionKey)
- // 确保密钥长度为32字节
- if len(key) < 32 {
- for len(key) < 32 {
- key = append(key, 0)
+ // 1. 如果变量值等于默认常量,说明未注入,直接使用默认明文密钥
+ if KeyToneAlbumSignatureEncryptionKey == DefaultAlbumSignatureFieldKey {
+ key := []byte(DefaultAlbumSignatureFieldKey)
+ if len(key) < 32 {
+ for len(key) < 32 {
+ key = append(key, 0)
+ }
}
+ return key[:32]
}
- return key[:32]
+ // 2. 否则说明已被注入,执行解混淆逻辑(hex -> xor -> plaintext)
+ return deobfuscateKey(KeyToneAlbumSignatureEncryptionKey)
}
// EncryptAlbumSignatureField 加密专辑配置中的签名字段
diff --git a/sdk/signature/encryption.go b/sdk/signature/encryption.go
index 11873c82..aeedcb63 100644
--- a/sdk/signature/encryption.go
+++ b/sdk/signature/encryption.go
@@ -26,42 +26,60 @@ import (
"golang.org/x/crypto/pbkdf2"
)
-// 密钥常量定义
+// ==============================
+// 签名相关对称密钥变量定义
+// 注意:这些变量不再是 const,而是 var,以便在编译时通过 -ldflags 进行注入
+// 注入的值应为经过 XOR 混淆后的 Hex 字符串(与授权流一致)
+// ==============================
+
+// 定义默认的开源密钥常量,用于运行时比对
+const (
+ DefaultKeyA = "KeyTone2024Signature_KeyA_SecureEncryptionKeyForIDEncryption"
+ DefaultKeyB = "KeyTone2024Signature_KeyB_SuperSecureEncryptionKeyForExportImportOperation"
+)
// KeyToneSignatureEncryptionKeyA 密钥A:用于加密签名ID和生成动态密钥
// 安全等级:标准
// 长度: 32字节
-const KeyToneSignatureEncryptionKeyA = "KeyTone2024Signature_KeyA_SecureEncryptionKeyForIDEncryption"
+var KeyToneSignatureEncryptionKeyA = DefaultKeyA
// KeyToneSignatureEncryptionKeyB 密钥B:用于导出/导入加密,安全级别更高
// 安全等级:高
// 长度: 32字节
-const KeyToneSignatureEncryptionKeyB = "KeyTone2024Signature_KeyB_SuperSecureEncryptionKeyForExportImportOperation"
+var KeyToneSignatureEncryptionKeyB = DefaultKeyB
// GetKeyA 获取密钥A (32字节)
// 用途:加密签名ID、生成动态密钥
func GetKeyA() []byte {
- key := []byte(KeyToneSignatureEncryptionKeyA)
- if len(key) < 32 {
- // 如果密钥长度不足,需要填充
- for len(key) < 32 {
- key = append(key, 0)
+ // 1. 如果变量值等于默认常量,说明未注入,直接使用默认明文密钥
+ if KeyToneSignatureEncryptionKeyA == DefaultKeyA {
+ key := []byte(DefaultKeyA)
+ if len(key) < 32 {
+ for len(key) < 32 {
+ key = append(key, 0)
+ }
}
+ return key[:32]
}
- return key[:32]
+ // 2. 否则说明已被注入,执行解混淆逻辑(hex -> xor -> plaintext)
+ return deobfuscateKey(KeyToneSignatureEncryptionKeyA)
}
// GetKeyB 获取密钥B (32字节)
// 用途:导出/导入签名文件加密
func GetKeyB() []byte {
- key := []byte(KeyToneSignatureEncryptionKeyB)
- if len(key) < 32 {
- // 如果密钥长度不足,需要填充
- for len(key) < 32 {
- key = append(key, 0)
+ // 1. 如果变量值等于默认常量,说明未注入,直接使用默认明文密钥
+ if KeyToneSignatureEncryptionKeyB == DefaultKeyB {
+ key := []byte(DefaultKeyB)
+ if len(key) < 32 {
+ for len(key) < 32 {
+ key = append(key, 0)
+ }
}
+ return key[:32]
}
- return key[:32]
+ // 2. 否则说明已被注入,执行解混淆逻辑(hex -> xor -> plaintext)
+ return deobfuscateKey(KeyToneSignatureEncryptionKeyB)
}
// GenerateDynamicKey 根据加密的签名ID生成动态密钥
diff --git a/tools/key-obfuscator/main.go b/tools/key-obfuscator/main.go
index 9aa7aad3..2fc1a720 100644
--- a/tools/key-obfuscator/main.go
+++ b/tools/key-obfuscator/main.go
@@ -16,13 +16,16 @@ func main() {
flag.Parse()
if *keyPtr == "" {
- fmt.Println("Please provide a key using -key flag")
+ // NOTE: stdout is reserved for the machine-consumable hex output.
+ // Any human-facing messages MUST go to stderr to avoid polluting build flags.
+ fmt.Fprintln(os.Stderr, "Please provide a key using -key flag")
os.Exit(1)
}
key := []byte(*keyPtr)
if len(key) != 32 {
- fmt.Printf("Warning: Key length is %d, expected 32 bytes.\n", len(key))
+ // NOTE: keep warning on stderr so stdout stays pure hex.
+ fmt.Fprintf(os.Stderr, "Warning: Key length is %d, expected 32 bytes.\n", len(key))
}
obfuscated := make([]byte, len(key))
diff --git a/tools/ktalbum-tools/README.md b/tools/ktalbum-tools/README.md
index fb17b57d..ec2733a5 100644
--- a/tools/ktalbum-tools/README.md
+++ b/tools/ktalbum-tools/README.md
@@ -37,9 +37,9 @@ ktalbum-tools-v0.1.0-windows-amd64.exe web
./ktalbum-tools-v0.1.0-darwin-arm64 web
```
-2. 打开浏览器访问 `http://localhost:8080`
+1. 打开浏览器访问 `http://localhost:8080`
-3. 通过界面拖放或选择 .ktalbum 文件进行操作
+1. 通过界面拖放或选择 .ktalbum 文件进行操作
### 命令行
@@ -81,7 +81,7 @@ cd web/frontend
npm install
```
-2. 构建:
+1. 构建:
```bash
# Linux/macOS
@@ -92,9 +92,45 @@ chmod +x build.sh
build.bat
```
-### 目录结构
+### (可选)私有密钥注入构建
+
+ktalbum-tools 仅用于本地查看/调试 `.ktalbum` 文件内容。
+
+它的密钥逻辑与项目主程序保持一致:
+
+- 支持 v1/v2 版本化 XOR 密钥
+- 支持通过 Go `-ldflags -X` 注入混淆密钥(与 SDK 授权流相同的 XOR+hex 格式)
+- **为保证“同时兼容开源版本与私有密钥版本的产物”**:解密时会按顺序尝试“注入密钥 → 开源默认密钥”,并在需要时回退尝试 v1(与 SDK 的兼容策略一致)
+
+#### 使用方式(推荐复用 SDK 私钥文件)
+
+1. 在 SDK 目录准备私钥文件:复制 `sdk/private_keys.template.env` 为 `sdk/private_keys.env` 并填入 `KEY_ALBUM_EXPORT_V1`、`KEY_ALBUM_EXPORT_V2`
+
+1. 在 ktalbum-tools 目录加载注入参数:
+
+```bash
+cd tools/ktalbum-tools
+source ./setup_build_env.sh
+```
+
+1. 构建(推荐使用 build.sh,会自动应用 EXTRA_LDFLAGS):
+```bash
+chmod +x build.sh
+./build.sh
```
+
+如果你只想构建当前平台(不打包多平台 release),也可以:
+
+```bash
+go build -ldflags "$EXTRA_LDFLAGS" ./...
+```
+
+如果你不想复用 SDK 私钥文件,可在 `tools/ktalbum-tools` 下创建 `private_keys.env`,脚本会自动回退读取。
+
+### 目录结构
+
+```text
ktalbum-tools/
├── commands/ # 命令实现
├── utils/ # 工具函数
@@ -108,9 +144,9 @@ ktalbum-tools/
## 注意事项
1. Web 服务默认只监听 localhost,仅供本地使用
-2. 确保有足够的磁盘空间用于临时文件
-3. 处理大文件时可能需要较长时间
-4. 在 macOS/Linux 上需要给可执行文件添加执行权限:
+1. 确保有足够的磁盘空间用于临时文件
+1. 处理大文件时可能需要较长时间
+1. 在 macOS/Linux 上需要给可执行文件添加执行权限:
```bash
chmod +x ktalbum-tools-*
diff --git a/tools/ktalbum-tools/build.sh b/tools/ktalbum-tools/build.sh
index 9d47a7fc..1e2a448e 100644
--- a/tools/ktalbum-tools/build.sh
+++ b/tools/ktalbum-tools/build.sh
@@ -3,6 +3,17 @@
# 版本号
VERSION="v0.1.0"
+# 额外 ldflags(用于构建时密钥注入)
+# - 若未设置 EXTRA_LDFLAGS,则保持开源默认密钥行为
+# - 若已通过 setup_build_env.sh 设置,则会自动合并到 -ldflags 中
+LDFLAGS="-s -w"
+if [ -n "$EXTRA_LDFLAGS" ]; then
+ echo "检测到 EXTRA_LDFLAGS,将在构建时注入密钥..."
+ LDFLAGS="$LDFLAGS $EXTRA_LDFLAGS"
+else
+ echo "未设置 EXTRA_LDFLAGS,将使用开源默认密钥构建。"
+fi
+
# 构建前端
echo "构建前端..."
cd web/frontend
@@ -30,19 +41,19 @@ mkdir -p release
echo "构建各平台版本..."
# Windows (64-bit, x86_64)
-GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o release/ktalbum-tools-${VERSION}-windows-amd64.exe
+GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o release/ktalbum-tools-${VERSION}-windows-amd64.exe
# Windows (64-bit, ARM64)
-GOOS=windows GOARCH=arm64 go build -ldflags="-s -w" -o release/ktalbum-tools-${VERSION}-windows-arm64.exe
+GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o release/ktalbum-tools-${VERSION}-windows-arm64.exe
# macOS (64-bit, Intel)
-GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o release/ktalbum-tools-${VERSION}-darwin-amd64
+GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o release/ktalbum-tools-${VERSION}-darwin-amd64
# macOS (64-bit, Apple Silicon)
-GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o release/ktalbum-tools-${VERSION}-darwin-arm64
+GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o release/ktalbum-tools-${VERSION}-darwin-arm64
# Linux (64-bit)
-GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o release/ktalbum-tools-${VERSION}-linux-amd64
+GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o release/ktalbum-tools-${VERSION}-linux-amd64
# 为 Unix-like 系统添加执行权限
chmod +x release/ktalbum-tools-${VERSION}-darwin-*
diff --git a/tools/ktalbum-tools/commands/extract.go b/tools/ktalbum-tools/commands/extract.go
index 074cdbd5..b5d13e2e 100644
--- a/tools/ktalbum-tools/commands/extract.go
+++ b/tools/ktalbum-tools/commands/extract.go
@@ -43,13 +43,35 @@ func Extract(inputFile, outputFile string, verbose bool) error {
return fmt.Errorf("读取加密数据失败: %v", err)
}
- // 解密数据
- zipData := utils.XorCrypt(encryptedData, utils.KeytoneEncryptKey)
+ // 解密数据(按版本选择候选密钥;私有密钥构建优先注入,回退默认,兼容开源产物)
+ var zipData []byte
+ decrypted := false
- // 验证校验和
- checksum := sha256.Sum256(zipData)
- if checksum != header.Checksum {
- return fmt.Errorf("文件校验失败,文件可能已损坏")
+ for _, decryptKey := range utils.GetDecryptKeyCandidatesByVersion(header.Version) {
+ candidate := utils.XorCrypt(encryptedData, decryptKey)
+ checksum := sha256.Sum256(candidate)
+ if checksum == header.Checksum {
+ zipData = candidate
+ decrypted = true
+ break
+ }
+ }
+
+ // 与 SDK 一致:若校验失败且版本不是 v1,尝试 v1 候选回退
+ if !decrypted && header.Version != 1 {
+ for _, decryptKey := range utils.GetDecryptKeyCandidatesByVersion(1) {
+ candidate := utils.XorCrypt(encryptedData, decryptKey)
+ checksum := sha256.Sum256(candidate)
+ if checksum == header.Checksum {
+ zipData = candidate
+ decrypted = true
+ break
+ }
+ }
+ }
+
+ if !decrypted {
+ return fmt.Errorf("文件校验失败,文件可能已损坏或密钥不匹配")
}
// 写入解密后的zip数据
diff --git a/tools/ktalbum-tools/commands/info.go b/tools/ktalbum-tools/commands/info.go
index e395035a..30718d70 100644
--- a/tools/ktalbum-tools/commands/info.go
+++ b/tools/ktalbum-tools/commands/info.go
@@ -43,13 +43,35 @@ func GetFileInfo(filePath string) (*FileInfo, error) {
return nil, fmt.Errorf("读取加密数据失败: %v", err)
}
- // 解密数据
- zipData := utils.XorCrypt(encryptedData, utils.KeytoneEncryptKey)
+ // 解密数据(按版本选择候选密钥;私有密钥构建优先注入,回退默认,兼容开源产物)
+ var zipData []byte
+ decrypted := false
- // 验证校验和
- checksum := utils.CalculateChecksum(zipData)
- if !bytes.Equal(checksum[:], header.Checksum[:]) {
- return nil, fmt.Errorf("文件校验失败,文件可能已损坏")
+ for _, decryptKey := range utils.GetDecryptKeyCandidatesByVersion(header.Version) {
+ candidate := utils.XorCrypt(encryptedData, decryptKey)
+ checksum := utils.CalculateChecksum(candidate)
+ if bytes.Equal(checksum[:], header.Checksum[:]) {
+ zipData = candidate
+ decrypted = true
+ break
+ }
+ }
+
+ // 与 SDK 一致:若校验失败且版本不是 v1,尝试 v1 候选回退
+ if !decrypted && header.Version != 1 {
+ for _, decryptKey := range utils.GetDecryptKeyCandidatesByVersion(1) {
+ candidate := utils.XorCrypt(encryptedData, decryptKey)
+ checksum := utils.CalculateChecksum(candidate)
+ if bytes.Equal(checksum[:], header.Checksum[:]) {
+ zipData = candidate
+ decrypted = true
+ break
+ }
+ }
+ }
+
+ if !decrypted {
+ return nil, fmt.Errorf("文件校验失败,文件可能已损坏或密钥不匹配")
}
// 从 zip 数据中读取 .keytone-album 文件
diff --git a/tools/ktalbum-tools/private_keys.template.env b/tools/ktalbum-tools/private_keys.template.env
new file mode 100644
index 00000000..8b5e8e34
--- /dev/null
+++ b/tools/ktalbum-tools/private_keys.template.env
@@ -0,0 +1,11 @@
+# ktalbum-tools 私钥配置模板(可选)
+#
+# 通常建议直接复用 SDK 的私钥文件:sdk/private_keys.env
+# 如果你希望 ktalbum-tools 使用独立文件,也可以在 tools/ktalbum-tools 下创建 private_keys.env
+# 并填写下列字段(不要提交到 git)。
+
+# 专辑导出文件 XOR 密钥 v1(旧版本,用于向后兼容)
+KEY_ALBUM_EXPORT_V1="PLACEHOLDER_ALBUM_EXPORT_KEY_V1"
+
+# 专辑导出文件 XOR 密钥 v2(当前版本)
+KEY_ALBUM_EXPORT_V2="PLACEHOLDER_ALBUM_EXPORT_KEY_V2"
diff --git a/tools/ktalbum-tools/setup_build_env.sh b/tools/ktalbum-tools/setup_build_env.sh
new file mode 100644
index 00000000..3d6be672
--- /dev/null
+++ b/tools/ktalbum-tools/setup_build_env.sh
@@ -0,0 +1,135 @@
+#!/bin/bash
+
+# tools/ktalbum-tools/setup_build_env.sh
+# ======================================================================================
+# 脚本功能说明:
+# 该脚本用于 ktalbum-tools 的本地构建(个人调试/查看用),支持与 SDK 相同的“构建时密钥注入”机制。
+#
+# 设计目标:
+# - 开源构建:不需要任何私钥文件即可工作(使用默认开源密钥)
+# - 私有构建:可读取 sdk/private_keys.env(或本地 private_keys.env),生成混淆后的 Hex,并注入到 ktalbum-tools
+# - 工具兼容性:ktalbum-tools 运行时会同时尝试“注入密钥”与“开源默认密钥”进行解密,以兼容两类产物
+# ======================================================================================
+
+# 使用方法:
+# 方式一(推荐):在当前终端加载环境变量
+# source ./setup_build_env.sh
+#
+# 方式二:仅获取 export 命令(可配合 eval)
+# ./setup_build_env.sh
+# eval $(./setup_build_env.sh)
+
+# =================配置区域=================
+
+# 1. 私钥文件路径
+# 优先复用 SDK 的私钥文件(不提交到 git)
+# 可通过环境变量 KEYS_FILE 覆盖
+DEFAULT_KEYS_FILE="../../sdk/private_keys.env"
+KEYS_FILE="${KEYS_FILE:-$DEFAULT_KEYS_FILE}"
+
+# 如果默认路径不存在,尝试使用当前目录的 private_keys.env
+if [ ! -f "$KEYS_FILE" ]; then
+ if [ -f "private_keys.env" ]; then
+ KEYS_FILE="private_keys.env"
+ fi
+fi
+
+# 2. 混淆工具源码路径(与 SDK 共用同一份)
+OBFUSCATOR_TOOL="../key-obfuscator/main.go"
+
+# 3. 定义需要处理的密钥列表
+# ktalbum-tools 只需要专辑导出文件 XOR key(v1/v2)即可解密 .ktalbum
+# 格式: "环境变量中的键名:Go代码中的变量全路径"
+KEYS_TO_PROCESS=(
+ "KEY_ALBUM_EXPORT_V1:ktalbum-tools/utils.KeytoneEncryptKeyV1"
+ "KEY_ALBUM_EXPORT_V2:ktalbum-tools/utils.KeytoneEncryptKeyV2"
+)
+
+# =================逻辑区域=================
+
+is_placeholder_value() {
+ local v="$1"
+ [[ -z "$v" ]] && return 0
+ [[ "$v" == PLACEHOLDER_* ]] && return 0
+ [[ "$v" == *REPLACE_ME* ]] && return 0
+ return 1
+}
+
+read_env_value_from_file() {
+ local key_name="$1"
+ local file_path="$2"
+ local line
+ line=$(grep -m 1 "^${key_name}=" "$file_path" 2>/dev/null || true)
+ if [ -z "$line" ]; then
+ echo ""
+ return 0
+ fi
+ local value
+ value="${line#*=}"
+ value="${value%$'\r'}"
+ if [[ "$value" == '"'*'"' ]]; then
+ value="${value#\"}"
+ value="${value%\"}"
+ elif [[ "$value" == "'"*"'" ]]; then
+ value="${value#\'}"
+ value="${value%\'}"
+ fi
+ echo "$value"
+}
+
+# 检查混淆工具是否存在
+if [ ! -f "$OBFUSCATOR_TOOL" ]; then
+ echo "错误: 未找到混淆工具源码 ${OBFUSCATOR_TOOL}" >&2
+ return 1 2>/dev/null || exit 1
+fi
+
+# 如果没有私钥文件,允许继续(开源构建不需要 EXTRA_LDFLAGS)
+if [ ! -f "$KEYS_FILE" ]; then
+ echo "提示: 未找到私钥文件 ${KEYS_FILE},将不设置 EXTRA_LDFLAGS(开源默认密钥模式)。" >&2
+ export EXTRA_LDFLAGS=""
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ echo "export EXTRA_LDFLAGS=\"\""
+ fi
+ return 0 2>/dev/null || exit 0
+fi
+
+# 初始化 LDFLAGS 字符串
+LDFLAGS=""
+
+echo "正在处理 ktalbum-tools 密钥混淆..." >&2
+
+for entry in "${KEYS_TO_PROCESS[@]}"; do
+ KEY_NAME=$(echo "$entry" | cut -d':' -f1)
+ GO_VAR=$(echo "$entry" | cut -d':' -f2)
+
+ PLAINTEXT_KEY=$(read_env_value_from_file "${KEY_NAME}" "${KEYS_FILE}")
+ if is_placeholder_value "$PLAINTEXT_KEY"; then
+ echo "提示: 跳过 ${KEY_NAME}(未配置或仍为模板占位符),将不设置 EXTRA_LDFLAGS(开源默认密钥模式)。" >&2
+ continue
+ fi
+
+ OBFUSCATED_VAL=$(go run "$OBFUSCATOR_TOOL" -key "$PLAINTEXT_KEY")
+ if [ $? -ne 0 ]; then
+ echo "错误: 密钥 $KEY_NAME 混淆失败" >&2
+ return 1 2>/dev/null || exit 1
+ fi
+
+ LDFLAGS="$LDFLAGS -X '$GO_VAR=$OBFUSCATED_VAL'"
+done
+
+export EXTRA_LDFLAGS="$LDFLAGS"
+
+# 若没有任何 key 被注入,则显式清空(避免上次会话残留)
+if [ -z "$LDFLAGS" ]; then
+ export EXTRA_LDFLAGS=""
+fi
+
+echo "成功!已设置 EXTRA_LDFLAGS(ktalbum-tools)。" >&2
+
+echo "示例构建:" >&2
+
+echo " go build -ldflags \"$EXTRA_LDFLAGS\" ./..." >&2
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ echo "export EXTRA_LDFLAGS=\"$LDFLAGS\""
+fi
diff --git a/tools/ktalbum-tools/utils/header.go b/tools/ktalbum-tools/utils/header.go
index 7f23fc3c..c65fe414 100644
--- a/tools/ktalbum-tools/utils/header.go
+++ b/tools/ktalbum-tools/utils/header.go
@@ -1,12 +1,98 @@
package utils
-import "time"
+import (
+ "encoding/hex"
+ "time"
+)
const (
KeytoneFileSignature = "KTALBUM"
- KeytoneEncryptKey = "KeyTone2024SecretKey"
)
+// ==============================
+// 专辑导出文件对称密钥(版本化)
+// 注意:这些变量不再是 const,而是 var,以便在编译时通过 -ldflags 进行注入
+// 注入的值应为经过 XOR 混淆后的 Hex 字符串(与 SDK 授权流一致)
+// ==============================
+
+// xorMask 用于混淆密钥的掩码,必须与 SDK 授权流一致
+var xorMask = []byte{0x55, 0xAA, 0x33, 0xCC, 0x99, 0x66, 0x11, 0xEE, 0x77, 0xBB, 0x22, 0xDD, 0x88, 0x44, 0xFF, 0x00}
+
+// 默认开源密钥常量(明文)
+const (
+ DefaultKeytoneEncryptKeyV1 = "KeyTone2024SecretKey" // v1
+ DefaultKeytoneEncryptKeyV2 = "KeyTone2025AlbumSecureEncryptionKeyV2" // v2
+)
+
+// 版本化加密密钥(可注入)
+var (
+ KeytoneEncryptKeyV1 = DefaultKeytoneEncryptKeyV1
+ KeytoneEncryptKeyV2 = DefaultKeytoneEncryptKeyV2
+ // 向后兼容:旧变量名仍保留(等价于 v1)
+ KeytoneEncryptKey = KeytoneEncryptKeyV1
+)
+
+func deobfuscateString(obfuscatedHex string) string {
+ obfuscated, err := hex.DecodeString(obfuscatedHex)
+ if err != nil {
+ return obfuscatedHex
+ }
+ realBytes := make([]byte, len(obfuscated))
+ for i, b := range obfuscated {
+ realBytes[i] = b ^ xorMask[i%len(xorMask)]
+ }
+ return string(realBytes)
+}
+
+func getPlainEncryptKey(value string, defaultValue string) string {
+ if value == defaultValue {
+ return defaultValue
+ }
+ return deobfuscateString(value)
+}
+
+func getDecryptKeyCandidates(value string, defaultValue string) []string {
+ // 未注入:仅返回默认值
+ if value == defaultValue {
+ return []string{defaultValue}
+ }
+
+ // 已注入:优先返回注入后的明文密钥,并追加默认值作为“兼容开源产物”的回退
+ primary := getPlainEncryptKey(value, defaultValue)
+ if primary == defaultValue {
+ return []string{defaultValue}
+ }
+ return []string{primary, defaultValue}
+}
+
+// GetEncryptKeyByVersion 根据文件头版本号返回对应的明文密钥。
+func GetEncryptKeyByVersion(version uint8) string {
+ switch version {
+ case 1:
+ return getPlainEncryptKey(KeytoneEncryptKeyV1, DefaultKeytoneEncryptKeyV1)
+ case 2:
+ return getPlainEncryptKey(KeytoneEncryptKeyV2, DefaultKeytoneEncryptKeyV2)
+ default:
+ // 未知版本:保守回退到 v2(与 SDK 保持一致的“当前版本”语义)
+ return getPlainEncryptKey(KeytoneEncryptKeyV2, DefaultKeytoneEncryptKeyV2)
+ }
+}
+
+// GetDecryptKeyCandidatesByVersion 返回解密候选密钥列表。
+// 设计目标:
+// - 开源构建:仅使用默认密钥
+// - 私有密钥构建(注入后):优先使用注入密钥,同时回退尝试默认密钥,保证工具可同时解密两类产物
+func GetDecryptKeyCandidatesByVersion(version uint8) []string {
+ switch version {
+ case 1:
+ return getDecryptKeyCandidates(KeytoneEncryptKeyV1, DefaultKeytoneEncryptKeyV1)
+ case 2:
+ return getDecryptKeyCandidates(KeytoneEncryptKeyV2, DefaultKeytoneEncryptKeyV2)
+ default:
+ return getDecryptKeyCandidates(KeytoneEncryptKeyV2, DefaultKeytoneEncryptKeyV2)
+ }
+}
+
type KeytoneFileHeader struct {
Signature [7]byte
Version uint8