@aikdna/kdna-cli 0.19.2 → 0.20.0
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.
- package/LICENSE +1 -1
- package/README.md +42 -28
- package/package.json +4 -4
- package/skills/kdna-loader/SKILL.md +3 -1
- package/src/capsule-verify.js +70 -0
- package/src/cli.js +72 -47
- package/src/cmds/_common.js +66 -8
- package/src/cmds/domain.js +20 -1
- package/src/cmds/governance.js +1 -1
- package/src/cmds/protect.js +313 -0
- package/src/cmds/protect.js.bak +245 -0
- package/src/cmds/protocol.js +181 -0
- package/src/cmds/studio.js +7 -10
- package/src/cmds/workpack.js +875 -0
- package/src/dev-pack-v2.js +117 -0
- package/src/init.js +14 -6
- package/src/install.js +116 -2
- package/src/kdf-spec.js +42 -0
- package/src/package-store.js +29 -7
- package/src/paths.js +3 -2
- package/src/publish.js +92 -184
- package/src/registry.js +73 -3
- package/src/signature.js +39 -0
- package/src/verify.js +78 -0
- package/templates/cluster/README.md +1 -1
- package/templates/standard-domain/USAGE.md +2 -1
- package/templates/standard-domain/kdna.json +1 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KDNA Protected Asset Commands (RFC-0009)
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* kdna protect <file.kdna> --out <file.kdna> [--entries <list>]
|
|
6
|
+
* kdna unlock <file.kdna> [--profile compact|index|full]
|
|
7
|
+
* kdna recover <file.kdna> --out <file.kdna> [--code-stdin]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { EXIT, error, promptPassword } = require('./_common');
|
|
13
|
+
const {
|
|
14
|
+
createKdnaAssetReader,
|
|
15
|
+
createPasswordDecryptEntry,
|
|
16
|
+
createRecoveryDecryptEntry,
|
|
17
|
+
encryptProtectedEntry,
|
|
18
|
+
generateRecoveryCode,
|
|
19
|
+
} = require('@aikdna/kdna-core');
|
|
20
|
+
|
|
21
|
+
function parseEntriesFlag(flag) {
|
|
22
|
+
if (!flag) return ['KDNA_Core.json'];
|
|
23
|
+
return flag.split(',').map((s) => s.trim());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cmdProtect(args) {
|
|
27
|
+
const file = args[0];
|
|
28
|
+
if (!file)
|
|
29
|
+
error(
|
|
30
|
+
'Usage: kdna protect <file.kdna> --out <file.kdna> [--entries KDNA_Core.json,KDNA_Patterns.json]',
|
|
31
|
+
EXIT.INPUT_ERROR,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const outIdx = args.indexOf('--out');
|
|
35
|
+
const outPath = outIdx >= 0 ? args[outIdx + 1] : null;
|
|
36
|
+
if (!outPath) error('Missing --out', EXIT.INPUT_ERROR);
|
|
37
|
+
|
|
38
|
+
const entriesIdx = args.indexOf('--entries');
|
|
39
|
+
const entriesToEncrypt = parseEntriesFlag(entriesIdx >= 0 ? args[entriesIdx + 1] : null);
|
|
40
|
+
|
|
41
|
+
if (!fs.existsSync(file)) error(`File not found: ${file}`, EXIT.INPUT_ERROR);
|
|
42
|
+
|
|
43
|
+
const password = promptPassword('Password: ');
|
|
44
|
+
if (!password) error('Password is required.', EXIT.INPUT_ERROR);
|
|
45
|
+
|
|
46
|
+
const reader = createKdnaAssetReader();
|
|
47
|
+
const asset = reader.openSync(file);
|
|
48
|
+
const manifest = reader.readManifestSync(asset);
|
|
49
|
+
|
|
50
|
+
if (manifest.access === 'protected') {
|
|
51
|
+
error('Asset is already protected. Use recover to change password.', EXIT.INPUT_ERROR);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Update manifest
|
|
55
|
+
const newManifest = {
|
|
56
|
+
...manifest,
|
|
57
|
+
access: 'protected',
|
|
58
|
+
encryption: { profile: 'kdna-password-protected-v1', encrypted_entries: entriesToEncrypt },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Build new ZIP with encrypted entries
|
|
62
|
+
const allEntries = reader.listEntriesSync(asset);
|
|
63
|
+
const zipEntries = {};
|
|
64
|
+
const recoveryCode = generateRecoveryCode();
|
|
65
|
+
|
|
66
|
+
for (const entryName of allEntries) {
|
|
67
|
+
if (entryName === 'kdna.json') {
|
|
68
|
+
zipEntries[entryName] = JSON.stringify(newManifest);
|
|
69
|
+
} else if (entriesToEncrypt.includes(entryName)) {
|
|
70
|
+
const plaintext = reader.readEntrySync(asset, entryName);
|
|
71
|
+
const encrypted = encryptProtectedEntry(plaintext, {
|
|
72
|
+
entryName,
|
|
73
|
+
manifest: newManifest,
|
|
74
|
+
password,
|
|
75
|
+
recoveryCode,
|
|
76
|
+
});
|
|
77
|
+
zipEntries[entryName] = JSON.stringify(encrypted);
|
|
78
|
+
} else {
|
|
79
|
+
zipEntries[entryName] = reader.readEntrySync(asset, entryName);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Add mimetype if missing
|
|
84
|
+
if (!zipEntries.mimetype) {
|
|
85
|
+
zipEntries.mimetype = 'application/vnd.aikdna.kdna+zip';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Recompute content digest and strip invalidated signature after encryption
|
|
89
|
+
updateManifestDigest(zipEntries, reader);
|
|
90
|
+
|
|
91
|
+
const zipBuffer = buildZip(zipEntries);
|
|
92
|
+
fs.writeFileSync(outPath, zipBuffer);
|
|
93
|
+
|
|
94
|
+
console.log(`Protected asset written to: ${outPath}`);
|
|
95
|
+
console.log(`Encrypted entries: ${entriesToEncrypt.join(', ')}`);
|
|
96
|
+
console.log('Recovery code: (displayed once — save it)');
|
|
97
|
+
console.log(` ${recoveryCode}`);
|
|
98
|
+
console.log(' Use `kdna recover` if you forget the password.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function cmdUnlock(args) {
|
|
102
|
+
const file = args[0];
|
|
103
|
+
if (!file)
|
|
104
|
+
error('Usage: kdna unlock <file.kdna> [--profile compact|index|full]', EXIT.INPUT_ERROR);
|
|
105
|
+
|
|
106
|
+
const profileIdx = args.indexOf('--profile');
|
|
107
|
+
const profile = profileIdx >= 0 ? args[profileIdx + 1] : 'compact';
|
|
108
|
+
|
|
109
|
+
if (!fs.existsSync(file)) error(`File not found: ${file}`, EXIT.INPUT_ERROR);
|
|
110
|
+
|
|
111
|
+
const password = promptPassword('Password: ');
|
|
112
|
+
if (!password) error('Password is required.', EXIT.INPUT_ERROR);
|
|
113
|
+
|
|
114
|
+
const reader = createKdnaAssetReader();
|
|
115
|
+
const asset = reader.openSync(file);
|
|
116
|
+
const manifest = reader.readManifestSync(asset);
|
|
117
|
+
|
|
118
|
+
if (manifest.access !== 'protected') {
|
|
119
|
+
error(`Asset access is "${manifest.access}", expected "protected"`, EXIT.INPUT_ERROR);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const decryptEntry = createPasswordDecryptEntry({ password });
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const loaded = reader.loadProfileSync(asset, profile, { decryptEntry });
|
|
126
|
+
console.log(JSON.stringify(loaded, null, 2));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
error(`Unlock failed: ${e.message}`, EXIT.TRUST_FAILED);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cmdRecover(args) {
|
|
133
|
+
const file = args[0];
|
|
134
|
+
if (!file)
|
|
135
|
+
error('Usage: kdna recover <file.kdna> --out <file.kdna> [--code-stdin]', EXIT.INPUT_ERROR);
|
|
136
|
+
|
|
137
|
+
const outIdx = args.indexOf('--out');
|
|
138
|
+
const outPath = outIdx >= 0 ? args[outIdx + 1] : null;
|
|
139
|
+
if (!outPath) error('Missing --out', EXIT.INPUT_ERROR);
|
|
140
|
+
|
|
141
|
+
if (!fs.existsSync(file)) error(`File not found: ${file}`, EXIT.INPUT_ERROR);
|
|
142
|
+
|
|
143
|
+
let recoveryCode;
|
|
144
|
+
if (args.includes('--code-stdin')) {
|
|
145
|
+
const stdinData = fs.readFileSync(0, 'utf8').trim();
|
|
146
|
+
if (!stdinData) error('No recovery code provided on stdin.', EXIT.INPUT_ERROR);
|
|
147
|
+
recoveryCode = stdinData;
|
|
148
|
+
} else {
|
|
149
|
+
recoveryCode = promptPassword('Recovery code: ');
|
|
150
|
+
if (!recoveryCode) error('Recovery code is required.', EXIT.INPUT_ERROR);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const newPassword = promptPassword('New password: ');
|
|
154
|
+
if (!newPassword) error('New password is required.', EXIT.INPUT_ERROR);
|
|
155
|
+
|
|
156
|
+
const reader = createKdnaAssetReader();
|
|
157
|
+
const asset = reader.openSync(file);
|
|
158
|
+
const manifest = reader.readManifestSync(asset);
|
|
159
|
+
|
|
160
|
+
if (manifest.access !== 'protected') {
|
|
161
|
+
error(`Asset access is "${manifest.access}", expected "protected"`, EXIT.INPUT_ERROR);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const decryptEntry = createRecoveryDecryptEntry({ recoveryCode });
|
|
165
|
+
|
|
166
|
+
// Decrypt all encrypted entries with recovery code, then re-encrypt with new password
|
|
167
|
+
const entriesToEncrypt = manifest.encryption?.encrypted_entries || ['KDNA_Core.json'];
|
|
168
|
+
const allEntries = reader.listEntriesSync(asset);
|
|
169
|
+
const zipEntries = {};
|
|
170
|
+
const newRecoveryCode = generateRecoveryCode();
|
|
171
|
+
|
|
172
|
+
for (const entryName of allEntries) {
|
|
173
|
+
if (entriesToEncrypt.includes(entryName)) {
|
|
174
|
+
// Decrypt with recovery code
|
|
175
|
+
const encryptedData = reader.readEntrySync(asset, entryName);
|
|
176
|
+
const plaintext = decryptEntry({ entryName, ciphertext: encryptedData, manifest });
|
|
177
|
+
|
|
178
|
+
// Re-encrypt with new password and new recovery code
|
|
179
|
+
const encrypted = encryptProtectedEntry(plaintext, {
|
|
180
|
+
entryName,
|
|
181
|
+
manifest: {
|
|
182
|
+
...manifest,
|
|
183
|
+
encryption: { ...manifest.encryption, encrypted_entries: entriesToEncrypt },
|
|
184
|
+
},
|
|
185
|
+
password: newPassword,
|
|
186
|
+
recoveryCode: newRecoveryCode,
|
|
187
|
+
});
|
|
188
|
+
zipEntries[entryName] = JSON.stringify(encrypted);
|
|
189
|
+
} else {
|
|
190
|
+
zipEntries[entryName] = reader.readEntrySync(asset, entryName);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!zipEntries.mimetype) {
|
|
195
|
+
zipEntries.mimetype = 'application/vnd.aikdna.kdna+zip';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Recompute content digest and strip invalidated signature after re-encryption
|
|
199
|
+
updateManifestDigest(zipEntries, reader);
|
|
200
|
+
|
|
201
|
+
const zipBuffer = buildZip(zipEntries);
|
|
202
|
+
fs.writeFileSync(outPath, zipBuffer);
|
|
203
|
+
|
|
204
|
+
console.log(`Recovered asset written to: ${outPath}`);
|
|
205
|
+
console.log('Password has been reset.');
|
|
206
|
+
console.log('New recovery code: (displayed once — save it)');
|
|
207
|
+
console.log(` ${newRecoveryCode}`);
|
|
208
|
+
console.log(' The old recovery code is no longer valid.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Recompute content_digest after encryption changes and strip invalidated signature fields.
|
|
213
|
+
* The old signature and any stale digest fields are removed because the ciphertext has changed.
|
|
214
|
+
*/
|
|
215
|
+
function updateManifestDigest(zipEntries, reader) {
|
|
216
|
+
const tempAsset = reader.openSync(buildZip(zipEntries));
|
|
217
|
+
const newDigest = reader.contentDigestSync(tempAsset);
|
|
218
|
+
let manifest = {};
|
|
219
|
+
try {
|
|
220
|
+
manifest = JSON.parse(zipEntries['kdna.json'] || '{}');
|
|
221
|
+
} catch {
|
|
222
|
+
manifest = {};
|
|
223
|
+
}
|
|
224
|
+
delete manifest.signature;
|
|
225
|
+
delete manifest.asset_digest;
|
|
226
|
+
delete manifest.container_sha256;
|
|
227
|
+
manifest.content_digest = newDigest;
|
|
228
|
+
zipEntries['kdna.json'] = JSON.stringify(manifest);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Simple ZIP builder for CLI usage
|
|
232
|
+
function u16(n) {
|
|
233
|
+
const b = Buffer.alloc(2);
|
|
234
|
+
b.writeUInt16LE(n);
|
|
235
|
+
return b;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function u32(n) {
|
|
239
|
+
const b = Buffer.alloc(4);
|
|
240
|
+
b.writeUInt32LE(n);
|
|
241
|
+
return b;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildZip(entries) {
|
|
245
|
+
const localParts = [];
|
|
246
|
+
const centralParts = [];
|
|
247
|
+
let offset = 0;
|
|
248
|
+
|
|
249
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
250
|
+
const nameBuf = Buffer.from(name);
|
|
251
|
+
const data = Buffer.from(value);
|
|
252
|
+
const local = Buffer.concat([
|
|
253
|
+
u32(0x04034b50),
|
|
254
|
+
u16(20),
|
|
255
|
+
u16(0),
|
|
256
|
+
u16(0),
|
|
257
|
+
u16(0),
|
|
258
|
+
u16(0),
|
|
259
|
+
u32(0),
|
|
260
|
+
u32(data.length),
|
|
261
|
+
u32(data.length),
|
|
262
|
+
u16(nameBuf.length),
|
|
263
|
+
u16(0),
|
|
264
|
+
nameBuf,
|
|
265
|
+
data,
|
|
266
|
+
]);
|
|
267
|
+
localParts.push(local);
|
|
268
|
+
|
|
269
|
+
centralParts.push(
|
|
270
|
+
Buffer.concat([
|
|
271
|
+
u32(0x02014b50),
|
|
272
|
+
u16(20),
|
|
273
|
+
u16(20),
|
|
274
|
+
u16(0),
|
|
275
|
+
u16(0),
|
|
276
|
+
u16(0),
|
|
277
|
+
u16(0),
|
|
278
|
+
u32(0),
|
|
279
|
+
u32(data.length),
|
|
280
|
+
u32(data.length),
|
|
281
|
+
u16(nameBuf.length),
|
|
282
|
+
u16(0),
|
|
283
|
+
u16(0),
|
|
284
|
+
u16(0),
|
|
285
|
+
u16(0),
|
|
286
|
+
u32(0),
|
|
287
|
+
u32(offset),
|
|
288
|
+
nameBuf,
|
|
289
|
+
]),
|
|
290
|
+
);
|
|
291
|
+
offset += local.length;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const central = Buffer.concat(centralParts);
|
|
295
|
+
const local = Buffer.concat(localParts);
|
|
296
|
+
const eocd = Buffer.concat([
|
|
297
|
+
u32(0x06054b50),
|
|
298
|
+
u16(0),
|
|
299
|
+
u16(0),
|
|
300
|
+
u16(centralParts.length),
|
|
301
|
+
u16(centralParts.length),
|
|
302
|
+
u32(central.length),
|
|
303
|
+
u32(local.length),
|
|
304
|
+
u16(0),
|
|
305
|
+
]);
|
|
306
|
+
return Buffer.concat([local, central, eocd]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
cmdProtect,
|
|
311
|
+
cmdUnlock,
|
|
312
|
+
cmdRecover,
|
|
313
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KDNA Protected Asset Commands (RFC-0009)
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* kdna protect <file.kdna> --out <file.kdna> [--entries <list>]
|
|
6
|
+
* kdna unlock <file.kdna> [--profile compact|index|full]
|
|
7
|
+
* kdna recover <file.kdna> --out <file.kdna> [--code-stdin]
|
|
8
|
+
*
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { EXIT, error, promptPassword } = require('./_common');
|
|
13
|
+
const {
|
|
14
|
+
createKdnaAssetReader,
|
|
15
|
+
createPasswordDecryptEntry,
|
|
16
|
+
createRecoveryDecryptEntry,
|
|
17
|
+
encryptProtectedEntry,
|
|
18
|
+
generateRecoveryCode,
|
|
19
|
+
} = require('@aikdna/kdna-core');
|
|
20
|
+
|
|
21
|
+
function parseEntriesFlag(flag) {
|
|
22
|
+
if (!flag) return ['KDNA_Core.json'];
|
|
23
|
+
return flag.split(',').map((s) => s.trim());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cmdProtect(args) {
|
|
27
|
+
const file = args[0];
|
|
28
|
+
if (!file) error('Usage: kdna protect <file.kdna> --out <file.kdna> [--entries KDNA_Core.json,KDNA_Patterns.json]', EXIT.INPUT_ERROR);
|
|
29
|
+
|
|
30
|
+
const outIdx = args.indexOf('--out');
|
|
31
|
+
const outPath = outIdx >= 0 ? args[outIdx + 1] : null;
|
|
32
|
+
if (!outPath) error('Missing --out', EXIT.INPUT_ERROR);
|
|
33
|
+
|
|
34
|
+
const entriesIdx = args.indexOf('--entries');
|
|
35
|
+
const entriesToEncrypt = parseEntriesFlag(entriesIdx >= 0 ? args[entriesIdx + 1] : null);
|
|
36
|
+
|
|
37
|
+
if (!fs.existsSync(file)) error(`File not found: ${file}`, EXIT.INPUT_ERROR);
|
|
38
|
+
|
|
39
|
+
const password = promptPassword('Password: ');
|
|
40
|
+
if (!password) error('Password is required.', EXIT.INPUT_ERROR);
|
|
41
|
+
|
|
42
|
+
const reader = createKdnaAssetReader();
|
|
43
|
+
const asset = reader.openSync(file);
|
|
44
|
+
const manifest = reader.readManifestSync(asset);
|
|
45
|
+
|
|
46
|
+
if (manifest.access === 'protected') {
|
|
47
|
+
error('Asset is already protected. Use recover to change password.', EXIT.INPUT_ERROR);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Update manifest
|
|
51
|
+
const newManifest = { ...manifest, access: 'protected', encryption: { profile: 'kdna-password-protected-v1', encrypted_entries: entriesToEncrypt } };
|
|
52
|
+
|
|
53
|
+
// Build new ZIP with encrypted entries
|
|
54
|
+
const allEntries = reader.listEntriesSync(asset);
|
|
55
|
+
const zipEntries = {};
|
|
56
|
+
const recoveryCode = generateRecoveryCode();
|
|
57
|
+
|
|
58
|
+
for (const entryName of allEntries) {
|
|
59
|
+
if (entryName === 'kdna.json') {
|
|
60
|
+
zipEntries[entryName] = JSON.stringify(newManifest);
|
|
61
|
+
} else if (entriesToEncrypt.includes(entryName)) {
|
|
62
|
+
const plaintext = reader.readEntrySync(asset, entryName);
|
|
63
|
+
const encrypted = encryptProtectedEntry(plaintext, {
|
|
64
|
+
entryName,
|
|
65
|
+
manifest: newManifest,
|
|
66
|
+
password,
|
|
67
|
+
recoveryCode,
|
|
68
|
+
});
|
|
69
|
+
zipEntries[entryName] = JSON.stringify(encrypted);
|
|
70
|
+
} else {
|
|
71
|
+
zipEntries[entryName] = reader.readEntrySync(asset, entryName);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Add mimetype if missing
|
|
76
|
+
if (!zipEntries.mimetype) {
|
|
77
|
+
zipEntries.mimetype = 'application/vnd.aikdna.kdna+zip';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Write new asset using core's internal ZIP builder or a simple approach
|
|
81
|
+
// For the CLI, we use the asset reader's internal helper if available, otherwise manual ZIP
|
|
82
|
+
const zipBuffer = buildZip(zipEntries);
|
|
83
|
+
fs.writeFileSync(outPath, zipBuffer);
|
|
84
|
+
|
|
85
|
+
console.log(`Protected asset written to: ${outPath}`);
|
|
86
|
+
console.log(`Encrypted entries: ${entriesToEncrypt.join(', ')}`);
|
|
87
|
+
console.log('Recovery code: (displayed once — save it)');
|
|
88
|
+
console.log(` ${recoveryCode}`);
|
|
89
|
+
console.log(' Use `kdna recover` if you forget the password.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cmdUnlock(args) {
|
|
93
|
+
const file = args[0];
|
|
94
|
+
if (!file) error('Usage: kdna unlock <file.kdna> [--profile compact|index|full]', EXIT.INPUT_ERROR);
|
|
95
|
+
|
|
96
|
+
const profileIdx = args.indexOf('--profile');
|
|
97
|
+
const profile = profileIdx >= 0 ? args[profileIdx + 1] : 'compact';
|
|
98
|
+
|
|
99
|
+
if (!fs.existsSync(file)) error(`File not found: ${file}`, EXIT.INPUT_ERROR);
|
|
100
|
+
|
|
101
|
+
const password = promptPassword('Password: ');
|
|
102
|
+
if (!password) error('Password is required.', EXIT.INPUT_ERROR);
|
|
103
|
+
|
|
104
|
+
const reader = createKdnaAssetReader();
|
|
105
|
+
const asset = reader.openSync(file);
|
|
106
|
+
const manifest = reader.readManifestSync(asset);
|
|
107
|
+
|
|
108
|
+
if (manifest.access !== 'protected') {
|
|
109
|
+
error(`Asset access is "${manifest.access}", expected "protected"`, EXIT.INPUT_ERROR);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const decryptEntry = createPasswordDecryptEntry({ password });
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const loaded = reader.loadProfileSync(asset, profile, { decryptEntry });
|
|
116
|
+
console.log(JSON.stringify(loaded, null, 2));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
error(`Unlock failed: ${e.message}`, EXIT.TRUST_FAILED);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cmdRecover(args) {
|
|
123
|
+
const file = args[0];
|
|
124
|
+
if (!file) error('Usage: kdna recover <file.kdna> --out <file.kdna> [--code-stdin]', EXIT.INPUT_ERROR);
|
|
125
|
+
|
|
126
|
+
const outIdx = args.indexOf('--out');
|
|
127
|
+
const outPath = outIdx >= 0 ? args[outIdx + 1] : null;
|
|
128
|
+
if (!outPath) error('Missing --out', EXIT.INPUT_ERROR);
|
|
129
|
+
|
|
130
|
+
if (!fs.existsSync(file)) error(`File not found: ${file}`, EXIT.INPUT_ERROR);
|
|
131
|
+
|
|
132
|
+
let recoveryCode;
|
|
133
|
+
if (args.includes('--code-stdin')) {
|
|
134
|
+
const stdinData = fs.readFileSync(0, 'utf8').trim();
|
|
135
|
+
if (!stdinData) error('No recovery code provided on stdin.', EXIT.INPUT_ERROR);
|
|
136
|
+
recoveryCode = stdinData;
|
|
137
|
+
} else {
|
|
138
|
+
recoveryCode = promptPassword('Recovery code: ');
|
|
139
|
+
if (!recoveryCode) error('Recovery code is required.', EXIT.INPUT_ERROR);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const newPassword = promptPassword('New password: ');
|
|
143
|
+
if (!newPassword) error('New password is required.', EXIT.INPUT_ERROR);
|
|
144
|
+
|
|
145
|
+
const reader = createKdnaAssetReader();
|
|
146
|
+
const asset = reader.openSync(file);
|
|
147
|
+
const manifest = reader.readManifestSync(asset);
|
|
148
|
+
|
|
149
|
+
if (manifest.access !== 'protected') {
|
|
150
|
+
error(`Asset access is "${manifest.access}", expected "protected"`, EXIT.INPUT_ERROR);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const decryptEntry = createRecoveryDecryptEntry({ recoveryCode });
|
|
154
|
+
|
|
155
|
+
// Decrypt all encrypted entries with recovery code, then re-encrypt with new password
|
|
156
|
+
const entriesToEncrypt = manifest.encryption?.encrypted_entries || ['KDNA_Core.json'];
|
|
157
|
+
const allEntries = reader.listEntriesSync(asset);
|
|
158
|
+
const zipEntries = {};
|
|
159
|
+
const newRecoveryCode = generateRecoveryCode();
|
|
160
|
+
|
|
161
|
+
for (const entryName of allEntries) {
|
|
162
|
+
if (entriesToEncrypt.includes(entryName)) {
|
|
163
|
+
// Decrypt with recovery code
|
|
164
|
+
const encryptedData = reader.readEntrySync(asset, entryName);
|
|
165
|
+
const plaintext = decryptEntry({ entryName, ciphertext: encryptedData, manifest });
|
|
166
|
+
|
|
167
|
+
// Re-encrypt with new password and new recovery code
|
|
168
|
+
const encrypted = encryptProtectedEntry(plaintext, {
|
|
169
|
+
entryName,
|
|
170
|
+
manifest: { ...manifest, encryption: { ...manifest.encryption, encrypted_entries: entriesToEncrypt } },
|
|
171
|
+
password: newPassword,
|
|
172
|
+
recoveryCode: newRecoveryCode,
|
|
173
|
+
});
|
|
174
|
+
zipEntries[entryName] = JSON.stringify(encrypted);
|
|
175
|
+
} else {
|
|
176
|
+
zipEntries[entryName] = reader.readEntrySync(asset, entryName);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!zipEntries.mimetype) {
|
|
181
|
+
zipEntries.mimetype = 'application/vnd.aikdna.kdna+zip';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const zipBuffer = buildZip(zipEntries);
|
|
185
|
+
fs.writeFileSync(outPath, zipBuffer);
|
|
186
|
+
|
|
187
|
+
console.log(`Recovered asset written to: ${outPath}`);
|
|
188
|
+
console.log('Password has been reset.');
|
|
189
|
+
console.log('New recovery code: (displayed once — save it)');
|
|
190
|
+
console.log(` ${newRecoveryCode}`);
|
|
191
|
+
console.log(' The old recovery code is no longer valid.');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Simple ZIP builder for CLI usage
|
|
195
|
+
function u16(n) {
|
|
196
|
+
const b = Buffer.alloc(2);
|
|
197
|
+
b.writeUInt16LE(n);
|
|
198
|
+
return b;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function u32(n) {
|
|
202
|
+
const b = Buffer.alloc(4);
|
|
203
|
+
b.writeUInt32LE(n);
|
|
204
|
+
return b;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildZip(entries) {
|
|
208
|
+
const localParts = [];
|
|
209
|
+
const centralParts = [];
|
|
210
|
+
let offset = 0;
|
|
211
|
+
|
|
212
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
213
|
+
const nameBuf = Buffer.from(name);
|
|
214
|
+
const data = Buffer.from(value);
|
|
215
|
+
const local = Buffer.concat([
|
|
216
|
+
u32(0x04034b50), u16(20), u16(0), u16(0), u16(0), u16(0),
|
|
217
|
+
u32(0), u32(data.length), u32(data.length), u16(nameBuf.length), u16(0),
|
|
218
|
+
nameBuf, data,
|
|
219
|
+
]);
|
|
220
|
+
localParts.push(local);
|
|
221
|
+
|
|
222
|
+
centralParts.push(
|
|
223
|
+
Buffer.concat([
|
|
224
|
+
u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(0), u16(0),
|
|
225
|
+
u32(0), u32(data.length), u32(data.length), u16(nameBuf.length), u16(0),
|
|
226
|
+
u16(0), u16(0), u16(0), u32(0), u32(offset), nameBuf,
|
|
227
|
+
]),
|
|
228
|
+
);
|
|
229
|
+
offset += local.length;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const central = Buffer.concat(centralParts);
|
|
233
|
+
const local = Buffer.concat(localParts);
|
|
234
|
+
const eocd = Buffer.concat([
|
|
235
|
+
u32(0x06054b50), u16(0), u16(0), u16(centralParts.length), u16(centralParts.length),
|
|
236
|
+
u32(central.length), u32(local.length), u16(0),
|
|
237
|
+
]);
|
|
238
|
+
return Buffer.concat([local, central, eocd]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
cmdProtect,
|
|
243
|
+
cmdUnlock,
|
|
244
|
+
cmdRecover,
|
|
245
|
+
};
|