sealcode 0.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/src/kdf.js ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Key derivation for sealcode.
5
+ *
6
+ * A user-typeable passphrase isn't a key. We run it through scrypt with strong
7
+ * parameters to produce a 32-byte key suitable for AES-GCM.
8
+ *
9
+ * Scrypt parameters (per OWASP 2024 / RFC 9106 ballpark for password hashing):
10
+ * N = 2^17 (131072), r = 8, p = 1
11
+ * memory cost: ~128 MB. CPU cost: ~1s on a modern laptop.
12
+ *
13
+ * This makes brute-forcing a stolen sealcode directory wildly expensive even
14
+ * for a weak passphrase. Strong passphrases remain infeasible.
15
+ */
16
+
17
+ const crypto = require('crypto');
18
+ const { promisify } = require('util');
19
+
20
+ const scryptAsync = promisify(crypto.scrypt);
21
+
22
+ const SCRYPT_OPTS = {
23
+ N: 1 << 17, // 131072
24
+ r: 8,
25
+ p: 1,
26
+ maxmem: 256 * 1024 * 1024, // 256 MB ceiling
27
+ };
28
+
29
+ const KEY_LEN = 32;
30
+ const SALT_LEN = 16;
31
+
32
+ /**
33
+ * Derive a 32-byte key from a passphrase + salt.
34
+ * @param {string} passphrase - user-typed passphrase or hex-encoded recovery seed
35
+ * @param {Buffer} salt - 16 random bytes; non-secret, stored in the locked dir
36
+ * @returns {Promise<Buffer>} 32 bytes
37
+ */
38
+ async function deriveKey(passphrase, salt) {
39
+ if (typeof passphrase !== 'string' || passphrase.length === 0) {
40
+ throw new Error('passphrase must be a non-empty string');
41
+ }
42
+ if (!Buffer.isBuffer(salt) || salt.length !== SALT_LEN) {
43
+ throw new Error(`salt must be a ${SALT_LEN}-byte Buffer`);
44
+ }
45
+ // Normalize passphrase to NFKC so Unicode passphrases roundtrip cleanly
46
+ // across operating systems. Don't trim — leading/trailing space is part of
47
+ // the user's intent if they typed it.
48
+ const normalized = passphrase.normalize('NFKC');
49
+ return scryptAsync(Buffer.from(normalized, 'utf8'), salt, KEY_LEN, SCRYPT_OPTS);
50
+ }
51
+
52
+ /**
53
+ * Generate a fresh salt for a new project.
54
+ */
55
+ function makeSalt() {
56
+ return crypto.randomBytes(SALT_LEN);
57
+ }
58
+
59
+ /**
60
+ * Quick passphrase quality check. Not gospel — just a friendly warning.
61
+ * @param {string} pp
62
+ * @returns {{level: 'weak'|'ok'|'strong', reason: string}}
63
+ */
64
+ function passphraseStrength(pp) {
65
+ if (!pp || pp.length < 8) return { level: 'weak', reason: 'shorter than 8 characters' };
66
+ if (pp.length < 12) return { level: 'weak', reason: 'shorter than 12 characters' };
67
+ const classes = [
68
+ /[a-z]/.test(pp),
69
+ /[A-Z]/.test(pp),
70
+ /[0-9]/.test(pp),
71
+ /[^a-zA-Z0-9]/.test(pp),
72
+ ].filter(Boolean).length;
73
+ if (pp.length >= 20 || classes >= 3) return { level: 'strong', reason: '' };
74
+ return { level: 'ok', reason: 'good enough; longer = better' };
75
+ }
76
+
77
+ module.exports = {
78
+ SALT_LEN,
79
+ KEY_LEN,
80
+ deriveKey,
81
+ makeSalt,
82
+ passphraseStrength,
83
+ };
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Keystore — manages how the master data key K is stored and unwrapped.
5
+ *
6
+ * On disk inside lockedDir we keep three small files with opaque names derived
7
+ * from public sentinels (no key required to find them):
8
+ *
9
+ * <salt> — 16 random bytes (plain). Input to scrypt.
10
+ * <wrap1> — encrypted K, wrapped by passphrase-derived key.
11
+ * <wrap2> — encrypted K, wrapped by recovery-seed-derived key.
12
+ *
13
+ * Unlock flow:
14
+ * 1. Read salt.
15
+ * 2. Ask user for passphrase OR recovery code.
16
+ * 3. scrypt(input, salt) → wrapping key.
17
+ * 4. AES-GCM-decrypt wrapX → K.
18
+ * 5. Use K to read manifest and decrypt files.
19
+ *
20
+ * Why two wrap files? Because losing your passphrase is human; we want a
21
+ * second, write-once recovery path that doesn't require the user's memory.
22
+ *
23
+ * Session cache (~/.sealcode/sessions/) holds short-lived unlocked K's so
24
+ * the user doesn't retype their passphrase on every command. Cache files are
25
+ * gated by a 64-bit project ID and protected by the OS user permissions.
26
+ *
27
+ * Backward-compat: we still read ~/.vaultline/sessions/ as a fallback so
28
+ * anyone upgrading from vaultline 1.x keeps their active session.
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+ const { seal, open, randomBytes, metaName, sha256Hex } = require('./crypto');
35
+ const { deriveKey, makeSalt } = require('./kdf');
36
+ const { ensureDir } = require('./util');
37
+
38
+ const SALT_NAME = metaName('salt');
39
+ const WRAP_PASS_NAME = metaName('wrap.pass');
40
+ const WRAP_RECOVERY_NAME = metaName('wrap.recovery');
41
+ const MANIFEST_NAME = metaName('manifest');
42
+
43
+ const SESSION_DIR = path.join(os.homedir(), '.sealcode', 'sessions');
44
+ const LEGACY_SESSION_DIR = path.join(os.homedir(), '.vaultline', 'sessions');
45
+ const SESSION_TTL_MS = 8 * 60 * 60 * 1000; // 8 hours
46
+
47
+ function lockedAbs(projectRoot, lockedDir, relName) {
48
+ return path.join(projectRoot, lockedDir, relName);
49
+ }
50
+
51
+ function projectId(projectRoot) {
52
+ // Stable per-absolute-path. Different worktrees of the same repo get
53
+ // different sessions, which is fine.
54
+ return sha256Hex(path.resolve(projectRoot)).slice(0, 16);
55
+ }
56
+
57
+ function saltPath(projectRoot, lockedDir) {
58
+ return lockedAbs(projectRoot, lockedDir, SALT_NAME);
59
+ }
60
+
61
+ function readSalt(projectRoot, lockedDir) {
62
+ const p = saltPath(projectRoot, lockedDir);
63
+ if (!fs.existsSync(p)) return null;
64
+ const buf = fs.readFileSync(p);
65
+ return buf.length === 16 ? buf : null;
66
+ }
67
+
68
+ function writeSalt(projectRoot, lockedDir, salt) {
69
+ const p = saltPath(projectRoot, lockedDir);
70
+ ensureDir(path.dirname(p));
71
+ fs.writeFileSync(p, salt);
72
+ }
73
+
74
+ function readWrapped(projectRoot, lockedDir, which) {
75
+ const name = which === 'recovery' ? WRAP_RECOVERY_NAME : WRAP_PASS_NAME;
76
+ const p = lockedAbs(projectRoot, lockedDir, name);
77
+ return fs.existsSync(p) ? fs.readFileSync(p) : null;
78
+ }
79
+
80
+ function writeWrapped(projectRoot, lockedDir, which, blob) {
81
+ const name = which === 'recovery' ? WRAP_RECOVERY_NAME : WRAP_PASS_NAME;
82
+ const p = lockedAbs(projectRoot, lockedDir, name);
83
+ ensureDir(path.dirname(p));
84
+ fs.writeFileSync(p, blob);
85
+ }
86
+
87
+ function manifestBlobPath(projectRoot, lockedDir) {
88
+ return lockedAbs(projectRoot, lockedDir, MANIFEST_NAME);
89
+ }
90
+
91
+ /**
92
+ * Bootstrap a brand-new keystore: generate K, salt, wrap K twice.
93
+ * @param {string} passphrase
94
+ * @param {Buffer} recoverySeed 16 bytes
95
+ * @returns {Promise<{K: Buffer, salt: Buffer, wrappedPass: Buffer, wrappedRecovery: Buffer}>}
96
+ */
97
+ async function bootstrap(passphrase, recoverySeed) {
98
+ const K = randomBytes(32);
99
+ const salt = makeSalt();
100
+ const passKey = await deriveKey(passphrase, salt);
101
+ const recoveryPass = recoverySeed.toString('hex');
102
+ const recoveryKey = await deriveKey(recoveryPass, salt);
103
+ const wrappedPass = seal(K, passKey);
104
+ const wrappedRecovery = seal(K, recoveryKey);
105
+ return { K, salt, wrappedPass, wrappedRecovery };
106
+ }
107
+
108
+ /**
109
+ * Persist the bootstrap output into lockedDir.
110
+ */
111
+ function persistBootstrap(projectRoot, lockedDir, { salt, wrappedPass, wrappedRecovery }) {
112
+ writeSalt(projectRoot, lockedDir, salt);
113
+ writeWrapped(projectRoot, lockedDir, 'pass', wrappedPass);
114
+ writeWrapped(projectRoot, lockedDir, 'recovery', wrappedRecovery);
115
+ }
116
+
117
+ /**
118
+ * Unwrap K from disk using either a passphrase or recovery seed.
119
+ * @param {string} projectRoot
120
+ * @param {string} lockedDir
121
+ * @param {{ passphrase?: string, recoverySeed?: Buffer }} input
122
+ * @returns {Promise<Buffer>} 32-byte master data key
123
+ */
124
+ async function unwrap(projectRoot, lockedDir, input) {
125
+ const salt = readSalt(projectRoot, lockedDir);
126
+ if (!salt) {
127
+ throw new Error('SEALCODE_NO_MANIFEST');
128
+ }
129
+ if (input.passphrase != null) {
130
+ const wrapped = readWrapped(projectRoot, lockedDir, 'pass');
131
+ if (!wrapped) throw new Error('SEALCODE_NO_MANIFEST');
132
+ const wrappingKey = await deriveKey(input.passphrase, salt);
133
+ return open(wrapped, wrappingKey);
134
+ }
135
+ if (input.recoverySeed != null) {
136
+ const wrapped = readWrapped(projectRoot, lockedDir, 'recovery');
137
+ if (!wrapped) throw new Error('SEALCODE_NO_MANIFEST');
138
+ const wrappingKey = await deriveKey(input.recoverySeed.toString('hex'), salt);
139
+ return open(wrapped, wrappingKey);
140
+ }
141
+ throw new Error('unwrap(): need passphrase or recoverySeed');
142
+ }
143
+
144
+ /**
145
+ * Re-wrap the master key with a new passphrase. The recovery-code path is
146
+ * unchanged. Does not touch encrypted files or the manifest.
147
+ * @returns {Promise<void>}
148
+ */
149
+ async function rotatePassphrase(projectRoot, lockedDir, oldPassphrase, newPassphrase) {
150
+ const K = await unwrap(projectRoot, lockedDir, { passphrase: oldPassphrase });
151
+ const salt = readSalt(projectRoot, lockedDir);
152
+ const newWrapKey = await deriveKey(newPassphrase, salt);
153
+ writeWrapped(projectRoot, lockedDir, 'pass', seal(K, newWrapKey));
154
+ saveSession(projectRoot, K);
155
+ }
156
+
157
+ /**
158
+ * Save unwrapped K to a short-lived session file so subsequent commands skip
159
+ * scrypt. Session is encrypted by a host-binding key derived from machine
160
+ * details + project id, so copying the file to another machine doesn't help.
161
+ */
162
+ function saveSession(projectRoot, K) {
163
+ ensureDir(SESSION_DIR);
164
+ const id = projectId(projectRoot);
165
+ const hostBind = sha256Hex(`${os.hostname()}|${os.userInfo().username}|${id}`);
166
+ const sessionKey = Buffer.from(hostBind.slice(0, 64), 'hex');
167
+ const blob = seal(K, sessionKey);
168
+ const meta = Buffer.from(JSON.stringify({ exp: Date.now() + SESSION_TTL_MS }), 'utf8');
169
+ const wrapped = Buffer.concat([
170
+ Buffer.from([meta.length & 0xff, (meta.length >> 8) & 0xff]),
171
+ meta,
172
+ blob,
173
+ ]);
174
+ fs.writeFileSync(path.join(SESSION_DIR, id), wrapped, { mode: 0o600 });
175
+ }
176
+
177
+ function loadSession(projectRoot) {
178
+ const id = projectId(projectRoot);
179
+ let p = path.join(SESSION_DIR, id);
180
+ if (!fs.existsSync(p)) {
181
+ // Fall back to the legacy ~/.vaultline/sessions location for users
182
+ // upgrading from vaultline 1.x.
183
+ const legacy = path.join(LEGACY_SESSION_DIR, id);
184
+ if (!fs.existsSync(legacy)) return null;
185
+ p = legacy;
186
+ }
187
+ let raw;
188
+ try {
189
+ raw = fs.readFileSync(p);
190
+ } catch (_) {
191
+ return null;
192
+ }
193
+ if (raw.length < 4) return null;
194
+ const metaLen = raw[0] | (raw[1] << 8);
195
+ if (raw.length < 2 + metaLen + 29) return null;
196
+ let meta;
197
+ try {
198
+ meta = JSON.parse(raw.subarray(2, 2 + metaLen).toString('utf8'));
199
+ } catch (_) {
200
+ return null;
201
+ }
202
+ if (!meta.exp || Date.now() > meta.exp) {
203
+ try {
204
+ fs.unlinkSync(p);
205
+ } catch (_) { /* ignore */ }
206
+ return null;
207
+ }
208
+ const blob = raw.subarray(2 + metaLen);
209
+ const hostBind = sha256Hex(`${os.hostname()}|${os.userInfo().username}|${id}`);
210
+ const sessionKey = Buffer.from(hostBind.slice(0, 64), 'hex');
211
+ try {
212
+ return open(blob, sessionKey);
213
+ } catch (_) {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function clearSession(projectRoot) {
219
+ const id = projectId(projectRoot);
220
+ for (const dir of [SESSION_DIR, LEGACY_SESSION_DIR]) {
221
+ try {
222
+ fs.unlinkSync(path.join(dir, id));
223
+ } catch (_) { /* ignore */ }
224
+ }
225
+ }
226
+
227
+ function isInitialized(projectRoot, lockedDir) {
228
+ return (
229
+ readSalt(projectRoot, lockedDir) != null &&
230
+ readWrapped(projectRoot, lockedDir, 'pass') != null
231
+ );
232
+ }
233
+
234
+ module.exports = {
235
+ SALT_NAME,
236
+ WRAP_PASS_NAME,
237
+ WRAP_RECOVERY_NAME,
238
+ MANIFEST_NAME,
239
+ bootstrap,
240
+ persistBootstrap,
241
+ unwrap,
242
+ rotatePassphrase,
243
+ saveSession,
244
+ loadSession,
245
+ clearSession,
246
+ isInitialized,
247
+ manifestBlobPath,
248
+ projectId,
249
+ };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Project ↔ web app linking state.
5
+ *
6
+ * The link is stored in the project's .sealcoderc.json under a `link` key
7
+ * (apiUrl + projectId + linkedAt). Living next to the rest of the config
8
+ * means `git add .sealcoderc.json` never accidentally happens (the file is
9
+ * already gitignored by `sealcode init`), and `where`/`status` can show the
10
+ * link without an extra lookup.
11
+ *
12
+ * We deliberately do NOT store any auth tokens in the project config — those
13
+ * live in ~/.sealcode/credentials.json so an accidental commit of the project
14
+ * config leaks nothing sensitive.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const {
20
+ CONFIG_FILE,
21
+ LEGACY_CONFIG_FILE,
22
+ loadConfig,
23
+ writeConfig,
24
+ } = require('./config');
25
+
26
+ function findConfigFile(projectRoot) {
27
+ const newp = path.join(projectRoot, CONFIG_FILE);
28
+ if (fs.existsSync(newp)) return newp;
29
+ const oldp = path.join(projectRoot, LEGACY_CONFIG_FILE);
30
+ if (fs.existsSync(oldp)) return oldp;
31
+ return null;
32
+ }
33
+
34
+ function getLink(projectRoot) {
35
+ const cfg = loadConfig(projectRoot);
36
+ if (!cfg || !cfg.link || typeof cfg.link !== 'object') return null;
37
+ return cfg.link;
38
+ }
39
+
40
+ function setLink(projectRoot, link) {
41
+ const existing = loadConfig(projectRoot);
42
+ if (!existing) {
43
+ throw new Error(
44
+ `No ${CONFIG_FILE} found in ${projectRoot}. Run \`sealcode init\` first.`,
45
+ );
46
+ }
47
+ const merged = { ...existing, link };
48
+ // writeConfig drops the _file key for us.
49
+ writeConfig(projectRoot, merged);
50
+ }
51
+
52
+ function clearLink(projectRoot) {
53
+ const existing = loadConfig(projectRoot);
54
+ if (!existing) return;
55
+ const { link: _omit, ...rest } = existing;
56
+ void _omit;
57
+ writeConfig(projectRoot, rest);
58
+ }
59
+
60
+ module.exports = { findConfigFile, getLink, setLink, clearLink };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { seal, open } = require('./crypto');
6
+ const { manifestBlobPath } = require('./keystore');
7
+ const { ensureDir } = require('./util');
8
+
9
+ function writeManifest(projectRoot, lockedDir, manifest, K) {
10
+ const file = manifestBlobPath(projectRoot, lockedDir);
11
+ ensureDir(path.dirname(file));
12
+ fs.writeFileSync(file, seal(JSON.stringify(manifest), K));
13
+ }
14
+
15
+ function readManifest(projectRoot, lockedDir, K) {
16
+ const file = manifestBlobPath(projectRoot, lockedDir);
17
+ if (!fs.existsSync(file)) {
18
+ throw new Error('SEALCODE_NO_MANIFEST');
19
+ }
20
+ const blob = fs.readFileSync(file);
21
+ const plain = open(blob, K);
22
+ return JSON.parse(plain.toString('utf8'));
23
+ }
24
+
25
+ module.exports = { writeManifest, readManifest };
package/src/open.js ADDED
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { open } = require('./crypto');
6
+ const { readManifest } = require('./manifest');
7
+ const { ensureDir, writeFileEnsuringDir } = require('./util');
8
+
9
+ async function runUnlock({ projectRoot, config, K, removeStubs = true, log = () => {} }) {
10
+ const manifest = readManifest(projectRoot, config.lockedDir, K);
11
+ const lockedRoot = path.join(projectRoot, config.lockedDir);
12
+
13
+ if (removeStubs && manifest.stubs) {
14
+ for (const stubPath of Object.keys(manifest.stubs)) {
15
+ const abs = path.join(projectRoot, stubPath);
16
+ const isAlsoSealed = manifest.files.some((f) => f.p === stubPath);
17
+ if (isAlsoSealed && fs.existsSync(abs)) {
18
+ try {
19
+ fs.unlinkSync(abs);
20
+ } catch (_) {
21
+ /* ignore */
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ for (const entry of manifest.files) {
28
+ const lockedAbs = path.join(lockedRoot, entry.l);
29
+ if (!fs.existsSync(lockedAbs)) {
30
+ throw new Error(`unlock: missing locked blob for ${entry.p} (expected ${entry.l})`);
31
+ }
32
+ const blob = fs.readFileSync(lockedAbs);
33
+ const plain = open(blob, K);
34
+ const target = path.join(projectRoot, entry.p);
35
+ ensureDir(path.dirname(target));
36
+ writeFileEnsuringDir(target, plain, entry.m);
37
+ log(` unlocked ${entry.p}`);
38
+ }
39
+
40
+ return { count: manifest.files.length, sealedAt: manifest.sealedAt };
41
+ }
42
+
43
+ module.exports = { runUnlock };