sealcode 0.1.0 → 0.3.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 +3 -0
- package/package.json +1 -1
- package/src/cli-ci-tokens.js +123 -0
- package/src/cli-escrow.js +236 -0
- package/src/cli-watch.js +288 -0
- package/src/cli.js +141 -0
- package/src/errors.js +18 -0
package/README.md
CHANGED
|
@@ -85,6 +85,9 @@ 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 (Pro; needs link + login)
|
|
89
|
+
sealcode redeem <code> # accept an access code shared with you
|
|
90
|
+
sealcode watch <code> # poll the server; auto-lock if the owner revokes (Pro)
|
|
88
91
|
sealcode pro # Pro tier info
|
|
89
92
|
sealcode where # debug: print paths and state (no secrets)
|
|
90
93
|
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CI/CD short-lived tokens.
|
|
5
|
+
*
|
|
6
|
+
* sealcode ci-token create --label "GH Actions" --ttl 24h --scope unlock
|
|
7
|
+
* sealcode ci-token list
|
|
8
|
+
* sealcode ci-token revoke <id>
|
|
9
|
+
*
|
|
10
|
+
* Requires the project to be linked (`sealcode link <id>`). All three
|
|
11
|
+
* commands authenticate as the logged-in user; the *resulting* token is what
|
|
12
|
+
* the CI pipeline uses afterwards via SEALCODE_CI_TOKEN.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { request } = require('./api');
|
|
16
|
+
const { getLink } = require('./link-state');
|
|
17
|
+
const ui = require('./ui');
|
|
18
|
+
|
|
19
|
+
function requireLink(projectRoot) {
|
|
20
|
+
const link = getLink(projectRoot);
|
|
21
|
+
if (!link) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'This project is not linked. Run `sealcode link <projectId>` first.',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return link;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a TTL string into hours. Accepts: "1h" "8h" "1d" "30d" "168" (bare = hours).
|
|
31
|
+
*/
|
|
32
|
+
function parseTtl(input) {
|
|
33
|
+
if (input == null) return 24;
|
|
34
|
+
const s = String(input).trim().toLowerCase();
|
|
35
|
+
const m = s.match(/^(\d+)\s*(h|hr|hour|hours|d|day|days|w|week|weeks)?$/);
|
|
36
|
+
if (!m) throw new Error(`Invalid --ttl: "${input}". Try "1h", "8h", "1d", "1w".`);
|
|
37
|
+
const n = parseInt(m[1], 10);
|
|
38
|
+
const unit = m[2] || 'h';
|
|
39
|
+
if (unit.startsWith('h')) return n;
|
|
40
|
+
if (unit.startsWith('d')) return n * 24;
|
|
41
|
+
if (unit.startsWith('w')) return n * 24 * 7;
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runCiCreate({ projectRoot, label, ttl, scope, json = false }) {
|
|
46
|
+
const link = requireLink(projectRoot);
|
|
47
|
+
if (!label || !label.trim()) {
|
|
48
|
+
throw new Error('--label is required (e.g. --label "GitHub Actions · deploy").');
|
|
49
|
+
}
|
|
50
|
+
const expiresInHours = parseTtl(ttl);
|
|
51
|
+
const allowedScopes = ['read', 'unlock'];
|
|
52
|
+
if (!allowedScopes.includes(scope)) {
|
|
53
|
+
throw new Error(`--scope must be one of: ${allowedScopes.join(', ')}.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const res = await request(
|
|
57
|
+
'POST',
|
|
58
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/ci-tokens`,
|
|
59
|
+
{ auth: true, body: { label: label.trim(), scope, expiresInHours } },
|
|
60
|
+
);
|
|
61
|
+
const t = res.token;
|
|
62
|
+
if (json) {
|
|
63
|
+
process.stdout.write(JSON.stringify({ token: t }, null, 2) + '\n');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
ui.box('CI token — copy now, shown only once', [
|
|
67
|
+
` ${ui.c.bold(ui.c.green(t.token))}`,
|
|
68
|
+
'',
|
|
69
|
+
`${ui.c.dim('label ')} ${ui.c.bold(t.label)}`,
|
|
70
|
+
`${ui.c.dim('scope ')} ${t.scope}`,
|
|
71
|
+
`${ui.c.dim('expires')} ${t.expiresAt}`,
|
|
72
|
+
`${ui.c.dim('id ')} ${t.id}`,
|
|
73
|
+
], { tone: 'green' });
|
|
74
|
+
ui.hint('In your CI provider, set this as a masked secret:');
|
|
75
|
+
process.stdout.write(` ${ui.c.cyan(`SEALCODE_CI_TOKEN=${t.token.slice(0, 12)}…`)}\n\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runCiList({ projectRoot, json = false }) {
|
|
79
|
+
const link = requireLink(projectRoot);
|
|
80
|
+
const res = await request(
|
|
81
|
+
'GET',
|
|
82
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/ci-tokens`,
|
|
83
|
+
{ auth: true },
|
|
84
|
+
);
|
|
85
|
+
const tokens = res.tokens || [];
|
|
86
|
+
if (json) {
|
|
87
|
+
process.stdout.write(JSON.stringify({ tokens }, null, 2) + '\n');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (tokens.length === 0) {
|
|
91
|
+
process.stdout.write(
|
|
92
|
+
'No CI tokens yet. Create one with `sealcode ci-token create --label "…"`.\n',
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
process.stdout.write(
|
|
97
|
+
`\n${link.projectName || link.projectId}: ${tokens.length} CI token(s)\n`,
|
|
98
|
+
);
|
|
99
|
+
for (const t of tokens) {
|
|
100
|
+
const state = t.state.padEnd(8);
|
|
101
|
+
const scope = (t.scope || '').padEnd(7);
|
|
102
|
+
process.stdout.write(
|
|
103
|
+
` ${state} ${scope} ${t.tokenPrefix.padEnd(10)} ${t.label} ${ui.c.dim('id=' + t.id)}\n`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runCiRevoke({ tokenId }) {
|
|
110
|
+
if (!tokenId) throw new Error('Usage: sealcode ci-token revoke <id>');
|
|
111
|
+
const res = await request(
|
|
112
|
+
'DELETE',
|
|
113
|
+
`/api/v1/ci-tokens/${encodeURIComponent(tokenId)}`,
|
|
114
|
+
{ auth: true },
|
|
115
|
+
);
|
|
116
|
+
if (res.alreadyTerminal) {
|
|
117
|
+
process.stdout.write(`(token was already revoked or expired)\n`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
process.stdout.write(`✓ revoked CI token ${tokenId}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { runCiCreate, runCiList, runCiRevoke, parseTtl };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloud key escrow.
|
|
5
|
+
*
|
|
6
|
+
* sealcode escrow status → print whether escrow is enabled
|
|
7
|
+
* sealcode escrow enable → prompt for a recovery passphrase, wrap K,
|
|
8
|
+
* upload ciphertext + KDF params
|
|
9
|
+
* sealcode escrow disable → delete the server-side blob
|
|
10
|
+
* sealcode escrow recover → download blob, prompt for passphrase,
|
|
11
|
+
* unwrap K, save session so unlock works
|
|
12
|
+
*
|
|
13
|
+
* The server NEVER sees the recovery passphrase or the plaintext master key.
|
|
14
|
+
* What's stored is:
|
|
15
|
+
* { ciphertext: base64(IV ‖ tag ‖ AES-GCM(wrap_key, K)),
|
|
16
|
+
* kdfParams: { kind: "scrypt", N, r, p, saltB64 } }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
|
|
22
|
+
const { request } = require('./api');
|
|
23
|
+
const { getLink } = require('./link-state');
|
|
24
|
+
const { loadConfig } = require('./config');
|
|
25
|
+
const { detectPreset } = require('./presets');
|
|
26
|
+
const { isInitialized, unwrap, saveSession } = require('./keystore');
|
|
27
|
+
const { deriveKey, makeSalt, passphraseStrength } = require('./kdf');
|
|
28
|
+
const { hidden } = require('./prompt');
|
|
29
|
+
const ui = require('./ui');
|
|
30
|
+
|
|
31
|
+
function requireLink(projectRoot) {
|
|
32
|
+
const link = getLink(projectRoot);
|
|
33
|
+
if (!link) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'This project is not linked. Run `sealcode link <projectId>` first.',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return link;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getActiveConfig(projectRoot) {
|
|
42
|
+
const fromFile = loadConfig(projectRoot);
|
|
43
|
+
if (fromFile) return fromFile;
|
|
44
|
+
const preset = detectPreset(projectRoot);
|
|
45
|
+
return {
|
|
46
|
+
version: 1,
|
|
47
|
+
preset: preset.id,
|
|
48
|
+
lockedDir: preset.lockedDir,
|
|
49
|
+
include: preset.include,
|
|
50
|
+
exclude: preset.exclude,
|
|
51
|
+
stubs: preset.stubs || {},
|
|
52
|
+
_file: null,
|
|
53
|
+
_implicit: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clientMeta() {
|
|
58
|
+
return {
|
|
59
|
+
hostname: os.hostname(),
|
|
60
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runEscrowStatus({ projectRoot, json = false }) {
|
|
65
|
+
const link = requireLink(projectRoot);
|
|
66
|
+
const res = await request(
|
|
67
|
+
'GET',
|
|
68
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/escrow`,
|
|
69
|
+
{ auth: true },
|
|
70
|
+
);
|
|
71
|
+
if (json) {
|
|
72
|
+
process.stdout.write(JSON.stringify({ escrow: res.escrow }, null, 2) + '\n');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!res.escrow.exists) {
|
|
76
|
+
process.stdout.write('Cloud escrow: disabled.\n');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const s = res.escrow;
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
`\nCloud escrow: ${ui.c.green('enabled')}\n`
|
|
82
|
+
+ ` uploaded: ${s.uploadedAt}\n`
|
|
83
|
+
+ ` updated: ${s.updatedAt}\n`
|
|
84
|
+
+ ` from: ${s.uploadedFromHostname || '(unknown)'} ${s.uploadedFromPlatform ? `· ${s.uploadedFromPlatform}` : ''}\n`
|
|
85
|
+
+ ` last touch: ${s.lastAccessedAt || '(never)'}\n\n`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runEscrowEnable({ projectRoot }) {
|
|
90
|
+
const link = requireLink(projectRoot);
|
|
91
|
+
const config = getActiveConfig(projectRoot);
|
|
92
|
+
if (!isInitialized(projectRoot, config.lockedDir)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'This project has no vault yet. Run `sealcode init` first.',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 1. Resolve the actual master key. We need the user's normal passphrase
|
|
99
|
+
// (or a cached session) — escrow is *additional* protection, not a
|
|
100
|
+
// replacement.
|
|
101
|
+
process.stdout.write('First, prove you can unlock this vault.\n');
|
|
102
|
+
const projectPp = await hidden('Project passphrase:');
|
|
103
|
+
let K;
|
|
104
|
+
try {
|
|
105
|
+
K = await unwrap(projectRoot, config.lockedDir, { passphrase: projectPp });
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error('Wrong passphrase. Escrow not enabled.');
|
|
108
|
+
}
|
|
109
|
+
saveSession(projectRoot, K);
|
|
110
|
+
|
|
111
|
+
// 2. Take a separate recovery passphrase. We tell the user this is the one
|
|
112
|
+
// they'll need if they lose the laptop AND the project passphrase.
|
|
113
|
+
process.stdout.write(
|
|
114
|
+
'\nNow choose a separate ' + ui.c.bold('recovery passphrase') + ' for cloud escrow.\n'
|
|
115
|
+
+ ui.c.dim(' This is what you\'ll type on a new laptop after `sealcode escrow recover`.\n')
|
|
116
|
+
+ ui.c.dim(' Store it somewhere different from your project passphrase.\n\n'),
|
|
117
|
+
);
|
|
118
|
+
const recoveryPp = await hidden('Recovery passphrase:');
|
|
119
|
+
if (!recoveryPp) throw new Error('Recovery passphrase is required.');
|
|
120
|
+
const confirmPp = await hidden('Confirm recovery passphrase:');
|
|
121
|
+
if (recoveryPp !== confirmPp) {
|
|
122
|
+
throw new Error('Recovery passphrases do not match.');
|
|
123
|
+
}
|
|
124
|
+
const strength = passphraseStrength(recoveryPp);
|
|
125
|
+
if (strength.level === 'weak') {
|
|
126
|
+
throw new Error(`Recovery passphrase is too weak (${strength.reason}). Try at least 16 chars or a passphrase like "correct-horse-battery-staple".`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Derive wrap key with a fresh salt.
|
|
130
|
+
const salt = makeSalt();
|
|
131
|
+
const wrapKey = await deriveKey(recoveryPp, salt);
|
|
132
|
+
|
|
133
|
+
// 4. Encrypt K under wrapKey with AES-256-GCM.
|
|
134
|
+
const iv = crypto.randomBytes(12);
|
|
135
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', wrapKey, iv);
|
|
136
|
+
const enc = Buffer.concat([cipher.update(K), cipher.final()]);
|
|
137
|
+
const tag = cipher.getAuthTag();
|
|
138
|
+
const blob = Buffer.concat([iv, tag, enc]).toString('base64');
|
|
139
|
+
|
|
140
|
+
// 5. Ship to the server.
|
|
141
|
+
const meta = clientMeta();
|
|
142
|
+
await request(
|
|
143
|
+
'PUT',
|
|
144
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/escrow`,
|
|
145
|
+
{
|
|
146
|
+
auth: true,
|
|
147
|
+
body: {
|
|
148
|
+
ciphertext: blob,
|
|
149
|
+
kdfParams: {
|
|
150
|
+
kind: 'scrypt',
|
|
151
|
+
N: 1 << 17,
|
|
152
|
+
r: 8,
|
|
153
|
+
p: 1,
|
|
154
|
+
saltB64: salt.toString('base64'),
|
|
155
|
+
version: 1,
|
|
156
|
+
},
|
|
157
|
+
hostname: meta.hostname,
|
|
158
|
+
platform: meta.platform,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// 6. Wipe wrapKey from memory ASAP.
|
|
164
|
+
wrapKey.fill(0);
|
|
165
|
+
|
|
166
|
+
ui.ok('Cloud escrow enabled.');
|
|
167
|
+
ui.hint(' Recover on a new machine with: sealcode escrow recover');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runEscrowDisable({ projectRoot }) {
|
|
171
|
+
const link = requireLink(projectRoot);
|
|
172
|
+
const res = await request(
|
|
173
|
+
'DELETE',
|
|
174
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/escrow`,
|
|
175
|
+
{ auth: true },
|
|
176
|
+
);
|
|
177
|
+
if (res.deleted) {
|
|
178
|
+
ui.ok('Cloud escrow disabled. Server-side blob deleted.');
|
|
179
|
+
} else {
|
|
180
|
+
process.stdout.write('(nothing to disable — escrow wasn\'t set up)\n');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runEscrowRecover({ projectRoot }) {
|
|
185
|
+
const link = requireLink(projectRoot);
|
|
186
|
+
const config = getActiveConfig(projectRoot);
|
|
187
|
+
if (!isInitialized(projectRoot, config.lockedDir)) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
'No vault on this machine yet. Clone the repo first, then re-run.',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const res = await request(
|
|
193
|
+
'GET',
|
|
194
|
+
`/api/v1/projects/${encodeURIComponent(link.projectId)}/escrow?full=1`,
|
|
195
|
+
{ auth: true },
|
|
196
|
+
);
|
|
197
|
+
if (!res.ciphertext || !res.kdfParams) {
|
|
198
|
+
throw new Error('No escrow blob for this project. Set it up first with `sealcode escrow enable`.');
|
|
199
|
+
}
|
|
200
|
+
if (res.kdfParams.kind !== 'scrypt') {
|
|
201
|
+
throw new Error(`Unsupported KDF kind: ${res.kdfParams.kind}`);
|
|
202
|
+
}
|
|
203
|
+
const salt = Buffer.from(res.kdfParams.saltB64, 'base64');
|
|
204
|
+
|
|
205
|
+
const recoveryPp = await hidden('Recovery passphrase:');
|
|
206
|
+
if (!recoveryPp) throw new Error('Recovery passphrase is required.');
|
|
207
|
+
|
|
208
|
+
// Re-derive wrap key with the server-supplied salt.
|
|
209
|
+
const wrapKey = await deriveKey(recoveryPp, salt);
|
|
210
|
+
|
|
211
|
+
// Decrypt blob.
|
|
212
|
+
const blob = Buffer.from(res.ciphertext, 'base64');
|
|
213
|
+
const iv = blob.subarray(0, 12);
|
|
214
|
+
const tag = blob.subarray(12, 28);
|
|
215
|
+
const ct = blob.subarray(28);
|
|
216
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', wrapKey, iv);
|
|
217
|
+
decipher.setAuthTag(tag);
|
|
218
|
+
let K;
|
|
219
|
+
try {
|
|
220
|
+
K = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
221
|
+
} catch {
|
|
222
|
+
wrapKey.fill(0);
|
|
223
|
+
throw new Error('Wrong recovery passphrase, or the escrow blob has been tampered with.');
|
|
224
|
+
}
|
|
225
|
+
wrapKey.fill(0);
|
|
226
|
+
|
|
227
|
+
saveSession(projectRoot, K);
|
|
228
|
+
ui.ok('Recovery successful. Session cached — you can now run `sealcode unlock`.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
runEscrowStatus,
|
|
233
|
+
runEscrowEnable,
|
|
234
|
+
runEscrowDisable,
|
|
235
|
+
runEscrowRecover,
|
|
236
|
+
};
|
package/src/cli-watch.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `sealcode watch <code>` — long-running agent for an access-code recipient.
|
|
5
|
+
*
|
|
6
|
+
* This is the missing piece of the "real-time revoke" story:
|
|
7
|
+
*
|
|
8
|
+
* On the OWNER side: `sealcode share` mints a code, `sealcode revoke` (or
|
|
9
|
+
* the dashboard) kills it.
|
|
10
|
+
*
|
|
11
|
+
* On the RECIPIENT side: `sealcode redeem` validates the code once, then
|
|
12
|
+
* the user runs `sealcode unlock` to start working. While they're working,
|
|
13
|
+
* `sealcode watch <code>` polls the heartbeat endpoint and:
|
|
14
|
+
*
|
|
15
|
+
* - prints a heartbeat line every interval so the demo is visible
|
|
16
|
+
* - on `action: "lock"` (revoked / expired / auto-lock reached), it
|
|
17
|
+
* immediately runs the same lock pipeline as `sealcode lock`, wipes
|
|
18
|
+
* the cached session, and exits with status 0.
|
|
19
|
+
* - on `action: "warn"` (close to expiry), warns the user.
|
|
20
|
+
* - on transient network failure, retries; only an explicit terminal
|
|
21
|
+
* response from the server triggers a lock.
|
|
22
|
+
*
|
|
23
|
+
* The watcher does NOT need an account / login — the access code itself is
|
|
24
|
+
* the authentication for these endpoints. That matches the contractor flow:
|
|
25
|
+
* they may not have a sealcode.dev account at all.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const { request, ApiError } = require('./api');
|
|
31
|
+
const { loadConfig } = require('./config');
|
|
32
|
+
const { detectPreset } = require('./presets');
|
|
33
|
+
const { isInitialized, loadSession, clearSession } = require('./keystore');
|
|
34
|
+
const { runLock } = require('./seal');
|
|
35
|
+
const { SealcodeError } = require('./errors');
|
|
36
|
+
const ui = require('./ui');
|
|
37
|
+
|
|
38
|
+
const DEFAULT_INTERVAL_SEC = 30;
|
|
39
|
+
const MIN_INTERVAL_SEC = 5;
|
|
40
|
+
const MAX_INTERVAL_SEC = 600;
|
|
41
|
+
const TRANSIENT_BACKOFF_SEC = 5;
|
|
42
|
+
|
|
43
|
+
function getActiveConfig(projectRoot) {
|
|
44
|
+
const fromFile = loadConfig(projectRoot);
|
|
45
|
+
if (fromFile) return fromFile;
|
|
46
|
+
const preset = detectPreset(projectRoot);
|
|
47
|
+
return {
|
|
48
|
+
version: 1,
|
|
49
|
+
preset: preset.id,
|
|
50
|
+
lockedDir: preset.lockedDir,
|
|
51
|
+
include: preset.include,
|
|
52
|
+
exclude: preset.exclude,
|
|
53
|
+
stubs: preset.stubs || {},
|
|
54
|
+
_file: null,
|
|
55
|
+
_implicit: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fmtRemaining(ms) {
|
|
60
|
+
if (ms == null) return '';
|
|
61
|
+
if (ms <= 0) return 'expired';
|
|
62
|
+
const sec = Math.floor(ms / 1000);
|
|
63
|
+
if (sec < 60) return `${sec}s`;
|
|
64
|
+
const min = Math.floor(sec / 60);
|
|
65
|
+
if (min < 60) return `${min}m ${sec % 60}s`;
|
|
66
|
+
const hr = Math.floor(min / 60);
|
|
67
|
+
if (hr < 24) return `${hr}h ${min % 60}m`;
|
|
68
|
+
return `${Math.floor(hr / 24)}d ${hr % 24}h`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ts() {
|
|
72
|
+
const d = new Date();
|
|
73
|
+
return d.toTimeString().slice(0, 8);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sleep(ms) {
|
|
77
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Single heartbeat round-trip. Returns `{ ok, response, transient }`:
|
|
82
|
+
* - ok=true with response on a real 2xx server reply
|
|
83
|
+
* - ok=false with transient=true on a network / 5xx blip — caller retries
|
|
84
|
+
* - throws SealcodeError on a malformed code / 404 etc. — caller exits
|
|
85
|
+
*/
|
|
86
|
+
async function heartbeatOnce(code) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await request('POST', '/api/v1/access/heartbeat', {
|
|
89
|
+
body: { code },
|
|
90
|
+
});
|
|
91
|
+
return { ok: true, response: res };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err instanceof ApiError) {
|
|
94
|
+
if (err.status === 0 || err.status >= 500) {
|
|
95
|
+
return { ok: false, transient: true, err };
|
|
96
|
+
}
|
|
97
|
+
// 4xx (code not found, bad request) is terminal — don't keep guessing.
|
|
98
|
+
throw new SealcodeError('SEALCODE_WATCH_BAD_CODE', {
|
|
99
|
+
detail: `Heartbeat rejected (${err.status} ${err.apiCode}): ${err.message}`,
|
|
100
|
+
hint: 'Try: sealcode redeem <code> (re-validate the access code)',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, transient: true, err };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {Object} opts
|
|
109
|
+
* @param {string} opts.projectRoot
|
|
110
|
+
* @param {string} opts.code — access code (SC-XXXX-…)
|
|
111
|
+
* @param {number} [opts.intervalSec] — override poll interval
|
|
112
|
+
* @param {boolean} [opts.verbose]
|
|
113
|
+
* @param {boolean} [opts.json] — emit JSONL events instead of pretty
|
|
114
|
+
*/
|
|
115
|
+
async function runWatch({ projectRoot, code, intervalSec, verbose = false, json = false }) {
|
|
116
|
+
if (!code || typeof code !== 'string') {
|
|
117
|
+
throw new SealcodeError('SEALCODE_WATCH_NO_CODE', {
|
|
118
|
+
detail: 'Pass the access code as the first argument.',
|
|
119
|
+
hint: 'Try: sealcode watch SC-XXXX-XXXX-XXXX-XXXX',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const config = getActiveConfig(projectRoot);
|
|
123
|
+
if (!isInitialized(projectRoot, config.lockedDir)) {
|
|
124
|
+
throw new SealcodeError('SEALCODE_NO_MANIFEST');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const trimmedCode = code.trim();
|
|
128
|
+
|
|
129
|
+
// First call doubles as a fast-fail validator and as a way to learn the
|
|
130
|
+
// server-side heartbeat interval, which we honor unless --interval is set.
|
|
131
|
+
const first = await heartbeatOnce(trimmedCode);
|
|
132
|
+
if (!first.ok) {
|
|
133
|
+
throw new SealcodeError('SEALCODE_WATCH_OFFLINE', {
|
|
134
|
+
detail: `Cannot reach sealcode.dev: ${first.err?.message || 'network error'}.`,
|
|
135
|
+
hint: 'Try: check connectivity, then re-run `sealcode watch`.',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const serverInterval = Number(first.response?.heartbeatIntervalSeconds) || DEFAULT_INTERVAL_SEC;
|
|
139
|
+
const effectiveInterval = Math.max(
|
|
140
|
+
MIN_INTERVAL_SEC,
|
|
141
|
+
Math.min(MAX_INTERVAL_SEC, intervalSec || serverInterval),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Handle the "already locked at startup" case before announcing.
|
|
145
|
+
if (first.response.action === 'lock') {
|
|
146
|
+
return finalLock(projectRoot, config, trimmedCode, first.response.reason, json);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (json) {
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
JSON.stringify({ type: 'start', intervalSec: effectiveInterval, ...first.response }) + '\n',
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
ui.box('Watching access code', [
|
|
155
|
+
`${ui.c.dim('code ')} ${ui.c.bold(trimmedCode)}`,
|
|
156
|
+
`${ui.c.dim('interval ')} every ${effectiveInterval}s`,
|
|
157
|
+
`${ui.c.dim('expires ')} ${first.response.expiresAt || '(unknown)'}`,
|
|
158
|
+
first.response.autoLockAt
|
|
159
|
+
? `${ui.c.dim('auto-lock ')} ${first.response.autoLockAt}`
|
|
160
|
+
: `${ui.c.dim('auto-lock ')} ${ui.c.dim('(none)')}`,
|
|
161
|
+
`${ui.c.dim('strict ')} ${first.response.strictMode ? 'yes' : 'no'}`,
|
|
162
|
+
'',
|
|
163
|
+
ui.c.dim('Press Ctrl-C to stop. If the owner revokes this code, your local'),
|
|
164
|
+
ui.c.dim('files will be re-locked automatically.'),
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Print one immediate "ok" line so the demo viewer sees activity right away.
|
|
169
|
+
printTick(first.response, json, verbose);
|
|
170
|
+
|
|
171
|
+
// Make Ctrl-C clean.
|
|
172
|
+
let stopped = false;
|
|
173
|
+
const onSig = () => {
|
|
174
|
+
if (stopped) return;
|
|
175
|
+
stopped = true;
|
|
176
|
+
if (!json) ui.say('\n' + ui.c.dim('stopped watching (files left as-is)'));
|
|
177
|
+
process.exit(0);
|
|
178
|
+
};
|
|
179
|
+
process.on('SIGINT', onSig);
|
|
180
|
+
process.on('SIGTERM', onSig);
|
|
181
|
+
|
|
182
|
+
let consecutiveTransient = 0;
|
|
183
|
+
while (!stopped) {
|
|
184
|
+
await sleep(effectiveInterval * 1000);
|
|
185
|
+
if (stopped) break;
|
|
186
|
+
let r;
|
|
187
|
+
try {
|
|
188
|
+
r = await heartbeatOnce(trimmedCode);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// Terminal error (malformed code etc.) — propagate.
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
if (!r.ok) {
|
|
194
|
+
consecutiveTransient += 1;
|
|
195
|
+
if (!json) {
|
|
196
|
+
ui.warn(`[${ts()}] transient: ${r.err?.message || 'network'} (retry in ${TRANSIENT_BACKOFF_SEC}s)`);
|
|
197
|
+
} else {
|
|
198
|
+
process.stdout.write(
|
|
199
|
+
JSON.stringify({ type: 'transient', at: new Date().toISOString(), error: String(r.err?.message || r.err) }) + '\n',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
// After a long offline streak the recipient should know — but we don't
|
|
203
|
+
// auto-lock on a server outage. Owner intent (revoke) must be observable.
|
|
204
|
+
if (consecutiveTransient > 6 && !json) {
|
|
205
|
+
ui.warn(` still offline after ${consecutiveTransient} attempts — owner revokes are not visible while offline.`);
|
|
206
|
+
}
|
|
207
|
+
await sleep(TRANSIENT_BACKOFF_SEC * 1000);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
consecutiveTransient = 0;
|
|
211
|
+
if (r.response.action === 'lock') {
|
|
212
|
+
return finalLock(projectRoot, config, trimmedCode, r.response.reason, json);
|
|
213
|
+
}
|
|
214
|
+
printTick(r.response, json, verbose);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function printTick(resp, json, verbose) {
|
|
219
|
+
if (json) {
|
|
220
|
+
process.stdout.write(
|
|
221
|
+
JSON.stringify({ type: 'tick', at: new Date().toISOString(), ...resp }) + '\n',
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const remaining = fmtRemaining(resp.remainingMs);
|
|
226
|
+
if (resp.action === 'warn') {
|
|
227
|
+
ui.warn(`[${ts()}] ${ui.c.yellow('warn')} — ${remaining} left, lock incoming`);
|
|
228
|
+
} else if (verbose) {
|
|
229
|
+
ui.say(`[${ts()}] ${ui.c.green('ok')} — ${remaining} left`);
|
|
230
|
+
} else {
|
|
231
|
+
// Single quiet line; overwrite-in-place if TTY for a clean demo.
|
|
232
|
+
if (ui.STDERR_TTY) {
|
|
233
|
+
process.stderr.write(
|
|
234
|
+
`\r${ui.c.green('●')} watching · ${ui.c.dim(ts())} · ${ui.c.dim(remaining + ' left')}\x1b[K`,
|
|
235
|
+
);
|
|
236
|
+
} else {
|
|
237
|
+
ui.say(`[${ts()}] ok — ${remaining} left`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function finalLock(projectRoot, config, code, reason, json) {
|
|
243
|
+
// Erase the in-place tick line if we were drawing one.
|
|
244
|
+
if (!json && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
|
|
245
|
+
|
|
246
|
+
const label = reason || 'lock';
|
|
247
|
+
if (json) {
|
|
248
|
+
process.stdout.write(
|
|
249
|
+
JSON.stringify({ type: 'lock', at: new Date().toISOString(), reason: label, code }) + '\n',
|
|
250
|
+
);
|
|
251
|
+
} else {
|
|
252
|
+
ui.warn(`[${ts()}] server says LOCK — reason: ${ui.c.bold(label)}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const K = loadSession(projectRoot);
|
|
256
|
+
if (!K) {
|
|
257
|
+
// The recipient never had a key cached (didn't run `sealcode unlock`),
|
|
258
|
+
// so there's no plaintext to seal. We still exit non-zero so a wrapping
|
|
259
|
+
// shell script knows the grant is dead.
|
|
260
|
+
if (!json) {
|
|
261
|
+
ui.fail(
|
|
262
|
+
'access revoked — but no cached session was found, so there is nothing to re-lock here.',
|
|
263
|
+
);
|
|
264
|
+
ui.hint(' (this is normal if you never ran `sealcode unlock` on this machine.)');
|
|
265
|
+
}
|
|
266
|
+
process.exit(2);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let res;
|
|
270
|
+
try {
|
|
271
|
+
res = await runLock({ projectRoot, config, K });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (!json) ui.fail(`re-lock failed: ${err.message}`);
|
|
274
|
+
process.exit(3);
|
|
275
|
+
}
|
|
276
|
+
clearSession(projectRoot);
|
|
277
|
+
if (json) {
|
|
278
|
+
process.stdout.write(
|
|
279
|
+
JSON.stringify({ type: 'locked', at: new Date().toISOString(), count: res.count, reason: label }) + '\n',
|
|
280
|
+
);
|
|
281
|
+
} else {
|
|
282
|
+
ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')} — session cleared`);
|
|
283
|
+
ui.hint(' Your local plaintext has been wiped. Ask the owner for a fresh code if you still need access.');
|
|
284
|
+
}
|
|
285
|
+
process.exit(0);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { runWatch };
|
package/src/cli.js
CHANGED
|
@@ -34,6 +34,18 @@ const { exportBundle, importBundle } = require('./bundle');
|
|
|
34
34
|
const { runLogin, runSignout, runWhoami } = require('./cli-auth');
|
|
35
35
|
const { runLink, runUnlink, runLinkInfo } = require('./cli-link');
|
|
36
36
|
const { runShare, runGrants, runRevoke, runRedeem } = require('./cli-grants');
|
|
37
|
+
const {
|
|
38
|
+
runCiCreate,
|
|
39
|
+
runCiList,
|
|
40
|
+
runCiRevoke,
|
|
41
|
+
} = require('./cli-ci-tokens');
|
|
42
|
+
const {
|
|
43
|
+
runEscrowStatus,
|
|
44
|
+
runEscrowEnable,
|
|
45
|
+
runEscrowDisable,
|
|
46
|
+
runEscrowRecover,
|
|
47
|
+
} = require('./cli-escrow');
|
|
48
|
+
const { runWatch } = require('./cli-watch');
|
|
37
49
|
const ui = require('./ui');
|
|
38
50
|
|
|
39
51
|
function logger(verbose) {
|
|
@@ -658,6 +670,135 @@ function build() {
|
|
|
658
670
|
}
|
|
659
671
|
});
|
|
660
672
|
|
|
673
|
+
// -------- watch --------
|
|
674
|
+
program
|
|
675
|
+
.command('watch')
|
|
676
|
+
.argument('<code>', 'access code to monitor (the one you redeemed)')
|
|
677
|
+
.description('Stay online and self-lock if the owner revokes this code or it expires.')
|
|
678
|
+
.option('--interval <seconds>', 'override server-suggested heartbeat interval', (v) => parseInt(v, 10))
|
|
679
|
+
.option('-v, --verbose', 'print every heartbeat (instead of a single live status line)', false)
|
|
680
|
+
.option('--json', 'machine-readable JSONL output (one event per line)', false)
|
|
681
|
+
.action(async (code, opts) => {
|
|
682
|
+
try {
|
|
683
|
+
const projectRoot = resolveProject(program.opts());
|
|
684
|
+
await runWatch({
|
|
685
|
+
projectRoot,
|
|
686
|
+
code,
|
|
687
|
+
intervalSec: opts.interval,
|
|
688
|
+
verbose: !!opts.verbose,
|
|
689
|
+
json: !!opts.json,
|
|
690
|
+
});
|
|
691
|
+
} catch (err) {
|
|
692
|
+
process.exitCode = reportError(err);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// -------- ci-token --------
|
|
697
|
+
const ciToken = program
|
|
698
|
+
.command('ci-token')
|
|
699
|
+
.description('Manage CI/CD short-lived tokens for the linked project (Pro feature).');
|
|
700
|
+
|
|
701
|
+
ciToken
|
|
702
|
+
.command('create')
|
|
703
|
+
.description('Create a new CI token. Shown once — save it to your CI secret store.')
|
|
704
|
+
.requiredOption('--label <text>', 'human-readable label (e.g. "GitHub Actions · deploy")')
|
|
705
|
+
.option('--ttl <duration>', 'lifetime: 1h, 8h, 1d, 1w (default 24h)', '24h')
|
|
706
|
+
.option('--scope <scope>', 'read | unlock (default unlock)', 'unlock')
|
|
707
|
+
.option('--json', 'machine-readable output', false)
|
|
708
|
+
.action(async (opts) => {
|
|
709
|
+
try {
|
|
710
|
+
const projectRoot = resolveProject(program.opts());
|
|
711
|
+
await runCiCreate({
|
|
712
|
+
projectRoot,
|
|
713
|
+
label: opts.label,
|
|
714
|
+
ttl: opts.ttl,
|
|
715
|
+
scope: opts.scope,
|
|
716
|
+
json: !!opts.json,
|
|
717
|
+
});
|
|
718
|
+
} catch (err) {
|
|
719
|
+
process.exitCode = reportError(err);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
ciToken
|
|
724
|
+
.command('list')
|
|
725
|
+
.description('List CI tokens for the linked project.')
|
|
726
|
+
.option('--json', 'machine-readable output', false)
|
|
727
|
+
.action(async (opts) => {
|
|
728
|
+
try {
|
|
729
|
+
const projectRoot = resolveProject(program.opts());
|
|
730
|
+
await runCiList({ projectRoot, json: !!opts.json });
|
|
731
|
+
} catch (err) {
|
|
732
|
+
process.exitCode = reportError(err);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
ciToken
|
|
737
|
+
.command('revoke')
|
|
738
|
+
.argument('<id>', 'CI token id (see `sealcode ci-token list`)')
|
|
739
|
+
.description('Revoke a CI token immediately.')
|
|
740
|
+
.action(async (id) => {
|
|
741
|
+
try {
|
|
742
|
+
await runCiRevoke({ tokenId: id });
|
|
743
|
+
} catch (err) {
|
|
744
|
+
process.exitCode = reportError(err);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// -------- escrow --------
|
|
749
|
+
const escrow = program
|
|
750
|
+
.command('escrow')
|
|
751
|
+
.description('Cloud key escrow: a passphrase-recovery safety net (Pro feature).');
|
|
752
|
+
|
|
753
|
+
escrow
|
|
754
|
+
.command('status')
|
|
755
|
+
.description('Print whether escrow is enabled for the linked project.')
|
|
756
|
+
.option('--json', 'machine-readable output', false)
|
|
757
|
+
.action(async (opts) => {
|
|
758
|
+
try {
|
|
759
|
+
const projectRoot = resolveProject(program.opts());
|
|
760
|
+
await runEscrowStatus({ projectRoot, json: !!opts.json });
|
|
761
|
+
} catch (err) {
|
|
762
|
+
process.exitCode = reportError(err);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
escrow
|
|
767
|
+
.command('enable')
|
|
768
|
+
.description('Wrap the master key with a separate recovery passphrase and upload the ciphertext.')
|
|
769
|
+
.action(async () => {
|
|
770
|
+
try {
|
|
771
|
+
const projectRoot = resolveProject(program.opts());
|
|
772
|
+
await runEscrowEnable({ projectRoot });
|
|
773
|
+
} catch (err) {
|
|
774
|
+
process.exitCode = reportError(err);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
escrow
|
|
779
|
+
.command('disable')
|
|
780
|
+
.description('Delete the server-side escrow blob. The local vault is unaffected.')
|
|
781
|
+
.action(async () => {
|
|
782
|
+
try {
|
|
783
|
+
const projectRoot = resolveProject(program.opts());
|
|
784
|
+
await runEscrowDisable({ projectRoot });
|
|
785
|
+
} catch (err) {
|
|
786
|
+
process.exitCode = reportError(err);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
escrow
|
|
791
|
+
.command('recover')
|
|
792
|
+
.description('On a new machine: download the blob, decrypt with the recovery passphrase, cache K.')
|
|
793
|
+
.action(async () => {
|
|
794
|
+
try {
|
|
795
|
+
const projectRoot = resolveProject(program.opts());
|
|
796
|
+
await runEscrowRecover({ projectRoot });
|
|
797
|
+
} catch (err) {
|
|
798
|
+
process.exitCode = reportError(err);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
661
802
|
return program;
|
|
662
803
|
}
|
|
663
804
|
|
package/src/errors.js
CHANGED
|
@@ -54,6 +54,24 @@ const CODES = {
|
|
|
54
54
|
'The free CLI handles lock / unlock / verify forever. Pro adds team key sharing, key rotation across N projects, audit log sync, and cloud key escrow.',
|
|
55
55
|
try: 'Try: visit https://sealcode.dev/pro (start a free 14-day trial)',
|
|
56
56
|
},
|
|
57
|
+
SEALCODE_WATCH_NO_CODE: {
|
|
58
|
+
headline: 'sealcode watch needs an access code.',
|
|
59
|
+
detail:
|
|
60
|
+
'Pass the access code shared with you (the one you got from sealcode share / redeem).',
|
|
61
|
+
try: 'Try: sealcode watch SC-XXXX-XXXX-XXXX-XXXX',
|
|
62
|
+
},
|
|
63
|
+
SEALCODE_WATCH_BAD_CODE: {
|
|
64
|
+
headline: 'The server rejected that access code.',
|
|
65
|
+
detail:
|
|
66
|
+
"The code may be malformed, already expired, revoked, or it doesn't exist on this server.",
|
|
67
|
+
try: 'Try: sealcode redeem <code> (re-validate the code first)',
|
|
68
|
+
},
|
|
69
|
+
SEALCODE_WATCH_OFFLINE: {
|
|
70
|
+
headline: "I couldn't reach sealcode.dev to start watching.",
|
|
71
|
+
detail:
|
|
72
|
+
'The first heartbeat failed. We refuse to start the watcher without confirming the code is reachable — otherwise a revoke would never be observed.',
|
|
73
|
+
try: 'Try: check connectivity, then re-run `sealcode watch <code>`.',
|
|
74
|
+
},
|
|
57
75
|
};
|
|
58
76
|
|
|
59
77
|
class SealcodeError extends Error {
|