diff --git a/__tests__/genTaskOriginSig.test.ts b/__tests__/genTaskOriginSig.test.ts index a3d2b84..d175e4c 100644 --- a/__tests__/genTaskOriginSig.test.ts +++ b/__tests__/genTaskOriginSig.test.ts @@ -18,6 +18,7 @@ import { resolveSignatureHash, FacilitatorType, } from '../scripts/genTaskOriginSig'; +import { createDeterministicTarball } from '../src/lib/task-origin-validate'; // Fixture paths const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -295,12 +296,13 @@ describe('signTaskWithCert', () => { expect(bundle.messageSignature.messageDigest.algorithm).toBe('SHA2_256'); // RSA key -> SHA-256 expect(bundle.verificationMaterial.x509CertificateChain.certificates[0].rawBytes).toBeTruthy(); - const tarballPath = path.resolve(process.cwd(), 'task.tar'); - const tarball = await fsp.readFile(tarballPath).catch(() => null); - if (tarball) { + const tarballPath = await createDeterministicTarball(taskFolder); + try { + const tarball = await fsp.readFile(tarballPath); const expectedDigest = createHash('sha256').update(new Uint8Array(tarball)).digest('base64'); expect(bundle.messageSignature.messageDigest.digest).toBe(expectedDigest); - await fsp.unlink(tarballPath).catch(() => {}); + } finally { + await cleanupCreatedTarball(tarballPath); } }); }); @@ -398,6 +400,14 @@ async function writeFixtureLeafCertificate(bundlePath: string, directory: string return certificatePath; } +async function cleanupCreatedTarball(tarballPath: string): Promise { + const tarballDir = path.dirname(tarballPath); + const tmpRoot = await fsp.realpath(os.tmpdir()); + const tarballDirReal = await fsp.realpath(tarballDir); + expect(tarballDirReal.startsWith(`${tmpRoot}${path.sep}`)).toBe(true); + await fsp.rm(tarballDir, { recursive: true, force: true }); +} + // Returns the full certificate chain from a Sigstore bundle as a PEM string, // alongside the original per-certificate DER (base64). function certificateChainFromBundle(bundlePath: string): { diff --git a/__tests__/task-origin-validate.test.ts b/__tests__/task-origin-validate.test.ts index b8f6b11..cb265ad 100644 --- a/__tests__/task-origin-validate.test.ts +++ b/__tests__/task-origin-validate.test.ts @@ -38,6 +38,14 @@ async function listTarEntries(tarballPath: string): Promise { return entries; } +async function cleanupCreatedTarball(tarballPath: string): Promise { + const tarballDir = path.dirname(tarballPath); + const tmpRoot = await fs.realpath(os.tmpdir()); + const tarballDirReal = await fs.realpath(tarballDir); + expect(tarballDirReal.startsWith(`${tmpRoot}${path.sep}`)).toBe(true); + await fs.rm(tarballDir, { recursive: true, force: true }); +} + describe('createDeterministicTarball', () => { let tempDir: string; let createdTarballs: string[] = []; @@ -55,7 +63,7 @@ describe('createDeterministicTarball', () => { // Clean up any created tarballs for (const tarball of createdTarballs) { try { - await fs.unlink(tarball); + await cleanupCreatedTarball(tarball); } catch { // Ignore errors if file doesn't exist } @@ -80,14 +88,16 @@ describe('createDeterministicTarball', () => { expect(entries).toContain('test.txt'); }); - it('excludes cache/, out/, and signer-tool/ directories', async () => { + it('excludes cache/, out/, signer-tool/, and signatures/ directories', async () => { // Create excluded directories with files await fs.mkdir(path.join(tempDir, 'cache'), { recursive: true }); await fs.mkdir(path.join(tempDir, 'out'), { recursive: true }); await fs.mkdir(path.join(tempDir, 'signer-tool'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'signatures'), { recursive: true }); await fs.writeFile(path.join(tempDir, 'cache', 'cached.txt'), 'cached data'); await fs.writeFile(path.join(tempDir, 'out', 'output.txt'), 'output data'); await fs.writeFile(path.join(tempDir, 'signer-tool', 'tool.txt'), 'tool data'); + await fs.writeFile(path.join(tempDir, 'signatures', 'creator-signature.json'), '{}'); // Create an included file await fs.writeFile(path.join(tempDir, 'included.txt'), 'included data'); @@ -104,6 +114,7 @@ describe('createDeterministicTarball', () => { expect(entries.some(e => e.includes('cache'))).toBe(false); expect(entries.some(e => e.includes('out'))).toBe(false); expect(entries.some(e => e.includes('signer-tool'))).toBe(false); + expect(entries.some(e => e.includes('signatures'))).toBe(false); }); it('produces deterministic output with identical hash', async () => { @@ -118,8 +129,9 @@ describe('createDeterministicTarball', () => { createdTarballs.push(tarball1); const hash1 = await computeFileHash(tarball1); - // Delete the tarball - await fs.unlink(tarball1); + // Delete the tarball directory before creating the second one to verify + // determinism does not depend on the temporary output path. + await cleanupCreatedTarball(tarball1); createdTarballs = createdTarballs.filter(t => t !== tarball1); // Create second tarball and compute hash @@ -131,6 +143,17 @@ describe('createDeterministicTarball', () => { expect(hash1).toBe(hash2); }); + it('creates unique temporary tarball paths for the same folder', async () => { + await fs.writeFile(path.join(tempDir, 'test.txt'), 'test content'); + + const tarball1 = await createDeterministicTarball(tempDir); + const tarball2 = await createDeterministicTarball(tempDir); + createdTarballs.push(tarball1, tarball2); + + expect(tarball1).not.toBe(tarball2); + expect(path.basename(tarball1)).toBe(path.basename(tarball2)); + }); + it('sorts files alphabetically in the tarball', async () => { // Create files and folders out of alphabetical order await fs.mkdir(path.join(tempDir, 'zebra'), { recursive: true }); @@ -170,39 +193,18 @@ describe('createDeterministicTarball', () => { }); describe('buildAndValidateSignature', () => { - let createdTarballs: string[] = []; - beforeEach(() => { - createdTarballs = []; jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'warn').mockImplementation(() => {}); }); - afterEach(async () => { - // Clean up any created tarballs - for (const tarball of createdTarballs) { - try { - await fs.unlink(tarball); - } catch { - // Ignore errors if file doesn't exist - } - } - + afterEach(() => { jest.restoreAllMocks(); }); - // Helper to track tarballs created during tests - const trackTarball = (taskFolder: string) => { - const folderName = taskFolder.split('/').pop(); - const tarballPath = path.resolve(process.cwd(), `${folderName}.tar`); - createdTarballs.push(tarballPath); - }; - describe('valid signatures', () => { it('validates a valid signature with matching tarball', async () => { - trackTarball(VALID_TASK_FOLDER); - await expect( buildAndValidateSignature({ taskFolderPath: VALID_TASK_FOLDER, @@ -216,8 +218,6 @@ describe('buildAndValidateSignature', () => { describe('tarball mismatch', () => { it('fails validation when signature does not match tarball content', async () => { - trackTarball(MODIFIED_TASK_FOLDER); - // Use valid signature but with modified task folder await expect( buildAndValidateSignature({ @@ -232,8 +232,6 @@ describe('buildAndValidateSignature', () => { describe('SAN mismatch', () => { it('fails validation with wrong email for task creator', async () => { - trackTarball(VALID_TASK_FOLDER); - await expect( buildAndValidateSignature({ taskFolderPath: VALID_TASK_FOLDER, @@ -245,8 +243,6 @@ describe('buildAndValidateSignature', () => { }); it('fails validation with a common name that is a prefix of the real identity', async () => { - trackTarball(VALID_TASK_FOLDER); - const commonName = TASK_CREATOR_EMAIL.replace(/\.com$/, ''); // alexis.williams.1@coinbase await expect( buildAndValidateSignature({ @@ -261,8 +257,6 @@ describe('buildAndValidateSignature', () => { }); it('fails validation with a common name that uses a regex wildcard', async () => { - trackTarball(VALID_TASK_FOLDER); - const commonName = TASK_CREATOR_EMAIL.replace('.1@', '..@'); // alexis.williams..@coinbase.com await expect( buildAndValidateSignature({ @@ -277,8 +271,6 @@ describe('buildAndValidateSignature', () => { }); it('fails validation when task creator signature is verified as facilitator role', async () => { - trackTarball(VALID_TASK_FOLDER); - // Try to verify a task creator signature with facilitator role // This should fail because SAN prefix is user:/// but we expect ldap:/// await expect( @@ -292,8 +284,6 @@ describe('buildAndValidateSignature', () => { }); it('fails validation when facilitator signature is verified as task creator role', async () => { - trackTarball(VALID_TASK_FOLDER); - // Try to verify a facilitator signature with task creator role // This should fail because SAN prefix is ldap:/// but we expect user:/// await expect( diff --git a/scripts/genTaskOriginSig.ts b/scripts/genTaskOriginSig.ts index 5e5a257..6c2ddd6 100644 --- a/scripts/genTaskOriginSig.ts +++ b/scripts/genTaskOriginSig.ts @@ -66,6 +66,9 @@ function printUsage(): void { --facilitator, -f Facilitator type: "base" or "security-council" (used for 'sign' and 'verify' commands, omit this flag to sign/verify as task creator) --common-name, -c Common name for task creator (required when not using --facilitator in 'verify' and 'verify-all' commands) --help, -h Show this help message + + Example: + tsx scripts/genTaskOriginSig.ts sign --task-folder ../active/evm --signature-path ../active/evm/tasks/2026-06-18-beryl-1/config/mainnet/signatures `; console.log(msg); } @@ -229,44 +232,52 @@ export async function signTaskWithCert( const tarballPath = await createDeterministicTarball(taskFolderPath); console.log(` Tarball: ${tarballPath}`); - const keyPem = await fs.readFile(keyPath, 'utf8'); - const certificateChainPem = await fs.readFile(certPath, 'utf8'); + try { + const keyPem = await fs.readFile(keyPath, 'utf8'); + const certificateChainPem = await fs.readFile(certPath, 'utf8'); - const certificateChain = parseCertificateChainPEM(certificateChainPem); - if (certificateChain.length === 0) { - console.error(' Error: No certificates found in certificate chain file'); - return undefined; - } + const certificateChain = parseCertificateChainPEM(certificateChainPem); + if (certificateChain.length === 0) { + console.error(' Error: No certificates found in certificate chain file'); + return undefined; + } - assertTimestampAuthorityTrustedRoot(); + assertTimestampAuthorityTrustedRoot(); - const { nodeDigest, sigstoreAlgorithm } = resolveSignatureHash(keyPem); - const bundler = new MessageSignatureBundleBuilder({ - signer: new DeviceCertificateSigner(keyPem, pemUtils.fromDER(certificateChain[0]), nodeDigest), - witnesses: [new TSAWitness({ tsaBaseURL: TSA_BASE_URL })], - }); + const { nodeDigest, sigstoreAlgorithm } = resolveSignatureHash(keyPem); + const bundler = new MessageSignatureBundleBuilder({ + signer: new DeviceCertificateSigner( + keyPem, + pemUtils.fromDER(certificateChain[0]), + nodeDigest + ), + witnesses: [new TSAWitness({ tsaBaseURL: TSA_BASE_URL })], + }); - const tarball = await fs.readFile(tarballPath); - const bundle = await bundler.create({ data: tarball }); + const tarball = await fs.readFile(tarballPath); + const bundle = await bundler.create({ data: tarball }); - if (bundle.content.$case === 'messageSignature') { - bundle.content.messageSignature.messageDigest = { - algorithm: sigstoreAlgorithm, - digest: createHash(nodeDigest).update(new Uint8Array(tarball)).digest(), - }; - } + if (bundle.content.$case === 'messageSignature') { + bundle.content.messageSignature.messageDigest = { + algorithm: sigstoreAlgorithm, + digest: createHash(nodeDigest).update(new Uint8Array(tarball)).digest(), + }; + } - if (bundle.verificationMaterial.content.$case === 'x509CertificateChain') { - bundle.verificationMaterial.content.x509CertificateChain.certificates = certificateChain.map( - rawBytes => ({ rawBytes }) - ); - } + if (bundle.verificationMaterial.content.$case === 'x509CertificateChain') { + bundle.verificationMaterial.content.x509CertificateChain.certificates = certificateChain.map( + rawBytes => ({ rawBytes }) + ); + } - const bundleJson = bundleToJSON(bundle); - await fs.writeFile(signatureFileOut, JSON.stringify(bundleJson, null, 2)); + const bundleJson = bundleToJSON(bundle); + await fs.writeFile(signatureFileOut, JSON.stringify(bundleJson, null, 2)); - console.log(` Signature: ${signatureFileOut}`); - return signatureFileOut; + console.log(` Signature: ${signatureFileOut}`); + return signatureFileOut; + } finally { + await fs.rm(path.dirname(tarballPath), { recursive: true, force: true }); + } } export function facilitatorToRole(facilitator: FacilitatorType | undefined): TaskOriginRole { diff --git a/scripts/genValidationFile.ts b/scripts/genValidationFile.ts index 946486f..3864d08 100644 --- a/scripts/genValidationFile.ts +++ b/scripts/genValidationFile.ts @@ -30,19 +30,19 @@ Examples: # Basic validation file generation tsx scripts/genValidationFile.ts \ --rpc-url https://mainnet.example \ - --workdir mainnet/2025-06-04-upgrade-foo \ + --workdir active/evm \ --forge-cmd "forge script script/Simulate.s.sol:Simulate --sig 'run()' --sender 0xabc --json" \ - --out mainnet/2025-06-04-upgrade-foo/validations/base-sc.json + --out active/evm/tasks//config//validations/base-sc.json # With L2 gas estimation for deposit transactions (-vvvv is added automatically) tsx scripts/genValidationFile.ts \ --rpc-url https://mainnet.example \ - --workdir mainnet/2025-06-04-my-l2-deposit \ + --workdir active/evm \ --forge-cmd "forge script script/MyDeposit.s.sol:MyDeposit --sig 'run()' --sender 0xabc --json" \ --estimate-l2-gas \ --l2-rpc-url https://base-mainnet.example \ --l2-gas-buffer 25 \ - --out validations/base-sc.json + --out active/evm/tasks//config//validations/base-sc.json `; console.log(msg); } diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index 44f8985..d5575b3 100644 --- a/src/app/api/validate/__tests__/route.test.ts +++ b/src/app/api/validate/__tests__/route.test.ts @@ -33,7 +33,7 @@ describe('POST /api/validate', () => { it('accepts zeronet as a supported network', async () => { const res = await POST( createRequest({ - upgradeId: '2025-08-01-upgrade-qux', + upgradeId: '2026-06-18-beryl-1', network: 'zeronet', userType: 'base-sc', }) @@ -41,7 +41,7 @@ describe('POST /api/validate', () => { expect(res.status).toBe(200); expect(mockValidateUpgrade).toHaveBeenCalledWith({ - upgradeId: '2025-08-01-upgrade-qux', + upgradeId: '2026-06-18-beryl-1', network: NetworkType.Zeronet, taskConfigFileName: 'base-sc', }); @@ -50,7 +50,7 @@ describe('POST /api/validate', () => { it('rejects unsupported networks and lists zeronet in supported values', async () => { const res = await POST( createRequest({ - upgradeId: '2025-08-01-upgrade-qux', + upgradeId: '2026-06-18-beryl-1', network: 'hoodi', userType: 'base-sc', }) @@ -63,4 +63,36 @@ describe('POST /api/validate', () => { expect(body.message).toMatch(/unsupported network/i); expect(body.message).toContain('zeronet'); }); + + it('rejects path separators in upgradeId', async () => { + const res = await POST( + createRequest({ + upgradeId: '../2026-06-18-beryl-1', + network: 'mainnet', + userType: 'base-sc', + }) + ); + + expect(res.status).toBe(400); + expect(mockValidateUpgrade).not.toHaveBeenCalled(); + + const body = await res.json(); + expect(body.message).toMatch(/invalid upgradeId or userType/i); + }); + + it('rejects path separators in userType', async () => { + const res = await POST( + createRequest({ + upgradeId: '2026-06-18-beryl-1', + network: 'mainnet', + userType: '../base-sc', + }) + ); + + expect(res.status).toBe(400); + expect(mockValidateUpgrade).not.toHaveBeenCalled(); + + const body = await res.json(); + expect(body.message).toMatch(/invalid upgradeId or userType/i); + }); }); diff --git a/src/app/api/validate/route.ts b/src/app/api/validate/route.ts index 7a17e09..c4c1607 100644 --- a/src/app/api/validate/route.ts +++ b/src/app/api/validate/route.ts @@ -1,6 +1,7 @@ import { validateUpgrade } from '@/lib/validation-service'; import { NextRequest, NextResponse } from 'next/server'; import { NetworkType } from '@/lib/types'; +import { isSafePathSegment } from '@/lib/path-validation'; export async function POST(req: NextRequest) { try { @@ -25,6 +26,16 @@ export async function POST(req: NextRequest) { const trimmedUserType = userType.trim(); const normalizedNetwork = network.trim().toLowerCase(); + if (!isSafePathSegment(trimmedUpgradeId) || !isSafePathSegment(trimmedUserType)) { + return NextResponse.json( + { + message: + 'Invalid upgradeId or userType: only alphanumeric characters, hyphens, and underscores are allowed', + }, + { status: 400 } + ); + } + if (!Object.values(NetworkType).includes(normalizedNetwork as NetworkType)) { return NextResponse.json( { diff --git a/src/lib/path-validation.ts b/src/lib/path-validation.ts index 4e932a2..680366a 100644 --- a/src/lib/path-validation.ts +++ b/src/lib/path-validation.ts @@ -13,3 +13,9 @@ export function assertWithinDir(targetPath: string, allowedDir: string): string } return resolved; } + +const SAFE_PATH_SEGMENT_PATTERN = /^[a-zA-Z0-9_-]+$/; + +export function isSafePathSegment(value: string): boolean { + return SAFE_PATH_SEGMENT_PATTERN.test(value); +} diff --git a/src/lib/state-diff.ts b/src/lib/state-diff.ts index 7bd6eee..dd1a1df 100644 --- a/src/lib/state-diff.ts +++ b/src/lib/state-diff.ts @@ -93,7 +93,7 @@ export class StateDiffClient { console.log(`🔧 Running forge in ${normalizedWorkdir}: ${cmd}`); const { command, args, env: envAssignments } = this.extractCommandDetails(forgeCmdParts); - const spawnEnv = { ...process.env, ...envAssignments }; + const spawnEnv = { ...process.env, ...envAssignments, RECORD_STATE_DIFF: 'true' }; const { stdout, stderr, code } = await this.runCommand( command, diff --git a/src/lib/task-origin-validate.ts b/src/lib/task-origin-validate.ts index b3a99f6..d2f95ef 100644 --- a/src/lib/task-origin-validate.ts +++ b/src/lib/task-origin-validate.ts @@ -1,4 +1,5 @@ import * as tar from 'tar'; +import os from 'os'; import path from 'path'; import fs from 'fs/promises'; import { Verifier, toTrustMaterial, toSignedEntity } from '@sigstore/verify'; @@ -34,8 +35,10 @@ async function getAllFilesRecursively( const entries = await fs.readdir(currentDir, { withFileTypes: true }); const files: string[] = []; - // Exclude cache, out, and signer-tool folders from the tarball - const excludedFolders = ['cache', 'out', 'signer-tool']; + // Exclude generated folders from the signed tarball. In the active EVM layout, + // signatures are stored under tasks//config//signatures, inside the task + // origin directory, so they must not be part of the payload they attest to. + const excludedFolders = ['cache', 'out', 'signer-tool', 'signatures']; for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); @@ -64,9 +67,9 @@ export async function createDeterministicTarball( assertWithinDir(resolvedTaskFolderPath, allowedDir); } - // Take the last '/' separate part of the folder path to be the tarfile name - const folderName = resolvedTaskFolderPath.split('/').pop(); - const tarballPath = path.resolve(process.cwd(), `${folderName}.tar`); + const folderName = path.basename(resolvedTaskFolderPath); + const tarballDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-origin-')); + const tarballPath = path.join(tarballDir, `${folderName}.tar`); // Check if lib/ folder exists for reproducibility const libPath = path.join(resolvedTaskFolderPath, 'lib'); @@ -110,6 +113,10 @@ export async function createDeterministicTarball( return tarballPath; } +async function cleanupTarball(tarballPath: string): Promise { + await fs.rm(path.dirname(tarballPath), { recursive: true, force: true }); +} + export async function buildAndValidateSignature(options: TaskOriginVerifyOptions): Promise { const { taskFolderPath, signatureFile, commonName, role, allowedDir } = options; console.log(` Task folder: ${taskFolderPath}`); @@ -127,122 +134,128 @@ export async function buildAndValidateSignature(options: TaskOriginVerifyOptions // Regenerate the tarball from the provided task folder const tarballPath = await createDeterministicTarball(taskFolderPath, allowedDir); - const tarball = await fs.readFile(tarballPath); // Read as binary Buffer - - // Extract the deployment-specific intermediate CA from bundle - // Bundle structure: [0]=leaf, [1]=runtime intermediate, [2]=static intermediate, [3]=root - // Trusted root has: [static intermediate, root] - // We need to inject [1] (runtime intermediate) to build the complete chain - const bundleCerts = - bundleSig.verificationMaterial?.content?.$case === 'x509CertificateChain' - ? bundleSig.verificationMaterial.content.x509CertificateChain.certificates - : []; - - // Extract the runtime intermediate (cert [1]) and root (cert [3]) - keep as Buffers - const runtimeIntermediate = bundleCerts[1] ? { rawBytes: bundleCerts[1].rawBytes } : null; - const rootCert = bundleCerts[3] ? { rawBytes: bundleCerts[3].rawBytes } : null; - - // Prepare trust material with: - // 1. Date strings converted to Date objects (required for filtering) - // 2. Runtime intermediate CA injected into certificate chain - const normalizedTrustedRoot = { - ...trustedRoot, - tlogs: trustedRoot.tlogs || [], - ctlogs: trustedRoot.ctlogs || [], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - certificateAuthorities: trustedRoot.certificateAuthorities?.map((ca: any) => { - // Convert base64 strings to Buffers for all certificates - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const baseCerts = ca.certChain.certificates.map((cert: any) => ({ - rawBytes: Buffer.from(cert.rawBytes, 'base64'), - })); - - // Build the certificate chain: base certs + runtime intermediate + root - const certChain = [...baseCerts]; - if (runtimeIntermediate) certChain.push(runtimeIntermediate); - if (rootCert) certChain.push(rootCert); - + try { + const tarball = await fs.readFile(tarballPath); // Read as binary Buffer + + // Extract the deployment-specific intermediate CA from bundle + // Bundle structure: [0]=leaf, [1]=runtime intermediate, [2]=static intermediate, [3]=root + // Trusted root has: [static intermediate, root] + // We need to inject [1] (runtime intermediate) to build the complete chain + const bundleCerts = + bundleSig.verificationMaterial?.content?.$case === 'x509CertificateChain' + ? bundleSig.verificationMaterial.content.x509CertificateChain.certificates + : []; + + // Extract the runtime intermediate (cert [1]) and root (cert [3]) - keep as Buffers + const runtimeIntermediate = bundleCerts[1] ? { rawBytes: bundleCerts[1].rawBytes } : null; + const rootCert = bundleCerts[3] ? { rawBytes: bundleCerts[3].rawBytes } : null; + + // Prepare trust material with: + // 1. Date strings converted to Date objects (required for filtering) + // 2. Runtime intermediate CA injected into certificate chain + const normalizedTrustedRoot = { + ...trustedRoot, + tlogs: trustedRoot.tlogs || [], + ctlogs: trustedRoot.ctlogs || [], // eslint-disable-next-line @typescript-eslint/no-explicit-any - const normalized: any = { - subject: ca.subject, - certChain: { - certificates: certChain, - }, - validFor: { - start: ca.validFor?.start ? new Date(ca.validFor.start) : undefined, - end: ca.validFor?.end ? new Date(ca.validFor.end) : undefined, - }, - }; - if (ca.uri) normalized.uri = ca.uri; - return normalized; - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - timestampAuthorities: trustedRoot.timestampAuthorities?.map((tsa: any) => { + certificateAuthorities: trustedRoot.certificateAuthorities?.map((ca: any) => { + // Convert base64 strings to Buffers for all certificates + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const baseCerts = ca.certChain.certificates.map((cert: any) => ({ + rawBytes: Buffer.from(cert.rawBytes, 'base64'), + })); + + // Build the certificate chain: base certs + runtime intermediate + root + const certChain = [...baseCerts]; + if (runtimeIntermediate) certChain.push(runtimeIntermediate); + if (rootCert) certChain.push(rootCert); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalized: any = { + subject: ca.subject, + certChain: { + certificates: certChain, + }, + validFor: { + start: ca.validFor?.start ? new Date(ca.validFor.start) : undefined, + end: ca.validFor?.end ? new Date(ca.validFor.end) : undefined, + }, + }; + if (ca.uri) normalized.uri = ca.uri; + return normalized; + }), // eslint-disable-next-line @typescript-eslint/no-explicit-any - const normalized: any = { - subject: tsa.subject, - uri: tsa.uri, - certChain: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - certificates: tsa.certChain.certificates.map((cert: any) => ({ - rawBytes: Buffer.from(cert.rawBytes, 'base64'), - })), - }, - validFor: { - start: tsa.validFor?.start ? new Date(tsa.validFor.start) : undefined, - end: tsa.validFor?.end ? new Date(tsa.validFor.end) : undefined, - }, - }; - return normalized; - }), - }; - - // Create trust material from the custom trusted root - const trustMaterial = toTrustMaterial(normalizedTrustedRoot); - - // Configure verifier options - const verifierOptions = { - tsaThreshold: 1, // Require TSA timestamp verification - ctlogThreshold: 0, // No CT logs for custom CA - tlogThreshold: 0, // No transparency logs for custom CA - }; - - // Create the verifier with custom trust material - const verifier = new Verifier(trustMaterial, verifierOptions); - - // Convert bundle to signed entity for verification - const signedEntity = toSignedEntity(bundleSig, tarball); // tarball is already a Buffer - - const uriPrefix = getURIPrefix(role); - const expectedSubjectAlternativeName = `${uriPrefix}${commonName}`; - - // Define the verification policy with the expected subject alternative name. - const verificationPolicy = { - subjectAlternativeName: expectedSubjectAlternativeName, - }; - - // Verify the signature. @sigstore/verify validates the certificate chain, - // signature, and timestamp, checks the certificate identity against the policy, and - // returns the signer whose identity is extracted from the verified leaf certificate. - let signer; - try { - console.log(' Performing verification...'); - signer = verifier.verify(signedEntity, verificationPolicy); - } catch (error: unknown) { - throw new Error(`Validation failed: ${error instanceof Error ? error.message : String(error)}`); - } + timestampAuthorities: trustedRoot.timestampAuthorities?.map((tsa: any) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalized: any = { + subject: tsa.subject, + uri: tsa.uri, + certChain: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + certificates: tsa.certChain.certificates.map((cert: any) => ({ + rawBytes: Buffer.from(cert.rawBytes, 'base64'), + })), + }, + validFor: { + start: tsa.validFor?.start ? new Date(tsa.validFor.start) : undefined, + end: tsa.validFor?.end ? new Date(tsa.validFor.end) : undefined, + }, + }; + return normalized; + }), + }; + + // Create trust material from the custom trusted root + const trustMaterial = toTrustMaterial(normalizedTrustedRoot); + + // Configure verifier options + const verifierOptions = { + tsaThreshold: 1, // Require TSA timestamp verification + ctlogThreshold: 0, // No CT logs for custom CA + tlogThreshold: 0, // No transparency logs for custom CA + }; + + // Create the verifier with custom trust material + const verifier = new Verifier(trustMaterial, verifierOptions); + + // Convert bundle to signed entity for verification + const signedEntity = toSignedEntity(bundleSig, tarball); // tarball is already a Buffer + + const uriPrefix = getURIPrefix(role); + const expectedSubjectAlternativeName = `${uriPrefix}${commonName}`; + + // Define the verification policy with the expected subject alternative name. + const verificationPolicy = { + subjectAlternativeName: expectedSubjectAlternativeName, + }; + + // Verify the signature. @sigstore/verify validates the certificate chain, + // signature, and timestamp, checks the certificate identity against the policy, and + // returns the signer whose identity is extracted from the verified leaf certificate. + let signer; + try { + console.log(' Performing verification...'); + signer = verifier.verify(signedEntity, verificationPolicy); + } catch (error: unknown) { + throw new Error( + `Validation failed: ${error instanceof Error ? error.message : String(error)}` + ); + } - // @sigstore/verify matches the policy Subject Alternative Name as a regular - // expression, so the policy check above also accepts prefixes and wildcards of - // the identity. We add this guardrail to require an exact match. - const actualSubjectAlternativeName = signer.identity?.subjectAlternativeName; - if (actualSubjectAlternativeName !== expectedSubjectAlternativeName) { - throw new Error( - `❌ Verification failed: certificate identity error, expected ${expectedSubjectAlternativeName} but received ${actualSubjectAlternativeName ?? 'undefined'}` - ); - } + // @sigstore/verify matches the policy Subject Alternative Name as a regular + // expression, so the policy check above also accepts prefixes and wildcards of + // the identity. We add this guardrail to require an exact match. + const actualSubjectAlternativeName = signer.identity?.subjectAlternativeName; + if (actualSubjectAlternativeName !== expectedSubjectAlternativeName) { + throw new Error( + `❌ Verification failed: certificate identity error, expected ${expectedSubjectAlternativeName} but received ${actualSubjectAlternativeName ?? 'undefined'}` + ); + } - console.log('✅ Verification successful!'); + console.log('✅ Verification successful!'); + } finally { + await cleanupTarball(tarballPath); + } } export async function verifyTaskOrigin(options: TaskOriginVerifyOptions): Promise { diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index ddf1baf..be640fc 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -3,7 +3,7 @@ import path from 'path'; import { TASK_ORIGIN_COMMON_NAMES, TASK_ORIGIN_SIGNATURE_FILE_NAMES } from './constants'; import { findContractDeploymentsRoot } from './deployments'; import { getValidationSummary, parseFromString } from './parser'; -import { assertWithinDir } from './path-validation'; +import { assertWithinDir, isSafePathSegment } from './path-validation'; import { StateDiffClient } from './state-diff'; import { verifyTaskOrigin } from './task-origin-validate'; import { @@ -30,15 +30,51 @@ export type ValidationServiceOpts = { const CONTRACT_DEPLOYMENTS_ROOT = findContractDeploymentsRoot(); const stateDiffClient = new StateDiffClient(0, CONTRACT_DEPLOYMENTS_ROOT); +let activeValidation: Promise = Promise.resolve(); + +async function withValidationLock(fn: () => Promise): Promise { + const previousValidation = activeValidation; + let releaseCurrentValidation!: () => void; + activeValidation = new Promise(resolve => { + releaseCurrentValidation = resolve; + }); + + await previousValidation; + try { + return await fn(); + } finally { + releaseCurrentValidation(); + } +} + async function getConfigData( opts: ValidationServiceOpts -): Promise<{ cfg: TaskConfig; scriptPath: string }> { - const upgradePath = path.join(CONTRACT_DEPLOYMENTS_ROOT, opts.network, opts.upgradeId); - assertWithinDir(upgradePath, CONTRACT_DEPLOYMENTS_ROOT); +): Promise<{ cfg: TaskConfig; scriptPath: string; taskOriginDir: string; signatureDir: string }> { + if (!isSafePathSegment(opts.upgradeId) || !isSafePathSegment(opts.taskConfigFileName)) { + throw new Error( + 'ValidationService::getConfigData: upgradeId and taskConfigFileName must be path-safe segments' + ); + } + const scriptPath = assertWithinDir( + path.join(CONTRACT_DEPLOYMENTS_ROOT, 'active', 'evm'), + CONTRACT_DEPLOYMENTS_ROOT + ); + const taskPath = assertWithinDir( + path.join(scriptPath, 'tasks', opts.upgradeId), + CONTRACT_DEPLOYMENTS_ROOT + ); + const configDir = assertWithinDir( + path.join(taskPath, 'config', opts.network, 'validations'), + CONTRACT_DEPLOYMENTS_ROOT + ); + const networkConfigDir = assertWithinDir( + path.join(taskPath, 'config', opts.network), + CONTRACT_DEPLOYMENTS_ROOT + ); const configFileName = `${opts.taskConfigFileName}.json`; - const configPath = path.join(upgradePath, 'validations', configFileName); - assertWithinDir(configPath, CONTRACT_DEPLOYMENTS_ROOT); + const configPath = path.join(configDir, configFileName); + assertWithinDir(configPath, configDir); let configContent: string; try { @@ -63,7 +99,15 @@ async function getConfigData( } console.log(`✅ Loaded config data from ${configFileName}`); - return { cfg: parsedConfig.config, scriptPath: upgradePath }; + return { + cfg: parsedConfig.config, + scriptPath, + taskOriginDir: scriptPath, + signatureDir: assertWithinDir( + path.join(networkConfigDir, 'signatures'), + CONTRACT_DEPLOYMENTS_ROOT + ), + }; } function getExpectedData(parsedConfig: TaskConfig): { @@ -115,17 +159,17 @@ async function runStateDiffSimulation( } async function validateSigner( - opts: ValidationServiceOpts, + taskOriginDir: string, + signatureDir: string, role: TaskOriginRole, commonNameOverride?: string // Only used for taskCreator ): Promise { - const networkPath = path.join(CONTRACT_DEPLOYMENTS_ROOT, opts.network); - const taskFolderPath = path.join(networkPath, opts.upgradeId); + const taskFolderPath = taskOriginDir; assertWithinDir(taskFolderPath, CONTRACT_DEPLOYMENTS_ROOT); // Get signatureFileName from constants (hardcoded for all roles) const signatureFileName = TASK_ORIGIN_SIGNATURE_FILE_NAMES[role]; - const signatureFile = path.join(networkPath, 'signatures', opts.upgradeId, signatureFileName); + const signatureFile = path.join(signatureDir, signatureFileName); assertWithinDir(signatureFile, CONTRACT_DEPLOYMENTS_ROOT); // Get commonName: from config for taskCreator, from constants for facilitators @@ -150,7 +194,8 @@ async function validateSigner( * Validates task origin signatures. Aggregates all results and returns instead of throwing. */ async function runTaskOriginValidation( - opts: ValidationServiceOpts, + taskOriginDir: string, + signatureDir: string, config: TaskOriginValidationConfig ): Promise { const results: TaskOriginSignerResult[] = []; @@ -158,7 +203,14 @@ async function runTaskOriginValidation( // Validate task creator - uses commonName from config console.log(`🔐 Validating ${TASK_ORIGIN_ROLE_LABELS.taskCreator} signature...`); try { - results.push(await validateSigner(opts, 'taskCreator', config.taskCreator.commonName)); + results.push( + await validateSigner( + taskOriginDir, + signatureDir, + 'taskCreator', + config.taskCreator.commonName + ) + ); console.log(` ✓ ${TASK_ORIGIN_ROLE_LABELS.taskCreator} signature verified`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -173,7 +225,7 @@ async function runTaskOriginValidation( // Validate base facilitator console.log(`🔐 Validating ${TASK_ORIGIN_ROLE_LABELS.baseFacilitator} signature...`); try { - results.push(await validateSigner(opts, 'baseFacilitator')); + results.push(await validateSigner(taskOriginDir, signatureDir, 'baseFacilitator')); console.log(` ✓ ${TASK_ORIGIN_ROLE_LABELS.baseFacilitator} signature verified`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -188,7 +240,7 @@ async function runTaskOriginValidation( // Validate security council facilitator console.log(`🔐 Validating ${TASK_ORIGIN_ROLE_LABELS.securityCouncilFacilitator} signature...`); try { - results.push(await validateSigner(opts, 'securityCouncilFacilitator')); + results.push(await validateSigner(taskOriginDir, signatureDir, 'securityCouncilFacilitator')); console.log(` ✓ ${TASK_ORIGIN_ROLE_LABELS.securityCouncilFacilitator} signature verified`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -222,56 +274,62 @@ async function runTaskOriginValidation( * Main validation flow that orchestrates script extraction, simulation, and config parsing. */ export async function validateUpgrade(opts: ValidationServiceOpts): Promise { - console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); - - const { cfg, scriptPath } = await getConfigData(opts); - - // Determine task origin validation state - let taskOriginValidation: TaskOriginValidation; - if (cfg.skipTaskOriginValidation === true) { - console.log( - '⚠️ Task origin validation is explicitly skipped in config (acceptable for testnet)' - ); - taskOriginValidation = { - enabled: false, - results: [], - hidden: cfg.hideTaskOriginSkippedPage === true, - }; - } else if (!cfg.taskOriginConfig) { - throw new Error( - 'ValidationService::validateUpgrade: taskOriginConfig is required when task origin validation is enabled. ' + - 'Set skipTaskOriginValidation: true to disable validation (acceptable for testnet environments).' - ); - } else { - console.log('🔐 Running task origin validation (must pass before simulation)...'); - taskOriginValidation = await runTaskOriginValidation(opts, cfg.taskOriginConfig); - } + return withValidationLock(async () => { + console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); + + const { cfg, scriptPath, taskOriginDir, signatureDir } = await getConfigData(opts); + + // Determine task origin validation state + let taskOriginValidation: TaskOriginValidation; + if (cfg.skipTaskOriginValidation === true) { + console.log( + '⚠️ Task origin validation is explicitly skipped in config (acceptable for testnet)' + ); + taskOriginValidation = { + enabled: false, + results: [], + hidden: cfg.hideTaskOriginSkippedPage === true, + }; + } else if (!cfg.taskOriginConfig) { + throw new Error( + 'ValidationService::validateUpgrade: taskOriginConfig is required when task origin validation is enabled. ' + + 'Set skipTaskOriginValidation: true to disable validation (acceptable for testnet environments).' + ); + } else { + console.log('🔐 Running task origin validation (must pass before simulation)...'); + taskOriginValidation = await runTaskOriginValidation( + taskOriginDir, + signatureDir, + cfg.taskOriginConfig + ); + } - // Check if task origin validation failed - if so, skip simulation - const hasTaskOriginFailure = - taskOriginValidation.enabled && taskOriginValidation.results.some(r => !r.success); + // Check if task origin validation failed - if so, skip simulation + const hasTaskOriginFailure = + taskOriginValidation.enabled && taskOriginValidation.results.some(r => !r.success); + + if (hasTaskOriginFailure) { + console.log('❌ Task origin validation failed - skipping simulation'); + const expected = getExpectedData(cfg); + return { + expected, + actual: { + stateOverrides: [], + stateChanges: [], + balanceChanges: [], + }, + taskOriginValidation, + }; + } - if (hasTaskOriginFailure) { - console.log('❌ Task origin validation failed - skipping simulation'); + // Run the task simulation const expected = getExpectedData(cfg); + const actual = await runStateDiffSimulation(scriptPath, cfg); + return { expected, - actual: { - stateOverrides: [], - stateChanges: [], - balanceChanges: [], - }, + actual, taskOriginValidation, }; - } - - // Run the task simulation - const expected = getExpectedData(cfg); - const actual = await runStateDiffSimulation(scriptPath, cfg); - - return { - expected, - actual, - taskOriginValidation, - }; + }); }