sealcode 0.3.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 -3
- package/package.json +1 -1
- package/src/api.js +14 -1
- package/src/cli-auth.js +26 -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 +659 -85
- package/src/cli.js +153 -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/README.md
CHANGED
|
@@ -85,9 +85,21 @@ sealcode uninstall-hook # remove hook block
|
|
|
85
85
|
sealcode panic # immediate re-lock + session wipe
|
|
86
86
|
sealcode logout # clear cached session, force passphrase next time
|
|
87
87
|
sealcode presets # list supported ecosystems
|
|
88
|
-
sealcode share <opts> # mint a temporary access code (
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
sealcode share <opts> # mint a temporary access code (--email <addr> wraps the key for them)
|
|
89
|
+
# 1.1 controls (all optional):
|
|
90
|
+
# --paths src/api/,tests/ only decrypt these prefixes
|
|
91
|
+
# --mode ro read-only; watcher re-locks on edit
|
|
92
|
+
# --watermark "Licensed to {email} · grant {grantId}"
|
|
93
|
+
# --idle 30 auto-lock after N idle minutes
|
|
94
|
+
# --allow-ip 10.0.0.0/8 restrict redeem + heartbeat IPs
|
|
95
|
+
# --allow-country US GB ISO-2 country allowlist (Cloudflare-fed)
|
|
96
|
+
# --single-device hard-reject 2nd device fingerprint
|
|
97
|
+
# --nda "text" require typed "I agree" on redeem
|
|
98
|
+
sealcode redeem <code> # accept a code; auto-unlocks + spawns watcher when team-shared
|
|
99
|
+
sealcode watch <code> # poll the server with long-poll (~1s revoke); --daemon for background
|
|
100
|
+
sealcode lockdown # 1.1: panic — revoke ALL active grants for the linked project
|
|
101
|
+
sealcode install-service # macOS launchd / Linux systemd unit so the watcher survives reboots
|
|
102
|
+
sealcode uninstall-service
|
|
91
103
|
sealcode pro # Pro tier info
|
|
92
104
|
sealcode where # debug: print paths and state (no secrets)
|
|
93
105
|
```
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -86,7 +86,7 @@ class ApiError extends Error {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
async function request(method, urlPath, { body, auth = false, apiUrl } = {}) {
|
|
89
|
+
async function request(method, urlPath, { body, auth = false, apiUrl, timeoutMs } = {}) {
|
|
90
90
|
const base = apiUrl || getApiUrl();
|
|
91
91
|
const url = new URL(urlPath, base).toString();
|
|
92
92
|
const headers = {
|
|
@@ -106,12 +106,23 @@ async function request(method, urlPath, { body, auth = false, apiUrl } = {}) {
|
|
|
106
106
|
headers.authorization = `Bearer ${token}`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// sealcode@1.1.0 — optional per-call timeout. We use an AbortController
|
|
110
|
+
// and let the caller pass `timeoutMs` for long-polled heartbeats
|
|
111
|
+
// (where we want a generous budget) without changing the default
|
|
112
|
+
// behavior of every other call.
|
|
113
|
+
const ac = typeof AbortController === 'function' ? new AbortController() : null;
|
|
114
|
+
let timer = null;
|
|
115
|
+
if (timeoutMs && ac) {
|
|
116
|
+
timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
let res;
|
|
110
120
|
try {
|
|
111
121
|
res = await fetch(url, {
|
|
112
122
|
method,
|
|
113
123
|
headers,
|
|
114
124
|
body: body == null ? undefined : JSON.stringify(body),
|
|
125
|
+
signal: ac ? ac.signal : undefined,
|
|
115
126
|
});
|
|
116
127
|
} catch (err) {
|
|
117
128
|
throw new ApiError(
|
|
@@ -119,6 +130,8 @@ async function request(method, urlPath, { body, auth = false, apiUrl } = {}) {
|
|
|
119
130
|
'network',
|
|
120
131
|
`Could not reach ${base}: ${err.message || err}.`,
|
|
121
132
|
);
|
|
133
|
+
} finally {
|
|
134
|
+
if (timer) clearTimeout(timer);
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
const ct = res.headers.get('content-type') || '';
|
package/src/cli-auth.js
CHANGED
|
@@ -24,6 +24,7 @@ const {
|
|
|
24
24
|
request,
|
|
25
25
|
writeCreds,
|
|
26
26
|
} = require('./api');
|
|
27
|
+
const keypair = require('./keypair');
|
|
27
28
|
const ui = require('./ui');
|
|
28
29
|
|
|
29
30
|
function sleep(ms) {
|
|
@@ -163,6 +164,31 @@ async function runLogin({ apiUrl } = {}) {
|
|
|
163
164
|
createdAt: new Date().toISOString(),
|
|
164
165
|
});
|
|
165
166
|
|
|
167
|
+
// sealcode@1.0.0: publish the user's X25519 pubkey. This enables team
|
|
168
|
+
// key sharing (other users can wrap K for us via `sealcode share --to`).
|
|
169
|
+
// Best-effort: if the server doesn't support pubkeys yet (older deploy)
|
|
170
|
+
// we still consider login successful. The keypair is generated locally
|
|
171
|
+
// either way and persisted to ~/.sealcode/keypair.json.
|
|
172
|
+
try {
|
|
173
|
+
const pub = keypair.publicKey();
|
|
174
|
+
await request('POST', '/api/v1/users/pubkey', {
|
|
175
|
+
body: pub,
|
|
176
|
+
auth: true,
|
|
177
|
+
apiUrl: baseUrl,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
181
|
+
// Server predates 1.0; not a real error, just log it in verbose paths.
|
|
182
|
+
if (process.env.SEALCODE_DEBUG) {
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`(note: server hasn't enabled team key sharing yet — your pubkey was generated locally but not published)\n`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
} else if (process.env.SEALCODE_DEBUG) {
|
|
188
|
+
process.stderr.write(`(warning: pubkey publish failed: ${err.message || err})\n`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
166
192
|
ui.ok(`Logged in as ${ui.c.bold(user.email || user.id || 'unknown')} ${ui.c.dim(`at ${baseUrl}`)}`);
|
|
167
193
|
}
|
|
168
194
|
|
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 };
|