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/presets.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ecosystem presets. Each preset is a starting include/exclude template for a
|
|
5
|
+
* given stack. The init wizard auto-detects by sniffing for marker files in
|
|
6
|
+
* the project root.
|
|
7
|
+
*
|
|
8
|
+
* Adding a new preset:
|
|
9
|
+
* 1. Pick a unique id (e.g. "elixir").
|
|
10
|
+
* 2. List marker files that strongly imply this ecosystem.
|
|
11
|
+
* 3. Provide include/exclude globs.
|
|
12
|
+
* 4. Pick a non-suspicious lockedDir name for that ecosystem.
|
|
13
|
+
*
|
|
14
|
+
* The `lockedDir` choice matters for stealth: it should look native to the
|
|
15
|
+
* stack so an AI scanning the repo doesn't go "huh, that's odd."
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
/** @typedef {{id:string,label:string,markers:string[],lockedDir:string,include:string[],exclude:string[],stubs?:object}} Preset */
|
|
22
|
+
|
|
23
|
+
const SHARED_EXCLUDES = [
|
|
24
|
+
'.git/**',
|
|
25
|
+
'.DS_Store',
|
|
26
|
+
'*.log',
|
|
27
|
+
'*.tmp',
|
|
28
|
+
'*.swp',
|
|
29
|
+
'.idea/**',
|
|
30
|
+
'.vscode/**',
|
|
31
|
+
'coverage/**',
|
|
32
|
+
'.sealcoderc.json',
|
|
33
|
+
'.sealcode.key',
|
|
34
|
+
'.vaultlinerc.json',
|
|
35
|
+
'.vaultline.key',
|
|
36
|
+
'*.pem',
|
|
37
|
+
'*.crt',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const STUB_GENERIC_PKG_JSON =
|
|
41
|
+
'{\n "name": "app",\n "version": "1.0.0",\n "private": true\n}\n';
|
|
42
|
+
|
|
43
|
+
/** @type {Preset[]} */
|
|
44
|
+
const PRESETS = [
|
|
45
|
+
{
|
|
46
|
+
id: 'node',
|
|
47
|
+
label: 'Node.js / TypeScript',
|
|
48
|
+
markers: ['package.json'],
|
|
49
|
+
lockedDir: 'vendor',
|
|
50
|
+
include: [
|
|
51
|
+
'src/**/*',
|
|
52
|
+
'lib/**/*',
|
|
53
|
+
'package.json',
|
|
54
|
+
'package-lock.json',
|
|
55
|
+
'tsconfig.json',
|
|
56
|
+
'Dockerfile',
|
|
57
|
+
'docker-compose*.yml',
|
|
58
|
+
'docker-compose*.yaml',
|
|
59
|
+
'.env.example',
|
|
60
|
+
'README.md',
|
|
61
|
+
],
|
|
62
|
+
exclude: [
|
|
63
|
+
...SHARED_EXCLUDES,
|
|
64
|
+
'node_modules/**',
|
|
65
|
+
'dist/**',
|
|
66
|
+
'build/**',
|
|
67
|
+
'.next/**',
|
|
68
|
+
'.turbo/**',
|
|
69
|
+
'.nuxt/**',
|
|
70
|
+
'out/**',
|
|
71
|
+
'vendor/**',
|
|
72
|
+
],
|
|
73
|
+
stubs: { 'package.json': STUB_GENERIC_PKG_JSON },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'nextjs',
|
|
77
|
+
label: 'Next.js',
|
|
78
|
+
markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
|
|
79
|
+
lockedDir: 'vendor',
|
|
80
|
+
include: [
|
|
81
|
+
'src/**/*',
|
|
82
|
+
'app/**/*',
|
|
83
|
+
'pages/**/*',
|
|
84
|
+
'components/**/*',
|
|
85
|
+
'lib/**/*',
|
|
86
|
+
'public/**/*',
|
|
87
|
+
'package.json',
|
|
88
|
+
'next.config.*',
|
|
89
|
+
'tsconfig.json',
|
|
90
|
+
'tailwind.config.*',
|
|
91
|
+
'postcss.config.*',
|
|
92
|
+
'.env.example',
|
|
93
|
+
],
|
|
94
|
+
exclude: [
|
|
95
|
+
...SHARED_EXCLUDES,
|
|
96
|
+
'node_modules/**',
|
|
97
|
+
'.next/**',
|
|
98
|
+
'out/**',
|
|
99
|
+
'vendor/**',
|
|
100
|
+
],
|
|
101
|
+
stubs: { 'package.json': STUB_GENERIC_PKG_JSON },
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'python',
|
|
105
|
+
label: 'Python (generic)',
|
|
106
|
+
markers: ['requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile'],
|
|
107
|
+
lockedDir: '_site_packages',
|
|
108
|
+
include: [
|
|
109
|
+
'**/*.py',
|
|
110
|
+
'pyproject.toml',
|
|
111
|
+
'requirements*.txt',
|
|
112
|
+
'Pipfile',
|
|
113
|
+
'Pipfile.lock',
|
|
114
|
+
'setup.py',
|
|
115
|
+
'setup.cfg',
|
|
116
|
+
'Dockerfile',
|
|
117
|
+
'docker-compose*.yml',
|
|
118
|
+
'.env.example',
|
|
119
|
+
'README.md',
|
|
120
|
+
],
|
|
121
|
+
exclude: [
|
|
122
|
+
...SHARED_EXCLUDES,
|
|
123
|
+
'venv/**',
|
|
124
|
+
'.venv/**',
|
|
125
|
+
'env/**',
|
|
126
|
+
'__pycache__/**',
|
|
127
|
+
'**/__pycache__/**',
|
|
128
|
+
'*.pyc',
|
|
129
|
+
'.pytest_cache/**',
|
|
130
|
+
'.mypy_cache/**',
|
|
131
|
+
'.tox/**',
|
|
132
|
+
'dist/**',
|
|
133
|
+
'build/**',
|
|
134
|
+
'_site_packages/**',
|
|
135
|
+
'.eggs/**',
|
|
136
|
+
'*.egg-info/**',
|
|
137
|
+
],
|
|
138
|
+
stubs: { 'README.md': '# app\n' },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'django',
|
|
142
|
+
label: 'Python — Django',
|
|
143
|
+
markers: ['manage.py'],
|
|
144
|
+
lockedDir: '_site_packages',
|
|
145
|
+
include: [
|
|
146
|
+
'**/*.py',
|
|
147
|
+
'manage.py',
|
|
148
|
+
'requirements*.txt',
|
|
149
|
+
'pyproject.toml',
|
|
150
|
+
'Dockerfile',
|
|
151
|
+
'docker-compose*.yml',
|
|
152
|
+
'.env.example',
|
|
153
|
+
'templates/**/*',
|
|
154
|
+
'static/**/*',
|
|
155
|
+
],
|
|
156
|
+
exclude: [
|
|
157
|
+
...SHARED_EXCLUDES,
|
|
158
|
+
'venv/**',
|
|
159
|
+
'.venv/**',
|
|
160
|
+
'__pycache__/**',
|
|
161
|
+
'**/__pycache__/**',
|
|
162
|
+
'*.pyc',
|
|
163
|
+
'media/**',
|
|
164
|
+
'staticfiles/**',
|
|
165
|
+
'db.sqlite3',
|
|
166
|
+
'_site_packages/**',
|
|
167
|
+
],
|
|
168
|
+
stubs: { 'README.md': '# app\n' },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'go',
|
|
172
|
+
label: 'Go',
|
|
173
|
+
markers: ['go.mod'],
|
|
174
|
+
// Avoid Go's own `vendor/` convention.
|
|
175
|
+
lockedDir: 'internal/sealed',
|
|
176
|
+
include: [
|
|
177
|
+
'**/*.go',
|
|
178
|
+
'go.mod',
|
|
179
|
+
'go.sum',
|
|
180
|
+
'Dockerfile',
|
|
181
|
+
'docker-compose*.yml',
|
|
182
|
+
'.env.example',
|
|
183
|
+
'README.md',
|
|
184
|
+
'Makefile',
|
|
185
|
+
],
|
|
186
|
+
exclude: [
|
|
187
|
+
...SHARED_EXCLUDES,
|
|
188
|
+
'vendor/**',
|
|
189
|
+
'bin/**',
|
|
190
|
+
'dist/**',
|
|
191
|
+
'internal/sealed/**',
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'rust',
|
|
196
|
+
label: 'Rust / Cargo',
|
|
197
|
+
markers: ['Cargo.toml'],
|
|
198
|
+
lockedDir: 'target/.cache',
|
|
199
|
+
include: [
|
|
200
|
+
'src/**/*',
|
|
201
|
+
'tests/**/*',
|
|
202
|
+
'benches/**/*',
|
|
203
|
+
'examples/**/*',
|
|
204
|
+
'Cargo.toml',
|
|
205
|
+
'Cargo.lock',
|
|
206
|
+
'build.rs',
|
|
207
|
+
'Dockerfile',
|
|
208
|
+
'.env.example',
|
|
209
|
+
'README.md',
|
|
210
|
+
],
|
|
211
|
+
exclude: [
|
|
212
|
+
...SHARED_EXCLUDES,
|
|
213
|
+
'target/**/!(.cache)',
|
|
214
|
+
'target/**',
|
|
215
|
+
'pkg/**',
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: 'rails',
|
|
220
|
+
label: 'Ruby on Rails',
|
|
221
|
+
markers: ['Gemfile'],
|
|
222
|
+
lockedDir: 'vendor/sealed',
|
|
223
|
+
include: [
|
|
224
|
+
'app/**/*',
|
|
225
|
+
'config/**/*',
|
|
226
|
+
'db/**/*',
|
|
227
|
+
'lib/**/*',
|
|
228
|
+
'Gemfile',
|
|
229
|
+
'Gemfile.lock',
|
|
230
|
+
'Rakefile',
|
|
231
|
+
'config.ru',
|
|
232
|
+
'.env.example',
|
|
233
|
+
'Dockerfile',
|
|
234
|
+
],
|
|
235
|
+
exclude: [
|
|
236
|
+
...SHARED_EXCLUDES,
|
|
237
|
+
'tmp/**',
|
|
238
|
+
'log/**',
|
|
239
|
+
'vendor/bundle/**',
|
|
240
|
+
'vendor/sealed/**',
|
|
241
|
+
'storage/**',
|
|
242
|
+
'public/assets/**',
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: 'php',
|
|
247
|
+
label: 'PHP / Composer / Laravel',
|
|
248
|
+
markers: ['composer.json'],
|
|
249
|
+
// Avoid Composer's `vendor/` convention.
|
|
250
|
+
lockedDir: 'storage/sealed',
|
|
251
|
+
include: [
|
|
252
|
+
'app/**/*',
|
|
253
|
+
'src/**/*',
|
|
254
|
+
'config/**/*',
|
|
255
|
+
'database/**/*',
|
|
256
|
+
'routes/**/*',
|
|
257
|
+
'resources/**/*',
|
|
258
|
+
'composer.json',
|
|
259
|
+
'composer.lock',
|
|
260
|
+
'artisan',
|
|
261
|
+
'.env.example',
|
|
262
|
+
'Dockerfile',
|
|
263
|
+
],
|
|
264
|
+
exclude: [
|
|
265
|
+
...SHARED_EXCLUDES,
|
|
266
|
+
'vendor/**',
|
|
267
|
+
'storage/sealed/**',
|
|
268
|
+
'storage/logs/**',
|
|
269
|
+
'storage/framework/**',
|
|
270
|
+
'bootstrap/cache/**',
|
|
271
|
+
'public/build/**',
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: 'java',
|
|
276
|
+
label: 'Java / Gradle / Maven',
|
|
277
|
+
markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
|
|
278
|
+
lockedDir: '.sealed',
|
|
279
|
+
include: [
|
|
280
|
+
'src/**/*',
|
|
281
|
+
'pom.xml',
|
|
282
|
+
'build.gradle*',
|
|
283
|
+
'settings.gradle*',
|
|
284
|
+
'gradle.properties',
|
|
285
|
+
'Dockerfile',
|
|
286
|
+
'.env.example',
|
|
287
|
+
'README.md',
|
|
288
|
+
],
|
|
289
|
+
exclude: [
|
|
290
|
+
...SHARED_EXCLUDES,
|
|
291
|
+
'target/**',
|
|
292
|
+
'build/**',
|
|
293
|
+
'.gradle/**',
|
|
294
|
+
'.sealed/**',
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
id: 'generic',
|
|
299
|
+
label: 'Other / generic',
|
|
300
|
+
markers: [],
|
|
301
|
+
lockedDir: 'vendor',
|
|
302
|
+
include: [
|
|
303
|
+
'src/**/*',
|
|
304
|
+
'lib/**/*',
|
|
305
|
+
'README.md',
|
|
306
|
+
'Dockerfile',
|
|
307
|
+
'.env.example',
|
|
308
|
+
],
|
|
309
|
+
exclude: [...SHARED_EXCLUDES, 'vendor/**', 'dist/**', 'build/**'],
|
|
310
|
+
},
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Sniff the project root and guess the ecosystem.
|
|
315
|
+
* @param {string} projectRoot
|
|
316
|
+
* @returns {Preset}
|
|
317
|
+
*/
|
|
318
|
+
function detectPreset(projectRoot) {
|
|
319
|
+
for (const preset of PRESETS) {
|
|
320
|
+
if (preset.id === 'generic') continue;
|
|
321
|
+
for (const marker of preset.markers) {
|
|
322
|
+
if (fs.existsSync(path.join(projectRoot, marker))) {
|
|
323
|
+
return preset;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return PRESETS[PRESETS.length - 1]; // generic
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getPreset(id) {
|
|
331
|
+
return PRESETS.find((p) => p.id === id) || PRESETS[PRESETS.length - 1];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function listPresets() {
|
|
335
|
+
return PRESETS.map((p) => ({ id: p.id, label: p.label }));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { PRESETS, detectPreset, getPreset, listPresets };
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny interactive prompt utility. Avoids pulling in inquirer/enquirer to keep
|
|
5
|
+
* the dependency surface (and the `npm ls` output) minimal and uncontroversial.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
|
|
10
|
+
function rl() {
|
|
11
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function question(prompt, { default: def } = {}) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const r = rl();
|
|
17
|
+
const suffix = def != null ? ` [${def}]` : '';
|
|
18
|
+
r.question(`${prompt}${suffix} `, (answer) => {
|
|
19
|
+
r.close();
|
|
20
|
+
resolve(answer.trim() === '' && def != null ? String(def) : answer.trim());
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function confirm(prompt, { default: def = true } = {}) {
|
|
26
|
+
const hint = def ? 'Y/n' : 'y/N';
|
|
27
|
+
while (true) {
|
|
28
|
+
const a = (await question(`${prompt} (${hint})`)).toLowerCase();
|
|
29
|
+
if (a === '') return def;
|
|
30
|
+
if (['y', 'yes'].includes(a)) return true;
|
|
31
|
+
if (['n', 'no'].includes(a)) return false;
|
|
32
|
+
process.stdout.write('Please answer y or n.\n');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Hidden input — reads from stdin char by char in raw mode, prints '*' per char.
|
|
38
|
+
* Falls back to plain readline (visible) on non-TTY (CI, pipes).
|
|
39
|
+
*/
|
|
40
|
+
function hidden(prompt) {
|
|
41
|
+
if (!process.stdin.isTTY) {
|
|
42
|
+
// CI / piped input: just read a line. Echoing or not is moot when piped.
|
|
43
|
+
return question(prompt);
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const out = process.stdout;
|
|
47
|
+
const stdin = process.stdin;
|
|
48
|
+
out.write(`${prompt} `);
|
|
49
|
+
let buf = '';
|
|
50
|
+
const onData = (chunk) => {
|
|
51
|
+
const str = chunk.toString('utf8');
|
|
52
|
+
for (const ch of str) {
|
|
53
|
+
const code = ch.charCodeAt(0);
|
|
54
|
+
if (code === 13 || code === 10) {
|
|
55
|
+
stdin.removeListener('data', onData);
|
|
56
|
+
stdin.setRawMode(false);
|
|
57
|
+
stdin.pause();
|
|
58
|
+
out.write('\n');
|
|
59
|
+
return resolve(buf);
|
|
60
|
+
}
|
|
61
|
+
if (code === 3) {
|
|
62
|
+
stdin.removeListener('data', onData);
|
|
63
|
+
stdin.setRawMode(false);
|
|
64
|
+
stdin.pause();
|
|
65
|
+
out.write('\n');
|
|
66
|
+
return reject(new Error('cancelled'));
|
|
67
|
+
}
|
|
68
|
+
if (code === 127 || code === 8) {
|
|
69
|
+
if (buf.length > 0) {
|
|
70
|
+
buf = buf.slice(0, -1);
|
|
71
|
+
out.write('\b \b');
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (code < 32) continue; // ignore other control chars
|
|
76
|
+
buf += ch;
|
|
77
|
+
out.write('*');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
stdin.setRawMode(true);
|
|
81
|
+
stdin.resume();
|
|
82
|
+
stdin.on('data', onData);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Single-select from a list. Renders numbered options; user types the number.
|
|
88
|
+
* @param {string} prompt
|
|
89
|
+
* @param {{value:string,label:string}[]} options
|
|
90
|
+
* @param {number} [defaultIndex=0]
|
|
91
|
+
*/
|
|
92
|
+
async function select(prompt, options, defaultIndex = 0) {
|
|
93
|
+
process.stdout.write(prompt + '\n');
|
|
94
|
+
options.forEach((opt, i) => {
|
|
95
|
+
const marker = i === defaultIndex ? '›' : ' ';
|
|
96
|
+
process.stdout.write(` ${marker} ${i + 1}. ${opt.label}\n`);
|
|
97
|
+
});
|
|
98
|
+
while (true) {
|
|
99
|
+
const a = await question(`Choose 1-${options.length}`, { default: defaultIndex + 1 });
|
|
100
|
+
const n = parseInt(a, 10);
|
|
101
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) {
|
|
102
|
+
return options[n - 1].value;
|
|
103
|
+
}
|
|
104
|
+
process.stdout.write(`Please type a number between 1 and ${options.length}.\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { question, confirm, hidden, select };
|
package/src/recovery.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recovery codes.
|
|
5
|
+
*
|
|
6
|
+
* On `sealcode init` we generate a 16-byte recovery seed. We encode it as a
|
|
7
|
+
* human-typeable string using Crockford Base32 (no ambiguous chars like O/0,
|
|
8
|
+
* I/1) in five 5-character groups separated by hyphens. The first 25 chars
|
|
9
|
+
* encode the seed; the last 4 are a checksum.
|
|
10
|
+
*
|
|
11
|
+
* Example:
|
|
12
|
+
* VL5K8-J3Q2N-A7BCD-E4F9P-X2YR-K9MT
|
|
13
|
+
* └────── seed (25 chars × 5 bits = 125 bits ≥ 128 - 3 padding bits) ──┘
|
|
14
|
+
* └─ 4-char CRC32 checksum at end ─┘
|
|
15
|
+
*
|
|
16
|
+
* Properties:
|
|
17
|
+
* - 16 bytes of entropy is enough to be practically unbreakable (2^128).
|
|
18
|
+
* - Typeable from a sticky note. Hyphens are decorative — input is lenient.
|
|
19
|
+
* - Checksum catches transcription errors before we waste a second on scrypt.
|
|
20
|
+
*
|
|
21
|
+
* We deliberately avoid BIP39 because pulling in a 2048-word list bloats the
|
|
22
|
+
* package and screams "crypto" on `npm ls`. Anonymous-looking base32 keeps
|
|
23
|
+
* dependencies and ratlines minimal.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
|
|
28
|
+
// Crockford Base32 alphabet — no I, L, O, U.
|
|
29
|
+
const ALPHA = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
30
|
+
const DECODE = new Map();
|
|
31
|
+
for (let i = 0; i < ALPHA.length; i++) DECODE.set(ALPHA[i], i);
|
|
32
|
+
// Crockford-canonical aliases for ambiguity tolerance.
|
|
33
|
+
DECODE.set('O', 0);
|
|
34
|
+
DECODE.set('I', 1);
|
|
35
|
+
DECODE.set('L', 1);
|
|
36
|
+
DECODE.set('U', DECODE.get('V'));
|
|
37
|
+
|
|
38
|
+
const SEED_BYTES = 16;
|
|
39
|
+
const SEED_BITS = SEED_BYTES * 8; // 128
|
|
40
|
+
const SEED_CHARS = Math.ceil(SEED_BITS / 5); // 26
|
|
41
|
+
// We'll round up the bitstream by zero-padding to 130 bits (26 chars × 5).
|
|
42
|
+
|
|
43
|
+
function encodeBase32(bytes) {
|
|
44
|
+
let bits = 0n;
|
|
45
|
+
let nbits = 0;
|
|
46
|
+
let out = '';
|
|
47
|
+
for (const b of bytes) {
|
|
48
|
+
bits = (bits << 8n) | BigInt(b);
|
|
49
|
+
nbits += 8;
|
|
50
|
+
}
|
|
51
|
+
// Pad to multiple of 5 bits at the LEFT (high) end so decoding is symmetric.
|
|
52
|
+
const pad = (5 - (nbits % 5)) % 5;
|
|
53
|
+
bits <<= BigInt(pad);
|
|
54
|
+
nbits += pad;
|
|
55
|
+
for (let i = nbits - 5; i >= 0; i -= 5) {
|
|
56
|
+
const idx = Number((bits >> BigInt(i)) & 0x1fn);
|
|
57
|
+
out += ALPHA[idx];
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function decodeBase32(str, expectedBytes) {
|
|
63
|
+
let bits = 0n;
|
|
64
|
+
let nbits = 0;
|
|
65
|
+
for (const ch of str.toUpperCase()) {
|
|
66
|
+
if (!DECODE.has(ch)) {
|
|
67
|
+
throw new Error(`invalid recovery code character: ${ch}`);
|
|
68
|
+
}
|
|
69
|
+
bits = (bits << 5n) | BigInt(DECODE.get(ch));
|
|
70
|
+
nbits += 5;
|
|
71
|
+
}
|
|
72
|
+
const pad = nbits - expectedBytes * 8;
|
|
73
|
+
if (pad < 0) throw new Error('recovery code too short');
|
|
74
|
+
bits >>= BigInt(pad);
|
|
75
|
+
const out = Buffer.alloc(expectedBytes);
|
|
76
|
+
for (let i = expectedBytes - 1; i >= 0; i--) {
|
|
77
|
+
out[i] = Number(bits & 0xffn);
|
|
78
|
+
bits >>= 8n;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function checksumChars(bytes) {
|
|
84
|
+
// 20 bits of SHA-256 → 4 base32 chars. Plenty for typo detection.
|
|
85
|
+
const h = crypto.createHash('sha256').update(bytes).digest();
|
|
86
|
+
let bits = (BigInt(h[0]) << 16n) | (BigInt(h[1]) << 8n) | BigInt(h[2]);
|
|
87
|
+
bits >>= 4n; // take top 20 bits of those 24
|
|
88
|
+
let out = '';
|
|
89
|
+
for (let i = 15; i >= 0; i -= 5) {
|
|
90
|
+
out += ALPHA[Number((bits >> BigInt(i)) & 0x1fn)];
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate a fresh recovery seed and its human-readable code.
|
|
97
|
+
* @returns {{ seed: Buffer, code: string }}
|
|
98
|
+
*/
|
|
99
|
+
function makeRecoveryCode() {
|
|
100
|
+
const seed = crypto.randomBytes(SEED_BYTES);
|
|
101
|
+
const code = formatCode(seed);
|
|
102
|
+
return { seed, code };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatCode(seed) {
|
|
106
|
+
const body = encodeBase32(seed); // 26 chars
|
|
107
|
+
const check = checksumChars(seed); // 4 chars
|
|
108
|
+
const all = body + check; // 30 chars
|
|
109
|
+
// Group in 5s for readability: 5-5-5-5-5-5
|
|
110
|
+
return all.match(/.{1,5}/g).join('-');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse a user-typed recovery code back into 16 seed bytes.
|
|
115
|
+
* Tolerant: ignores whitespace, hyphens, case, and Crockford-aliased chars.
|
|
116
|
+
* @param {string} input
|
|
117
|
+
* @returns {Buffer} 16-byte seed
|
|
118
|
+
*/
|
|
119
|
+
function parseRecoveryCode(input) {
|
|
120
|
+
if (typeof input !== 'string') throw new Error('recovery code must be a string');
|
|
121
|
+
const cleaned = input.replace(/[\s-]+/g, '').toUpperCase();
|
|
122
|
+
if (cleaned.length !== 30) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`recovery code must be 30 characters (got ${cleaned.length}). Tip: paste the whole thing including the hyphens.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const body = cleaned.slice(0, 26);
|
|
128
|
+
const check = cleaned.slice(26);
|
|
129
|
+
const seed = decodeBase32(body, SEED_BYTES);
|
|
130
|
+
const expected = checksumChars(seed);
|
|
131
|
+
if (expected !== check) {
|
|
132
|
+
throw new Error('recovery code checksum mismatch — please re-check what you typed');
|
|
133
|
+
}
|
|
134
|
+
return seed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convert seed to a hex string for use as a "passphrase" by deriveKey.
|
|
139
|
+
* (We treat the recovery seed as an extra-strength passphrase.)
|
|
140
|
+
*/
|
|
141
|
+
function seedToPassphrase(seed) {
|
|
142
|
+
if (!Buffer.isBuffer(seed) || seed.length !== SEED_BYTES) {
|
|
143
|
+
throw new Error(`recovery seed must be ${SEED_BYTES} bytes`);
|
|
144
|
+
}
|
|
145
|
+
return seed.toString('hex');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
SEED_BYTES,
|
|
150
|
+
makeRecoveryCode,
|
|
151
|
+
parseRecoveryCode,
|
|
152
|
+
seedToPassphrase,
|
|
153
|
+
formatCode,
|
|
154
|
+
};
|