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/README.md
CHANGED
|
@@ -85,6 +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 (--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
|
|
88
103
|
sealcode pro # Pro tier info
|
|
89
104
|
sealcode where # debug: print paths and state (no secrets)
|
|
90
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
|
|
|
@@ -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
|
+
};
|