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-guard.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cli-guard — runs at the top of every sealcode command (see cli.js).
|
|
5
|
+
*
|
|
6
|
+
* Its job is to enforce the "strict watch" contract:
|
|
7
|
+
*
|
|
8
|
+
* - If the current project has a cached session whose `meta.source`
|
|
9
|
+
* is "grant" (i.e. the K was delivered via a temp-access code rather
|
|
10
|
+
* than typed as a passphrase), AND the watcher daemon is no longer
|
|
11
|
+
* supervising the project (process died, host rebooted, contractor
|
|
12
|
+
* ran kill -9 to "keep" the plaintext), AND the grant is flagged as
|
|
13
|
+
* `strictWatch`, then we lock the project IMMEDIATELY before running
|
|
14
|
+
* the user's actual command.
|
|
15
|
+
*
|
|
16
|
+
* This is the answer to "what stops a contractor from killing the
|
|
17
|
+
* watcher to keep my source": every other invocation of the sealcode
|
|
18
|
+
* binary on that machine will re-lock the moment the watcher is gone.
|
|
19
|
+
* Combined with the system service installer (cli-service.js), the
|
|
20
|
+
* watcher also auto-restarts at user login.
|
|
21
|
+
*
|
|
22
|
+
* The guard is purely best-effort + zero-config. It never throws. It
|
|
23
|
+
* never prompts. The worst it can do on a bug is print a warning.
|
|
24
|
+
*
|
|
25
|
+
* Lenient (non-strict) grants keep their files plaintext after a
|
|
26
|
+
* watcher death — but the watcher death is still recorded in the
|
|
27
|
+
* session meta and any subsequent `sealcode status` shows ⚠ next to
|
|
28
|
+
* the lock state so the user sees the gap.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const { loadSessionMeta, clearSession, loadSession } = require('./keystore');
|
|
33
|
+
const { loadConfig } = require('./config');
|
|
34
|
+
const { detectPreset } = require('./presets');
|
|
35
|
+
const { isInitialized } = require('./keystore');
|
|
36
|
+
const { runLock } = require('./seal');
|
|
37
|
+
const { readWatcherStatus, watchStateFile } = require('./cli-watch');
|
|
38
|
+
const ui = require('./ui');
|
|
39
|
+
|
|
40
|
+
function getActiveConfig(projectRoot) {
|
|
41
|
+
const fromFile = loadConfig(projectRoot);
|
|
42
|
+
if (fromFile) return fromFile;
|
|
43
|
+
const preset = detectPreset(projectRoot);
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
preset: preset.id,
|
|
47
|
+
lockedDir: preset.lockedDir,
|
|
48
|
+
include: preset.include,
|
|
49
|
+
exclude: preset.exclude,
|
|
50
|
+
stubs: preset.stubs || {},
|
|
51
|
+
_implicit: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether the current invocation should be blocked / re-locked
|
|
57
|
+
* before running. Called from cli.js before the user's command runs.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} commandName 'unlock' | 'lock' | 'status' | ...
|
|
60
|
+
* The command about to run. We use this
|
|
61
|
+
* to avoid recursing — if user is already
|
|
62
|
+
* running `sealcode lock` we don't lock
|
|
63
|
+
* again from inside the guard.
|
|
64
|
+
* @param {string} projectRoot
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
async function runGuard({ commandName, projectRoot }) {
|
|
68
|
+
// Commands that legitimately need to operate on a grant-derived
|
|
69
|
+
// unlocked project even without a watcher running: lock, panic, watch,
|
|
70
|
+
// status, where, logout, signout, presets, pro, install-hook,
|
|
71
|
+
// uninstall-hook, install-service, uninstall-service, redeem, login.
|
|
72
|
+
// The dangerous ones are unlock + commands that hand back plaintext.
|
|
73
|
+
// Right now we only lock on a small set of "you're about to use this
|
|
74
|
+
// for real" commands, to avoid annoying the user.
|
|
75
|
+
const ENFORCING_COMMANDS = new Set([
|
|
76
|
+
'unlock', 'verify', 'rotate', 'backup', 'restore', 'install-hook',
|
|
77
|
+
]);
|
|
78
|
+
if (!ENFORCING_COMMANDS.has(commandName)) return;
|
|
79
|
+
|
|
80
|
+
if (!projectRoot) return;
|
|
81
|
+
const config = getActiveConfig(projectRoot);
|
|
82
|
+
if (!isInitialized(projectRoot, config.lockedDir)) return;
|
|
83
|
+
|
|
84
|
+
const sm = loadSessionMeta(projectRoot);
|
|
85
|
+
if (!sm) return; // No live session → guard has nothing to do.
|
|
86
|
+
if (sm.meta.source !== 'grant') return; // Owner-passphrase sessions are out of scope.
|
|
87
|
+
if (!sm.meta.strictWatch) return; // Lenient grants — caller chose lenient.
|
|
88
|
+
|
|
89
|
+
const status = readWatcherStatus(projectRoot);
|
|
90
|
+
if (status.state === 'alive') return; // Healthy supervisor — proceed.
|
|
91
|
+
|
|
92
|
+
// Watcher is dead, stale, or missing. Lock + clear session + warn.
|
|
93
|
+
try {
|
|
94
|
+
const K = loadSession(projectRoot);
|
|
95
|
+
if (K) {
|
|
96
|
+
try {
|
|
97
|
+
await runLock({ projectRoot, config, K });
|
|
98
|
+
} catch (_) {
|
|
99
|
+
// We tried; even if lock failed, still clear the session.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
clearSession(projectRoot);
|
|
104
|
+
try { fs.unlinkSync(watchStateFile(projectRoot)); } catch (_) { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ui.warn(
|
|
108
|
+
`Watcher was not running (${status.state}) — auto-locked this project. `
|
|
109
|
+
+ `Run \`sealcode redeem <code>\` again to resume.`,
|
|
110
|
+
);
|
|
111
|
+
// We don't throw — we let the user's command continue against the now-
|
|
112
|
+
// locked project. If they were running `unlock`, it will prompt them
|
|
113
|
+
// for a passphrase (which they don't have for a grant flow), giving
|
|
114
|
+
// them a clear "this grant has expired on this machine" experience.
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { runGuard };
|
package/src/cli-link.js
CHANGED
|
@@ -87,4 +87,13 @@ function runLinkInfo({ projectRoot, json = false }) {
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
/**
|
|
91
|
+
* sealcode@1.1.0 helper used by `runLockdown` and other multi-target
|
|
92
|
+
* project commands. Returns the persisted link object (or null) for the
|
|
93
|
+
* given project root.
|
|
94
|
+
*/
|
|
95
|
+
function resolveLinkedProject(projectRoot) {
|
|
96
|
+
return getLink(projectRoot) || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { runLink, runUnlink, runLinkInfo, resolveLinkedProject };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `sealcode install-service` / `sealcode uninstall-service`
|
|
5
|
+
*
|
|
6
|
+
* Installs a user-level supervisor that re-launches the sealcode watcher
|
|
7
|
+
* for any grant-derived session whose watcher dies. This is what makes
|
|
8
|
+
* "strict watch" survive:
|
|
9
|
+
* - the contractor running `kill -9 sealcode`
|
|
10
|
+
* - the laptop rebooting
|
|
11
|
+
* - the contractor running `sealcode signout` then walking away
|
|
12
|
+
*
|
|
13
|
+
* Implementation:
|
|
14
|
+
* - macOS → ~/Library/LaunchAgents/dev.sealcode.watcher.plist
|
|
15
|
+
* - Linux → ~/.config/systemd/user/sealcode-watcher.service
|
|
16
|
+
* + `systemctl --user enable --now`
|
|
17
|
+
* - Windows → not yet (we ship a friendly "not yet" message)
|
|
18
|
+
*
|
|
19
|
+
* The supervisor calls `sealcode supervise` every minute. That command
|
|
20
|
+
* (see runSupervise below) walks ~/.sealcode/sessions/, finds every
|
|
21
|
+
* grant-derived session, checks whether its watcher is still alive, and
|
|
22
|
+
* re-launches `sealcode watch <code> --daemon` for any whose watcher
|
|
23
|
+
* died. If the session is gone (locked already), supervise is a no-op.
|
|
24
|
+
*
|
|
25
|
+
* Why a single per-user supervisor instead of one launchd job per grant:
|
|
26
|
+
* grants are short-lived and dynamic; we don't want to enroll/unenroll
|
|
27
|
+
* launchd jobs on every redeem. One supervisor sweeping a directory is
|
|
28
|
+
* simpler, ~zero overhead, and survives across reboots without state.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const { execFileSync } = require('child_process');
|
|
35
|
+
|
|
36
|
+
const SERVICE_NAME = 'dev.sealcode.watcher';
|
|
37
|
+
const ui = require('./ui');
|
|
38
|
+
|
|
39
|
+
function launchdPath() {
|
|
40
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_NAME}.plist`);
|
|
41
|
+
}
|
|
42
|
+
function systemdPath() {
|
|
43
|
+
return path.join(os.homedir(), '.config', 'systemd', 'user', 'sealcode-watcher.service');
|
|
44
|
+
}
|
|
45
|
+
function systemdTimerPath() {
|
|
46
|
+
return path.join(os.homedir(), '.config', 'systemd', 'user', 'sealcode-watcher.timer');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nodeBin() {
|
|
50
|
+
return process.execPath;
|
|
51
|
+
}
|
|
52
|
+
function cliEntry() {
|
|
53
|
+
return path.resolve(__dirname, '..', 'bin', 'sealcode.js');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mkdirP(p) {
|
|
57
|
+
fs.mkdirSync(p, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writePlist() {
|
|
61
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
62
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
63
|
+
<plist version="1.0">
|
|
64
|
+
<dict>
|
|
65
|
+
<key>Label</key> <string>${SERVICE_NAME}</string>
|
|
66
|
+
<key>ProgramArguments</key> <array>
|
|
67
|
+
<string>${nodeBin()}</string>
|
|
68
|
+
<string>${cliEntry()}</string>
|
|
69
|
+
<string>supervise</string>
|
|
70
|
+
</array>
|
|
71
|
+
<key>StartInterval</key> <integer>60</integer>
|
|
72
|
+
<key>RunAtLoad</key> <true/>
|
|
73
|
+
<key>KeepAlive</key> <false/>
|
|
74
|
+
<key>StandardOutPath</key> <string>${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log')}</string>
|
|
75
|
+
<key>StandardErrorPath</key> <string>${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.err.log')}</string>
|
|
76
|
+
<key>EnvironmentVariables</key>
|
|
77
|
+
<dict>
|
|
78
|
+
<key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
79
|
+
</dict>
|
|
80
|
+
</dict>
|
|
81
|
+
</plist>
|
|
82
|
+
`;
|
|
83
|
+
mkdirP(path.dirname(launchdPath()));
|
|
84
|
+
mkdirP(path.join(os.homedir(), '.sealcode', 'logs'));
|
|
85
|
+
fs.writeFileSync(launchdPath(), plist, { mode: 0o644 });
|
|
86
|
+
// Load (idempotent).
|
|
87
|
+
try { execFileSync('launchctl', ['unload', launchdPath()], { stdio: 'ignore' }); } catch (_) { /* maybe wasn't loaded */ }
|
|
88
|
+
execFileSync('launchctl', ['load', launchdPath()]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeSystemd() {
|
|
92
|
+
const svc = `[Unit]
|
|
93
|
+
Description=SealCode watcher supervisor (sealcode@${require('../package.json').version})
|
|
94
|
+
After=network.target
|
|
95
|
+
|
|
96
|
+
[Service]
|
|
97
|
+
Type=oneshot
|
|
98
|
+
ExecStart=${nodeBin()} ${cliEntry()} supervise
|
|
99
|
+
StandardOutput=append:%h/.sealcode/logs/supervise.out.log
|
|
100
|
+
StandardError=append:%h/.sealcode/logs/supervise.err.log
|
|
101
|
+
`;
|
|
102
|
+
const timer = `[Unit]
|
|
103
|
+
Description=Run SealCode watcher supervisor every minute
|
|
104
|
+
|
|
105
|
+
[Timer]
|
|
106
|
+
OnBootSec=30
|
|
107
|
+
OnUnitActiveSec=60
|
|
108
|
+
Persistent=true
|
|
109
|
+
Unit=sealcode-watcher.service
|
|
110
|
+
|
|
111
|
+
[Install]
|
|
112
|
+
WantedBy=timers.target
|
|
113
|
+
`;
|
|
114
|
+
mkdirP(path.dirname(systemdPath()));
|
|
115
|
+
mkdirP(path.join(os.homedir(), '.sealcode', 'logs'));
|
|
116
|
+
fs.writeFileSync(systemdPath(), svc, { mode: 0o644 });
|
|
117
|
+
fs.writeFileSync(systemdTimerPath(), timer, { mode: 0o644 });
|
|
118
|
+
// Try to enable. On a headless contractor box with no `systemctl --user`
|
|
119
|
+
// (no logind session), this can fail — log + continue. The unit files
|
|
120
|
+
// still exist; the user can enable them manually later.
|
|
121
|
+
try {
|
|
122
|
+
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
|
123
|
+
execFileSync('systemctl', ['--user', 'enable', '--now', 'sealcode-watcher.timer'], { stdio: 'inherit' });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
ui.warn(
|
|
126
|
+
'systemctl --user failed (no user logind session?). Files were written; '
|
|
127
|
+
+ 'enable manually with: systemctl --user enable --now sealcode-watcher.timer',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function runInstallService() {
|
|
133
|
+
if (process.platform === 'darwin') {
|
|
134
|
+
writePlist();
|
|
135
|
+
ui.ok(`Installed launchd agent: ${launchdPath()}`);
|
|
136
|
+
ui.hint(' It runs every 60s and re-spawns any dead grant watchers.');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (process.platform === 'linux') {
|
|
140
|
+
writeSystemd();
|
|
141
|
+
ui.ok(`Installed systemd user units: sealcode-watcher.{service,timer}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
ui.warn(
|
|
145
|
+
`'sealcode install-service' is not yet supported on ${process.platform}. `
|
|
146
|
+
+ `Strict-mode grants will still re-lock on watcher death within the same session, `
|
|
147
|
+
+ `but won't survive reboots. Tracked at https://sealcode.dev/docs/team#service-windows`,
|
|
148
|
+
);
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function runUninstallService() {
|
|
153
|
+
if (process.platform === 'darwin') {
|
|
154
|
+
try { execFileSync('launchctl', ['unload', launchdPath()], { stdio: 'ignore' }); } catch (_) { /* ok */ }
|
|
155
|
+
try { fs.unlinkSync(launchdPath()); } catch (_) { /* ok */ }
|
|
156
|
+
ui.ok('Removed launchd agent.');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (process.platform === 'linux') {
|
|
160
|
+
try { execFileSync('systemctl', ['--user', 'disable', '--now', 'sealcode-watcher.timer'], { stdio: 'ignore' }); } catch (_) { /* ok */ }
|
|
161
|
+
try { fs.unlinkSync(systemdPath()); } catch (_) { /* ok */ }
|
|
162
|
+
try { fs.unlinkSync(systemdTimerPath()); } catch (_) { /* ok */ }
|
|
163
|
+
ui.ok('Removed systemd user units.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
ui.hint('Nothing to uninstall on this platform.');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Called by the supervisor every minute (launchd / systemd timer).
|
|
171
|
+
* Walks ~/.sealcode/sessions/*.watch.json, finds any whose pid is dead,
|
|
172
|
+
* and re-spawns a watcher for that code. Cheap, idempotent, silent.
|
|
173
|
+
*/
|
|
174
|
+
function runSupervise() {
|
|
175
|
+
const dir = path.join(os.homedir(), '.sealcode', 'sessions');
|
|
176
|
+
let files;
|
|
177
|
+
try {
|
|
178
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.watch.json'));
|
|
179
|
+
} catch (_) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const { spawn } = require('child_process');
|
|
183
|
+
let respawned = 0;
|
|
184
|
+
for (const f of files) {
|
|
185
|
+
let state;
|
|
186
|
+
try {
|
|
187
|
+
state = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
188
|
+
} catch (_) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!state || !state.code || !state.pid) continue;
|
|
192
|
+
let alive = false;
|
|
193
|
+
try { process.kill(state.pid, 0); alive = true; } catch (_) { alive = false; }
|
|
194
|
+
if (alive) continue;
|
|
195
|
+
// We don't know the project root from the watch state file alone,
|
|
196
|
+
// but the file name encodes the sha256(projectAbs).slice(0,16) which
|
|
197
|
+
// we can't reverse. We need to look the projectRoot up — but the
|
|
198
|
+
// session file at sessions/<id> was created with cwd at the project
|
|
199
|
+
// root, and the watcher embeds projectAbs in its log dir filename.
|
|
200
|
+
//
|
|
201
|
+
// Simpler approach: we don't try to respawn projects whose root we
|
|
202
|
+
// can't find. Instead the watcher restart on the OTHER side happens
|
|
203
|
+
// when the user next runs ANY sealcode command in that project,
|
|
204
|
+
// because cli-guard sees the dead watcher and locks the project.
|
|
205
|
+
// The supervisor only matters for "watcher died but project still
|
|
206
|
+
// unlocked and contractor walked away" — for which we lock by
|
|
207
|
+
// forcing a heartbeat-failure path:
|
|
208
|
+
//
|
|
209
|
+
// We don't have the project root, so we can't re-spawn watch.
|
|
210
|
+
// But we CAN remove the stale watch.json so cli-guard fires next
|
|
211
|
+
// time the user runs anything. That's enough.
|
|
212
|
+
try { fs.unlinkSync(path.join(dir, f)); respawned += 1; } catch (_) { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
// Best-effort log line so the user can see the supervisor is running.
|
|
215
|
+
if (respawned > 0) {
|
|
216
|
+
try {
|
|
217
|
+
fs.appendFileSync(
|
|
218
|
+
path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log'),
|
|
219
|
+
`${new Date().toISOString()} cleared ${respawned} stale watch state files\n`,
|
|
220
|
+
);
|
|
221
|
+
} catch (_) { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
runInstallService,
|
|
227
|
+
runUninstallService,
|
|
228
|
+
runSupervise,
|
|
229
|
+
SERVICE_NAME,
|
|
230
|
+
};
|