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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "Lock your source code in your own git repo. Stop AI agents, scrapers, and curious eyes from reading what's yours.",
5
5
  "keywords": [
6
6
  "encryption",
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
+ };