sealcode 0.3.0 → 1.1.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.
- package/README.md +15 -3
- package/package.json +1 -1
- package/src/api.js +14 -1
- package/src/cli-auth.js +26 -0
- package/src/cli-grants.js +409 -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 +659 -85
- package/src/cli.js +154 -3
- package/src/device.js +163 -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
|
@@ -45,7 +45,13 @@ const {
|
|
|
45
45
|
runEscrowDisable,
|
|
46
46
|
runEscrowRecover,
|
|
47
47
|
} = require('./cli-escrow');
|
|
48
|
-
const { runWatch } = require('./cli-watch');
|
|
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');
|
|
49
55
|
const ui = require('./ui');
|
|
50
56
|
|
|
51
57
|
function logger(verbose) {
|
|
@@ -169,6 +175,21 @@ function build() {
|
|
|
169
175
|
.option('-p, --project <dir>', 'project root (default: nearest ancestor with .sealcoderc.json)')
|
|
170
176
|
.addHelpText('beforeAll', ui.banner(pkg.version) + '\n');
|
|
171
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
|
+
|
|
172
193
|
// -------- init --------
|
|
173
194
|
program
|
|
174
195
|
.command('init')
|
|
@@ -253,6 +274,7 @@ function build() {
|
|
|
253
274
|
.option('-v, --verbose', 'log each file as it is processed', false)
|
|
254
275
|
.option('--recovery', 'use recovery code instead of passphrase', false)
|
|
255
276
|
.option('--keep-stubs', 'do not remove stub files', false)
|
|
277
|
+
.option('--no-watcher', 'skip auto-spawning the watcher (grant-derived sessions)')
|
|
256
278
|
.action(async (opts) => {
|
|
257
279
|
try {
|
|
258
280
|
const projectRoot = resolveProject(program.opts());
|
|
@@ -261,11 +283,27 @@ function build() {
|
|
|
261
283
|
ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
|
|
262
284
|
const sp = new ui.Spinner('decrypting').start();
|
|
263
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;
|
|
264
300
|
const res = await runUnlock({
|
|
265
301
|
projectRoot,
|
|
266
302
|
config,
|
|
267
303
|
K,
|
|
268
304
|
removeStubs: !opts.keepStubs,
|
|
305
|
+
policy: _policy,
|
|
306
|
+
watermarkCtx: _wctx,
|
|
269
307
|
log: (msg) => {
|
|
270
308
|
n += 1;
|
|
271
309
|
const file = String(msg).replace(/^\s*unlocked\s+/, '');
|
|
@@ -275,7 +313,41 @@ function build() {
|
|
|
275
313
|
}
|
|
276
314
|
},
|
|
277
315
|
});
|
|
278
|
-
|
|
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
|
+
}
|
|
279
351
|
} catch (err) {
|
|
280
352
|
process.exitCode = reportError(err);
|
|
281
353
|
}
|
|
@@ -608,6 +680,17 @@ function build() {
|
|
|
608
680
|
.option('--strict', 'lock the project root read-only between heartbeats', false)
|
|
609
681
|
.option('--heartbeat <seconds>', 'how often the client must heartbeat', (v) => parseInt(v, 10), 300)
|
|
610
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')
|
|
611
694
|
.option('--json', 'machine-readable output', false)
|
|
612
695
|
.action(async (opts) => {
|
|
613
696
|
try {
|
|
@@ -622,7 +705,16 @@ function build() {
|
|
|
622
705
|
strict: !!opts.strict,
|
|
623
706
|
heartbeatSec: opts.heartbeat,
|
|
624
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,
|
|
625
716
|
json: !!opts.json,
|
|
717
|
+
resolveKey,
|
|
626
718
|
});
|
|
627
719
|
} catch (err) {
|
|
628
720
|
process.exitCode = reportError(err);
|
|
@@ -656,6 +748,21 @@ function build() {
|
|
|
656
748
|
}
|
|
657
749
|
});
|
|
658
750
|
|
|
751
|
+
// -------- lockdown (sealcode@1.1.0) --------
|
|
752
|
+
program
|
|
753
|
+
.command('lockdown')
|
|
754
|
+
.description('Project panic button — revoke ALL active access codes for this project in one shot. All connected watchers re-lock within ~1 second.')
|
|
755
|
+
.option('--yes', 'skip the interactive confirmation', false)
|
|
756
|
+
.action(async (opts) => {
|
|
757
|
+
try {
|
|
758
|
+
const projectRoot = resolveProject(program.opts());
|
|
759
|
+
const { runLockdown } = require('./cli-grants');
|
|
760
|
+
await runLockdown({ projectRoot, confirm: !opts.yes });
|
|
761
|
+
} catch (err) {
|
|
762
|
+
process.exitCode = reportError(err);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
659
766
|
// -------- redeem --------
|
|
660
767
|
program
|
|
661
768
|
.command('redeem')
|
|
@@ -664,7 +771,12 @@ function build() {
|
|
|
664
771
|
.option('--json', 'machine-readable output', false)
|
|
665
772
|
.action(async (code, opts) => {
|
|
666
773
|
try {
|
|
667
|
-
|
|
774
|
+
// Pass projectRoot so runRedeem can cache an unwrapped K against
|
|
775
|
+
// this repo when the grant was team-shared. If the user is NOT
|
|
776
|
+
// inside a project root, resolveProject returns the cwd, and
|
|
777
|
+
// runRedeem just won't cache (it checks isInitialized).
|
|
778
|
+
const projectRoot = resolveProject(program.opts());
|
|
779
|
+
await runRedeem({ projectRoot, code, json: !!opts.json });
|
|
668
780
|
} catch (err) {
|
|
669
781
|
process.exitCode = reportError(err);
|
|
670
782
|
}
|
|
@@ -678,6 +790,8 @@ function build() {
|
|
|
678
790
|
.option('--interval <seconds>', 'override server-suggested heartbeat interval', (v) => parseInt(v, 10))
|
|
679
791
|
.option('-v, --verbose', 'print every heartbeat (instead of a single live status line)', false)
|
|
680
792
|
.option('--json', 'machine-readable JSONL output (one event per line)', false)
|
|
793
|
+
.option('--daemon', 'detach from the terminal and log to ~/.sealcode/logs/', false)
|
|
794
|
+
.option('--no-exfil', 'disable filesystem exfiltration heuristics (debug only)')
|
|
681
795
|
.action(async (code, opts) => {
|
|
682
796
|
try {
|
|
683
797
|
const projectRoot = resolveProject(program.opts());
|
|
@@ -687,12 +801,49 @@ function build() {
|
|
|
687
801
|
intervalSec: opts.interval,
|
|
688
802
|
verbose: !!opts.verbose,
|
|
689
803
|
json: !!opts.json,
|
|
804
|
+
daemon: !!opts.daemon,
|
|
805
|
+
exfil: opts.exfil !== false,
|
|
690
806
|
});
|
|
691
807
|
} catch (err) {
|
|
692
808
|
process.exitCode = reportError(err);
|
|
693
809
|
}
|
|
694
810
|
});
|
|
695
811
|
|
|
812
|
+
// -------- install-service / uninstall-service / supervise --------
|
|
813
|
+
// Per-user supervisor that re-launches dead grant watchers across
|
|
814
|
+
// reboots (macOS launchd / Linux systemd user units). Optional but
|
|
815
|
+
// recommended for any machine that redeems strict-mode grants.
|
|
816
|
+
program
|
|
817
|
+
.command('install-service')
|
|
818
|
+
.description('Install a per-user supervisor so the strict-watch daemon survives reboots.')
|
|
819
|
+
.action(async () => {
|
|
820
|
+
try {
|
|
821
|
+
runInstallService();
|
|
822
|
+
} catch (err) {
|
|
823
|
+
process.exitCode = reportError(err);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
program
|
|
827
|
+
.command('uninstall-service')
|
|
828
|
+
.description('Remove the per-user supervisor installed by `install-service`.')
|
|
829
|
+
.action(async () => {
|
|
830
|
+
try {
|
|
831
|
+
runUninstallService();
|
|
832
|
+
} catch (err) {
|
|
833
|
+
process.exitCode = reportError(err);
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
program
|
|
837
|
+
.command('supervise')
|
|
838
|
+
.description('(internal) Called every minute by launchd/systemd to sweep dead watchers.')
|
|
839
|
+
.action(async () => {
|
|
840
|
+
try {
|
|
841
|
+
runSupervise();
|
|
842
|
+
} catch (err) {
|
|
843
|
+
process.exitCode = reportError(err);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
696
847
|
// -------- ci-token --------
|
|
697
848
|
const ciToken = program
|
|
698
849
|
.command('ci-token')
|
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 };
|
|
@@ -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
|
+
};
|
package/src/keypair.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-user X25519 keypair used for team key sharing.
|
|
5
|
+
*
|
|
6
|
+
* ~/.sealcode/keypair.json (mode 0600)
|
|
7
|
+
* {
|
|
8
|
+
* "algo": "x25519-v1",
|
|
9
|
+
* "publicKey": "<base64 32B raw>",
|
|
10
|
+
* "privateKey": "<base64 32B raw>",
|
|
11
|
+
* "createdAt": "2026-05-19T18:00:00Z"
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* The PRIVATE key never leaves this machine. The PUBLIC key is uploaded
|
|
15
|
+
* to sealcode.dev on `sealcode login` and used by other users to wrap the
|
|
16
|
+
* project master key K when they `sealcode share --to your-email`.
|
|
17
|
+
*
|
|
18
|
+
* Rotation: deleting keypair.json forces a fresh keypair on next login.
|
|
19
|
+
* Old grants wrapped to the old pubkey will fail to unwrap and the user
|
|
20
|
+
* will need a fresh share — same UX as losing your laptop. We treat that
|
|
21
|
+
* as acceptable because a private key never being exfiltrated is more
|
|
22
|
+
* valuable than convenience around rotation.
|
|
23
|
+
*
|
|
24
|
+
* We use Node's built-in crypto (no new dependencies):
|
|
25
|
+
* - generateKeyPairSync('x25519')
|
|
26
|
+
* - createPrivateKey / createPublicKey
|
|
27
|
+
* - diffieHellman() (raw 32-byte X25519 ECDH shared secret)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
|
|
35
|
+
const CONFIG_DIR = path.join(os.homedir(), '.sealcode');
|
|
36
|
+
const KEYPAIR_PATH = path.join(CONFIG_DIR, 'keypair.json');
|
|
37
|
+
const ALGO = 'x25519-v1';
|
|
38
|
+
|
|
39
|
+
function ensureConfigDir() {
|
|
40
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Raw 32-byte X25519 key extraction. Node's KeyObject only gives DER/PEM
|
|
45
|
+
* by default; we strip the SPKI/PKCS#8 prefix to get the raw bytes that
|
|
46
|
+
* libsodium / age / browser WebCrypto all interoperate with.
|
|
47
|
+
*
|
|
48
|
+
* For X25519 the DER prefixes are constant lengths:
|
|
49
|
+
* - SPKI (public) prefix: 12 bytes → raw key is last 32 bytes
|
|
50
|
+
* - PKCS#8 (priv) prefix: 16 bytes → raw key is last 32 bytes
|
|
51
|
+
*/
|
|
52
|
+
function rawPublic(keyObject) {
|
|
53
|
+
const der = keyObject.export({ type: 'spki', format: 'der' });
|
|
54
|
+
return Buffer.from(der.subarray(der.length - 32));
|
|
55
|
+
}
|
|
56
|
+
function rawPrivate(keyObject) {
|
|
57
|
+
const der = keyObject.export({ type: 'pkcs8', format: 'der' });
|
|
58
|
+
return Buffer.from(der.subarray(der.length - 32));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function publicFromRaw(raw32) {
|
|
62
|
+
// Wrap raw 32 bytes back into an SPKI-formatted DER so Node accepts it
|
|
63
|
+
// as a peer's pubkey for ECDH.
|
|
64
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
65
|
+
return crypto.createPublicKey({
|
|
66
|
+
key: Buffer.concat([SPKI_PREFIX, raw32]),
|
|
67
|
+
format: 'der',
|
|
68
|
+
type: 'spki',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function privateFromRaw(raw32) {
|
|
72
|
+
const PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
73
|
+
return crypto.createPrivateKey({
|
|
74
|
+
key: Buffer.concat([PKCS8_PREFIX, raw32]),
|
|
75
|
+
format: 'der',
|
|
76
|
+
type: 'pkcs8',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function generate() {
|
|
81
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
82
|
+
return {
|
|
83
|
+
algo: ALGO,
|
|
84
|
+
publicKey: rawPublic(publicKey).toString('base64'),
|
|
85
|
+
privateKey: rawPrivate(privateKey).toString('base64'),
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function read() {
|
|
91
|
+
if (!fs.existsSync(KEYPAIR_PATH)) return null;
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(fs.readFileSync(KEYPAIR_PATH, 'utf8'));
|
|
94
|
+
if (!parsed || parsed.algo !== ALGO || !parsed.publicKey || !parsed.privateKey) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
} catch (_) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function write(kp) {
|
|
104
|
+
ensureConfigDir();
|
|
105
|
+
const tmp = KEYPAIR_PATH + '.tmp';
|
|
106
|
+
fs.writeFileSync(tmp, JSON.stringify(kp, null, 2) + '\n', { mode: 0o600 });
|
|
107
|
+
try {
|
|
108
|
+
fs.chmodSync(tmp, 0o600);
|
|
109
|
+
} catch (_) { /* windows ok */ }
|
|
110
|
+
fs.renameSync(tmp, KEYPAIR_PATH);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read the existing keypair or generate + persist a fresh one. Returns
|
|
115
|
+
* the parsed JSON struct including the private key.
|
|
116
|
+
*/
|
|
117
|
+
function ensureKeypair() {
|
|
118
|
+
const existing = read();
|
|
119
|
+
if (existing) return existing;
|
|
120
|
+
const fresh = generate();
|
|
121
|
+
write(fresh);
|
|
122
|
+
return fresh;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Just the pubkey (algo + base64), suitable to upload to the server. */
|
|
126
|
+
function publicKey() {
|
|
127
|
+
const kp = ensureKeypair();
|
|
128
|
+
return { algo: kp.algo, publicKey: kp.publicKey };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Compute raw 32-byte X25519 shared secret. Internal helper for share-crypto. */
|
|
132
|
+
function sharedSecret(ourPrivateB64, theirPublicB64) {
|
|
133
|
+
const ourPriv = privateFromRaw(Buffer.from(ourPrivateB64, 'base64'));
|
|
134
|
+
const theirPub = publicFromRaw(Buffer.from(theirPublicB64, 'base64'));
|
|
135
|
+
return crypto.diffieHellman({ privateKey: ourPriv, publicKey: theirPub });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function clear() {
|
|
139
|
+
try {
|
|
140
|
+
fs.unlinkSync(KEYPAIR_PATH);
|
|
141
|
+
} catch (_) { /* ignore */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
KEYPAIR_PATH,
|
|
146
|
+
ALGO,
|
|
147
|
+
ensureKeypair,
|
|
148
|
+
read,
|
|
149
|
+
publicKey,
|
|
150
|
+
sharedSecret,
|
|
151
|
+
generate,
|
|
152
|
+
write,
|
|
153
|
+
clear,
|
|
154
|
+
// exposed for share-crypto only
|
|
155
|
+
_rawPublic: rawPublic,
|
|
156
|
+
_rawPrivate: rawPrivate,
|
|
157
|
+
_publicFromRaw: publicFromRaw,
|
|
158
|
+
_privateFromRaw: privateFromRaw,
|
|
159
|
+
};
|