/** * Constant-time lint. Scans every `packages/universal/crypto-*\/src/primitives/ * /impl/.native.ts` for the obvious class of data-dependent bugs: * * (a) secret-indexed array access: `arr[secret]` or `secret[index]` where * the indexer or the base is a parameter conventionally holding secret * bytes (key / nonce / secret / priv / msg). * (b) early returns on secret comparisons: `if (secretByte === x) return …`. * (c) data-dependent branches on parameters typed as secret / key / nonce: * `if (key[i] !== …) …`. * * This is a regex-grade scan, not a full AST analysis. It catches the * obvious class of bugs and is cheap enough to run on every precommit and * in CI. Missing a subtle leak here is not failure; a false positive that * blocks a commit IS, so the patterns are tight and the names list is * conservative. Natives should be reviewed by hand regardless. * * For Phase 0 every native.ts is a `PrimitiveNotImplemented` stub, so the * lint passes cleanly. As natives land this file becomes the first gate. */ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const SECRET_NAME_RE = /(?:secret|key|priv|private|nonce|iv|msg|message|password|salt|seed)/i; interface Finding { readonly file: string; readonly line: number; readonly rule: "secret-indexed" | "secret-early-return" | "secret-branch"; readonly evidence: string; } function* walk(dir: string): Generator { for (const entry of readdirSync(dir)) { const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) yield* walk(full); else if (full.endsWith(".native.ts")) yield full; } } function listNativeFiles(): string[] { const out: string[] = []; const pkgRoot = join(REPO_ROOT, "universal/auth"); for (const pkg of readdirSync(pkgRoot)) { if (!pkg.startsWith("crypto-")) continue; const primitivesDir = join(pkgRoot, pkg, "src", "primitives"); try { statSync(primitivesDir); } catch { continue; } for (const f of walk(primitivesDir)) out.push(f); } return out; } function stripComments(source: string): string { return source .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1"); } function scan(source: string): Finding[] { const cleaned = stripComments(source); const lines = cleaned.split("\n"); const raw = source.split("\n"); const findings: Finding[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (!line.trim()) continue; // Rule (a): base[secret] — indexer name matches a secret convention. const indexRe = /([A-Za-z_$][A-Za-z0-9_$]*)\s*\[\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\]/g; for (const m of line.matchAll(indexRe)) { const base = m[1]!; const idx = m[2]!; if (SECRET_NAME_RE.test(idx) || SECRET_NAME_RE.test(base)) { findings.push({ file: "", line: i + 1, rule: "secret-indexed", evidence: raw[i]!.trim(), }); } } // Rule (b): early return gated on a comparison involving a // secret-named identifier. if ( /\bif\s*\([^)]*\b(?:secret|key|priv|private|nonce|iv|password)[A-Za-z0-9_$]*\s*(?:===|!==|==|!=)/i.test( line ) && /return\b/.test(line) ) { findings.push({ file: "", line: i + 1, rule: "secret-early-return", evidence: raw[i]!.trim(), }); } // Rule (c): `if (secret…)` / `if (priv…)` / `if (nonce…)` branches. // Exempt `.length` — caller-visible length is not secret. const branchMatch = /\bif\s*\(([^)]*)\)/.exec(line); if (branchMatch) { const cond = branchMatch[1]!; const condWithoutLength = cond.replace(/\.length\b/g, ""); if ( /\b(?:secret|priv|private|nonce|iv)\w*/i.test(condWithoutLength) ) { findings.push({ file: "", line: i + 1, rule: "secret-branch", evidence: raw[i]!.trim(), }); } } } return findings; } async function main(): Promise { const files = listNativeFiles(); const all: Finding[] = []; for (const file of files) { const source = readFileSync(file, "utf8"); const rel = relative(REPO_ROOT, file); for (const f of scan(source)) { all.push({ ...f, file: rel }); } } if (all.length === 0) { console.log(`ct-lint: OK (${files.length} native files scanned)`); return; } console.error(`ct-lint: ${all.length} finding(s):`); for (const f of all) { console.error(` ${f.file}:${f.line} [${f.rule}] ${f.evidence}`); } process.exit(1); } if (import.meta.main) { await main(); }