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-grants.js
CHANGED
|
@@ -13,8 +13,16 @@
|
|
|
13
13
|
* that doesn't need auth — it's used by the developer receiving the code.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
16
18
|
const { ApiError, clientInfo, request } = require('./api');
|
|
17
19
|
const { getLink } = require('./link-state');
|
|
20
|
+
const { loadConfig } = require('./config');
|
|
21
|
+
const { detectPreset } = require('./presets');
|
|
22
|
+
const { isInitialized, loadSession, saveSession } = require('./keystore');
|
|
23
|
+
const { getDeviceFingerprint, getDeviceInfo } = require('./device');
|
|
24
|
+
const keypair = require('./keypair');
|
|
25
|
+
const shareCrypto = require('./share-crypto');
|
|
18
26
|
const ui = require('./ui');
|
|
19
27
|
|
|
20
28
|
function requireLink(projectRoot) {
|
|
@@ -38,6 +46,86 @@ function formatRemaining(ms) {
|
|
|
38
46
|
return `${Math.floor(hr / 24)}d ${hr % 24}h`;
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
function getActiveConfig(projectRoot) {
|
|
50
|
+
const fromFile = loadConfig(projectRoot);
|
|
51
|
+
if (fromFile) return fromFile;
|
|
52
|
+
const preset = detectPreset(projectRoot);
|
|
53
|
+
return {
|
|
54
|
+
version: 1,
|
|
55
|
+
preset: preset.id,
|
|
56
|
+
lockedDir: preset.lockedDir,
|
|
57
|
+
include: preset.include,
|
|
58
|
+
exclude: preset.exclude,
|
|
59
|
+
stubs: preset.stubs || {},
|
|
60
|
+
_implicit: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* sealcode@1.0.0: fetch the recipient's published X25519 pubkey and wrap
|
|
66
|
+
* the project master key K to it. Returns `{ wrappedKey, wrappedKeyRecipient }`
|
|
67
|
+
* suitable for splatting into the create-grant request body.
|
|
68
|
+
*
|
|
69
|
+
* Returns `null` if the recipient hasn't published a pubkey yet OR the
|
|
70
|
+
* server doesn't support the endpoint (pre-1.0 deploy). The caller falls
|
|
71
|
+
* back to the legacy "share-the-passphrase-out-of-band" flow.
|
|
72
|
+
*
|
|
73
|
+
* Will throw if we have K but the wrap itself fails (which would indicate
|
|
74
|
+
* a real cryptographic bug; we don't want to silently downgrade in that
|
|
75
|
+
* case).
|
|
76
|
+
*/
|
|
77
|
+
async function maybeWrapKeyForRecipient({ projectRoot, recipientEmail, verbose = false }) {
|
|
78
|
+
if (!recipientEmail) return null;
|
|
79
|
+
|
|
80
|
+
// Need the master key K to wrap. If there's no live session on this
|
|
81
|
+
// machine, we can't wrap — owner has to `sealcode unlock` first.
|
|
82
|
+
const config = getActiveConfig(projectRoot);
|
|
83
|
+
if (!isInitialized(projectRoot, config.lockedDir)) return null;
|
|
84
|
+
const K = loadSession(projectRoot);
|
|
85
|
+
if (!K) {
|
|
86
|
+
if (verbose) {
|
|
87
|
+
ui.hint('(no cached session — skipping team-key wrap; recipient will need the passphrase)');
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fetch their pubkey.
|
|
93
|
+
let pub;
|
|
94
|
+
try {
|
|
95
|
+
pub = await request(
|
|
96
|
+
'GET',
|
|
97
|
+
`/api/v1/users/pubkey?email=${encodeURIComponent(recipientEmail)}`,
|
|
98
|
+
{ auth: true },
|
|
99
|
+
);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof ApiError) {
|
|
102
|
+
if (err.status === 404) {
|
|
103
|
+
// Either the user doesn't exist or hasn't published a pubkey
|
|
104
|
+
// yet. We still mint the code; the recipient just falls back to
|
|
105
|
+
// the legacy flow when they redeem.
|
|
106
|
+
if (verbose) {
|
|
107
|
+
ui.hint(
|
|
108
|
+
`(recipient hasn't published a pubkey yet — falling back to passphrase-share flow)`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (err.status === 0) {
|
|
114
|
+
// Network blip — let the caller surface this; we don't want to
|
|
115
|
+
// silently strip security.
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
if (!pub || !pub.publicKey || pub.algo !== keypair.ALGO) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const wrapped = shareCrypto.wrapForRecipient(K, pub.publicKey);
|
|
126
|
+
return { wrappedKey: wrapped, wrappedKeyRecipient: recipientEmail };
|
|
127
|
+
}
|
|
128
|
+
|
|
41
129
|
async function runShare({
|
|
42
130
|
projectRoot,
|
|
43
131
|
hours,
|
|
@@ -48,9 +136,38 @@ async function runShare({
|
|
|
48
136
|
strict = false,
|
|
49
137
|
heartbeatSec = 300,
|
|
50
138
|
offlineGraceSec = 1800,
|
|
139
|
+
// sealcode@1.1.0 — admin precision controls. All optional.
|
|
140
|
+
paths = null, // string[] | null → allowedPaths
|
|
141
|
+
mode = 'rw', // 'rw' | 'ro'
|
|
142
|
+
watermark = null, // string | null → template
|
|
143
|
+
idleAutoLockMinutes = 0, // 0 = disabled
|
|
144
|
+
allowedIpCidrs = null, // string[] | null → CIDRs
|
|
145
|
+
allowedCountries = null, // string[] | null → ISO-2 codes
|
|
146
|
+
singleDeviceEnforce = false,
|
|
147
|
+
ndaText = null,
|
|
51
148
|
json = false,
|
|
52
149
|
}) {
|
|
53
150
|
const link = requireLink(projectRoot);
|
|
151
|
+
|
|
152
|
+
// sealcode@1.0.0: if `--email <addr>` is supplied AND we can reach the
|
|
153
|
+
// recipient's pubkey AND we have K cached, wrap K to them. This makes
|
|
154
|
+
// `sealcode redeem` self-sufficient on the recipient side (no
|
|
155
|
+
// out-of-band passphrase exchange).
|
|
156
|
+
let wrap = null;
|
|
157
|
+
try {
|
|
158
|
+
wrap = await maybeWrapKeyForRecipient({
|
|
159
|
+
projectRoot,
|
|
160
|
+
recipientEmail: email,
|
|
161
|
+
verbose: !json,
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Network errors during pubkey lookup shouldn't be silent — degrade
|
|
165
|
+
// loudly so the owner knows the recipient will need the passphrase.
|
|
166
|
+
if (!json) {
|
|
167
|
+
ui.warn(`Pubkey lookup failed (${err.message || err}) — minting code without wrapped key.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
54
171
|
const body = {
|
|
55
172
|
developerEmail: email || '',
|
|
56
173
|
developerLabel: label || '',
|
|
@@ -60,6 +177,18 @@ async function runShare({
|
|
|
60
177
|
strictMode: !!strict,
|
|
61
178
|
heartbeatIntervalSeconds: Math.max(30, Math.floor(heartbeatSec)),
|
|
62
179
|
offlineGraceSeconds: Math.max(60, Math.floor(offlineGraceSec)),
|
|
180
|
+
...(wrap ? wrap : {}),
|
|
181
|
+
// sealcode@1.1.0 — admin precision controls (server validates again)
|
|
182
|
+
...(paths && paths.length ? { allowedPaths: paths } : {}),
|
|
183
|
+
mode: mode === 'ro' ? 'ro' : 'rw',
|
|
184
|
+
...(watermark ? { watermark } : {}),
|
|
185
|
+
idleAutoLockMinutes: Math.max(0, Math.floor(idleAutoLockMinutes || 0)),
|
|
186
|
+
...(allowedIpCidrs && allowedIpCidrs.length ? { allowedIpCidrs } : {}),
|
|
187
|
+
...(allowedCountries && allowedCountries.length
|
|
188
|
+
? { allowedCountries: allowedCountries.map((c) => c.toUpperCase()) }
|
|
189
|
+
: {}),
|
|
190
|
+
singleDeviceEnforce: !!singleDeviceEnforce,
|
|
191
|
+
...(ndaText ? { ndaText } : {}),
|
|
63
192
|
};
|
|
64
193
|
|
|
65
194
|
let res;
|
|
@@ -88,6 +217,8 @@ async function runShare({
|
|
|
88
217
|
codePrefix: g.codePrefix,
|
|
89
218
|
projectId: g.projectId,
|
|
90
219
|
expiresAt: g.expiresAt,
|
|
220
|
+
wrappedFor: g.wrappedFor ?? null,
|
|
221
|
+
hasWrappedKey: !!g.hasWrappedKey,
|
|
91
222
|
},
|
|
92
223
|
null,
|
|
93
224
|
2,
|
|
@@ -97,11 +228,17 @@ async function runShare({
|
|
|
97
228
|
}
|
|
98
229
|
|
|
99
230
|
const meta = [
|
|
100
|
-
`${ui.c.dim('project
|
|
101
|
-
`${ui.c.dim('expires
|
|
231
|
+
`${ui.c.dim('project ')} ${ui.c.bold(link.projectName || link.projectId)}`,
|
|
232
|
+
`${ui.c.dim('expires ')} ${g.expiresAt}`,
|
|
102
233
|
];
|
|
103
|
-
if (g.autoLockAt) meta.push(`${ui.c.dim('auto-lock
|
|
104
|
-
if (email) meta.push(`${ui.c.dim('email
|
|
234
|
+
if (g.autoLockAt) meta.push(`${ui.c.dim('auto-lock ')} ${g.autoLockAt}`);
|
|
235
|
+
if (email) meta.push(`${ui.c.dim('email ')} ${email}`);
|
|
236
|
+
if (strict) meta.push(`${ui.c.dim('strict ')} ${ui.c.yellow('yes — watcher required')}`);
|
|
237
|
+
if (g.hasWrappedKey) {
|
|
238
|
+
meta.push(`${ui.c.dim('wrapped to ')} ${ui.c.green(g.wrappedFor)} ${ui.c.dim('(no passphrase exchange)')}`);
|
|
239
|
+
} else if (email) {
|
|
240
|
+
meta.push(`${ui.c.dim('wrapped to ')} ${ui.c.yellow('— (recipient hasn\'t published a pubkey; passphrase still needed)')}`);
|
|
241
|
+
}
|
|
105
242
|
|
|
106
243
|
ui.box('Access code — copy now, shown only once', [
|
|
107
244
|
` ${ui.c.bold(ui.c.green(g.code))}`,
|
|
@@ -109,8 +246,14 @@ async function runShare({
|
|
|
109
246
|
...meta,
|
|
110
247
|
], { tone: 'green' });
|
|
111
248
|
|
|
112
|
-
|
|
113
|
-
|
|
249
|
+
if (g.hasWrappedKey) {
|
|
250
|
+
ui.hint('Recipient runs:');
|
|
251
|
+
process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n`);
|
|
252
|
+
process.stdout.write(` ${ui.c.cyan(`sealcode unlock`)} ${ui.c.dim('# no passphrase prompt — K is delivered by the redeem call')}\n\n`);
|
|
253
|
+
} else {
|
|
254
|
+
ui.hint('Share it with the developer over a secure channel. They redeem with:');
|
|
255
|
+
process.stdout.write(` ${ui.c.cyan(`sealcode redeem ${g.code}`)}\n\n`);
|
|
256
|
+
}
|
|
114
257
|
}
|
|
115
258
|
|
|
116
259
|
async function runGrants({ projectRoot, json = false }) {
|
|
@@ -156,17 +299,113 @@ async function runRevoke({ grantId }) {
|
|
|
156
299
|
process.stdout.write(`✓ revoked grant ${grantId}\n`);
|
|
157
300
|
}
|
|
158
301
|
|
|
159
|
-
async function runRedeem({ code, json = false }) {
|
|
302
|
+
async function runRedeem({ projectRoot, code, json = false, agreeNda = false }) {
|
|
160
303
|
if (!code) throw new Error('Usage: sealcode redeem <CODE>');
|
|
161
304
|
const trimmed = code.trim();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
305
|
+
|
|
306
|
+
// Include the device fingerprint so the server pins it to the grant
|
|
307
|
+
// on first redeem (and warns on mismatch on subsequent redeems).
|
|
308
|
+
const client = { ...clientInfo(), deviceFingerprint: getDeviceFingerprint() };
|
|
309
|
+
|
|
310
|
+
// First call without the NDA flag — server tells us whether one is
|
|
311
|
+
// required by including `nda_required: true` and `ndaText` in the
|
|
312
|
+
// 409-NDA response (or in the success payload's policy block). We
|
|
313
|
+
// use a separate pre-flight to a metadata endpoint to avoid the
|
|
314
|
+
// "race the server pins the device fingerprint before user has
|
|
315
|
+
// even agreed" problem.
|
|
316
|
+
let res;
|
|
317
|
+
try {
|
|
318
|
+
res = await request('POST', '/api/v1/access/redeem', {
|
|
319
|
+
body: { code: trimmed, client, ndaAccepted: !!agreeNda },
|
|
320
|
+
});
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (err instanceof ApiError && err.status === 409 && err.apiCode === 'nda_required') {
|
|
323
|
+
const ndaText = (err.raw && (err.raw.ndaText || err.raw.error?.ndaText)) || '(no text provided by owner)';
|
|
324
|
+
ui.box('Acceptance required', [
|
|
325
|
+
ui.c.dim('The owner of this project requires you to accept the following before unlocking:'),
|
|
326
|
+
'',
|
|
327
|
+
...String(ndaText).split('\n'),
|
|
328
|
+
], { tone: 'yellow' });
|
|
329
|
+
const { question } = require('./prompt');
|
|
330
|
+
const reply = await question('Type "I agree" to continue (or Ctrl-C to abort): ');
|
|
331
|
+
if (String(reply).trim().toLowerCase() !== 'i agree') {
|
|
332
|
+
ui.fail('Did not accept — aborting redeem.');
|
|
333
|
+
process.exitCode = 1;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
res = await request('POST', '/api/v1/access/redeem', {
|
|
337
|
+
body: { code: trimmed, client, ndaAccepted: true },
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
165
343
|
const g = res.grant;
|
|
344
|
+
|
|
345
|
+
// sealcode@1.0.0: if the server returned a wrapped K AND we're running
|
|
346
|
+
// inside a project root that has a manifest, unwrap it and cache the
|
|
347
|
+
// session so subsequent `sealcode unlock` skips the passphrase prompt.
|
|
348
|
+
// This is the magic "no passphrase exchange needed" path.
|
|
349
|
+
let cachedK = false;
|
|
350
|
+
let unwrapNote = null;
|
|
351
|
+
if (g.wrappedKey) {
|
|
352
|
+
const kp = keypair.read();
|
|
353
|
+
if (!kp) {
|
|
354
|
+
unwrapNote = ui.c.yellow(
|
|
355
|
+
'(received a wrapped key, but no local keypair was found. '
|
|
356
|
+
+ 'Run `sealcode login` to generate one.)',
|
|
357
|
+
);
|
|
358
|
+
} else if (g.wrappedKeyRecipient && g.wrappedKeyRecipient.toLowerCase() !== readMyEmail().toLowerCase()) {
|
|
359
|
+
unwrapNote = ui.c.yellow(
|
|
360
|
+
`(received a wrapped key, but it was wrapped for ${g.wrappedKeyRecipient}; `
|
|
361
|
+
+ `you are logged in as ${readMyEmail()}. Ask the owner to re-share.)`,
|
|
362
|
+
);
|
|
363
|
+
} else {
|
|
364
|
+
try {
|
|
365
|
+
const K = shareCrypto.unwrapWithRecipient(g.wrappedKey, kp.privateKey, kp.publicKey);
|
|
366
|
+
if (projectRoot) {
|
|
367
|
+
// Cache K against THIS project root. The grant-derived session
|
|
368
|
+
// is tagged so cli-guard and cli-watch know it requires a
|
|
369
|
+
// running watcher. sealcode@1.1.0 — also stash the policy
|
|
370
|
+
// bundle so subsequent commands enforce paths/ro/watermark/idle.
|
|
371
|
+
saveSession(projectRoot, K, {
|
|
372
|
+
source: 'grant',
|
|
373
|
+
grantId: g.id,
|
|
374
|
+
grantCodeHash: require('crypto').createHash('sha256').update(trimmed).digest('hex'),
|
|
375
|
+
grantExpiresAt: g.expiresAt,
|
|
376
|
+
deviceFingerprint: getDeviceFingerprint(),
|
|
377
|
+
strictWatch: !!g.strictMode,
|
|
378
|
+
policy: g.policy || {
|
|
379
|
+
allowedPaths: g.allowedPaths || null,
|
|
380
|
+
mode: g.mode || 'rw',
|
|
381
|
+
watermark: g.watermark || null,
|
|
382
|
+
idleAutoLockMinutes: g.idleAutoLockMinutes || 0,
|
|
383
|
+
},
|
|
384
|
+
recipientEmail: g.wrappedKeyRecipient || readMyEmail(),
|
|
385
|
+
projectName: g.projectName || null,
|
|
386
|
+
});
|
|
387
|
+
cachedK = true;
|
|
388
|
+
} else {
|
|
389
|
+
unwrapNote = ui.c.dim(
|
|
390
|
+
'(unwrapped K, but you are not inside a project root — '
|
|
391
|
+
+ 'cd into the project and run `sealcode unlock` to materialize plaintext.)',
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
// Best-effort scrub of K from this process's memory.
|
|
395
|
+
K.fill(0);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
unwrapNote = ui.c.red(`(could not unwrap delivered key: ${err.message || err})`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
166
402
|
if (json) {
|
|
167
|
-
process.stdout.write(
|
|
403
|
+
process.stdout.write(
|
|
404
|
+
JSON.stringify({ grant: g, cachedSession: cachedK }, null, 2) + '\n',
|
|
405
|
+
);
|
|
168
406
|
return;
|
|
169
407
|
}
|
|
408
|
+
|
|
170
409
|
process.stdout.write(`\n✓ access code accepted\n`);
|
|
171
410
|
process.stdout.write(` project: ${g.projectName} (${g.projectId})\n`);
|
|
172
411
|
process.stdout.write(` fingerprint: ${g.projectFingerprint}\n`);
|
|
@@ -174,12 +413,141 @@ async function runRedeem({ code, json = false }) {
|
|
|
174
413
|
if (g.autoLockAt) process.stdout.write(` auto-lock: ${g.autoLockAt}\n`);
|
|
175
414
|
process.stdout.write(` strict: ${g.strictMode ? 'yes' : 'no'}\n`);
|
|
176
415
|
process.stdout.write(
|
|
177
|
-
` heartbeat: every ${g.heartbeatIntervalSeconds}s, ${g.offlineGraceSeconds}s offline grace\n
|
|
178
|
-
);
|
|
179
|
-
process.stdout.write(
|
|
180
|
-
'Next step (when the owner shares the unlocked vault with you):\n'
|
|
181
|
-
+ ' sealcode unlock\n\n',
|
|
416
|
+
` heartbeat: every ${g.heartbeatIntervalSeconds}s, ${g.offlineGraceSeconds}s offline grace\n`,
|
|
182
417
|
);
|
|
418
|
+
if (g.deviceFingerprintWarning) {
|
|
419
|
+
ui.warn(g.deviceFingerprintWarning);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (cachedK) {
|
|
423
|
+
process.stdout.write('\n');
|
|
424
|
+
ui.ok('Key delivered via team-share envelope — no passphrase needed.');
|
|
425
|
+
// Auto-unlock + auto-spawn watcher. This is the user-asked-for
|
|
426
|
+
// "magic" flow: one command (`sealcode redeem`) handles redeem +
|
|
427
|
+
// unwrap + decrypt + supervise. Strict mode is implied by the
|
|
428
|
+
// server's `strictMode` flag on the grant.
|
|
429
|
+
try {
|
|
430
|
+
const { runUnlock } = require('./open');
|
|
431
|
+
const config = getActiveConfig(projectRoot);
|
|
432
|
+
ui.step(`auto-unlocking ${ui.c.dim(projectRoot)}`);
|
|
433
|
+
const sp = new ui.Spinner('decrypting').start();
|
|
434
|
+
let n = 0;
|
|
435
|
+
const K = loadSession(projectRoot); // freshly cached above
|
|
436
|
+
const ures = await runUnlock({
|
|
437
|
+
projectRoot,
|
|
438
|
+
config,
|
|
439
|
+
K,
|
|
440
|
+
removeStubs: true,
|
|
441
|
+
log: (msg) => {
|
|
442
|
+
n += 1;
|
|
443
|
+
const file = String(msg).replace(/^\s*unlocked\s+/, '');
|
|
444
|
+
sp.update(`decrypting ${ui.c.dim(`(${n})`)} ${file}`);
|
|
445
|
+
},
|
|
446
|
+
// sealcode@1.1.0 — apply the server-issued policy
|
|
447
|
+
policy: g.policy || {
|
|
448
|
+
allowedPaths: g.allowedPaths || null,
|
|
449
|
+
mode: g.mode || 'rw',
|
|
450
|
+
watermark: g.watermark || null,
|
|
451
|
+
idleAutoLockMinutes: g.idleAutoLockMinutes || 0,
|
|
452
|
+
},
|
|
453
|
+
watermarkCtx: {
|
|
454
|
+
email: g.wrappedKeyRecipient || readMyEmail(),
|
|
455
|
+
grantId: g.id,
|
|
456
|
+
projectName: g.projectName || '',
|
|
457
|
+
date: new Date().toISOString().slice(0, 10),
|
|
458
|
+
fingerprint: getDeviceFingerprint(),
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
const sk = ures.skipped > 0 ? ` ${ui.c.dim(`(${ures.skipped} file(s) outside grant scope skipped)`)}` : '';
|
|
462
|
+
sp.succeed(
|
|
463
|
+
`unlocked ${ui.c.bold(ures.count)} files${sk} ${ui.c.dim(`(locked at ${ures.sealedAt})`)}`,
|
|
464
|
+
);
|
|
465
|
+
if (ures.policy.mode === 'ro') {
|
|
466
|
+
ui.hint(' Read-only grant: files are 0444. Any modification will trigger an immediate re-lock.');
|
|
467
|
+
}
|
|
468
|
+
if (ures.policy.watermark) {
|
|
469
|
+
ui.hint(' Files are watermarked with your grant ID — leaks are traceable.');
|
|
470
|
+
}
|
|
471
|
+
} catch (err) {
|
|
472
|
+
ui.warn(`auto-unlock failed: ${err.message || err}`);
|
|
473
|
+
ui.hint(` Run \`sealcode unlock\` manually, then \`sealcode watch ${trimmed} --daemon\`.`);
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const { spawnDaemonWatcher } = require('./cli-watch');
|
|
477
|
+
const r = spawnDaemonWatcher({ projectRoot, code: trimmed });
|
|
478
|
+
if (r.error) {
|
|
479
|
+
ui.warn(`auto-spawn watcher failed: ${r.error}`);
|
|
480
|
+
ui.hint(` Run \`sealcode watch ${trimmed} --daemon\` manually.`);
|
|
481
|
+
} else {
|
|
482
|
+
ui.ok(
|
|
483
|
+
`watcher running ${ui.c.dim(`(pid ${r.pid}; logs ~/.sealcode/logs/)`)}`,
|
|
484
|
+
);
|
|
485
|
+
ui.hint(
|
|
486
|
+
g.strictMode
|
|
487
|
+
? ' Strict mode: if you kill the watcher, your local plaintext re-locks immediately.'
|
|
488
|
+
: ' Lenient mode: watcher auto-locks on revoke / expiry only.',
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
} catch (err) {
|
|
492
|
+
ui.warn(`could not start watcher: ${err.message || err}`);
|
|
493
|
+
}
|
|
494
|
+
process.stdout.write('\n');
|
|
495
|
+
} else {
|
|
496
|
+
if (unwrapNote) {
|
|
497
|
+
process.stdout.write(` ${unwrapNote}\n`);
|
|
498
|
+
}
|
|
499
|
+
process.stdout.write(
|
|
500
|
+
'\nNext step (when the owner shares the unlocked vault with you):\n'
|
|
501
|
+
+ ` ${ui.c.cyan('sealcode unlock')}\n`
|
|
502
|
+
+ ` ${ui.c.cyan(`sealcode watch ${trimmed} &`)} ${ui.c.dim('# keep this running so revoke is instant')}\n\n`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function readMyEmail() {
|
|
508
|
+
try {
|
|
509
|
+
const { readCreds } = require('./api');
|
|
510
|
+
const c = readCreds();
|
|
511
|
+
return (c && c.email) || '';
|
|
512
|
+
} catch (_) {
|
|
513
|
+
return '';
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* sealcode@1.1.0 — Project lockdown. Bulk-revokes every active grant
|
|
519
|
+
* on the linked project in a single server-side transaction, then
|
|
520
|
+
* pushes instant lock signals to every connected watcher via the
|
|
521
|
+
* long-poll event bus.
|
|
522
|
+
*
|
|
523
|
+
* Requires the project to be linked (cli-link.js).
|
|
524
|
+
*/
|
|
525
|
+
async function runLockdown({ projectRoot, confirm = true } = {}) {
|
|
526
|
+
const { resolveLinkedProject } = require('./cli-link');
|
|
527
|
+
const linked = resolveLinkedProject(projectRoot);
|
|
528
|
+
if (!linked) {
|
|
529
|
+
ui.fail('This folder is not linked to a sealcode.dev project. Run `sealcode link` first.');
|
|
530
|
+
process.exitCode = 1;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (confirm) {
|
|
534
|
+
const { question } = require('./prompt');
|
|
535
|
+
ui.warn(`This will revoke EVERY active access code for ${ui.c.bold(linked.projectName || linked.projectId)}.`);
|
|
536
|
+
const reply = await question('Type "lockdown" to confirm: ');
|
|
537
|
+
if (String(reply).trim().toLowerCase() !== 'lockdown') {
|
|
538
|
+
ui.fail('Aborted.');
|
|
539
|
+
process.exitCode = 1;
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const res = await request('POST', `/api/v1/projects/${linked.projectId}/lockdown`, {
|
|
544
|
+
auth: true,
|
|
545
|
+
body: {},
|
|
546
|
+
});
|
|
547
|
+
ui.ok(`Lockdown complete — revoked ${ui.c.bold(res.revokedCount)} grant(s).`);
|
|
548
|
+
if (res.revokedCount > 0) {
|
|
549
|
+
ui.hint(' All connected watchers will re-lock within ~1 second.');
|
|
550
|
+
}
|
|
183
551
|
}
|
|
184
552
|
|
|
185
|
-
module.exports = { runShare, runGrants, runRevoke, runRedeem };
|
|
553
|
+
module.exports = { runShare, runGrants, runRevoke, runRedeem, runLockdown };
|
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 };
|