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/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
- sp.succeed(`unlocked ${ui.c.bold(res.count)} files ${ui.c.dim(`(locked at ${res.sealedAt})`)}`);
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
- await runRedeem({ code, json: !!opts.json });
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
+ };