@aikdna/kdna-cli 0.19.3 → 0.20.1

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,314 @@
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
+ buildZip,
311
+ cmdProtect,
312
+ cmdUnlock,
313
+ cmdRecover,
314
+ };
@@ -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
+ };