sealcode 0.1.0 → 1.1.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/README.md +15 -0
- package/package.json +1 -1
- package/src/api.js +14 -1
- package/src/cli-auth.js +26 -0
- package/src/cli-ci-tokens.js +123 -0
- package/src/cli-escrow.js +236 -0
- package/src/cli-grants.js +385 -17
- package/src/cli-guard.js +117 -0
- package/src/cli-link.js +10 -1
- package/src/cli-service.js +230 -0
- package/src/cli-watch.js +862 -0
- package/src/cli.js +293 -2
- package/src/device.js +163 -0
- package/src/errors.js +18 -0
- package/src/grant-policy.js +119 -0
- package/src/keypair.js +159 -0
- package/src/keystore.js +76 -6
- package/src/open.js +69 -6
- package/src/seal.js +68 -3
- package/src/share-crypto.js +155 -0
- package/src/watermark.js +212 -0
package/src/cli.js
CHANGED
|
@@ -34,6 +34,24 @@ const { exportBundle, importBundle } = require('./bundle');
|
|
|
34
34
|
const { runLogin, runSignout, runWhoami } = require('./cli-auth');
|
|
35
35
|
const { runLink, runUnlink, runLinkInfo } = require('./cli-link');
|
|
36
36
|
const { runShare, runGrants, runRevoke, runRedeem } = require('./cli-grants');
|
|
37
|
+
const {
|
|
38
|
+
runCiCreate,
|
|
39
|
+
runCiList,
|
|
40
|
+
runCiRevoke,
|
|
41
|
+
} = require('./cli-ci-tokens');
|
|
42
|
+
const {
|
|
43
|
+
runEscrowStatus,
|
|
44
|
+
runEscrowEnable,
|
|
45
|
+
runEscrowDisable,
|
|
46
|
+
runEscrowRecover,
|
|
47
|
+
} = require('./cli-escrow');
|
|
48
|
+
const { runWatch, spawnDaemonWatcher } = require('./cli-watch');
|
|
49
|
+
const { runGuard } = require('./cli-guard');
|
|
50
|
+
const {
|
|
51
|
+
runInstallService,
|
|
52
|
+
runUninstallService,
|
|
53
|
+
runSupervise,
|
|
54
|
+
} = require('./cli-service');
|
|
37
55
|
const ui = require('./ui');
|
|
38
56
|
|
|
39
57
|
function logger(verbose) {
|
|
@@ -157,6 +175,21 @@ function build() {
|
|
|
157
175
|
.option('-p, --project <dir>', 'project root (default: nearest ancestor with .sealcoderc.json)')
|
|
158
176
|
.addHelpText('beforeAll', ui.banner(pkg.version) + '\n');
|
|
159
177
|
|
|
178
|
+
// sealcode@1.0.0: run cli-guard before every command. The guard
|
|
179
|
+
// enforces strict-watch — if a grant-derived session's watcher died
|
|
180
|
+
// and the user is about to run a sensitive command (`unlock`,
|
|
181
|
+
// `verify`, etc.), we re-lock the project before that command sees
|
|
182
|
+
// plaintext. Best-effort + zero-config; never throws.
|
|
183
|
+
program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
184
|
+
try {
|
|
185
|
+
const commandName = actionCommand.name();
|
|
186
|
+
const projectRoot = resolveProject(program.opts());
|
|
187
|
+
await runGuard({ commandName, projectRoot });
|
|
188
|
+
} catch (_) {
|
|
189
|
+
// Guard MUST NOT block the user's command on a bug in itself.
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
160
193
|
// -------- init --------
|
|
161
194
|
program
|
|
162
195
|
.command('init')
|
|
@@ -241,6 +274,7 @@ function build() {
|
|
|
241
274
|
.option('-v, --verbose', 'log each file as it is processed', false)
|
|
242
275
|
.option('--recovery', 'use recovery code instead of passphrase', false)
|
|
243
276
|
.option('--keep-stubs', 'do not remove stub files', false)
|
|
277
|
+
.option('--no-watcher', 'skip auto-spawning the watcher (grant-derived sessions)')
|
|
244
278
|
.action(async (opts) => {
|
|
245
279
|
try {
|
|
246
280
|
const projectRoot = resolveProject(program.opts());
|
|
@@ -249,11 +283,27 @@ function build() {
|
|
|
249
283
|
ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
|
|
250
284
|
const sp = new ui.Spinner('decrypting').start();
|
|
251
285
|
let n = 0;
|
|
286
|
+
// sealcode@1.1.0 — for grant-derived sessions, propagate the
|
|
287
|
+
// policy bundle into unlock so paths/ro/watermark are enforced
|
|
288
|
+
// even on a manual `sealcode unlock` (not just the auto-unlock
|
|
289
|
+
// performed by `sealcode redeem`).
|
|
290
|
+
const { loadSessionMeta: _lsm } = require('./keystore');
|
|
291
|
+
const _sm = _lsm(projectRoot);
|
|
292
|
+
const _policy = (_sm && _sm.meta && _sm.meta.policy) || null;
|
|
293
|
+
const _wctx = _policy && _policy.watermark ? {
|
|
294
|
+
email: (_sm && _sm.meta && _sm.meta.recipientEmail) || '',
|
|
295
|
+
grantId: (_sm && _sm.meta && _sm.meta.grantId) || '',
|
|
296
|
+
projectName: (_sm && _sm.meta && _sm.meta.projectName) || '',
|
|
297
|
+
date: new Date().toISOString().slice(0, 10),
|
|
298
|
+
fingerprint: (_sm && _sm.meta && _sm.meta.deviceFingerprint) || '',
|
|
299
|
+
} : null;
|
|
252
300
|
const res = await runUnlock({
|
|
253
301
|
projectRoot,
|
|
254
302
|
config,
|
|
255
303
|
K,
|
|
256
304
|
removeStubs: !opts.keepStubs,
|
|
305
|
+
policy: _policy,
|
|
306
|
+
watermarkCtx: _wctx,
|
|
257
307
|
log: (msg) => {
|
|
258
308
|
n += 1;
|
|
259
309
|
const file = String(msg).replace(/^\s*unlocked\s+/, '');
|
|
@@ -263,7 +313,41 @@ function build() {
|
|
|
263
313
|
}
|
|
264
314
|
},
|
|
265
315
|
});
|
|
266
|
-
|
|
316
|
+
const _skipped = res.skipped > 0 ? ` ${ui.c.dim(`(${res.skipped} outside grant scope)`)}` : '';
|
|
317
|
+
sp.succeed(`unlocked ${ui.c.bold(res.count)} files${_skipped} ${ui.c.dim(`(locked at ${res.sealedAt})`)}`);
|
|
318
|
+
if (res.policy && res.policy.mode === 'ro') {
|
|
319
|
+
ui.hint(' Read-only grant: files set to 0444. Any edit triggers an immediate re-lock.');
|
|
320
|
+
}
|
|
321
|
+
if (res.policy && res.policy.watermark) {
|
|
322
|
+
ui.hint(' Files are watermarked — leaks are traceable to your grant.');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// sealcode@1.0.0: if this unlock came from a grant-derived
|
|
326
|
+
// session, auto-spawn a detached watcher so the contractor
|
|
327
|
+
// can't accidentally leave the project plaintext after revoke.
|
|
328
|
+
// The watcher writes its heartbeat to a sidecar file that
|
|
329
|
+
// cli-guard inspects on subsequent commands.
|
|
330
|
+
if (opts.watcher !== false) {
|
|
331
|
+
const { loadSessionMeta } = require('./keystore');
|
|
332
|
+
const sm = loadSessionMeta(projectRoot);
|
|
333
|
+
if (sm && sm.meta.source === 'grant' && sm.meta.grantCodeHash) {
|
|
334
|
+
// We can't recover the plaintext access code from the hash
|
|
335
|
+
// alone — it lives only with the user. So we ask: do we
|
|
336
|
+
// already know it via env (SEALCODE_GRANT_CODE) or were we
|
|
337
|
+
// invoked right after `sealcode redeem <code>` in the same
|
|
338
|
+
// shell (in which case the code is in argv of the parent)?
|
|
339
|
+
//
|
|
340
|
+
// Simpler: print a clear nudge. In the future this could
|
|
341
|
+
// be wired through `sealcode redeem` itself triggering
|
|
342
|
+
// unlock + watch as a single transactional flow.
|
|
343
|
+
ui.hint(
|
|
344
|
+
` ⤷ this session came from a temp-access code. To keep it strictly enforceable, run:`,
|
|
345
|
+
);
|
|
346
|
+
process.stdout.write(
|
|
347
|
+
` ${ui.c.cyan('sealcode watch <code> --daemon &')}\n`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
267
351
|
} catch (err) {
|
|
268
352
|
process.exitCode = reportError(err);
|
|
269
353
|
}
|
|
@@ -596,6 +680,17 @@ function build() {
|
|
|
596
680
|
.option('--strict', 'lock the project root read-only between heartbeats', false)
|
|
597
681
|
.option('--heartbeat <seconds>', 'how often the client must heartbeat', (v) => parseInt(v, 10), 300)
|
|
598
682
|
.option('--offline-grace <seconds>', 'how long a heartbeat may be late before auto-lock', (v) => parseInt(v, 10), 1800)
|
|
683
|
+
// sealcode@1.1.0 — admin precision controls
|
|
684
|
+
.option('--paths <list>', 'comma-separated allowed path prefixes (e.g. "src/api/,tests/")', (v) =>
|
|
685
|
+
v.split(',').map((s) => s.trim()).filter(Boolean),
|
|
686
|
+
)
|
|
687
|
+
.option('--mode <rw|ro>', 'access mode: rw (read+write, default) or ro (read-only, watcher-enforced)', 'rw')
|
|
688
|
+
.option('--watermark <template>', 'inject this comment at the top of every unlocked file (supports {email} {grantId} {projectName} {date} {fingerprint})')
|
|
689
|
+
.option('--idle <minutes>', 'auto-lock after N minutes of no file activity (0 = disabled)', (v) => parseInt(v, 10), 0)
|
|
690
|
+
.option('--allow-ip <cidr...>', 'restrict redeem/heartbeat to these CIDRs (repeatable)')
|
|
691
|
+
.option('--allow-country <iso2...>', 'restrict to these ISO-2 country codes (requires Cloudflare)')
|
|
692
|
+
.option('--single-device', 'absolutely reject a second device fingerprint (default: warn in lenient, block in strict)', false)
|
|
693
|
+
.option('--nda <text>', 'require the recipient to type "I agree" to this text before unlocking')
|
|
599
694
|
.option('--json', 'machine-readable output', false)
|
|
600
695
|
.action(async (opts) => {
|
|
601
696
|
try {
|
|
@@ -610,6 +705,14 @@ function build() {
|
|
|
610
705
|
strict: !!opts.strict,
|
|
611
706
|
heartbeatSec: opts.heartbeat,
|
|
612
707
|
offlineGraceSec: opts.offlineGrace,
|
|
708
|
+
paths: opts.paths || null,
|
|
709
|
+
mode: opts.mode === 'ro' ? 'ro' : 'rw',
|
|
710
|
+
watermark: opts.watermark || null,
|
|
711
|
+
idleAutoLockMinutes: opts.idle || 0,
|
|
712
|
+
allowedIpCidrs: opts.allowIp || null,
|
|
713
|
+
allowedCountries: opts.allowCountry || null,
|
|
714
|
+
singleDeviceEnforce: !!opts.singleDevice,
|
|
715
|
+
ndaText: opts.nda || null,
|
|
613
716
|
json: !!opts.json,
|
|
614
717
|
});
|
|
615
718
|
} catch (err) {
|
|
@@ -644,6 +747,21 @@ function build() {
|
|
|
644
747
|
}
|
|
645
748
|
});
|
|
646
749
|
|
|
750
|
+
// -------- lockdown (sealcode@1.1.0) --------
|
|
751
|
+
program
|
|
752
|
+
.command('lockdown')
|
|
753
|
+
.description('Project panic button — revoke ALL active access codes for this project in one shot. All connected watchers re-lock within ~1 second.')
|
|
754
|
+
.option('--yes', 'skip the interactive confirmation', false)
|
|
755
|
+
.action(async (opts) => {
|
|
756
|
+
try {
|
|
757
|
+
const projectRoot = resolveProject(program.opts());
|
|
758
|
+
const { runLockdown } = require('./cli-grants');
|
|
759
|
+
await runLockdown({ projectRoot, confirm: !opts.yes });
|
|
760
|
+
} catch (err) {
|
|
761
|
+
process.exitCode = reportError(err);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
647
765
|
// -------- redeem --------
|
|
648
766
|
program
|
|
649
767
|
.command('redeem')
|
|
@@ -652,7 +770,180 @@ function build() {
|
|
|
652
770
|
.option('--json', 'machine-readable output', false)
|
|
653
771
|
.action(async (code, opts) => {
|
|
654
772
|
try {
|
|
655
|
-
|
|
773
|
+
// Pass projectRoot so runRedeem can cache an unwrapped K against
|
|
774
|
+
// this repo when the grant was team-shared. If the user is NOT
|
|
775
|
+
// inside a project root, resolveProject returns the cwd, and
|
|
776
|
+
// runRedeem just won't cache (it checks isInitialized).
|
|
777
|
+
const projectRoot = resolveProject(program.opts());
|
|
778
|
+
await runRedeem({ projectRoot, code, json: !!opts.json });
|
|
779
|
+
} catch (err) {
|
|
780
|
+
process.exitCode = reportError(err);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// -------- watch --------
|
|
785
|
+
program
|
|
786
|
+
.command('watch')
|
|
787
|
+
.argument('<code>', 'access code to monitor (the one you redeemed)')
|
|
788
|
+
.description('Stay online and self-lock if the owner revokes this code or it expires.')
|
|
789
|
+
.option('--interval <seconds>', 'override server-suggested heartbeat interval', (v) => parseInt(v, 10))
|
|
790
|
+
.option('-v, --verbose', 'print every heartbeat (instead of a single live status line)', false)
|
|
791
|
+
.option('--json', 'machine-readable JSONL output (one event per line)', false)
|
|
792
|
+
.option('--daemon', 'detach from the terminal and log to ~/.sealcode/logs/', false)
|
|
793
|
+
.option('--no-exfil', 'disable filesystem exfiltration heuristics (debug only)')
|
|
794
|
+
.action(async (code, opts) => {
|
|
795
|
+
try {
|
|
796
|
+
const projectRoot = resolveProject(program.opts());
|
|
797
|
+
await runWatch({
|
|
798
|
+
projectRoot,
|
|
799
|
+
code,
|
|
800
|
+
intervalSec: opts.interval,
|
|
801
|
+
verbose: !!opts.verbose,
|
|
802
|
+
json: !!opts.json,
|
|
803
|
+
daemon: !!opts.daemon,
|
|
804
|
+
exfil: opts.exfil !== false,
|
|
805
|
+
});
|
|
806
|
+
} catch (err) {
|
|
807
|
+
process.exitCode = reportError(err);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// -------- install-service / uninstall-service / supervise --------
|
|
812
|
+
// Per-user supervisor that re-launches dead grant watchers across
|
|
813
|
+
// reboots (macOS launchd / Linux systemd user units). Optional but
|
|
814
|
+
// recommended for any machine that redeems strict-mode grants.
|
|
815
|
+
program
|
|
816
|
+
.command('install-service')
|
|
817
|
+
.description('Install a per-user supervisor so the strict-watch daemon survives reboots.')
|
|
818
|
+
.action(async () => {
|
|
819
|
+
try {
|
|
820
|
+
runInstallService();
|
|
821
|
+
} catch (err) {
|
|
822
|
+
process.exitCode = reportError(err);
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
program
|
|
826
|
+
.command('uninstall-service')
|
|
827
|
+
.description('Remove the per-user supervisor installed by `install-service`.')
|
|
828
|
+
.action(async () => {
|
|
829
|
+
try {
|
|
830
|
+
runUninstallService();
|
|
831
|
+
} catch (err) {
|
|
832
|
+
process.exitCode = reportError(err);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
program
|
|
836
|
+
.command('supervise')
|
|
837
|
+
.description('(internal) Called every minute by launchd/systemd to sweep dead watchers.')
|
|
838
|
+
.action(async () => {
|
|
839
|
+
try {
|
|
840
|
+
runSupervise();
|
|
841
|
+
} catch (err) {
|
|
842
|
+
process.exitCode = reportError(err);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// -------- ci-token --------
|
|
847
|
+
const ciToken = program
|
|
848
|
+
.command('ci-token')
|
|
849
|
+
.description('Manage CI/CD short-lived tokens for the linked project (Pro feature).');
|
|
850
|
+
|
|
851
|
+
ciToken
|
|
852
|
+
.command('create')
|
|
853
|
+
.description('Create a new CI token. Shown once — save it to your CI secret store.')
|
|
854
|
+
.requiredOption('--label <text>', 'human-readable label (e.g. "GitHub Actions · deploy")')
|
|
855
|
+
.option('--ttl <duration>', 'lifetime: 1h, 8h, 1d, 1w (default 24h)', '24h')
|
|
856
|
+
.option('--scope <scope>', 'read | unlock (default unlock)', 'unlock')
|
|
857
|
+
.option('--json', 'machine-readable output', false)
|
|
858
|
+
.action(async (opts) => {
|
|
859
|
+
try {
|
|
860
|
+
const projectRoot = resolveProject(program.opts());
|
|
861
|
+
await runCiCreate({
|
|
862
|
+
projectRoot,
|
|
863
|
+
label: opts.label,
|
|
864
|
+
ttl: opts.ttl,
|
|
865
|
+
scope: opts.scope,
|
|
866
|
+
json: !!opts.json,
|
|
867
|
+
});
|
|
868
|
+
} catch (err) {
|
|
869
|
+
process.exitCode = reportError(err);
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
ciToken
|
|
874
|
+
.command('list')
|
|
875
|
+
.description('List CI tokens for the linked project.')
|
|
876
|
+
.option('--json', 'machine-readable output', false)
|
|
877
|
+
.action(async (opts) => {
|
|
878
|
+
try {
|
|
879
|
+
const projectRoot = resolveProject(program.opts());
|
|
880
|
+
await runCiList({ projectRoot, json: !!opts.json });
|
|
881
|
+
} catch (err) {
|
|
882
|
+
process.exitCode = reportError(err);
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
ciToken
|
|
887
|
+
.command('revoke')
|
|
888
|
+
.argument('<id>', 'CI token id (see `sealcode ci-token list`)')
|
|
889
|
+
.description('Revoke a CI token immediately.')
|
|
890
|
+
.action(async (id) => {
|
|
891
|
+
try {
|
|
892
|
+
await runCiRevoke({ tokenId: id });
|
|
893
|
+
} catch (err) {
|
|
894
|
+
process.exitCode = reportError(err);
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// -------- escrow --------
|
|
899
|
+
const escrow = program
|
|
900
|
+
.command('escrow')
|
|
901
|
+
.description('Cloud key escrow: a passphrase-recovery safety net (Pro feature).');
|
|
902
|
+
|
|
903
|
+
escrow
|
|
904
|
+
.command('status')
|
|
905
|
+
.description('Print whether escrow is enabled for the linked project.')
|
|
906
|
+
.option('--json', 'machine-readable output', false)
|
|
907
|
+
.action(async (opts) => {
|
|
908
|
+
try {
|
|
909
|
+
const projectRoot = resolveProject(program.opts());
|
|
910
|
+
await runEscrowStatus({ projectRoot, json: !!opts.json });
|
|
911
|
+
} catch (err) {
|
|
912
|
+
process.exitCode = reportError(err);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
escrow
|
|
917
|
+
.command('enable')
|
|
918
|
+
.description('Wrap the master key with a separate recovery passphrase and upload the ciphertext.')
|
|
919
|
+
.action(async () => {
|
|
920
|
+
try {
|
|
921
|
+
const projectRoot = resolveProject(program.opts());
|
|
922
|
+
await runEscrowEnable({ projectRoot });
|
|
923
|
+
} catch (err) {
|
|
924
|
+
process.exitCode = reportError(err);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
escrow
|
|
929
|
+
.command('disable')
|
|
930
|
+
.description('Delete the server-side escrow blob. The local vault is unaffected.')
|
|
931
|
+
.action(async () => {
|
|
932
|
+
try {
|
|
933
|
+
const projectRoot = resolveProject(program.opts());
|
|
934
|
+
await runEscrowDisable({ projectRoot });
|
|
935
|
+
} catch (err) {
|
|
936
|
+
process.exitCode = reportError(err);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
escrow
|
|
941
|
+
.command('recover')
|
|
942
|
+
.description('On a new machine: download the blob, decrypt with the recovery passphrase, cache K.')
|
|
943
|
+
.action(async () => {
|
|
944
|
+
try {
|
|
945
|
+
const projectRoot = resolveProject(program.opts());
|
|
946
|
+
await runEscrowRecover({ projectRoot });
|
|
656
947
|
} catch (err) {
|
|
657
948
|
process.exitCode = reportError(err);
|
|
658
949
|
}
|
package/src/device.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable per-machine device fingerprint.
|
|
5
|
+
*
|
|
6
|
+
* Used by sealcode@1.0.0+ for:
|
|
7
|
+
* - Binding a temp-access grant to the first machine that redeems it
|
|
8
|
+
* (so the contractor can't sidestep revoke by cloning the unlocked
|
|
9
|
+
* project folder onto a second laptop).
|
|
10
|
+
* - Tagging audit events with "which laptop did this come from".
|
|
11
|
+
*
|
|
12
|
+
* Design constraints:
|
|
13
|
+
* - Must be stable across reboots and software upgrades on the same
|
|
14
|
+
* machine (otherwise we'd lock contractors out for no reason after a
|
|
15
|
+
* restart).
|
|
16
|
+
* - Must be unstable across different machines, even ones with the same
|
|
17
|
+
* hostname (e.g. two MacBooks both named "Sayem's MacBook").
|
|
18
|
+
* - Must NOT leak PII off the device. We never send hostname, MAC, or
|
|
19
|
+
* machine-id over the wire — only the sha-256-truncated fingerprint.
|
|
20
|
+
*
|
|
21
|
+
* Inputs (best-effort, ranked by stability):
|
|
22
|
+
* 1. /etc/machine-id / /var/lib/dbus/machine-id (Linux, very stable)
|
|
23
|
+
* 2. ioreg IOPlatformUUID (macOS, very stable)
|
|
24
|
+
* 3. First non-loopback MAC address (everywhere, fairly stable)
|
|
25
|
+
* 4. os.hostname() + os.userInfo().username (fallback, weakest)
|
|
26
|
+
*
|
|
27
|
+
* The fingerprint is base32(sha-256(joined inputs)) truncated to 16 chars
|
|
28
|
+
* — that's 80 bits of entropy, enough that collisions across the entire
|
|
29
|
+
* sealcode userbase are statistically impossible. Truncation also makes
|
|
30
|
+
* the fingerprint look like other sealcode IDs (manifest hashes, etc.).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const os = require('os');
|
|
35
|
+
const { execFileSync } = require('child_process');
|
|
36
|
+
const { sha256Hex } = require('./crypto');
|
|
37
|
+
|
|
38
|
+
// Cache the fingerprint for the life of the process so we don't shell out
|
|
39
|
+
// to ioreg / read machine-id repeatedly. The fingerprint is constant for
|
|
40
|
+
// the duration of the process anyway.
|
|
41
|
+
let cachedFingerprint = null;
|
|
42
|
+
let cachedDetails = null;
|
|
43
|
+
|
|
44
|
+
function readFirstLine(path) {
|
|
45
|
+
try {
|
|
46
|
+
return fs.readFileSync(path, 'utf8').split('\n')[0].trim();
|
|
47
|
+
} catch (_) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readMachineId() {
|
|
53
|
+
if (process.platform === 'linux') {
|
|
54
|
+
return readFirstLine('/etc/machine-id') || readFirstLine('/var/lib/dbus/machine-id');
|
|
55
|
+
}
|
|
56
|
+
if (process.platform === 'darwin') {
|
|
57
|
+
try {
|
|
58
|
+
const out = execFileSync('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
timeout: 1500,
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
});
|
|
63
|
+
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/i.exec(out);
|
|
64
|
+
return m ? m[1] : null;
|
|
65
|
+
} catch (_) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
try {
|
|
71
|
+
const out = execFileSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
timeout: 1500,
|
|
74
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
});
|
|
76
|
+
const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]+)/i.exec(out);
|
|
77
|
+
return m ? m[1] : null;
|
|
78
|
+
} catch (_) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function firstNonLoopbackMac() {
|
|
86
|
+
const ifaces = os.networkInterfaces();
|
|
87
|
+
// Sort interface names for determinism — different boot orders on the
|
|
88
|
+
// same machine occasionally list interfaces in a different order, but
|
|
89
|
+
// alphabetical is stable.
|
|
90
|
+
const names = Object.keys(ifaces).sort();
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
for (const addr of ifaces[name] || []) {
|
|
93
|
+
if (!addr.internal && addr.mac && addr.mac !== '00:00:00:00:00:00') {
|
|
94
|
+
return addr.mac.toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert hex sha-256 to short opaque base32 fingerprint.
|
|
103
|
+
* 16 chars of Crockford-ish base32 = 80 bits.
|
|
104
|
+
*/
|
|
105
|
+
function shortFingerprint(hex) {
|
|
106
|
+
// Use 0-9 a-z minus look-alikes (no l, o, i, u — matches sealcode's
|
|
107
|
+
// existing recovery-code alphabet style).
|
|
108
|
+
const alphabet = '0123456789abcdefghjkmnpqrstvwxyz';
|
|
109
|
+
const bytes = Buffer.from(hex.slice(0, 32), 'hex'); // 16 bytes = 128 bits
|
|
110
|
+
let bits = 0;
|
|
111
|
+
let acc = 0;
|
|
112
|
+
let out = '';
|
|
113
|
+
for (const b of bytes) {
|
|
114
|
+
acc = (acc << 8) | b;
|
|
115
|
+
bits += 8;
|
|
116
|
+
while (bits >= 5) {
|
|
117
|
+
bits -= 5;
|
|
118
|
+
out += alphabet[(acc >> bits) & 0x1f];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out.slice(0, 16);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compute() {
|
|
125
|
+
const machineId = readMachineId();
|
|
126
|
+
const mac = firstNonLoopbackMac();
|
|
127
|
+
const host = os.hostname() || '';
|
|
128
|
+
const user = (os.userInfo().username || '');
|
|
129
|
+
const platform = `${os.platform()}-${os.arch()}`;
|
|
130
|
+
|
|
131
|
+
// We deliberately include MORE than one input so a missing one (e.g. no
|
|
132
|
+
// machine-id on a fresh container) still produces a stable fingerprint.
|
|
133
|
+
const material = ['sealcode-device-v1', machineId || '', mac || '', host, user, platform].join('|');
|
|
134
|
+
const fp = shortFingerprint(sha256Hex(material));
|
|
135
|
+
cachedDetails = { hostname: host, platform, hasMachineId: !!machineId, hasMac: !!mac };
|
|
136
|
+
return fp;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getDeviceFingerprint() {
|
|
140
|
+
if (cachedFingerprint) return cachedFingerprint;
|
|
141
|
+
cachedFingerprint = compute();
|
|
142
|
+
return cachedFingerprint;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Non-secret descriptive info about the device. Used by `sealcode where`
|
|
147
|
+
* and shown in audit logs. We never send `machineId` or `mac` over the
|
|
148
|
+
* wire — only the derived fingerprint plus the hostname/platform pair.
|
|
149
|
+
*/
|
|
150
|
+
function getDeviceInfo() {
|
|
151
|
+
getDeviceFingerprint();
|
|
152
|
+
return {
|
|
153
|
+
fingerprint: cachedFingerprint,
|
|
154
|
+
hostname: cachedDetails.hostname,
|
|
155
|
+
platform: cachedDetails.platform,
|
|
156
|
+
sourcesUsed: {
|
|
157
|
+
machineId: cachedDetails.hasMachineId,
|
|
158
|
+
mac: cachedDetails.hasMac,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { getDeviceFingerprint, getDeviceInfo };
|
package/src/errors.js
CHANGED
|
@@ -54,6 +54,24 @@ const CODES = {
|
|
|
54
54
|
'The free CLI handles lock / unlock / verify forever. Pro adds team key sharing, key rotation across N projects, audit log sync, and cloud key escrow.',
|
|
55
55
|
try: 'Try: visit https://sealcode.dev/pro (start a free 14-day trial)',
|
|
56
56
|
},
|
|
57
|
+
SEALCODE_WATCH_NO_CODE: {
|
|
58
|
+
headline: 'sealcode watch needs an access code.',
|
|
59
|
+
detail:
|
|
60
|
+
'Pass the access code shared with you (the one you got from sealcode share / redeem).',
|
|
61
|
+
try: 'Try: sealcode watch SC-XXXX-XXXX-XXXX-XXXX',
|
|
62
|
+
},
|
|
63
|
+
SEALCODE_WATCH_BAD_CODE: {
|
|
64
|
+
headline: 'The server rejected that access code.',
|
|
65
|
+
detail:
|
|
66
|
+
"The code may be malformed, already expired, revoked, or it doesn't exist on this server.",
|
|
67
|
+
try: 'Try: sealcode redeem <code> (re-validate the code first)',
|
|
68
|
+
},
|
|
69
|
+
SEALCODE_WATCH_OFFLINE: {
|
|
70
|
+
headline: "I couldn't reach sealcode.dev to start watching.",
|
|
71
|
+
detail:
|
|
72
|
+
'The first heartbeat failed. We refuse to start the watcher without confirming the code is reachable — otherwise a revoke would never be observed.',
|
|
73
|
+
try: 'Try: check connectivity, then re-run `sealcode watch <code>`.',
|
|
74
|
+
},
|
|
57
75
|
};
|
|
58
76
|
|
|
59
77
|
class SealcodeError extends Error {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Grant policy: the in-memory representation of the admin-control bundle
|
|
5
|
+
* shipped in sealcode@1.1.0.
|
|
6
|
+
*
|
|
7
|
+
* A "policy" is the subset of a temp-access grant that *changes how the
|
|
8
|
+
* recipient's CLI behaves* once the grant is in force:
|
|
9
|
+
*
|
|
10
|
+
* - allowedPaths what subset of the project they can unlock
|
|
11
|
+
* - mode 'rw' (default) or 'ro' (chmod 0444 + watcher
|
|
12
|
+
* re-locks on detected modifications)
|
|
13
|
+
* - watermark template injected as a per-filetype comment
|
|
14
|
+
* at the top of every unlocked file
|
|
15
|
+
* - idleAutoLockMinutes lock after N minutes of no fs activity
|
|
16
|
+
* - ndaText prompt the recipient must accept on redeem
|
|
17
|
+
*
|
|
18
|
+
* The server hands the policy back inside the /redeem response. The CLI
|
|
19
|
+
* stashes it in the session meta and reads it again in unlock, watcher,
|
|
20
|
+
* and guard. Older 1.0 CLIs that don't know about these fields just
|
|
21
|
+
* ignore them — backward-compat by design.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const PATH_SEP = '/';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} GrantPolicy
|
|
28
|
+
* @property {string[]|null} allowedPaths POSIX prefixes; null = full project
|
|
29
|
+
* @property {'rw'|'ro'} mode access mode
|
|
30
|
+
* @property {string|null} watermark comment template
|
|
31
|
+
* @property {number} idleAutoLockMinutes 0 = disabled
|
|
32
|
+
* @property {string|null} ndaText prompt; null = no NDA
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} raw server JSON (the `policy` block of /redeem response)
|
|
37
|
+
* @returns {GrantPolicy}
|
|
38
|
+
*/
|
|
39
|
+
function normalize(raw) {
|
|
40
|
+
const r = raw || {};
|
|
41
|
+
return {
|
|
42
|
+
allowedPaths: Array.isArray(r.allowedPaths) && r.allowedPaths.length
|
|
43
|
+
? r.allowedPaths.map(normalizePrefix)
|
|
44
|
+
: null,
|
|
45
|
+
mode: r.mode === 'ro' ? 'ro' : 'rw',
|
|
46
|
+
watermark: typeof r.watermark === 'string' && r.watermark.trim()
|
|
47
|
+
? r.watermark.trim()
|
|
48
|
+
: null,
|
|
49
|
+
idleAutoLockMinutes: Math.max(0, Math.floor(Number(r.idleAutoLockMinutes) || 0)),
|
|
50
|
+
ndaText: typeof r.ndaText === 'string' && r.ndaText.trim()
|
|
51
|
+
? r.ndaText.trim()
|
|
52
|
+
: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Coerce a path prefix into the form we compare against manifest paths:
|
|
58
|
+
* - normalize Windows slashes to forward slashes
|
|
59
|
+
* - strip leading "./" and absolute-style leading slash
|
|
60
|
+
* - guarantee a trailing slash so "src/api" doesn't match "src/api2/..."
|
|
61
|
+
*/
|
|
62
|
+
function normalizePrefix(p) {
|
|
63
|
+
let s = String(p || '').replace(/\\/g, PATH_SEP);
|
|
64
|
+
if (s.startsWith('./')) s = s.slice(2);
|
|
65
|
+
while (s.startsWith(PATH_SEP)) s = s.slice(1);
|
|
66
|
+
if (!s.endsWith(PATH_SEP) && s !== '') s += PATH_SEP;
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Does the (already-POSIX-normalized) entry path fall inside any of the
|
|
72
|
+
* grant's allowed prefixes? Empty/null allowedPaths means no restriction.
|
|
73
|
+
*
|
|
74
|
+
* The watcher and unlock both call this — never the server, because the
|
|
75
|
+
* server can't see decrypted paths. The trust boundary is the signed CLI
|
|
76
|
+
* binary.
|
|
77
|
+
*/
|
|
78
|
+
function isPathAllowed(entryPath, allowedPaths) {
|
|
79
|
+
if (!allowedPaths || allowedPaths.length === 0) return true;
|
|
80
|
+
const p = String(entryPath || '').replace(/\\/g, PATH_SEP).replace(/^\.\//, '');
|
|
81
|
+
return allowedPaths.some((prefix) => p === prefix.slice(0, -1) || p.startsWith(prefix));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Filter a parsed manifest's `files` array down to the allowed subset.
|
|
86
|
+
* Returns a NEW array; doesn't mutate the input.
|
|
87
|
+
*/
|
|
88
|
+
function filterManifestFiles(files, allowedPaths) {
|
|
89
|
+
if (!allowedPaths || allowedPaths.length === 0) return files;
|
|
90
|
+
return files.filter((f) => isPathAllowed(f.p, allowedPaths));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render a one-line summary of the policy for the CLI banner / dashboard.
|
|
95
|
+
* Pure presentation — no behavior depends on this.
|
|
96
|
+
*/
|
|
97
|
+
function describe(policy) {
|
|
98
|
+
const bits = [];
|
|
99
|
+
if (policy.mode === 'ro') bits.push('read-only');
|
|
100
|
+
if (policy.allowedPaths && policy.allowedPaths.length) {
|
|
101
|
+
bits.push(
|
|
102
|
+
`scoped to ${policy.allowedPaths.length} path${policy.allowedPaths.length === 1 ? '' : 's'}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (policy.watermark) bits.push('watermarked');
|
|
106
|
+
if (policy.idleAutoLockMinutes > 0) {
|
|
107
|
+
bits.push(`idle-lock ${policy.idleAutoLockMinutes}m`);
|
|
108
|
+
}
|
|
109
|
+
if (policy.ndaText) bits.push('NDA');
|
|
110
|
+
return bits.join(' · ') || 'standard';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
normalize,
|
|
115
|
+
normalizePrefix,
|
|
116
|
+
isPathAllowed,
|
|
117
|
+
filterManifestFiles,
|
|
118
|
+
describe,
|
|
119
|
+
};
|