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/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/sealcode.js +11 -0
- package/package.json +53 -0
- package/src/api.js +162 -0
- package/src/bundle.js +133 -0
- package/src/cli-auth.js +233 -0
- package/src/cli-grants.js +185 -0
- package/src/cli-link.js +90 -0
- package/src/cli.js +683 -0
- package/src/config.js +76 -0
- package/src/crypto.js +151 -0
- package/src/errors.js +111 -0
- package/src/hooks.js +127 -0
- package/src/index.js +19 -0
- package/src/init.js +180 -0
- package/src/kdf.js +83 -0
- package/src/keystore.js +249 -0
- package/src/link-state.js +60 -0
- package/src/manifest.js +25 -0
- package/src/open.js +43 -0
- package/src/presets.js +338 -0
- package/src/prompt.js +108 -0
- package/src/recovery.js +154 -0
- package/src/seal.js +174 -0
- package/src/status.js +207 -0
- package/src/ui.js +270 -0
- package/src/util.js +59 -0
- package/src/verify.js +51 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project-level config. By convention lives at .sealcoderc.json in the
|
|
5
|
+
* project root, but it's gitignored — sealcode doesn't *need* the file to
|
|
6
|
+
* exist after init, since safe defaults are derived from the chosen preset
|
|
7
|
+
* at lock time.
|
|
8
|
+
*
|
|
9
|
+
* Backward-compat: we also read .vaultlinerc.json from projects that were
|
|
10
|
+
* set up under the old name, so existing users keep working without a manual
|
|
11
|
+
* migration step.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const CONFIG_FILE = '.sealcoderc.json';
|
|
18
|
+
const LEGACY_CONFIG_FILE = '.vaultlinerc.json';
|
|
19
|
+
const DEFAULT_LOCKED_DIR = 'vendor';
|
|
20
|
+
|
|
21
|
+
function configPathIfExists(projectRoot) {
|
|
22
|
+
const newp = path.join(projectRoot, CONFIG_FILE);
|
|
23
|
+
if (fs.existsSync(newp)) return newp;
|
|
24
|
+
const oldp = path.join(projectRoot, LEGACY_CONFIG_FILE);
|
|
25
|
+
if (fs.existsSync(oldp)) return oldp;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findProjectRoot(startDir) {
|
|
30
|
+
let dir = path.resolve(startDir);
|
|
31
|
+
while (true) {
|
|
32
|
+
if (
|
|
33
|
+
fs.existsSync(path.join(dir, CONFIG_FILE)) ||
|
|
34
|
+
fs.existsSync(path.join(dir, LEGACY_CONFIG_FILE))
|
|
35
|
+
) {
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
const parent = path.dirname(dir);
|
|
39
|
+
if (parent === dir) return path.resolve(startDir);
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadConfig(projectRoot) {
|
|
45
|
+
const file = configPathIfExists(projectRoot);
|
|
46
|
+
if (!file) return null;
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error(`failed to parse ${path.basename(file)}: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
return { ...parsed, _file: file };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeConfig(projectRoot, cfg) {
|
|
57
|
+
const file = path.join(projectRoot, CONFIG_FILE);
|
|
58
|
+
const out = { ...cfg };
|
|
59
|
+
delete out._file;
|
|
60
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 2) + '\n');
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function configExists(projectRoot) {
|
|
65
|
+
return configPathIfExists(projectRoot) !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
CONFIG_FILE,
|
|
70
|
+
LEGACY_CONFIG_FILE,
|
|
71
|
+
DEFAULT_LOCKED_DIR,
|
|
72
|
+
findProjectRoot,
|
|
73
|
+
loadConfig,
|
|
74
|
+
writeConfig,
|
|
75
|
+
configExists,
|
|
76
|
+
};
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Crypto core for sealcode.
|
|
5
|
+
*
|
|
6
|
+
* Threat model:
|
|
7
|
+
* - "Confused AI" attacker: a model or agent scanning the committed repo.
|
|
8
|
+
* Must see only opaque random bytes with no structural hints.
|
|
9
|
+
* - "Curious human" attacker: a teammate, contractor, or stranger with read
|
|
10
|
+
* access to the repo but no key/passphrase. Must be unable to read source.
|
|
11
|
+
* - NOT in scope: an attacker who already has the key/passphrase, or who can
|
|
12
|
+
* read your laptop's RAM while the project is unlocked.
|
|
13
|
+
*
|
|
14
|
+
* Algorithms:
|
|
15
|
+
* - AES-256-GCM for symmetric authenticated encryption.
|
|
16
|
+
* - gzip before encrypt to remove plaintext patterns and shrink ciphertext.
|
|
17
|
+
* - Filenames inside the locked dir are derived by HMAC-SHA256, truncated to
|
|
18
|
+
* 12 hex chars under a 2-char shard directory. Stable for a given key.
|
|
19
|
+
*
|
|
20
|
+
* On-disk blob format (raw binary, no header, no extension):
|
|
21
|
+
*
|
|
22
|
+
* [ 12 B IV ] [ 16 B AES-GCM tag ] [ N B ciphertext ]
|
|
23
|
+
*
|
|
24
|
+
* The whole point of the "no header" decision: a blob written by sealcode is
|
|
25
|
+
* byte-indistinguishable from any other random binary file. `file(1)` reports
|
|
26
|
+
* "data". `strings(1)` returns nothing useful.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
const zlib = require('zlib');
|
|
31
|
+
|
|
32
|
+
const ALGO = 'aes-256-gcm';
|
|
33
|
+
const IV_LEN = 12;
|
|
34
|
+
const TAG_LEN = 16;
|
|
35
|
+
const KEY_LEN = 32;
|
|
36
|
+
const MIN_BLOB_LEN = IV_LEN + TAG_LEN + 1;
|
|
37
|
+
|
|
38
|
+
function assertKey(key, label = 'key') {
|
|
39
|
+
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
|
|
40
|
+
throw new Error(`sealcode ${label} must be a 32-byte Buffer (got ${key && key.length})`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* AEAD-encrypt arbitrary bytes with gzip compression. Returns raw binary blob.
|
|
46
|
+
*/
|
|
47
|
+
function seal(plaintext, key) {
|
|
48
|
+
assertKey(key);
|
|
49
|
+
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, 'utf8');
|
|
50
|
+
const compressed = zlib.gzipSync(buf, { level: 9 });
|
|
51
|
+
const iv = crypto.randomBytes(IV_LEN);
|
|
52
|
+
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
53
|
+
const enc = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
|
54
|
+
const tag = cipher.getAuthTag();
|
|
55
|
+
return Buffer.concat([iv, tag, enc]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* AEAD-decrypt a sealcode blob. Throws on wrong key, corruption, or tampering.
|
|
60
|
+
*/
|
|
61
|
+
function open(blob, key) {
|
|
62
|
+
assertKey(key);
|
|
63
|
+
if (!Buffer.isBuffer(blob)) {
|
|
64
|
+
throw new Error('open() expected a Buffer');
|
|
65
|
+
}
|
|
66
|
+
if (blob.length < MIN_BLOB_LEN) {
|
|
67
|
+
throw new Error('blob too small to be a sealcode file');
|
|
68
|
+
}
|
|
69
|
+
const iv = blob.subarray(0, IV_LEN);
|
|
70
|
+
const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
71
|
+
const ct = blob.subarray(IV_LEN + TAG_LEN);
|
|
72
|
+
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
73
|
+
decipher.setAuthTag(tag);
|
|
74
|
+
let dec;
|
|
75
|
+
try {
|
|
76
|
+
dec = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
77
|
+
} catch (_err) {
|
|
78
|
+
throw new Error('SEALCODE_WRONG_KEY');
|
|
79
|
+
}
|
|
80
|
+
return zlib.gunzipSync(dec);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generate a fresh master data key (the "K" that actually encrypts your files).
|
|
85
|
+
* In practice K is wrapped by passphrase-derived and recovery-derived keys; the
|
|
86
|
+
* raw K never leaves memory.
|
|
87
|
+
*/
|
|
88
|
+
function makeKey() {
|
|
89
|
+
return crypto.randomBytes(KEY_LEN);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function randomBytes(n) {
|
|
93
|
+
return crypto.randomBytes(n);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stable opaque name for a project-relative path. Same input + same key →
|
|
98
|
+
* same locked path. Forward-slash normalized so Windows and POSIX checkouts
|
|
99
|
+
* agree.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} originalPath
|
|
102
|
+
* @param {Buffer} key
|
|
103
|
+
* @param {string} [ext=''] optional extension (default: none, for stealth)
|
|
104
|
+
*/
|
|
105
|
+
function opaqueName(originalPath, key, ext = '') {
|
|
106
|
+
assertKey(key);
|
|
107
|
+
const normalized = String(originalPath).replace(/\\/g, '/');
|
|
108
|
+
const h = crypto.createHmac('sha256', key).update(normalized).digest('hex');
|
|
109
|
+
return `_${h.slice(0, 2)}/_${h.slice(2, 14)}${ext || ''}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Public, key-less opaque names for meta files (salt + wrapped keys). These
|
|
114
|
+
* are derived from a sentinel via plain SHA-256 so anyone with the source code
|
|
115
|
+
* can find them — but the FILES THEMSELVES leak nothing without the key.
|
|
116
|
+
*
|
|
117
|
+
* Used during unlock-bootstrap: tool needs to read the salt before it has K.
|
|
118
|
+
*
|
|
119
|
+
* IMPORTANT: the literal "vaultline.v1." string below is part of the on-disk
|
|
120
|
+
* format — every salt/wrap/manifest filename in every existing vault is
|
|
121
|
+
* derived from it. Changing it would orphan every vault that anyone has ever
|
|
122
|
+
* created with this tool. The brand on the box changed; the file format
|
|
123
|
+
* sentinel must not.
|
|
124
|
+
*/
|
|
125
|
+
function metaName(sentinel) {
|
|
126
|
+
const h = crypto.createHash('sha256').update(`vaultline.v1.${sentinel}`).digest('hex');
|
|
127
|
+
return `_${h.slice(0, 2)}/_${h.slice(2, 14)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sha256Hex(input) {
|
|
131
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function constantTimeEq(a, b) {
|
|
135
|
+
if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b) || a.length !== b.length) return false;
|
|
136
|
+
return crypto.timingSafeEqual(a, b);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
KEY_LEN,
|
|
141
|
+
IV_LEN,
|
|
142
|
+
TAG_LEN,
|
|
143
|
+
seal,
|
|
144
|
+
open,
|
|
145
|
+
makeKey,
|
|
146
|
+
randomBytes,
|
|
147
|
+
opaqueName,
|
|
148
|
+
metaName,
|
|
149
|
+
sha256Hex,
|
|
150
|
+
constantTimeEq,
|
|
151
|
+
};
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Friendly error system. Every CLI error should have:
|
|
5
|
+
* - a one-line headline of what went wrong
|
|
6
|
+
* - a short paragraph of why
|
|
7
|
+
* - a "Try:" suggestion the user can copy-paste
|
|
8
|
+
*
|
|
9
|
+
* Sentinel codes (thrown from internal modules) get translated at the CLI
|
|
10
|
+
* boundary into these structured errors. This keeps internal modules free of
|
|
11
|
+
* UX strings.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const CODES = {
|
|
15
|
+
SEALCODE_NO_KEY: {
|
|
16
|
+
headline: "I couldn't find your passphrase.",
|
|
17
|
+
detail:
|
|
18
|
+
'sealcode needs a passphrase or recovery code to unlock this project. No keyring entry, env var, or saved session was found.',
|
|
19
|
+
try: 'Try: sealcode unlock (you will be prompted)\n sealcode init (if this project is brand new)',
|
|
20
|
+
},
|
|
21
|
+
SEALCODE_WRONG_KEY: {
|
|
22
|
+
headline: 'That passphrase did not work.',
|
|
23
|
+
detail:
|
|
24
|
+
"Either the passphrase you typed is wrong, or the locked files have been tampered with. Sealcode doesn't say which to keep guessing expensive.",
|
|
25
|
+
try: 'Try: sealcode unlock --recovery (use your recovery code instead)',
|
|
26
|
+
},
|
|
27
|
+
SEALCODE_NO_MANIFEST: {
|
|
28
|
+
headline: "This folder doesn't look like a sealcode project.",
|
|
29
|
+
detail:
|
|
30
|
+
"I couldn't find a sealcode manifest. Either this project has never been locked, or you're running from the wrong directory.",
|
|
31
|
+
try: 'Try: cd into the project root\n sealcode init (if the project has never been locked)',
|
|
32
|
+
},
|
|
33
|
+
SEALCODE_ALREADY_INIT: {
|
|
34
|
+
headline: 'This project already has a sealcode vault.',
|
|
35
|
+
detail:
|
|
36
|
+
"There's an existing vault here. Re-running `init` would overwrite it and orphan your locked files — refusing.",
|
|
37
|
+
try: 'Try: sealcode status (see what state you are in)\n sealcode reset --force (start over; DELETES locked files)',
|
|
38
|
+
},
|
|
39
|
+
SEALCODE_INIT_NEEDS_TTY: {
|
|
40
|
+
headline: 'sealcode init needs an interactive terminal.',
|
|
41
|
+
detail:
|
|
42
|
+
'The init wizard prompts for a passphrase. Run it from a real terminal, not a pipe or CI job. For non-interactive setup, pre-set SEALCODE_PASSPHRASE.',
|
|
43
|
+
try: 'Try: SEALCODE_PASSPHRASE="..." sealcode init --noninteractive',
|
|
44
|
+
},
|
|
45
|
+
SEALCODE_NOTHING_TO_LOCK: {
|
|
46
|
+
headline: "I couldn't find any files to lock.",
|
|
47
|
+
detail:
|
|
48
|
+
'None of your include patterns matched. Either the project is empty or the patterns in .sealcoderc.json need adjusting.',
|
|
49
|
+
try: 'Try: sealcode status (see what is on disk)\n edit .sealcoderc.json (review include / exclude patterns)',
|
|
50
|
+
},
|
|
51
|
+
SEALCODE_PRO_FEATURE: {
|
|
52
|
+
headline: 'That feature is part of sealcode Pro.',
|
|
53
|
+
detail:
|
|
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
|
+
try: 'Try: visit https://sealcode.dev/pro (start a free 14-day trial)',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
class SealcodeError extends Error {
|
|
60
|
+
constructor(code, { detail, hint } = {}) {
|
|
61
|
+
const meta = CODES[code] || {
|
|
62
|
+
headline: code,
|
|
63
|
+
detail: detail || 'Unknown error.',
|
|
64
|
+
try: hint || '',
|
|
65
|
+
};
|
|
66
|
+
super(meta.headline);
|
|
67
|
+
this.code = code;
|
|
68
|
+
this.headline = meta.headline;
|
|
69
|
+
this.detail = detail || meta.detail;
|
|
70
|
+
this.tryHint = hint || meta.try;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isSealcodeCode(s) {
|
|
75
|
+
return typeof s === 'string' && /^SEALCODE_/.test(s);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render a friendly error to stderr. Returns the exit code.
|
|
80
|
+
*/
|
|
81
|
+
function reportError(err) {
|
|
82
|
+
const isSC = err instanceof SealcodeError;
|
|
83
|
+
// Sentinel codes thrown deep inside modules surface here as Error.message.
|
|
84
|
+
const code = isSC ? err.code : isSealcodeCode(err.message) ? err.message : null;
|
|
85
|
+
let meta;
|
|
86
|
+
if (code && CODES[code]) {
|
|
87
|
+
meta = CODES[code];
|
|
88
|
+
} else if (isSC) {
|
|
89
|
+
meta = { headline: err.headline, detail: err.detail, try: err.tryHint };
|
|
90
|
+
} else {
|
|
91
|
+
meta = {
|
|
92
|
+
headline: err.message || 'Something went wrong.',
|
|
93
|
+
detail: process.env.SEALCODE_DEBUG ? err.stack || '' : '',
|
|
94
|
+
try: '',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push(`✗ ${meta.headline}`);
|
|
100
|
+
if (meta.detail) lines.push('');
|
|
101
|
+
if (meta.detail) lines.push(` ${meta.detail}`);
|
|
102
|
+
if (meta.try) {
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push(meta.try.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
105
|
+
}
|
|
106
|
+
lines.push('');
|
|
107
|
+
process.stderr.write(lines.join('\n'));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { SealcodeError, reportError, CODES };
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const MARK_BEGIN = '### sealcode begin';
|
|
7
|
+
const MARK_END = '### sealcode end';
|
|
8
|
+
// Legacy markers from vaultline 1.x; installHook strips these so the new
|
|
9
|
+
// block replaces the old cleanly, and uninstallHook can clean them up too.
|
|
10
|
+
const LEGACY_MARK_BEGIN = '### vaultline begin';
|
|
11
|
+
const LEGACY_MARK_END = '### vaultline end';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find the `.git` directory starting from `startDir` (usually project root).
|
|
15
|
+
* @param {string} startDir
|
|
16
|
+
* @returns {string|null}
|
|
17
|
+
*/
|
|
18
|
+
function findGitDir(startDir) {
|
|
19
|
+
let dir = path.resolve(startDir);
|
|
20
|
+
while (true) {
|
|
21
|
+
const git = path.join(dir, '.git');
|
|
22
|
+
if (fs.existsSync(git)) {
|
|
23
|
+
const st = fs.statSync(git);
|
|
24
|
+
if (st.isDirectory()) return git;
|
|
25
|
+
// git worktree: .git is a file pointing to the real dir
|
|
26
|
+
if (st.isFile()) {
|
|
27
|
+
const content = fs.readFileSync(git, 'utf8').trim();
|
|
28
|
+
const m = content.match(/^gitdir:\s*(.+)$/);
|
|
29
|
+
if (m) {
|
|
30
|
+
const real = path.resolve(path.dirname(git), m[1]);
|
|
31
|
+
if (fs.existsSync(real)) return real;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const parent = path.dirname(dir);
|
|
36
|
+
if (parent === dir) return null;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Install the pre-commit hook. Idempotent: replaces only the sealcode block
|
|
43
|
+
* (and any legacy vaultline block left over from an older install).
|
|
44
|
+
*/
|
|
45
|
+
function installHook(projectRoot) {
|
|
46
|
+
const gitDir = findGitDir(projectRoot);
|
|
47
|
+
if (!gitDir) {
|
|
48
|
+
throw new Error('No .git directory found above this folder. Run `git init` first.');
|
|
49
|
+
}
|
|
50
|
+
const hookPath = path.join(gitDir, 'hooks', 'pre-commit');
|
|
51
|
+
ensureHooksDir(gitDir);
|
|
52
|
+
|
|
53
|
+
const block = [
|
|
54
|
+
MARK_BEGIN,
|
|
55
|
+
'sealcode_check() {',
|
|
56
|
+
' ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0',
|
|
57
|
+
' cd "$ROOT" || return 0',
|
|
58
|
+
' if command -v sealcode >/dev/null 2>&1; then',
|
|
59
|
+
' sealcode status --check || exit 1',
|
|
60
|
+
' elif command -v npx >/dev/null 2>&1; then',
|
|
61
|
+
' npx --yes sealcode@latest status --check || exit 1',
|
|
62
|
+
' else',
|
|
63
|
+
' echo "sealcode: install the CLI (npm i -g sealcode) or npx for pre-commit checks." >&2',
|
|
64
|
+
' exit 1',
|
|
65
|
+
' fi',
|
|
66
|
+
'}',
|
|
67
|
+
'sealcode_check',
|
|
68
|
+
MARK_END,
|
|
69
|
+
'',
|
|
70
|
+
].join('\n');
|
|
71
|
+
|
|
72
|
+
let base = '';
|
|
73
|
+
if (fs.existsSync(hookPath)) {
|
|
74
|
+
base = fs.readFileSync(hookPath, 'utf8');
|
|
75
|
+
if (base.includes(MARK_BEGIN)) {
|
|
76
|
+
base = stripBlock(base, MARK_BEGIN, MARK_END);
|
|
77
|
+
}
|
|
78
|
+
if (base.includes(LEGACY_MARK_BEGIN)) {
|
|
79
|
+
base = stripBlock(base, LEGACY_MARK_BEGIN, LEGACY_MARK_END);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
base = '#!/bin/sh\n';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!base.startsWith('#!')) {
|
|
86
|
+
base = '#!/bin/sh\n' + base;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const merged = base.trimEnd() + '\n\n' + block;
|
|
90
|
+
fs.writeFileSync(hookPath, merged, { mode: 0o755 });
|
|
91
|
+
return hookPath;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function uninstallHook(projectRoot) {
|
|
95
|
+
const gitDir = findGitDir(projectRoot);
|
|
96
|
+
if (!gitDir) return { removed: false, reason: 'no .git' };
|
|
97
|
+
const hookPath = path.join(gitDir, 'hooks', 'pre-commit');
|
|
98
|
+
if (!fs.existsSync(hookPath)) return { removed: false, reason: 'no hook' };
|
|
99
|
+
let content = fs.readFileSync(hookPath, 'utf8');
|
|
100
|
+
const hasNew = content.includes(MARK_BEGIN);
|
|
101
|
+
const hasOld = content.includes(LEGACY_MARK_BEGIN);
|
|
102
|
+
if (!hasNew && !hasOld) return { removed: false, reason: 'no sealcode block' };
|
|
103
|
+
if (hasNew) content = stripBlock(content, MARK_BEGIN, MARK_END);
|
|
104
|
+
if (hasOld) content = stripBlock(content, LEGACY_MARK_BEGIN, LEGACY_MARK_END);
|
|
105
|
+
if (content.trim().length === 0 || content === '#!/bin/sh\n') {
|
|
106
|
+
fs.unlinkSync(hookPath);
|
|
107
|
+
return { removed: true, file: hookPath };
|
|
108
|
+
}
|
|
109
|
+
fs.writeFileSync(hookPath, content, { mode: 0o755 });
|
|
110
|
+
return { removed: true, file: hookPath };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ensureHooksDir(gitDir) {
|
|
114
|
+
const hooks = path.join(gitDir, 'hooks');
|
|
115
|
+
fs.mkdirSync(hooks, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stripBlock(text, begin, end) {
|
|
119
|
+
const start = text.indexOf(begin);
|
|
120
|
+
const stop = text.indexOf(end);
|
|
121
|
+
if (start === -1 || stop === -1) return text;
|
|
122
|
+
const before = text.slice(0, start);
|
|
123
|
+
const after = text.slice(stop + end.length);
|
|
124
|
+
return before + after.replace(/^\n+/, '');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { findGitDir, installHook, uninstallHook };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Library entrypoint — lets other tools (and the eventual Pro features) build
|
|
4
|
+
// on the sealcode core without shelling out to the CLI.
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
...require('./crypto'),
|
|
8
|
+
...require('./kdf'),
|
|
9
|
+
...require('./recovery'),
|
|
10
|
+
...require('./presets'),
|
|
11
|
+
...require('./config'),
|
|
12
|
+
...require('./keystore'),
|
|
13
|
+
...require('./manifest'),
|
|
14
|
+
...require('./seal'),
|
|
15
|
+
...require('./open'),
|
|
16
|
+
...require('./verify'),
|
|
17
|
+
...require('./status'),
|
|
18
|
+
cli: require('./cli'),
|
|
19
|
+
};
|
package/src/init.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `sealcode init` — the wizard. Run interactively to set up a new project:
|
|
5
|
+
*
|
|
6
|
+
* 1. Detect ecosystem and propose a preset.
|
|
7
|
+
* 2. Let user confirm or pick a different preset.
|
|
8
|
+
* 3. Prompt for passphrase (twice). Show strength rating.
|
|
9
|
+
* 4. Generate recovery code, display it ONCE, require explicit ack.
|
|
10
|
+
* 5. Write .sealcoderc.json (gitignored).
|
|
11
|
+
* 6. Add sealcode-related lines to .gitignore.
|
|
12
|
+
* 7. (Caller proceeds to the first lock.)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { detectPreset, listPresets, getPreset } = require('./presets');
|
|
18
|
+
const { writeConfig, configExists, CONFIG_FILE } = require('./config');
|
|
19
|
+
const { passphraseStrength } = require('./kdf');
|
|
20
|
+
const { makeRecoveryCode } = require('./recovery');
|
|
21
|
+
const { isInitialized } = require('./keystore');
|
|
22
|
+
const { SealcodeError } = require('./errors');
|
|
23
|
+
const { question, confirm, hidden, select } = require('./prompt');
|
|
24
|
+
|
|
25
|
+
const GITIGNORE_BLOCK = `
|
|
26
|
+
# sealcode — local-only files (never commit)
|
|
27
|
+
.sealcoderc.json
|
|
28
|
+
.sealcode.key
|
|
29
|
+
.vault.local
|
|
30
|
+
# legacy paths from vaultline 1.x
|
|
31
|
+
.vaultlinerc.json
|
|
32
|
+
.vaultline.key
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function passphraseFromEnv() {
|
|
36
|
+
return process.env.SEALCODE_PASSPHRASE || process.env.VAULTLINE_PASSPHRASE || '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendGitignoreOnce(projectRoot) {
|
|
40
|
+
const file = path.join(projectRoot, '.gitignore');
|
|
41
|
+
const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
42
|
+
if (lines.includes('.sealcoderc.json')) return false;
|
|
43
|
+
fs.writeFileSync(file, lines + (lines.endsWith('\n') || lines === '' ? '' : '\n') + GITIGNORE_BLOCK);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function pickPreset(projectRoot, { suggestedId, noninteractive = false } = {}) {
|
|
48
|
+
const guess = suggestedId ? getPreset(suggestedId) : detectPreset(projectRoot);
|
|
49
|
+
process.stdout.write(`\nDetected ecosystem: ${guess.label} (${guess.id})\n`);
|
|
50
|
+
if (noninteractive) {
|
|
51
|
+
process.stdout.write(' (non-interactive: accepting detected preset)\n');
|
|
52
|
+
return guess;
|
|
53
|
+
}
|
|
54
|
+
const keep = await confirm('Use this preset?', { default: true });
|
|
55
|
+
if (keep) return guess;
|
|
56
|
+
const all = listPresets();
|
|
57
|
+
const choice = await select(
|
|
58
|
+
'\nPick a preset:',
|
|
59
|
+
all.map((p) => ({ value: p.id, label: `${p.label} (${p.id})` })),
|
|
60
|
+
all.findIndex((p) => p.id === guess.id)
|
|
61
|
+
);
|
|
62
|
+
return getPreset(choice);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function askPassphrase() {
|
|
66
|
+
while (true) {
|
|
67
|
+
const pp = await hidden('Choose a passphrase:');
|
|
68
|
+
if (!pp) {
|
|
69
|
+
process.stdout.write(' Passphrase cannot be empty. Try again.\n');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const strength = passphraseStrength(pp);
|
|
73
|
+
if (strength.level === 'weak') {
|
|
74
|
+
process.stdout.write(` ⚠ Weak passphrase: ${strength.reason}.\n`);
|
|
75
|
+
const ok = await confirm(' Use it anyway?', { default: false });
|
|
76
|
+
if (!ok) continue;
|
|
77
|
+
} else if (strength.level === 'ok') {
|
|
78
|
+
process.stdout.write(` ✓ Strength: ok (${strength.reason}).\n`);
|
|
79
|
+
} else {
|
|
80
|
+
process.stdout.write(' ✓ Strength: strong.\n');
|
|
81
|
+
}
|
|
82
|
+
const again = await hidden('Confirm passphrase:');
|
|
83
|
+
if (again !== pp) {
|
|
84
|
+
process.stdout.write(' Passphrases did not match. Try again.\n');
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
return pp;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function showRecoveryCode(code) {
|
|
92
|
+
const banner = '─'.repeat(60);
|
|
93
|
+
process.stdout.write('\n' + banner + '\n');
|
|
94
|
+
process.stdout.write('RECOVERY CODE — write this down NOW. We never show it again.\n');
|
|
95
|
+
process.stdout.write('If you lose your passphrase, this is the only way back.\n\n');
|
|
96
|
+
process.stdout.write(` ${code}\n\n`);
|
|
97
|
+
process.stdout.write('Suggested places to keep it:\n');
|
|
98
|
+
process.stdout.write(' • Your password manager (1Password / Bitwarden)\n');
|
|
99
|
+
process.stdout.write(' • A printed sheet in a drawer\n');
|
|
100
|
+
process.stdout.write(' • A hardware key / encrypted USB\n');
|
|
101
|
+
process.stdout.write(banner + '\n\n');
|
|
102
|
+
while (true) {
|
|
103
|
+
const ack = await question('Type the word "saved" to confirm you stored it');
|
|
104
|
+
if (ack.toLowerCase() === 'saved') return;
|
|
105
|
+
process.stdout.write(' Please type "saved" to continue.\n');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Drive the full init flow. Returns the config + secrets the caller uses to
|
|
111
|
+
* bootstrap the keystore and run the first lock.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} opts
|
|
114
|
+
* @param {string} opts.projectRoot
|
|
115
|
+
* @param {string} [opts.presetId] optional override
|
|
116
|
+
* @param {boolean} [opts.force] overwrite an existing vault
|
|
117
|
+
*/
|
|
118
|
+
async function runInit({ projectRoot, presetId, force = false, noninteractive = false }) {
|
|
119
|
+
const ni =
|
|
120
|
+
noninteractive ||
|
|
121
|
+
!!process.env.SEALCODE_NONINTERACTIVE ||
|
|
122
|
+
!!process.env.VAULTLINE_NONINTERACTIVE;
|
|
123
|
+
const envPassphrase = passphraseFromEnv();
|
|
124
|
+
if (!ni && !process.stdin.isTTY && !envPassphrase) {
|
|
125
|
+
throw new SealcodeError('SEALCODE_INIT_NEEDS_TTY');
|
|
126
|
+
}
|
|
127
|
+
if (!force) {
|
|
128
|
+
if (configExists(projectRoot)) {
|
|
129
|
+
throw new SealcodeError('SEALCODE_ALREADY_INIT', {
|
|
130
|
+
detail: `Found ${CONFIG_FILE} at ${projectRoot}. Init refuses to clobber.`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const preset = presetId ? getPreset(presetId) : detectPreset(projectRoot);
|
|
134
|
+
if (isInitialized(projectRoot, preset.lockedDir)) {
|
|
135
|
+
throw new SealcodeError('SEALCODE_ALREADY_INIT', {
|
|
136
|
+
detail: `Found an existing vault at ${path.join(projectRoot, preset.lockedDir)}.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
process.stdout.write(`\nsealcode · setting up ${projectRoot}\n`);
|
|
142
|
+
|
|
143
|
+
const preset = await pickPreset(projectRoot, { suggestedId: presetId, noninteractive: ni });
|
|
144
|
+
|
|
145
|
+
// Passphrase
|
|
146
|
+
let passphrase;
|
|
147
|
+
if (envPassphrase) {
|
|
148
|
+
passphrase = envPassphrase;
|
|
149
|
+
const source = process.env.SEALCODE_PASSPHRASE ? 'SEALCODE_PASSPHRASE' : 'VAULTLINE_PASSPHRASE';
|
|
150
|
+
process.stdout.write(`Using passphrase from ${source}.\n`);
|
|
151
|
+
} else {
|
|
152
|
+
passphrase = await askPassphrase();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { seed: recoverySeed, code: recoveryCode } = makeRecoveryCode();
|
|
156
|
+
|
|
157
|
+
// Persist the config (gitignored).
|
|
158
|
+
const cfg = {
|
|
159
|
+
version: 1,
|
|
160
|
+
preset: preset.id,
|
|
161
|
+
lockedDir: preset.lockedDir,
|
|
162
|
+
include: preset.include,
|
|
163
|
+
exclude: preset.exclude,
|
|
164
|
+
stubs: preset.stubs || {},
|
|
165
|
+
};
|
|
166
|
+
writeConfig(projectRoot, cfg);
|
|
167
|
+
appendGitignoreOnce(projectRoot);
|
|
168
|
+
|
|
169
|
+
// Show recovery code AFTER config is written but BEFORE caller locks files,
|
|
170
|
+
// so the user has the code by the time anything dangerous happens.
|
|
171
|
+
if (ni) {
|
|
172
|
+
process.stdout.write(`RECOVERY_CODE=${recoveryCode}\n`);
|
|
173
|
+
} else {
|
|
174
|
+
await showRecoveryCode(recoveryCode);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { config: cfg, preset, passphrase, recoverySeed, recoveryCode };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { runInit };
|