publish-apk 0.0.1-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 鸿蒙 hvigor 签名 material 的生成与口令加解密。
3
+ *
4
+ * 背景:hvigor 的 build-profile.json5 不接受明文口令 ——
5
+ * `signingConfigs[].material.storePassword` / `keyPassword` 是「用 material 里的 workKey
6
+ * 加密过的十六进制串」,签名时由 hvigor 的 DecipherUtil 解密。解密的钥匙来自
7
+ * **storeFile(.p12) 同级目录**下的 material/:
8
+ *
9
+ * <storeFile 所在目录>/
10
+ * material/
11
+ * fd/0/<随机名> 16 字节随机数
12
+ * fd/1/<随机名> 16 字节随机数
13
+ * fd/2/<随机名> 16 字节随机数
14
+ * ac/<随机名> salt
15
+ * ce/<随机名> 被 rootKey 加密过的 workKey
16
+ *
17
+ * rootKey = pbkdf2(xor(fd/0, fd/1, fd/2, 固定分量).toString(), ac 里的 salt, 10000 轮, 16 字节, sha256)
18
+ * workKey = aes-128-gcm 解密 ce(用 rootKey 解)
19
+ * 明文口令 = aes-128-gcm 解密 storePassword / keyPassword(用 workKey 解)
20
+ *
21
+ * 密文格式(DecipherUtil.decrypt 的读法):4 字节大端 e + IV + 密文 + 16 字节 tag,
22
+ * IV 长度 = 总长度 - 4 - e(DevEco 用 12 字节 IV,即 e = 密文长度 + 16)。
23
+ * xor 结果要按 utf8 转成字符串再进 pbkdf2 —— hvigor 就是这么写的。
24
+ *
25
+ * 这份实现与 hvigor 的 DecipherUtil 对应;material 由 DevEco Studio 或本工具生成,
26
+ * 与本机绑定:material 丢了,那两串密文就解不开(明文在手可以重新生成一套)。
27
+ */
28
+ import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto'
29
+ import fs from 'node:fs'
30
+ import path from 'node:path'
31
+
32
+ /** DecipherUtil.component:固定的 16 字节分量,参与 rootKey 的 xor */
33
+ const FD_COMPONENT = Buffer.from([
34
+ 49, 243, 9, 115, 214, 175, 91, 184, 211, 190, 177, 88, 101, 131, 192, 119,
35
+ ])
36
+
37
+ const GCM_IV_LENGTH = 12
38
+ const GCM_TAG_LENGTH = 16
39
+
40
+ /** 密钥 / 口令落盘时一律 0600 */
41
+ export function writeSecret(file, buffer) {
42
+ fs.mkdirSync(path.dirname(file), { recursive: true })
43
+ fs.writeFileSync(file, buffer, { mode: 0o600 })
44
+ return file
45
+ }
46
+
47
+ function entriesOf(dir) {
48
+ return fs.readdirSync(dir).filter((name) => name !== '.DS_Store')
49
+ }
50
+
51
+ /** hvigor 的 readDirBytes:目录下恰好一个文件,读它的内容 */
52
+ function readOnlyFile(dir) {
53
+ const names = entriesOf(dir)
54
+ if (names.length !== 1) {
55
+ throw new Error(`${dir} 下应当恰好有 1 个文件,实际 ${names.length} 个`)
56
+ }
57
+ return fs.readFileSync(path.join(dir, names[0]))
58
+ }
59
+
60
+ function aesGcmEncrypt(key, plain) {
61
+ const iv = randomBytes(GCM_IV_LENGTH)
62
+ const cipher = createCipheriv('aes-128-gcm', key, iv)
63
+ const body = Buffer.concat([cipher.update(plain), cipher.final()])
64
+ const tag = cipher.getAuthTag()
65
+ // 4 字节大端 e = 密文长度 + tag 长度(从末尾往回数的起点,IV 长度 = 总长 - 4 - e)
66
+ const head = Buffer.alloc(4)
67
+ head.writeUInt32BE(body.length + tag.length, 0)
68
+ return Buffer.concat([head, iv, body, tag])
69
+ }
70
+
71
+ function aesGcmDecrypt(key, blob) {
72
+ const tail = blob.readUInt32BE(0)
73
+ const ivLength = blob.length - 4 - tail
74
+ if (ivLength <= 0) {
75
+ throw new Error('密文格式不对:按 4 字节前缀算出来的 IV 长度不是正数')
76
+ }
77
+ const decipher = createDecipheriv('aes-128-gcm', key, blob.subarray(4, 4 + ivLength))
78
+ decipher.setAuthTag(blob.subarray(blob.length - GCM_TAG_LENGTH))
79
+ return Buffer.concat([
80
+ decipher.update(blob.subarray(4 + ivLength, blob.length - GCM_TAG_LENGTH)),
81
+ decipher.final(),
82
+ ])
83
+ }
84
+
85
+ /** 用 material 里的 workKey 把明文口令加密成 build-profile.json5 认识的那串十六进制 */
86
+ export function encryptSecret(workKey, plain) {
87
+ return aesGcmEncrypt(workKey, Buffer.from(plain, 'utf8')).toString('hex')
88
+ }
89
+
90
+ /**
91
+ * 现场生成一套 material。
92
+ *
93
+ * @param {string} dir material 目录(与 .p12 同级)
94
+ * @param {Buffer} workKey 16 字节 workKey
95
+ */
96
+ export function generateMaterial(dir, workKey) {
97
+ const randomName = () => randomBytes(16).toString('hex')
98
+ const fds = [0, 1, 2].map((index) => {
99
+ const bytes = randomBytes(16)
100
+ writeSecret(path.join(dir, 'fd', String(index), randomName()), bytes)
101
+ return bytes
102
+ })
103
+ const salt = randomBytes(16)
104
+ writeSecret(path.join(dir, 'ac', randomName()), salt)
105
+
106
+ const xor = Buffer.from(fds[0])
107
+ for (const bytes of fds.slice(1)) {
108
+ for (let index = 0; index < 16; index += 1) {
109
+ xor[index] ^= bytes[index]
110
+ }
111
+ }
112
+ for (let index = 0; index < 16; index += 1) {
113
+ xor[index] ^= FD_COMPONENT[index]
114
+ }
115
+ const rootKey = pbkdf2Sync(xor.toString(), salt, 1e4, 16, 'sha256')
116
+ writeSecret(path.join(dir, 'ce', randomName()), aesGcmEncrypt(rootKey, workKey))
117
+
118
+ // material 是解开密文口令的钥匙:目录 0700,文件 0600
119
+ for (const target of [dir, path.join(dir, 'fd'), path.join(dir, 'ac'), path.join(dir, 'ce')]) {
120
+ fs.chmodSync(target, 0o700)
121
+ }
122
+ for (const index of ['0', '1', '2']) {
123
+ fs.chmodSync(path.join(dir, 'fd', index), 0o700)
124
+ }
125
+ return workKey
126
+ }
127
+
128
+ /** 从 material 目录(.p12 同级那个 material/)解出 workKey */
129
+ export function readMaterialKey(materialDir) {
130
+ const dir = path.resolve(materialDir)
131
+ const fdRoot = path.join(dir, 'fd')
132
+ if (!fs.existsSync(fdRoot) || !fs.statSync(fdRoot).isDirectory()) {
133
+ throw new Error(`找不到 material 的 fd 目录:${fdRoot}(material 目录与 .p12 必须同级)`)
134
+ }
135
+ const fdNames = entriesOf(fdRoot)
136
+ if (fdNames.length !== 3) {
137
+ throw new Error(`${fdRoot} 下应当有 3 个子目录(fd/0、fd/1、fd/2),实际 ${fdNames.length} 个`)
138
+ }
139
+ const fds = fdNames.map((name) => readOnlyFile(path.join(fdRoot, name)))
140
+ for (const bytes of fds) {
141
+ if (bytes.length !== 16) {
142
+ throw new Error(`${fdRoot} 下的分量应当是 16 字节,实际 ${bytes.length} 字节`)
143
+ }
144
+ }
145
+ const salt = readOnlyFile(path.join(dir, 'ac'))
146
+ const ce = readOnlyFile(path.join(dir, 'ce'))
147
+ const xor = Buffer.from(fds[0])
148
+ for (const bytes of fds.slice(1)) {
149
+ for (let index = 0; index < 16; index += 1) {
150
+ xor[index] ^= bytes[index]
151
+ }
152
+ }
153
+ for (let index = 0; index < 16; index += 1) {
154
+ xor[index] ^= FD_COMPONENT[index]
155
+ }
156
+ const rootKey = pbkdf2Sync(xor.toString(), salt, 1e4, 16, 'sha256')
157
+ return aesGcmDecrypt(rootKey, ce)
158
+ }
159
+
160
+ /** 把 build-profile.json5 里的密文口令解成明文(本工具生成时用于往返自检) */
161
+ export function decryptSecret(materialDir, encryptedHex, label = '口令') {
162
+ if (
163
+ typeof encryptedHex !== 'string' ||
164
+ !/^[0-9a-fA-F]+$/.test(encryptedHex) ||
165
+ encryptedHex.length % 2 !== 0
166
+ ) {
167
+ throw new Error(`${label} 不是十六进制密文,无法解密`)
168
+ }
169
+ const blob = Buffer.from(encryptedHex, 'hex')
170
+ if (blob.length < 32) {
171
+ throw new Error(`${label} 只有 ${blob.length} 字节,不是 hvigor 认识的密文`)
172
+ }
173
+ return aesGcmDecrypt(readMaterialKey(materialDir), blob).toString('utf8')
174
+ }
package/src/help.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 帮助文档渲染:把「标题 + 用法 + 分节列表 + 说明」渲染成对齐的文本。
3
+ *
4
+ * 本模块只负责排版,不含任何厂商文案——厂商的帮助内容由各自模块构造后传进来。
5
+ * 中文等全角字符在终端中占两个字符宽度,直接用 String#padEnd 会让描述列错位,
6
+ * 因此这里按「显示宽度」补齐。
7
+ */
8
+
9
+ /** 占用两个字符宽度的字符(CJK 等全角字符) */
10
+ const WIDE_CHAR =
11
+ /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/
12
+
13
+ /** 计算字符串在终端中的显示宽度 */
14
+ function displayWidth(text) {
15
+ let width = 0
16
+ for (const char of text) {
17
+ width += WIDE_CHAR.test(char) ? 2 : 1
18
+ }
19
+ return width
20
+ }
21
+
22
+ /** 按显示宽度在右侧补空格 */
23
+ function padEndByWidth(text, width) {
24
+ return text + ' '.repeat(Math.max(0, width - displayWidth(text)))
25
+ }
26
+
27
+ /** 渲染一组「名称 + 描述」列表,描述列统一对齐 */
28
+ function renderEntries(entries) {
29
+ const width = entries.reduce((max, [name]) => Math.max(max, displayWidth(name)), 0) + 2
30
+ return entries
31
+ .map(([name, description]) => ` ${padEndByWidth(name, width)}${description ?? ''}`.trimEnd())
32
+ .join('\n')
33
+ }
34
+
35
+ /**
36
+ * 渲染一份帮助文档。
37
+ *
38
+ * @param {{ title: string, usage: string[], sections: { title: string, entries: [string, string][] }[], notes?: string[] }} doc
39
+ * @returns {string}
40
+ */
41
+ export function renderHelp({ title, usage, sections, notes = [] }) {
42
+ const lines = [title, '', '用法:', ...usage.map((line) => ` ${line}`)]
43
+ for (const { title: sectionTitle, entries } of sections) {
44
+ lines.push('', `${sectionTitle}:`, renderEntries(entries))
45
+ }
46
+ if (notes.length > 0) {
47
+ lines.push('', ...notes)
48
+ }
49
+ return lines.join('\n')
50
+ }