health-sync 0.3.0 → 0.3.2
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/README.md +61 -0
- package/package.json +1 -1
- package/src/cli.js +346 -1
- package/src/remote-bootstrap.js +711 -0
package/README.md
CHANGED
|
@@ -55,6 +55,12 @@ The wizard lets you:
|
|
|
55
55
|
- enter credentials directly into `health-sync.toml`
|
|
56
56
|
- run auth flows and save tokens in `.health-sync.creds`
|
|
57
57
|
|
|
58
|
+
Remote onboarding (for bot/operator handoff) is also available:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
health-sync init remote bootstrap
|
|
62
|
+
```
|
|
63
|
+
|
|
58
64
|
2. (Optional) re-run auth for a single provider later:
|
|
59
65
|
|
|
60
66
|
```bash
|
|
@@ -73,6 +79,57 @@ health-sync sync
|
|
|
73
79
|
health-sync status
|
|
74
80
|
```
|
|
75
81
|
|
|
82
|
+
## Remote Bootstrap Setup
|
|
83
|
+
|
|
84
|
+
This mode is designed for cross-device onboarding where the user runs setup locally and sends an encrypted bundle back to an operator/bot over untrusted transport (for example, Telegram).
|
|
85
|
+
|
|
86
|
+
### 1) Operator creates bootstrap token
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
health-sync init remote bootstrap --expires-in 24h
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This command:
|
|
93
|
+
- generates one bootstrap key/session
|
|
94
|
+
- prints a bootstrap token to share with the user
|
|
95
|
+
- stores private key material locally under `~/.health-sync/remote-bootstrap`
|
|
96
|
+
|
|
97
|
+
### 2) User runs onboarding with the shared token
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
health-sync init remote run <bootstrap-token>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
This command:
|
|
104
|
+
- runs normal guided `init` onboarding
|
|
105
|
+
- encrypts `health-sync.toml` + `.health-sync.creds` into an archive
|
|
106
|
+
- prints the archive path to send back to the operator
|
|
107
|
+
- purges local config/creds by default after archive creation
|
|
108
|
+
|
|
109
|
+
Options:
|
|
110
|
+
- `--output <path>`: choose archive output path
|
|
111
|
+
- `--keep-local`: keep local config/creds instead of purging
|
|
112
|
+
|
|
113
|
+
### 3) Operator imports encrypted archive
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
health-sync init remote finish <bootstrap-token-or-key-id> <archive-path>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This command:
|
|
120
|
+
- decrypts archive using stored bootstrap private key
|
|
121
|
+
- safely imports config/creds with timestamped backups
|
|
122
|
+
- marks bootstrap session as consumed (one-time use)
|
|
123
|
+
|
|
124
|
+
Options:
|
|
125
|
+
- `--target-config <path>`
|
|
126
|
+
- `--target-creds <path>`
|
|
127
|
+
|
|
128
|
+
Compatibility aliases:
|
|
129
|
+
- `health-sync init --remote-bootstrap`
|
|
130
|
+
- `health-sync init --remote <token>`
|
|
131
|
+
- `health-sync init --remote-bootstrap-finish <ref> <archive>`
|
|
132
|
+
|
|
76
133
|
## Basic Configuration
|
|
77
134
|
|
|
78
135
|
By default, `health-sync` reads `./health-sync.toml`.
|
|
@@ -105,6 +162,9 @@ See `health-sync.example.toml` for all provider options.
|
|
|
105
162
|
## CLI Commands
|
|
106
163
|
|
|
107
164
|
- `health-sync init`: create a scaffolded config (from `health-sync.example.toml`), create DB tables, and launch interactive provider setup when running in a TTY
|
|
165
|
+
- `health-sync init remote bootstrap`: generate a remote bootstrap token/session
|
|
166
|
+
- `health-sync init remote run <token>`: run onboarding and emit encrypted remote archive
|
|
167
|
+
- `health-sync init remote finish <ref> <archive>`: decrypt and import remote archive
|
|
108
168
|
- `health-sync init-db`: create DB tables only (legacy)
|
|
109
169
|
- `health-sync auth <provider>`: run auth flow for one provider/plugin
|
|
110
170
|
- `health-sync sync`: run sync for all enabled providers
|
|
@@ -133,6 +193,7 @@ The database keeps raw JSON payloads and sync metadata in generic tables:
|
|
|
133
193
|
- `sync_state`: per-resource watermarks/cursors
|
|
134
194
|
- `.health-sync.creds`: stored provider credentials and OAuth tokens
|
|
135
195
|
- `sync_runs`: run history and per-sync counters
|
|
196
|
+
- `~/.health-sync/remote-bootstrap`: private bootstrap sessions/keys for remote onboarding
|
|
136
197
|
|
|
137
198
|
This schema is intentionally generic so upstream API changes are less likely to require migrations.
|
|
138
199
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -11,6 +11,18 @@ import { openDb } from './db.js';
|
|
|
11
11
|
import { PluginHelpers, providerEnabled } from './plugins/base.js';
|
|
12
12
|
import { loadProviders } from './plugins/loader.js';
|
|
13
13
|
import { setRequestJsonVerbose } from './util.js';
|
|
14
|
+
import {
|
|
15
|
+
BOOTSTRAP_TOKEN_PREFIX,
|
|
16
|
+
bootstrapStoreDir,
|
|
17
|
+
buildRemotePayloadFromFiles,
|
|
18
|
+
createBootstrapSession,
|
|
19
|
+
defaultRemoteArchivePath,
|
|
20
|
+
encryptRemotePayload,
|
|
21
|
+
importRemoteArchive,
|
|
22
|
+
parseBootstrapToken,
|
|
23
|
+
parseDurationToSeconds,
|
|
24
|
+
writeRemoteArchiveFile,
|
|
25
|
+
} from './remote-bootstrap.js';
|
|
14
26
|
import {
|
|
15
27
|
authProviderDisplayName,
|
|
16
28
|
hasInteractiveAuthUi,
|
|
@@ -194,6 +206,179 @@ function parseProvidersArgs(args) {
|
|
|
194
206
|
return out;
|
|
195
207
|
}
|
|
196
208
|
|
|
209
|
+
function parseInitRemoteBootstrapArgs(args) {
|
|
210
|
+
const out = {
|
|
211
|
+
mode: 'remote-bootstrap',
|
|
212
|
+
expiresInSeconds: parseDurationToSeconds(null),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
216
|
+
const arg = args[i];
|
|
217
|
+
if (arg === '--expires-in') {
|
|
218
|
+
i += 1;
|
|
219
|
+
if (i >= args.length) {
|
|
220
|
+
throw new Error('--expires-in requires a value');
|
|
221
|
+
}
|
|
222
|
+
out.expiresInSeconds = parseDurationToSeconds(args[i]);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (arg.startsWith('--expires-in=')) {
|
|
226
|
+
out.expiresInSeconds = parseDurationToSeconds(arg.slice('--expires-in='.length));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
throw new Error(`Unknown init remote bootstrap option: ${arg}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseInitRemoteRunArgs(args) {
|
|
236
|
+
const out = {
|
|
237
|
+
mode: 'remote-run',
|
|
238
|
+
bootstrapToken: null,
|
|
239
|
+
outputPath: null,
|
|
240
|
+
purgeLocal: true,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
244
|
+
const arg = args[i];
|
|
245
|
+
if (arg === '--output') {
|
|
246
|
+
i += 1;
|
|
247
|
+
if (i >= args.length) {
|
|
248
|
+
throw new Error('--output requires a value');
|
|
249
|
+
}
|
|
250
|
+
out.outputPath = args[i];
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (arg.startsWith('--output=')) {
|
|
254
|
+
out.outputPath = arg.slice('--output='.length);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (arg === '--keep-local') {
|
|
258
|
+
out.purgeLocal = false;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (arg === '--purge-local') {
|
|
262
|
+
out.purgeLocal = true;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (arg.startsWith('--')) {
|
|
266
|
+
throw new Error(`Unknown init remote run option: ${arg}`);
|
|
267
|
+
}
|
|
268
|
+
if (!out.bootstrapToken) {
|
|
269
|
+
out.bootstrapToken = arg;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
throw new Error(`Unexpected init remote run argument: ${arg}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!out.bootstrapToken) {
|
|
276
|
+
throw new Error(`init remote run requires BOOTSTRAP_TOKEN (prefix: ${BOOTSTRAP_TOKEN_PREFIX})`);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseInitRemoteFinishArgs(args) {
|
|
282
|
+
const out = {
|
|
283
|
+
mode: 'remote-finish',
|
|
284
|
+
sessionRef: null,
|
|
285
|
+
archivePath: null,
|
|
286
|
+
targetConfigPath: null,
|
|
287
|
+
targetCredsPath: null,
|
|
288
|
+
assumeYes: false,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
292
|
+
const arg = args[i];
|
|
293
|
+
if (arg === '--target-config') {
|
|
294
|
+
i += 1;
|
|
295
|
+
if (i >= args.length) {
|
|
296
|
+
throw new Error('--target-config requires a value');
|
|
297
|
+
}
|
|
298
|
+
out.targetConfigPath = args[i];
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (arg.startsWith('--target-config=')) {
|
|
302
|
+
out.targetConfigPath = arg.slice('--target-config='.length);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (arg === '--target-creds') {
|
|
306
|
+
i += 1;
|
|
307
|
+
if (i >= args.length) {
|
|
308
|
+
throw new Error('--target-creds requires a value');
|
|
309
|
+
}
|
|
310
|
+
out.targetCredsPath = args[i];
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (arg.startsWith('--target-creds=')) {
|
|
314
|
+
out.targetCredsPath = arg.slice('--target-creds='.length);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (arg === '--yes' || arg === '-y') {
|
|
318
|
+
out.assumeYes = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (arg.startsWith('--')) {
|
|
322
|
+
throw new Error(`Unknown init remote finish option: ${arg}`);
|
|
323
|
+
}
|
|
324
|
+
if (!out.sessionRef) {
|
|
325
|
+
out.sessionRef = arg;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!out.archivePath) {
|
|
329
|
+
out.archivePath = arg;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
throw new Error(`Unexpected init remote finish argument: ${arg}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!out.sessionRef || !out.archivePath) {
|
|
336
|
+
throw new Error('init remote finish requires SESSION_REF and ARCHIVE_PATH');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseInitArgs(args) {
|
|
343
|
+
if (!args.length) {
|
|
344
|
+
return { mode: 'local' };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (args[0] === 'remote') {
|
|
348
|
+
if (args.length < 2) {
|
|
349
|
+
throw new Error('init remote requires a subcommand: bootstrap, run, or finish');
|
|
350
|
+
}
|
|
351
|
+
const subcommand = args[1];
|
|
352
|
+
const rest = args.slice(2);
|
|
353
|
+
if (subcommand === 'bootstrap') {
|
|
354
|
+
return parseInitRemoteBootstrapArgs(rest);
|
|
355
|
+
}
|
|
356
|
+
if (subcommand === 'run') {
|
|
357
|
+
return parseInitRemoteRunArgs(rest);
|
|
358
|
+
}
|
|
359
|
+
if (subcommand === 'finish') {
|
|
360
|
+
return parseInitRemoteFinishArgs(rest);
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`Unknown init remote subcommand: ${subcommand}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (args[0] === '--remote-bootstrap') {
|
|
366
|
+
return parseInitRemoteBootstrapArgs(args.slice(1));
|
|
367
|
+
}
|
|
368
|
+
if (args[0] === '--remote') {
|
|
369
|
+
return parseInitRemoteRunArgs(args.slice(1));
|
|
370
|
+
}
|
|
371
|
+
if (args[0].startsWith('--remote=')) {
|
|
372
|
+
const token = args[0].slice('--remote='.length);
|
|
373
|
+
return parseInitRemoteRunArgs([token, ...args.slice(1)]);
|
|
374
|
+
}
|
|
375
|
+
if (args[0] === '--remote-bootstrap-finish') {
|
|
376
|
+
return parseInitRemoteFinishArgs(args.slice(1));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
throw new Error(`Unexpected init arguments: ${args.join(' ')}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
197
382
|
function parseArgs(argv) {
|
|
198
383
|
const global = parseGlobalOptions(argv);
|
|
199
384
|
const [command, ...rest] = global.remaining;
|
|
@@ -238,7 +423,11 @@ function parseArgs(argv) {
|
|
|
238
423
|
parsed.options = parseProvidersArgs(rest);
|
|
239
424
|
return parsed;
|
|
240
425
|
}
|
|
241
|
-
if (command === 'init'
|
|
426
|
+
if (command === 'init') {
|
|
427
|
+
parsed.options = parseInitArgs(rest);
|
|
428
|
+
return parsed;
|
|
429
|
+
}
|
|
430
|
+
if (command === 'init-db' || command === 'status') {
|
|
242
431
|
if (rest.length) {
|
|
243
432
|
throw new Error(`Unexpected ${command} arguments: ${rest.join(' ')}`);
|
|
244
433
|
}
|
|
@@ -573,6 +762,19 @@ function usage() {
|
|
|
573
762
|
'',
|
|
574
763
|
'Commands:',
|
|
575
764
|
' init Initialize config/database and launch interactive setup (TTY)',
|
|
765
|
+
' init remote bootstrap Create one remote bootstrap token',
|
|
766
|
+
' --expires-in <duration> Token expiry (default 24h; e.g. 12h, 2d, 3600)',
|
|
767
|
+
' init remote run <token> Run onboarding and emit encrypted remote archive',
|
|
768
|
+
' --output <path> Output archive path (.enc JSON envelope)',
|
|
769
|
+
' --keep-local Keep local config/creds after archive creation',
|
|
770
|
+
' init remote finish <ref> <archive> Decrypt archive and import files safely',
|
|
771
|
+
' --target-config <path> Import destination for health-sync.toml',
|
|
772
|
+
' --target-creds <path> Import destination for .health-sync.creds',
|
|
773
|
+
'',
|
|
774
|
+
' Alias forms (compatible):',
|
|
775
|
+
' init --remote-bootstrap',
|
|
776
|
+
' init --remote <token>',
|
|
777
|
+
' init --remote-bootstrap-finish <ref> <archive>',
|
|
576
778
|
' init-db Initialize database only',
|
|
577
779
|
' auth <provider> Run provider authentication flow for one provider',
|
|
578
780
|
' --listen-host <host> OAuth callback listen host (default 127.0.0.1)',
|
|
@@ -597,7 +799,150 @@ async function loadContext(configPath) {
|
|
|
597
799
|
};
|
|
598
800
|
}
|
|
599
801
|
|
|
802
|
+
function removeFileIfExists(filePath) {
|
|
803
|
+
if (!fs.existsSync(filePath)) {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
fs.unlinkSync(filePath);
|
|
807
|
+
return true;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function purgeRemoteLocalSecrets(configPath, credsPath) {
|
|
811
|
+
const removed = [];
|
|
812
|
+
if (removeFileIfExists(configPath)) {
|
|
813
|
+
removed.push(configPath);
|
|
814
|
+
}
|
|
815
|
+
if (removeFileIfExists(credsPath)) {
|
|
816
|
+
removed.push(credsPath);
|
|
817
|
+
}
|
|
818
|
+
return removed;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function cmdInitRemoteBootstrap(parsed) {
|
|
822
|
+
const session = createBootstrapSession({
|
|
823
|
+
expiresInSeconds: parsed.options.expiresInSeconds,
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
console.log('Created remote bootstrap session.');
|
|
827
|
+
console.log(`Session fingerprint: ${session.fingerprint}`);
|
|
828
|
+
console.log(`Session expires at: ${session.expiresAt}`);
|
|
829
|
+
console.log(`Bootstrap store: ${bootstrapStoreDir()}`);
|
|
830
|
+
console.log('');
|
|
831
|
+
console.log('Share this command with the user:');
|
|
832
|
+
console.log(` health-sync init --remote ${session.token}`);
|
|
833
|
+
console.log('');
|
|
834
|
+
console.log('Bootstrap token:');
|
|
835
|
+
console.log(session.token);
|
|
836
|
+
|
|
837
|
+
return 0;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function cmdInitRemoteRun(parsed) {
|
|
841
|
+
const tokenDetails = parseBootstrapToken(parsed.options.bootstrapToken, {
|
|
842
|
+
requireNotExpired: true,
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const configPath = path.resolve(parsed.configPath);
|
|
846
|
+
const credsPath = resolveCredsPath(configPath);
|
|
847
|
+
const outputPath = parsed.options.outputPath
|
|
848
|
+
? path.resolve(parsed.options.outputPath)
|
|
849
|
+
: defaultRemoteArchivePath(configPath, tokenDetails.sessionId);
|
|
850
|
+
|
|
851
|
+
console.log('Remote onboarding mode enabled.');
|
|
852
|
+
console.log(`Bootstrap session: ${tokenDetails.keyId.slice(0, 12)}:${tokenDetails.sessionId.slice(0, 8)}`);
|
|
853
|
+
console.log(`Bootstrap expires at: ${tokenDetails.expiresAt}`);
|
|
854
|
+
console.log('');
|
|
855
|
+
|
|
856
|
+
const initCode = await cmdInitLocal(parsed);
|
|
857
|
+
if (initCode === 130) {
|
|
858
|
+
return 130;
|
|
859
|
+
}
|
|
860
|
+
if (initCode !== 0) {
|
|
861
|
+
console.warn('Init completed with warnings; packaging current config/creds anyway.');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const { payload } = buildRemotePayloadFromFiles({
|
|
865
|
+
configPath,
|
|
866
|
+
credsPath,
|
|
867
|
+
allowMissingCreds: true,
|
|
868
|
+
sourceVersion: cliVersion(),
|
|
869
|
+
});
|
|
870
|
+
const envelope = encryptRemotePayload(payload, parsed.options.bootstrapToken, {
|
|
871
|
+
requireNotExpired: true,
|
|
872
|
+
});
|
|
873
|
+
const archivePath = writeRemoteArchiveFile(envelope, outputPath);
|
|
874
|
+
|
|
875
|
+
console.log('');
|
|
876
|
+
console.log('Encrypted remote archive created successfully.');
|
|
877
|
+
console.log(`Archive path: ${archivePath}`);
|
|
878
|
+
console.log('Send this archive file to the bot/operator.');
|
|
879
|
+
|
|
880
|
+
if (parsed.options.purgeLocal) {
|
|
881
|
+
const removed = purgeRemoteLocalSecrets(configPath, credsPath);
|
|
882
|
+
if (removed.length) {
|
|
883
|
+
console.log('');
|
|
884
|
+
console.log('Purged local sensitive files after archive creation:');
|
|
885
|
+
for (const item of removed) {
|
|
886
|
+
console.log(` - ${item}`);
|
|
887
|
+
}
|
|
888
|
+
} else {
|
|
889
|
+
console.log('');
|
|
890
|
+
console.log('No local config/creds files found to purge.');
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
console.log('');
|
|
894
|
+
console.log('Kept local config/creds because --keep-local was set.');
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return initCode;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function cmdInitRemoteFinish(parsed) {
|
|
901
|
+
const configPath = path.resolve(parsed.configPath);
|
|
902
|
+
const targetConfigPath = parsed.options.targetConfigPath
|
|
903
|
+
? path.resolve(parsed.options.targetConfigPath)
|
|
904
|
+
: configPath;
|
|
905
|
+
const targetCredsPath = parsed.options.targetCredsPath
|
|
906
|
+
? path.resolve(parsed.options.targetCredsPath)
|
|
907
|
+
: resolveCredsPath(targetConfigPath);
|
|
908
|
+
|
|
909
|
+
const result = importRemoteArchive({
|
|
910
|
+
sessionRef: parsed.options.sessionRef,
|
|
911
|
+
archivePath: parsed.options.archivePath,
|
|
912
|
+
targetConfigPath,
|
|
913
|
+
targetCredsPath,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
console.log('Remote bootstrap import complete.');
|
|
917
|
+
console.log(`Imported config: ${result.targetConfigPath}`);
|
|
918
|
+
console.log(`Imported creds: ${result.targetCredsPath}`);
|
|
919
|
+
if (result.backups.length) {
|
|
920
|
+
console.log('Backups created:');
|
|
921
|
+
for (const backup of result.backups) {
|
|
922
|
+
console.log(` - ${backup}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
console.log(`Session consumed at: ${result.consumedAt}`);
|
|
926
|
+
console.log(`Imported token entries: ${result.tokenCount}`);
|
|
927
|
+
|
|
928
|
+
return 0;
|
|
929
|
+
}
|
|
930
|
+
|
|
600
931
|
async function cmdInit(parsed) {
|
|
932
|
+
const mode = parsed.options?.mode || 'local';
|
|
933
|
+
if (mode === 'remote-bootstrap') {
|
|
934
|
+
return cmdInitRemoteBootstrap(parsed);
|
|
935
|
+
}
|
|
936
|
+
if (mode === 'remote-run') {
|
|
937
|
+
return cmdInitRemoteRun(parsed);
|
|
938
|
+
}
|
|
939
|
+
if (mode === 'remote-finish') {
|
|
940
|
+
return cmdInitRemoteFinish(parsed);
|
|
941
|
+
}
|
|
942
|
+
return cmdInitLocal(parsed);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function cmdInitLocal(parsed) {
|
|
601
946
|
const configPath = path.resolve(parsed.configPath);
|
|
602
947
|
const explicitDbPath = parsed.dbPath ? String(parsed.dbPath) : null;
|
|
603
948
|
|
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
6
|
+
import { dtToIsoZ, sha256Hex, stableJsonStringify, utcNowIso } from './util.js';
|
|
7
|
+
|
|
8
|
+
export const BOOTSTRAP_TOKEN_PREFIX = 'hsr1.';
|
|
9
|
+
export const REMOTE_ARCHIVE_SCHEMA = 'health-sync-remote-archive-v1';
|
|
10
|
+
export const REMOTE_PAYLOAD_SCHEMA = 'health-sync-remote-payload-v1';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_BOOTSTRAP_EXPIRES_SECONDS = 24 * 60 * 60;
|
|
13
|
+
const SESSION_FILE_MODE = 0o600;
|
|
14
|
+
|
|
15
|
+
function hasText(value) {
|
|
16
|
+
return value !== null && value !== undefined && String(value).trim() !== '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ensureDir(dirPath) {
|
|
20
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function encodeBase64Url(buffer) {
|
|
24
|
+
return Buffer.from(buffer).toString('base64url');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decodeBase64Url(value, label = 'base64url input') {
|
|
28
|
+
try {
|
|
29
|
+
return Buffer.from(String(value), 'base64url');
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`Invalid ${label}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseIsoDate(value, label) {
|
|
36
|
+
const normalized = dtToIsoZ(value);
|
|
37
|
+
if (!normalized) {
|
|
38
|
+
throw new Error(`Invalid ${label}`);
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertObject(value, label) {
|
|
44
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
45
|
+
throw new Error(`Invalid ${label}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function randomHex(bytes = 16) {
|
|
51
|
+
return crypto.randomBytes(bytes).toString('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function defaultSessionStoreDir() {
|
|
55
|
+
if (hasText(process.env.HEALTH_SYNC_REMOTE_BOOTSTRAP_DIR)) {
|
|
56
|
+
return path.resolve(String(process.env.HEALTH_SYNC_REMOTE_BOOTSTRAP_DIR));
|
|
57
|
+
}
|
|
58
|
+
return path.join(os.homedir(), '.health-sync', 'remote-bootstrap');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sessionPath(sessionId, storeDir = null) {
|
|
62
|
+
return path.join(path.resolve(storeDir || defaultSessionStoreDir()), `${sessionId}.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function atomicWriteFile(filePath, content, mode = 0o600) {
|
|
66
|
+
ensureDir(path.dirname(path.resolve(filePath)));
|
|
67
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomHex(4)}`;
|
|
68
|
+
fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode });
|
|
69
|
+
fs.renameSync(tmpPath, filePath);
|
|
70
|
+
try {
|
|
71
|
+
fs.chmodSync(filePath, mode);
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore chmod failures on non-POSIX filesystems.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseDurationUnit(unitRaw) {
|
|
78
|
+
const unit = String(unitRaw || 's').trim().toLowerCase();
|
|
79
|
+
if (unit === '' || unit === 's' || unit === 'sec' || unit === 'secs' || unit === 'second' || unit === 'seconds') {
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (unit === 'm' || unit === 'min' || unit === 'mins' || unit === 'minute' || unit === 'minutes') {
|
|
83
|
+
return 60;
|
|
84
|
+
}
|
|
85
|
+
if (unit === 'h' || unit === 'hr' || unit === 'hrs' || unit === 'hour' || unit === 'hours') {
|
|
86
|
+
return 60 * 60;
|
|
87
|
+
}
|
|
88
|
+
if (unit === 'd' || unit === 'day' || unit === 'days') {
|
|
89
|
+
return 24 * 60 * 60;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`Unsupported duration unit: ${unitRaw}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeSessionDoc(doc, label = 'bootstrap session') {
|
|
95
|
+
const value = assertObject(doc, label);
|
|
96
|
+
const sessionId = hasText(value.session_id) ? String(value.session_id) : null;
|
|
97
|
+
const keyId = hasText(value.key_id) ? String(value.key_id) : null;
|
|
98
|
+
const createdAt = parseIsoDate(value.created_at, `${label}.created_at`);
|
|
99
|
+
const expiresAt = parseIsoDate(value.expires_at, `${label}.expires_at`);
|
|
100
|
+
const privateKeyPem = hasText(value.private_key_pkcs8_pem) ? String(value.private_key_pkcs8_pem) : null;
|
|
101
|
+
const recipientPublicDer = hasText(value.recipient_pub_der_b64u) ? String(value.recipient_pub_der_b64u) : null;
|
|
102
|
+
const consumedAt = value.consumed_at ? parseIsoDate(value.consumed_at, `${label}.consumed_at`) : null;
|
|
103
|
+
|
|
104
|
+
if (!sessionId || !/^[a-f0-9]{16,64}$/i.test(sessionId)) {
|
|
105
|
+
throw new Error(`Invalid ${label}.session_id`);
|
|
106
|
+
}
|
|
107
|
+
if (!keyId || !/^[a-f0-9]{8,64}$/i.test(keyId)) {
|
|
108
|
+
throw new Error(`Invalid ${label}.key_id`);
|
|
109
|
+
}
|
|
110
|
+
if (!privateKeyPem) {
|
|
111
|
+
throw new Error(`Missing ${label}.private_key_pkcs8_pem`);
|
|
112
|
+
}
|
|
113
|
+
if (!recipientPublicDer) {
|
|
114
|
+
throw new Error(`Missing ${label}.recipient_pub_der_b64u`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
version: Number(value.version) || 1,
|
|
119
|
+
sessionId,
|
|
120
|
+
keyId,
|
|
121
|
+
createdAt,
|
|
122
|
+
expiresAt,
|
|
123
|
+
privateKeyPem,
|
|
124
|
+
recipientPublicDer,
|
|
125
|
+
consumedAt,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatBackupTimestamp(value = new Date()) {
|
|
130
|
+
return value.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function validatePayloadFiles(payload) {
|
|
134
|
+
const files = assertObject(payload?.files, 'remote payload.files');
|
|
135
|
+
const config = files['health-sync.toml'];
|
|
136
|
+
const creds = files['.health-sync.creds'];
|
|
137
|
+
if (!config || !hasText(config.content)) {
|
|
138
|
+
throw new Error('Remote payload is missing health-sync.toml');
|
|
139
|
+
}
|
|
140
|
+
if (!creds || !hasText(creds.content)) {
|
|
141
|
+
throw new Error('Remote payload is missing .health-sync.creds');
|
|
142
|
+
}
|
|
143
|
+
return { config, creds };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function emptyCredsFileContent() {
|
|
147
|
+
return `${stableJsonStringify({
|
|
148
|
+
version: 1,
|
|
149
|
+
updatedAt: utcNowIso(),
|
|
150
|
+
tokens: {},
|
|
151
|
+
})}\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deriveSymmetricKey({ sharedSecret, salt, sessionId, keyId }) {
|
|
155
|
+
const info = Buffer.from(`health-sync-remote-v1|${sessionId}|${keyId}`, 'utf8');
|
|
156
|
+
return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function loadPublicKeyFromDerBase64Url(derBase64Url) {
|
|
160
|
+
return crypto.createPublicKey({
|
|
161
|
+
key: decodeBase64Url(derBase64Url, 'recipient public key'),
|
|
162
|
+
format: 'der',
|
|
163
|
+
type: 'spki',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function loadPrivateKeyFromPem(privateKeyPem) {
|
|
168
|
+
return crypto.createPrivateKey({
|
|
169
|
+
key: String(privateKeyPem),
|
|
170
|
+
format: 'pem',
|
|
171
|
+
type: 'pkcs8',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseArchiveEnvelope(raw) {
|
|
176
|
+
const envelope = assertObject(raw, 'remote archive envelope');
|
|
177
|
+
if (envelope.schema !== REMOTE_ARCHIVE_SCHEMA || Number(envelope.version) !== 1) {
|
|
178
|
+
throw new Error('Unsupported remote archive version');
|
|
179
|
+
}
|
|
180
|
+
if (!hasText(envelope.session_id) || !hasText(envelope.key_id)) {
|
|
181
|
+
throw new Error('Remote archive is missing session/key metadata');
|
|
182
|
+
}
|
|
183
|
+
if (!hasText(envelope.ephemeral_pub_der_b64u)) {
|
|
184
|
+
throw new Error('Remote archive is missing ephemeral key');
|
|
185
|
+
}
|
|
186
|
+
if (!hasText(envelope.salt_b64u) || !hasText(envelope.nonce_b64u) || !hasText(envelope.tag_b64u)) {
|
|
187
|
+
throw new Error('Remote archive is missing cryptographic parameters');
|
|
188
|
+
}
|
|
189
|
+
if (!hasText(envelope.ciphertext_b64u)) {
|
|
190
|
+
throw new Error('Remote archive is missing ciphertext');
|
|
191
|
+
}
|
|
192
|
+
if (!hasText(envelope.aad_json)) {
|
|
193
|
+
throw new Error('Remote archive is missing authenticated metadata');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const aad = assertObject(JSON.parse(String(envelope.aad_json)), 'remote archive aad');
|
|
198
|
+
if (!hasText(aad.created_at) || !hasText(aad.payload_sha256)) {
|
|
199
|
+
throw new Error('Remote archive AAD is missing required fields');
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof Error && err.message.includes('Remote archive AAD')) {
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
throw new Error('Remote archive has invalid authenticated metadata');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return envelope;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function parseDurationToSeconds(value, fallbackSeconds = DEFAULT_BOOTSTRAP_EXPIRES_SECONDS) {
|
|
212
|
+
if (value === null || value === undefined || value === '') {
|
|
213
|
+
return fallbackSeconds;
|
|
214
|
+
}
|
|
215
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
216
|
+
return Math.floor(value);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const text = String(value).trim();
|
|
220
|
+
if (!text) {
|
|
221
|
+
return fallbackSeconds;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const match = text.match(/^(\d+)\s*([a-zA-Z]+)?$/);
|
|
225
|
+
if (!match) {
|
|
226
|
+
throw new Error(`Invalid duration: ${text}`);
|
|
227
|
+
}
|
|
228
|
+
const quantity = Number.parseInt(match[1], 10);
|
|
229
|
+
if (!Number.isFinite(quantity) || quantity <= 0) {
|
|
230
|
+
throw new Error(`Invalid duration value: ${text}`);
|
|
231
|
+
}
|
|
232
|
+
const multiplier = parseDurationUnit(match[2] || 's');
|
|
233
|
+
return quantity * multiplier;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function bootstrapStoreDir() {
|
|
237
|
+
return defaultSessionStoreDir();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function parseBootstrapToken(token, options = {}) {
|
|
241
|
+
const requireNotExpired = options.requireNotExpired !== false;
|
|
242
|
+
const trimmed = String(token || '').trim();
|
|
243
|
+
if (!trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
|
244
|
+
throw new Error(`Bootstrap token must start with ${BOOTSTRAP_TOKEN_PREFIX}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const encoded = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length);
|
|
248
|
+
if (!encoded) {
|
|
249
|
+
throw new Error('Bootstrap token is empty');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let payload;
|
|
253
|
+
try {
|
|
254
|
+
payload = JSON.parse(decodeBase64Url(encoded, 'bootstrap token').toString('utf8'));
|
|
255
|
+
} catch {
|
|
256
|
+
throw new Error('Bootstrap token is not valid JSON');
|
|
257
|
+
}
|
|
258
|
+
assertObject(payload, 'bootstrap token payload');
|
|
259
|
+
|
|
260
|
+
const normalized = {
|
|
261
|
+
version: Number(payload.v) || 0,
|
|
262
|
+
sessionId: hasText(payload.session_id) ? String(payload.session_id) : '',
|
|
263
|
+
keyId: hasText(payload.key_id) ? String(payload.key_id) : '',
|
|
264
|
+
recipientPublicDer: hasText(payload.recipient_pub) ? String(payload.recipient_pub) : '',
|
|
265
|
+
createdAt: parseIsoDate(payload.created_at, 'bootstrap token created_at'),
|
|
266
|
+
expiresAt: parseIsoDate(payload.expires_at, 'bootstrap token expires_at'),
|
|
267
|
+
checksum: hasText(payload.checksum) ? String(payload.checksum) : '',
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (normalized.version !== 1) {
|
|
271
|
+
throw new Error('Unsupported bootstrap token version');
|
|
272
|
+
}
|
|
273
|
+
if (!normalized.sessionId || !normalized.keyId || !normalized.recipientPublicDer) {
|
|
274
|
+
throw new Error('Bootstrap token is missing required fields');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const checksumPayload = {
|
|
278
|
+
v: normalized.version,
|
|
279
|
+
session_id: normalized.sessionId,
|
|
280
|
+
key_id: normalized.keyId,
|
|
281
|
+
recipient_pub: normalized.recipientPublicDer,
|
|
282
|
+
created_at: normalized.createdAt,
|
|
283
|
+
expires_at: normalized.expiresAt,
|
|
284
|
+
};
|
|
285
|
+
const expectedChecksum = sha256Hex(stableJsonStringify(checksumPayload)).slice(0, 24);
|
|
286
|
+
if (normalized.checksum !== expectedChecksum) {
|
|
287
|
+
throw new Error('Bootstrap token checksum mismatch');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (requireNotExpired) {
|
|
291
|
+
const nowMs = Date.now();
|
|
292
|
+
if (Date.parse(normalized.expiresAt) <= nowMs) {
|
|
293
|
+
throw new Error(`Bootstrap token expired at ${normalized.expiresAt}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return normalized;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function createBootstrapSession(options = {}) {
|
|
301
|
+
const expiresInSeconds = parseDurationToSeconds(
|
|
302
|
+
options.expiresInSeconds,
|
|
303
|
+
DEFAULT_BOOTSTRAP_EXPIRES_SECONDS,
|
|
304
|
+
);
|
|
305
|
+
const storeDir = path.resolve(options.storeDir || defaultSessionStoreDir());
|
|
306
|
+
ensureDir(storeDir);
|
|
307
|
+
|
|
308
|
+
const createdAt = utcNowIso();
|
|
309
|
+
const expiresAt = dtToIsoZ(new Date(Date.parse(createdAt) + (expiresInSeconds * 1000)));
|
|
310
|
+
const sessionId = randomHex(16);
|
|
311
|
+
|
|
312
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519');
|
|
313
|
+
const recipientPublicDer = encodeBase64Url(publicKey.export({ format: 'der', type: 'spki' }));
|
|
314
|
+
const privateKeyPem = privateKey.export({ format: 'pem', type: 'pkcs8' });
|
|
315
|
+
const keyId = sha256Hex(recipientPublicDer).slice(0, 24);
|
|
316
|
+
|
|
317
|
+
const checksumPayload = {
|
|
318
|
+
v: 1,
|
|
319
|
+
session_id: sessionId,
|
|
320
|
+
key_id: keyId,
|
|
321
|
+
recipient_pub: recipientPublicDer,
|
|
322
|
+
created_at: createdAt,
|
|
323
|
+
expires_at: expiresAt,
|
|
324
|
+
};
|
|
325
|
+
const checksum = sha256Hex(stableJsonStringify(checksumPayload)).slice(0, 24);
|
|
326
|
+
const tokenPayload = { ...checksumPayload, checksum };
|
|
327
|
+
const token = `${BOOTSTRAP_TOKEN_PREFIX}${encodeBase64Url(Buffer.from(stableJsonStringify(tokenPayload), 'utf8'))}`;
|
|
328
|
+
|
|
329
|
+
const session = {
|
|
330
|
+
version: 1,
|
|
331
|
+
session_id: sessionId,
|
|
332
|
+
key_id: keyId,
|
|
333
|
+
created_at: createdAt,
|
|
334
|
+
expires_at: expiresAt,
|
|
335
|
+
recipient_pub_der_b64u: recipientPublicDer,
|
|
336
|
+
private_key_pkcs8_pem: String(privateKeyPem),
|
|
337
|
+
consumed_at: null,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const outPath = sessionPath(sessionId, storeDir);
|
|
341
|
+
atomicWriteFile(outPath, `${stableJsonStringify(session)}\n`, SESSION_FILE_MODE);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
token,
|
|
345
|
+
sessionId,
|
|
346
|
+
keyId,
|
|
347
|
+
createdAt,
|
|
348
|
+
expiresAt,
|
|
349
|
+
storePath: outPath,
|
|
350
|
+
fingerprint: `${keyId.slice(0, 12)}:${sessionId.slice(0, 8)}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function loadBootstrapSession(refOrToken, options = {}) {
|
|
355
|
+
const storeDir = path.resolve(options.storeDir || defaultSessionStoreDir());
|
|
356
|
+
if (!fs.existsSync(storeDir)) {
|
|
357
|
+
throw new Error(`Remote bootstrap store does not exist: ${storeDir}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let tokenDetails = null;
|
|
361
|
+
if (String(refOrToken || '').trim().startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
|
362
|
+
tokenDetails = parseBootstrapToken(refOrToken, { requireNotExpired: false });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const ref = tokenDetails
|
|
366
|
+
? tokenDetails.sessionId
|
|
367
|
+
: String(refOrToken || '').trim();
|
|
368
|
+
if (!ref) {
|
|
369
|
+
throw new Error('Missing bootstrap session reference');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const candidate = sessionPath(ref, storeDir);
|
|
373
|
+
if (fs.existsSync(candidate)) {
|
|
374
|
+
const raw = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
375
|
+
const session = normalizeSessionDoc(raw, `bootstrap session ${candidate}`);
|
|
376
|
+
if (tokenDetails && (session.sessionId !== tokenDetails.sessionId || session.keyId !== tokenDetails.keyId)) {
|
|
377
|
+
throw new Error('Bootstrap token does not match stored bootstrap session');
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
...session,
|
|
381
|
+
filePath: candidate,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const fileNames = fs.readdirSync(storeDir).filter((name) => name.endsWith('.json'));
|
|
386
|
+
for (const fileName of fileNames) {
|
|
387
|
+
const filePath = path.join(storeDir, fileName);
|
|
388
|
+
let raw = null;
|
|
389
|
+
try {
|
|
390
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
391
|
+
} catch {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const session = normalizeSessionDoc(raw, `bootstrap session ${filePath}`);
|
|
396
|
+
if (session.keyId === ref || session.sessionId === ref || session.recipientPublicDer === ref) {
|
|
397
|
+
if (tokenDetails && (session.sessionId !== tokenDetails.sessionId || session.keyId !== tokenDetails.keyId)) {
|
|
398
|
+
throw new Error('Bootstrap token does not match stored bootstrap session');
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
...session,
|
|
402
|
+
filePath,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
throw new Error(`Bootstrap session not found for reference: ${ref}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function markBootstrapSessionConsumed(session, options = {}) {
|
|
414
|
+
const storeDir = path.resolve(options.storeDir || defaultSessionStoreDir());
|
|
415
|
+
const loaded = session?.filePath
|
|
416
|
+
? normalizeSessionDoc(JSON.parse(fs.readFileSync(session.filePath, 'utf8')))
|
|
417
|
+
: loadBootstrapSession(session?.sessionId || session?.keyId, { storeDir });
|
|
418
|
+
const filePath = session?.filePath || sessionPath(loaded.sessionId, storeDir);
|
|
419
|
+
const consumedAt = utcNowIso();
|
|
420
|
+
const updated = {
|
|
421
|
+
version: loaded.version,
|
|
422
|
+
session_id: loaded.sessionId,
|
|
423
|
+
key_id: loaded.keyId,
|
|
424
|
+
created_at: loaded.createdAt,
|
|
425
|
+
expires_at: loaded.expiresAt,
|
|
426
|
+
recipient_pub_der_b64u: loaded.recipientPublicDer,
|
|
427
|
+
private_key_pkcs8_pem: loaded.privateKeyPem,
|
|
428
|
+
consumed_at: consumedAt,
|
|
429
|
+
};
|
|
430
|
+
atomicWriteFile(filePath, `${stableJsonStringify(updated)}\n`, SESSION_FILE_MODE);
|
|
431
|
+
return consumedAt;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function buildRemotePayloadFromFiles(options = {}) {
|
|
435
|
+
const configPath = path.resolve(String(options.configPath || 'health-sync.toml'));
|
|
436
|
+
const credsPath = path.resolve(String(options.credsPath || path.join(path.dirname(configPath), '.health-sync.creds')));
|
|
437
|
+
if (!fs.existsSync(configPath)) {
|
|
438
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const configContent = fs.readFileSync(configPath, 'utf8');
|
|
442
|
+
let credsContent = null;
|
|
443
|
+
if (fs.existsSync(credsPath)) {
|
|
444
|
+
credsContent = fs.readFileSync(credsPath, 'utf8');
|
|
445
|
+
} else if (options.allowMissingCreds !== false) {
|
|
446
|
+
credsContent = emptyCredsFileContent();
|
|
447
|
+
} else {
|
|
448
|
+
throw new Error(`Creds file not found: ${credsPath}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const payload = {
|
|
452
|
+
schema: REMOTE_PAYLOAD_SCHEMA,
|
|
453
|
+
version: 1,
|
|
454
|
+
created_at: utcNowIso(),
|
|
455
|
+
source_version: hasText(options.sourceVersion) ? String(options.sourceVersion) : '0.0.0',
|
|
456
|
+
files: {
|
|
457
|
+
'health-sync.toml': {
|
|
458
|
+
encoding: 'utf8',
|
|
459
|
+
content: configContent,
|
|
460
|
+
sha256: sha256Hex(configContent),
|
|
461
|
+
},
|
|
462
|
+
'.health-sync.creds': {
|
|
463
|
+
encoding: 'utf8',
|
|
464
|
+
content: credsContent,
|
|
465
|
+
sha256: sha256Hex(credsContent),
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
payload,
|
|
472
|
+
configPath,
|
|
473
|
+
credsPath,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function encryptRemotePayload(payload, bootstrapToken, options = {}) {
|
|
478
|
+
const token = parseBootstrapToken(bootstrapToken, {
|
|
479
|
+
requireNotExpired: options.requireNotExpired !== false,
|
|
480
|
+
});
|
|
481
|
+
const value = assertObject(payload, 'remote payload');
|
|
482
|
+
if (value.schema !== REMOTE_PAYLOAD_SCHEMA || Number(value.version) !== 1) {
|
|
483
|
+
throw new Error('Unsupported remote payload version');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const recipientPublicKey = loadPublicKeyFromDerBase64Url(token.recipientPublicDer);
|
|
487
|
+
const { privateKey: ephPrivateKey, publicKey: ephPublicKey } = crypto.generateKeyPairSync('x25519');
|
|
488
|
+
const sharedSecret = crypto.diffieHellman({
|
|
489
|
+
privateKey: ephPrivateKey,
|
|
490
|
+
publicKey: recipientPublicKey,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const salt = crypto.randomBytes(16);
|
|
494
|
+
const nonce = crypto.randomBytes(12);
|
|
495
|
+
const key = deriveSymmetricKey({
|
|
496
|
+
sharedSecret,
|
|
497
|
+
salt,
|
|
498
|
+
sessionId: token.sessionId,
|
|
499
|
+
keyId: token.keyId,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const payloadJson = stableJsonStringify(value);
|
|
503
|
+
const payloadSha256 = sha256Hex(payloadJson);
|
|
504
|
+
const compressed = gzipSync(Buffer.from(payloadJson, 'utf8'));
|
|
505
|
+
|
|
506
|
+
const archiveCreatedAt = utcNowIso();
|
|
507
|
+
const aad = {
|
|
508
|
+
schema: REMOTE_ARCHIVE_SCHEMA,
|
|
509
|
+
version: 1,
|
|
510
|
+
session_id: token.sessionId,
|
|
511
|
+
key_id: token.keyId,
|
|
512
|
+
created_at: archiveCreatedAt,
|
|
513
|
+
expires_at: token.expiresAt,
|
|
514
|
+
payload_sha256: payloadSha256,
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
518
|
+
cipher.setAAD(Buffer.from(stableJsonStringify(aad), 'utf8'));
|
|
519
|
+
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
520
|
+
const tag = cipher.getAuthTag();
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
schema: REMOTE_ARCHIVE_SCHEMA,
|
|
524
|
+
version: 1,
|
|
525
|
+
session_id: token.sessionId,
|
|
526
|
+
key_id: token.keyId,
|
|
527
|
+
aad_json: stableJsonStringify(aad),
|
|
528
|
+
ephemeral_pub_der_b64u: encodeBase64Url(ephPublicKey.export({ format: 'der', type: 'spki' })),
|
|
529
|
+
salt_b64u: encodeBase64Url(salt),
|
|
530
|
+
nonce_b64u: encodeBase64Url(nonce),
|
|
531
|
+
ciphertext_b64u: encodeBase64Url(ciphertext),
|
|
532
|
+
tag_b64u: encodeBase64Url(tag),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function decryptRemoteArchiveEnvelope(envelopeRaw, sessionRef, options = {}) {
|
|
537
|
+
const envelope = parseArchiveEnvelope(envelopeRaw);
|
|
538
|
+
const aad = JSON.parse(String(envelope.aad_json));
|
|
539
|
+
const session = loadBootstrapSession(sessionRef, {
|
|
540
|
+
storeDir: options.storeDir || defaultSessionStoreDir(),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
if (session.consumedAt) {
|
|
544
|
+
throw new Error(`Bootstrap session already consumed at ${session.consumedAt}`);
|
|
545
|
+
}
|
|
546
|
+
if (envelope.session_id !== session.sessionId || envelope.key_id !== session.keyId) {
|
|
547
|
+
throw new Error('Remote archive does not match the selected bootstrap session');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (Date.parse(aad.created_at || '') > Date.parse(session.expiresAt)) {
|
|
551
|
+
throw new Error('Remote archive was created after bootstrap session expiry');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const ephPublicKey = loadPublicKeyFromDerBase64Url(envelope.ephemeral_pub_der_b64u);
|
|
555
|
+
const privateKey = loadPrivateKeyFromPem(session.privateKeyPem);
|
|
556
|
+
const sharedSecret = crypto.diffieHellman({
|
|
557
|
+
privateKey,
|
|
558
|
+
publicKey: ephPublicKey,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const salt = decodeBase64Url(envelope.salt_b64u, 'remote archive salt');
|
|
562
|
+
const nonce = decodeBase64Url(envelope.nonce_b64u, 'remote archive nonce');
|
|
563
|
+
const tag = decodeBase64Url(envelope.tag_b64u, 'remote archive auth tag');
|
|
564
|
+
const ciphertext = decodeBase64Url(envelope.ciphertext_b64u, 'remote archive ciphertext');
|
|
565
|
+
|
|
566
|
+
const key = deriveSymmetricKey({
|
|
567
|
+
sharedSecret,
|
|
568
|
+
salt,
|
|
569
|
+
sessionId: session.sessionId,
|
|
570
|
+
keyId: session.keyId,
|
|
571
|
+
});
|
|
572
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
573
|
+
decipher.setAAD(Buffer.from(String(envelope.aad_json), 'utf8'));
|
|
574
|
+
decipher.setAuthTag(tag);
|
|
575
|
+
|
|
576
|
+
let compressed = null;
|
|
577
|
+
try {
|
|
578
|
+
compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
579
|
+
} catch {
|
|
580
|
+
throw new Error('Failed to decrypt remote archive (auth/tag mismatch)');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const payloadJson = gunzipSync(compressed).toString('utf8');
|
|
584
|
+
let payload = null;
|
|
585
|
+
try {
|
|
586
|
+
payload = JSON.parse(payloadJson);
|
|
587
|
+
} catch {
|
|
588
|
+
throw new Error('Decrypted remote archive payload is not valid JSON');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (aad.payload_sha256 !== sha256Hex(payloadJson)) {
|
|
592
|
+
throw new Error('Decrypted remote archive payload checksum mismatch');
|
|
593
|
+
}
|
|
594
|
+
if (payload.schema !== REMOTE_PAYLOAD_SCHEMA || Number(payload.version) !== 1) {
|
|
595
|
+
throw new Error('Unsupported remote archive payload schema');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
envelope,
|
|
600
|
+
payload,
|
|
601
|
+
session,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function writeRemoteArchiveFile(envelope, outputPath) {
|
|
606
|
+
const target = path.resolve(String(outputPath));
|
|
607
|
+
atomicWriteFile(target, `${stableJsonStringify(envelope)}\n`, 0o600);
|
|
608
|
+
return target;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function readRemoteArchiveFile(archivePath) {
|
|
612
|
+
const resolved = path.resolve(String(archivePath));
|
|
613
|
+
if (!fs.existsSync(resolved)) {
|
|
614
|
+
throw new Error(`Remote archive not found: ${resolved}`);
|
|
615
|
+
}
|
|
616
|
+
let parsed = null;
|
|
617
|
+
try {
|
|
618
|
+
parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
619
|
+
} catch {
|
|
620
|
+
throw new Error(`Remote archive is not valid JSON: ${resolved}`);
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
archivePath: resolved,
|
|
624
|
+
envelope: parseArchiveEnvelope(parsed),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function defaultRemoteArchivePath(configPath, sessionId) {
|
|
629
|
+
const safeSession = String(sessionId || '').slice(0, 12) || randomHex(6);
|
|
630
|
+
const dir = path.dirname(path.resolve(configPath));
|
|
631
|
+
return path.join(dir, `health-sync-remote-${safeSession}.enc`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function backupIfExists(targetPath) {
|
|
635
|
+
const resolved = path.resolve(targetPath);
|
|
636
|
+
if (!fs.existsSync(resolved)) {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
const backupPath = `${resolved}.bak-${formatBackupTimestamp()}`;
|
|
640
|
+
fs.copyFileSync(resolved, backupPath);
|
|
641
|
+
return backupPath;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function writeImportedFile(targetPath, content, mode) {
|
|
645
|
+
atomicWriteFile(path.resolve(targetPath), content, mode);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function importRemoteArchive(options = {}) {
|
|
649
|
+
const archivePath = path.resolve(String(options.archivePath || ''));
|
|
650
|
+
if (!archivePath) {
|
|
651
|
+
throw new Error('archivePath is required');
|
|
652
|
+
}
|
|
653
|
+
const sessionRef = options.sessionRef;
|
|
654
|
+
if (!sessionRef) {
|
|
655
|
+
throw new Error('sessionRef is required');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const parsed = readRemoteArchiveFile(archivePath);
|
|
659
|
+
const decrypted = decryptRemoteArchiveEnvelope(parsed.envelope, sessionRef, {
|
|
660
|
+
storeDir: options.storeDir || defaultSessionStoreDir(),
|
|
661
|
+
});
|
|
662
|
+
const payload = decrypted.payload;
|
|
663
|
+
const { config, creds } = validatePayloadFiles(payload);
|
|
664
|
+
|
|
665
|
+
if (sha256Hex(config.content) !== config.sha256) {
|
|
666
|
+
throw new Error('health-sync.toml checksum mismatch in remote payload');
|
|
667
|
+
}
|
|
668
|
+
if (sha256Hex(creds.content) !== creds.sha256) {
|
|
669
|
+
throw new Error('.health-sync.creds checksum mismatch in remote payload');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const targetConfigPath = path.resolve(String(options.targetConfigPath || 'health-sync.toml'));
|
|
673
|
+
const targetCredsPath = path.resolve(String(
|
|
674
|
+
options.targetCredsPath || path.join(path.dirname(targetConfigPath), '.health-sync.creds'),
|
|
675
|
+
));
|
|
676
|
+
|
|
677
|
+
const backups = [];
|
|
678
|
+
const configBackup = backupIfExists(targetConfigPath);
|
|
679
|
+
if (configBackup) {
|
|
680
|
+
backups.push(configBackup);
|
|
681
|
+
}
|
|
682
|
+
const credsBackup = backupIfExists(targetCredsPath);
|
|
683
|
+
if (credsBackup) {
|
|
684
|
+
backups.push(credsBackup);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
writeImportedFile(targetConfigPath, String(config.content), 0o600);
|
|
688
|
+
writeImportedFile(targetCredsPath, String(creds.content), 0o600);
|
|
689
|
+
const consumedAt = markBootstrapSessionConsumed(decrypted.session, {
|
|
690
|
+
storeDir: options.storeDir || defaultSessionStoreDir(),
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const tokenCount = (() => {
|
|
694
|
+
try {
|
|
695
|
+
const parsedCreds = JSON.parse(String(creds.content));
|
|
696
|
+
return Object.keys(parsedCreds?.tokens || {}).length;
|
|
697
|
+
} catch {
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
})();
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
targetConfigPath,
|
|
704
|
+
targetCredsPath,
|
|
705
|
+
backups,
|
|
706
|
+
consumedAt,
|
|
707
|
+
sessionId: decrypted.session.sessionId,
|
|
708
|
+
keyId: decrypted.session.keyId,
|
|
709
|
+
tokenCount,
|
|
710
|
+
};
|
|
711
|
+
}
|