- age-encryption 0.3.1, @noble/curves 2.4.0 (moved from dev) and @noble/hashes 2.4.0 become exact runtime dependencies (plan section 3, decision 5). The lockfile gains six packages: age-encryption, @noble/ciphers 2.4.0, @scure/base 2.4.0, @noble/post-quantum 0.5.4 and its own @noble/curves and @noble/hashes 2.0.1. No file of src/ imports them yet, so the site does not change. - src/lib/dependencies.test.ts guards them. package.json declares exactly these three, pinned. The lockfile has no tlock-js, drand-client or noble 1.x, and no noble 2.x copy other than 2.4.0 at the root and 2.0.1 under @noble/post-quantum. No file of src/ imports tlock-js or drand-client. Only ibe.ts, release.ts and the tests name @noble/, always subpaths of @noble/curves or @noble/hashes that resolve to the root 2.4.0 copy. Every check also runs on bad inputs. It replaces the "only tests import @noble/curves" test of bls12381.contrast.test.ts. - vite.config.ts records the modules of each client chunk in .svelte-kit/output/client-modules.json. check-build.mjs fails if the bundle holds tlock-js, drand-client or @babel/*, or a nested copy other than noble under @noble/post-quantum. It also reports the JavaScript each page loads: /inspect today loads 157 KB, 58.7 KB gzip. - Measured with a probe build (Vite 8, minified, gzip 9): the Decrypter is 48 KB gzip, with the Encrypter 56 KB, noble BLS12-381 plus SHA-256 28 KB, and all of them 73 KB. age-encryption imports its hybrid ML-KEM recipients statically, so post-quantum and its nested noble copy are about 99 KB of the Decrypter's 212 KB of rendered code. - npm audit --omit=dev: no vulnerabilities. The full audit finds two low ones in the tooling: cookie < 0.7.0 through @sveltejs/kit 2.70.3, which is the latest version and affects only SvelteKit's server. npm run verify is green: 2,384 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
e3e18ba97f
commit
74e1215ee1
@ -0,0 +1,188 @@
|
||||
// Guards of the runtime dependencies (plan of phase 2, section 3).
|
||||
//
|
||||
// The runtime dependencies are age-encryption 0.3.1, for the three age
|
||||
// files, and the two noble packages that the BLS12-381 code of phase 2
|
||||
// imports directly, @noble/curves and @noble/hashes 2.4.0; everything else
|
||||
// is what age-encryption drags in. These tests fail when:
|
||||
//
|
||||
// - package.json declares another runtime dependency, or one without its
|
||||
// exact version;
|
||||
// - package-lock.json holds tlock-js or drand-client, a noble 1.x, a root
|
||||
// copy of @noble/curves or @noble/hashes other than 2.4.0 (2.3.0 is the
|
||||
// first to reject non-canonical point encodings), or another 2.x copy
|
||||
// anywhere but under @noble/post-quantum, which pins ~2.0.0 and uses its
|
||||
// copy for ML-KEM only (plan decision 5);
|
||||
// - a file of src/ imports tlock-js or drand-client;
|
||||
// - a file of src/ other than ibe.ts, release.ts and the tests names
|
||||
// @noble/, or a noble import is not a subpath of @noble/curves or
|
||||
// @noble/hashes, the root copies that those files resolve to.
|
||||
//
|
||||
// Each check is also run on bad inputs, so that a guard that no longer
|
||||
// catches anything fails too.
|
||||
|
||||
import { bls12_381 } from '@noble/curves/bls12-381.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const rel = (p: string): string => relative(ROOT, p).split(sep).join('/');
|
||||
|
||||
// The approved runtime dependencies, with their exact versions.
|
||||
const RUNTIME: Record<string, string> = {
|
||||
'@noble/curves': '2.4.0',
|
||||
'@noble/hashes': '2.4.0',
|
||||
'age-encryption': '0.3.1',
|
||||
};
|
||||
// Packages that never enter the project: the IBE core is our own on noble 2
|
||||
// and releases are verified locally (plan decisions 1 and 4).
|
||||
const FORBIDDEN = ['tlock-js', 'drand-client'];
|
||||
// The only files besides the tests that may import noble.
|
||||
const NOBLE_IMPORTERS = ['src/lib/dkc/ibe.ts', 'src/lib/dkc/release.ts'];
|
||||
|
||||
type LockEntry = { version?: string; dev?: boolean; dependencies?: Record<string, string> };
|
||||
type Source = { name: string; text: string };
|
||||
const readJSON = <T>(path: string): T => JSON.parse(readFileSync(join(ROOT, path), 'utf8')) as T;
|
||||
|
||||
function walk(dir: string, keep: (name: string) => boolean): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(path, keep));
|
||||
else if (keep(entry.name)) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// The files of src/, except this one, whose bad imports are test data.
|
||||
const SELF = rel(fileURLToPath(import.meta.url));
|
||||
const sources = (): Source[] =>
|
||||
walk(join(ROOT, 'src'), (name) => /\.(ts|js|mjs|svelte)$/.test(name))
|
||||
.map((p) => ({ name: rel(p), text: readFileSync(p, 'utf8') }))
|
||||
.filter((s) => s.name !== SELF);
|
||||
|
||||
// The module specifiers of a source file: imports and re-exports, static,
|
||||
// dynamic or for side effects.
|
||||
function specifiers(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const re of [/\b(?:import|export)\b[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]/g, /\bimport\s*\(?\s*['"]([^'"]+)['"]/g]) {
|
||||
for (const [, s] of text.matchAll(re)) if (s !== undefined) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const packageOf = (specifier: string): string => specifier.split('/').slice(0, specifier.startsWith('@') ? 2 : 1).join('/');
|
||||
|
||||
// The problems of the lockfile's packages, and how many noble copies are
|
||||
// nested under @noble/post-quantum.
|
||||
function lockProblems(packages: Record<string, LockEntry>): { problems: string[]; nested: number } {
|
||||
const problems: string[] = [];
|
||||
let nested = 0;
|
||||
for (const [path, entry] of Object.entries(packages)) {
|
||||
if (path === '') continue;
|
||||
const name = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
|
||||
if (FORBIDDEN.includes(name)) problems.push(`${path} is in the lockfile`);
|
||||
if (name !== '@noble/curves' && name !== '@noble/hashes') continue;
|
||||
const version = entry.version ?? '';
|
||||
if (!version.startsWith('2.')) problems.push(`${path} ${version}: only noble 2 is allowed`);
|
||||
if (path === `node_modules/${name}`) {
|
||||
if (version !== '2.4.0') problems.push(`${path} ${version}: the root copy must be 2.4.0`);
|
||||
} else if (path.startsWith('node_modules/@noble/post-quantum/node_modules/')) {
|
||||
nested++;
|
||||
} else {
|
||||
problems.push(`${path} ${version}: a second copy outside @noble/post-quantum`);
|
||||
}
|
||||
}
|
||||
return { problems, nested };
|
||||
}
|
||||
|
||||
// The problems of the imports of the files of src/.
|
||||
function importProblems(files: Source[]): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const { name, text } of files) {
|
||||
if (!name.endsWith('.test.ts') && !NOBLE_IMPORTERS.includes(name) && text.includes('@noble/')) problems.push(`${name} names @noble/`);
|
||||
for (const s of specifiers(text)) {
|
||||
if (FORBIDDEN.includes(packageOf(s))) problems.push(`${name}: ${s} is forbidden`);
|
||||
else if (s.includes('node_modules')) problems.push(`${name}: ${s} reaches into node_modules`);
|
||||
else if (s.startsWith('@noble/') && !/^@noble\/(?:curves|hashes)\/[\w./-]+\.js$/.test(s)) problems.push(`${name}: ${s}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
describe('runtime dependencies', () => {
|
||||
it('package.json declares exactly the approved runtime dependencies, pinned', () => {
|
||||
const pkg = readJSON<{ dependencies?: Record<string, string>; devDependencies?: Record<string, string> }>('package.json');
|
||||
expect(pkg.dependencies).toEqual(RUNTIME);
|
||||
for (const name of Object.keys(RUNTIME)) expect(pkg.devDependencies?.[name], name).toBeUndefined();
|
||||
const lock = readJSON<{ packages: Record<string, LockEntry> }>('package-lock.json');
|
||||
expect(lock.packages['']?.dependencies).toEqual(RUNTIME);
|
||||
for (const [name, version] of Object.entries(RUNTIME)) {
|
||||
const entry = lock.packages[`node_modules/${name}`];
|
||||
expect(entry?.version, name).toBe(version);
|
||||
expect(entry?.dev, `${name} is a runtime dependency`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('package-lock.json holds noble 2.4.0 at the root, other 2.x copies only under @noble/post-quantum, and no tlock-js or drand-client', () => {
|
||||
const { problems, nested } = lockProblems(readJSON<{ packages: Record<string, LockEntry> }>('package-lock.json').packages);
|
||||
expect(problems).toEqual([]);
|
||||
// post-quantum 0.5.4 brings @noble/curves and @noble/hashes 2.0.1.
|
||||
expect(nested).toBe(2);
|
||||
});
|
||||
|
||||
it('the lockfile check rejects each forbidden layout', () => {
|
||||
const good: Record<string, LockEntry> = {
|
||||
'': {},
|
||||
'node_modules/@noble/curves': { version: '2.4.0' },
|
||||
'node_modules/@noble/hashes': { version: '2.4.0' },
|
||||
'node_modules/@noble/post-quantum/node_modules/@noble/curves': { version: '2.0.1' },
|
||||
};
|
||||
expect(lockProblems(good)).toEqual({ problems: [], nested: 1 });
|
||||
const bad: [label: string, path: string, version: string][] = [
|
||||
['a noble 1.x', 'node_modules/age-encryption/node_modules/@noble/hashes', '1.8.0'],
|
||||
['a root copy other than 2.4.0', 'node_modules/@noble/curves', '2.3.0'],
|
||||
['a second 2.x copy outside post-quantum', 'node_modules/age-encryption/node_modules/@noble/curves', '2.0.1'],
|
||||
['tlock-js', 'node_modules/tlock-js', '0.9.0'],
|
||||
['drand-client', 'node_modules/drand-client', '1.2.6'],
|
||||
];
|
||||
for (const [label, path, version] of bad) {
|
||||
expect(lockProblems({ ...good, [path]: { version } }).problems, label).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('only ibe.ts, release.ts and the tests import noble, only from @noble/curves and @noble/hashes, and nothing imports tlock-js or drand-client', () => {
|
||||
expect(importProblems(sources())).toEqual([]);
|
||||
});
|
||||
|
||||
it('the import check rejects each forbidden import', () => {
|
||||
const noble = "import { bls12_381 } from '@noble/curves/bls12-381.js';";
|
||||
expect(importProblems([{ name: 'src/lib/dkc/ibe.ts', text: noble }, { name: 'src/lib/dkc/x.test.ts', text: noble }])).toEqual([]);
|
||||
const bad: [label: string, name: string, text: string][] = [
|
||||
['noble outside the allowlist', 'src/lib/dkc/open.ts', noble],
|
||||
['noble from a page', 'src/routes/inspect/+page.svelte', noble],
|
||||
['another noble package', 'src/lib/dkc/ibe.ts', "import { chacha20poly1305 } from '@noble/ciphers/chacha.js';"],
|
||||
['the nested copy', 'src/lib/dkc/release.ts', "import { bls12_381 } from '../../../node_modules/@noble/post-quantum/node_modules/@noble/curves/bls12-381.js';"],
|
||||
['tlock-js', 'src/lib/dkc/open.ts', "import { timelockDecrypt } from 'tlock-js';"],
|
||||
['drand-client, dynamically', 'src/lib/dkc/open.test.ts', "const c = await import('drand-client');"],
|
||||
['tlock-js, re-exported', 'src/lib/dkc/index.ts', "export * from 'tlock-js/drand/timelock-decrypter';"],
|
||||
];
|
||||
for (const [label, name, text] of bad) {
|
||||
expect(importProblems([{ name, text }]), label).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('src/ resolves noble to the root copies, 2.4.0', async () => {
|
||||
// No node_modules directory inside src/ can shadow the root ones.
|
||||
expect(walk(join(ROOT, 'src'), () => true).filter((p) => rel(p).split('/').includes('node_modules'))).toEqual([]);
|
||||
for (const name of ['@noble/curves', '@noble/hashes']) {
|
||||
expect(readJSON<{ version: string }>(`node_modules/${name}/package.json`).version, name).toBe('2.4.0');
|
||||
}
|
||||
// The modules that a file of src/ imports by name are the files of those
|
||||
// root copies.
|
||||
const curves = (await import(pathToFileURL(join(ROOT, 'node_modules/@noble/curves/bls12-381.js')).href)) as { bls12_381: unknown };
|
||||
const hashes = (await import(pathToFileURL(join(ROOT, 'node_modules/@noble/hashes/sha2.js')).href)) as { sha256: unknown };
|
||||
expect(curves.bls12_381).toBe(bls12_381);
|
||||
expect(hashes.sha256).toBe(sha256);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue