From 3d570d16bf6ac1001a586deae0ea756b0e12b1f1 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Sun, 28 Jun 2026 12:26:59 -0400 Subject: [PATCH 01/20] feat: harden task origin verification Co-authored-by: Codex --- __tests__/genTaskOriginSig.test.ts | 18 +- __tests__/task-origin-validate.test.ts | 66 +++-- scripts/genTaskOriginSig.ts | 71 +++--- scripts/genValidationFile.ts | 8 +- src/app/api/validate/__tests__/route.test.ts | 38 ++- src/app/api/validate/route.ts | 11 + src/lib/path-validation.ts | 6 + src/lib/state-diff.ts | 2 +- src/lib/task-origin-validate.ts | 247 ++++++++++--------- src/lib/validation-service.ts | 178 ++++++++----- 10 files changed, 388 insertions(+), 257 deletions(-) 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, - }; + }); } From ec6c6919ce565233bef30e043ad2dd941f88c217 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Sun, 28 Jun 2026 18:21:22 -0400 Subject: [PATCH 02/20] fix: restore original upgradeId test value in validate route test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgradeId was renamed from '2025-08-01-upgrade-qux' to '2026-06-18-beryl-1' but this was unnecessary — test placeholder values should remain stable and not track real task names. Generated with Claude Code Co-Authored-By: Claude --- src/app/api/validate/__tests__/route.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index d5575b3..a70757b 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: '2026-06-18-beryl-1', + upgradeId: '2025-08-01-upgrade-qux', network: 'zeronet', userType: 'base-sc', }) @@ -41,7 +41,7 @@ describe('POST /api/validate', () => { expect(res.status).toBe(200); expect(mockValidateUpgrade).toHaveBeenCalledWith({ - upgradeId: '2026-06-18-beryl-1', + upgradeId: '2025-08-01-upgrade-qux', 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: '2026-06-18-beryl-1', + upgradeId: '2025-08-01-upgrade-qux', network: 'hoodi', userType: 'base-sc', }) @@ -83,7 +83,7 @@ describe('POST /api/validate', () => { it('rejects path separators in userType', async () => { const res = await POST( createRequest({ - upgradeId: '2026-06-18-beryl-1', + upgradeId: '2025-08-01-upgrade-qux', network: 'mainnet', userType: '../base-sc', }) From bb79b38b30b4dcfeb07146807752f23c5c2fdd8f Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 29 Jun 2026 12:20:02 -0400 Subject: [PATCH 03/20] fix: use generic upgradeId in validate test and remove hardcoded example - Replace '2025-08-01-upgrade-qux' with '2025-01-01-upgrade-example' for a more obviously synthetic test value - Remove the hardcoded task/network example from genTaskOriginSig printUsage; the README and --help flags section already document the usage pattern Generated with Claude Code Co-Authored-By: Claude --- scripts/genTaskOriginSig.ts | 3 --- src/app/api/validate/__tests__/route.test.ts | 8 ++++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/scripts/genTaskOriginSig.ts b/scripts/genTaskOriginSig.ts index 6c2ddd6..f666897 100644 --- a/scripts/genTaskOriginSig.ts +++ b/scripts/genTaskOriginSig.ts @@ -66,9 +66,6 @@ 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); } diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index a70757b..d11f571 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: '2025-01-01-upgrade-example', 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: '2025-01-01-upgrade-example', 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: '2025-01-01-upgrade-example', network: 'hoodi', userType: 'base-sc', }) @@ -83,7 +83,7 @@ describe('POST /api/validate', () => { it('rejects path separators in userType', async () => { const res = await POST( createRequest({ - upgradeId: '2025-08-01-upgrade-qux', + upgradeId: '2025-01-01-upgrade-example', network: 'mainnet', userType: '../base-sc', }) From 92b64c395f7064a490af70ce6b3d8c969db0bacd Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 29 Jun 2026 12:34:31 -0400 Subject: [PATCH 04/20] fix: use generic upgradeId in path-traversal test Replace real-looking task name with generic value in path-traversal test. Generated with Claude Code Co-Authored-By: Claude --- src/app/api/validate/__tests__/route.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index d11f571..26acdab 100644 --- a/src/app/api/validate/__tests__/route.test.ts +++ b/src/app/api/validate/__tests__/route.test.ts @@ -67,7 +67,7 @@ describe('POST /api/validate', () => { it('rejects path separators in upgradeId', async () => { const res = await POST( createRequest({ - upgradeId: '../2026-06-18-beryl-1', + upgradeId: '../2025-01-01-upgrade-example', network: 'mainnet', userType: 'base-sc', }) From 6fa069e33951e5cdfb0b33fd102eabda68d48cb1 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 6 Jul 2026 14:17:58 -0400 Subject: [PATCH 05/20] refactor: address jackchuma review comments - Move signature dir from tasks//config//signatures to tasks//signatures (signatures are not config) - Drop taskOriginDir from getConfigData return (always equalled scriptPath) - Remove taskFolderPath alias in validateSigner; pass taskOriginDir directly - Remove networkConfigDir intermediate var (no longer needed for sig path) - Rename cleanupCreatedTarball -> assertTarballInTmpDir; drop rm so tarballs persist for debugging - Refactor two identical path-traversal rejection tests to it.each - Update task-origin-validate.ts comment and genTaskOriginSig.ts help text Generated with Claude Code Co-Authored-By: Claude --- __tests__/genTaskOriginSig.test.ts | 14 ++++------ src/app/api/validate/__tests__/route.test.ts | 29 ++++---------------- src/lib/task-origin-validate.ts | 4 +-- src/lib/validation-service.ts | 18 ++++-------- 4 files changed, 18 insertions(+), 47 deletions(-) diff --git a/__tests__/genTaskOriginSig.test.ts b/__tests__/genTaskOriginSig.test.ts index d175e4c..6d94e38 100644 --- a/__tests__/genTaskOriginSig.test.ts +++ b/__tests__/genTaskOriginSig.test.ts @@ -297,13 +297,10 @@ describe('signTaskWithCert', () => { expect(bundle.verificationMaterial.x509CertificateChain.certificates[0].rawBytes).toBeTruthy(); 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); - } finally { - await cleanupCreatedTarball(tarballPath); - } + await assertTarballInTmpDir(tarballPath); + const tarball = await fsp.readFile(tarballPath); + const expectedDigest = createHash('sha256').update(new Uint8Array(tarball)).digest('base64'); + expect(bundle.messageSignature.messageDigest.digest).toBe(expectedDigest); }); }); @@ -400,12 +397,11 @@ async function writeFixtureLeafCertificate(bundlePath: string, directory: string return certificatePath; } -async function cleanupCreatedTarball(tarballPath: string): Promise { +async function assertTarballInTmpDir(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, diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index 26acdab..59179c9 100644 --- a/src/app/api/validate/__tests__/route.test.ts +++ b/src/app/api/validate/__tests__/route.test.ts @@ -64,30 +64,11 @@ describe('POST /api/validate', () => { expect(body.message).toContain('zeronet'); }); - it('rejects path separators in upgradeId', async () => { - const res = await POST( - createRequest({ - upgradeId: '../2025-01-01-upgrade-example', - 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: '2025-01-01-upgrade-example', - network: 'mainnet', - userType: '../base-sc', - }) - ); + it.each([ + ['upgradeId', { upgradeId: '../2025-01-01-upgrade-example', network: 'mainnet', userType: 'base-sc' }], + ['userType', { upgradeId: '2025-01-01-upgrade-example', network: 'mainnet', userType: '../base-sc' }], + ])('rejects unsafe path segments in %s', async (_field, requestBody) => { + const res = await POST(createRequest(requestBody)); expect(res.status).toBe(400); expect(mockValidateUpgrade).not.toHaveBeenCalled(); diff --git a/src/lib/task-origin-validate.ts b/src/lib/task-origin-validate.ts index d2f95ef..920c6ca 100644 --- a/src/lib/task-origin-validate.ts +++ b/src/lib/task-origin-validate.ts @@ -36,8 +36,8 @@ async function getAllFilesRecursively( const entries = await fs.readdir(currentDir, { withFileTypes: true }); const files: string[] = []; // 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. + // signatures are stored under tasks//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) { diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index be640fc..c5f041e 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -49,7 +49,7 @@ async function withValidationLock(fn: () => Promise): Promise { async function getConfigData( opts: ValidationServiceOpts -): Promise<{ cfg: TaskConfig; scriptPath: string; taskOriginDir: string; signatureDir: string }> { +): Promise<{ cfg: TaskConfig; scriptPath: string; signatureDir: string }> { if (!isSafePathSegment(opts.upgradeId) || !isSafePathSegment(opts.taskConfigFileName)) { throw new Error( 'ValidationService::getConfigData: upgradeId and taskConfigFileName must be path-safe segments' @@ -68,10 +68,6 @@ async function getConfigData( 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(configDir, configFileName); assertWithinDir(configPath, configDir); @@ -102,9 +98,8 @@ async function getConfigData( return { cfg: parsedConfig.config, scriptPath, - taskOriginDir: scriptPath, signatureDir: assertWithinDir( - path.join(networkConfigDir, 'signatures'), + path.join(taskPath, 'signatures'), CONTRACT_DEPLOYMENTS_ROOT ), }; @@ -164,8 +159,7 @@ async function validateSigner( role: TaskOriginRole, commonNameOverride?: string // Only used for taskCreator ): Promise { - const taskFolderPath = taskOriginDir; - assertWithinDir(taskFolderPath, CONTRACT_DEPLOYMENTS_ROOT); + assertWithinDir(taskOriginDir, CONTRACT_DEPLOYMENTS_ROOT); // Get signatureFileName from constants (hardcoded for all roles) const signatureFileName = TASK_ORIGIN_SIGNATURE_FILE_NAMES[role]; @@ -177,7 +171,7 @@ async function validateSigner( commonNameOverride ?? TASK_ORIGIN_COMMON_NAMES[role as keyof typeof TASK_ORIGIN_COMMON_NAMES]; await verifyTaskOrigin({ - taskFolderPath, + taskFolderPath: taskOriginDir, signatureFile, commonName, role, @@ -277,7 +271,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise { console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); - const { cfg, scriptPath, taskOriginDir, signatureDir } = await getConfigData(opts); + const { cfg, scriptPath, signatureDir } = await getConfigData(opts); // Determine task origin validation state let taskOriginValidation: TaskOriginValidation; @@ -298,7 +292,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise Date: Mon, 6 Jul 2026 14:23:51 -0400 Subject: [PATCH 06/20] refactor: remove assertTarballInTmpDir implementation detail test Generated with Claude Code Co-Authored-By: Claude --- __tests__/genTaskOriginSig.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/__tests__/genTaskOriginSig.test.ts b/__tests__/genTaskOriginSig.test.ts index 6d94e38..412ad13 100644 --- a/__tests__/genTaskOriginSig.test.ts +++ b/__tests__/genTaskOriginSig.test.ts @@ -297,7 +297,6 @@ describe('signTaskWithCert', () => { expect(bundle.verificationMaterial.x509CertificateChain.certificates[0].rawBytes).toBeTruthy(); const tarballPath = await createDeterministicTarball(taskFolder); - await assertTarballInTmpDir(tarballPath); const tarball = await fsp.readFile(tarballPath); const expectedDigest = createHash('sha256').update(new Uint8Array(tarball)).digest('base64'); expect(bundle.messageSignature.messageDigest.digest).toBe(expectedDigest); @@ -397,12 +396,6 @@ async function writeFixtureLeafCertificate(bundlePath: string, directory: string return certificatePath; } -async function assertTarballInTmpDir(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); -} // Returns the full certificate chain from a Sigstore bundle as a PEM string, // alongside the original per-certificate DER (base64). From 8f470931ac84a07fef920a2f68ecb7434279ad5a Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 6 Jul 2026 14:25:37 -0400 Subject: [PATCH 07/20] refactor: remove redundant isSafePathSegment check from route handler validation-service.ts already validates at the service layer; the route check was pure duplication. Removed the guard, its import, and the tests that were written specifically to cover it. Generated with Claude Code Co-Authored-By: Claude --- src/app/api/validate/__tests__/route.test.ts | 11 ----------- src/app/api/validate/route.ts | 11 ----------- 2 files changed, 22 deletions(-) diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index 59179c9..cea59a5 100644 --- a/src/app/api/validate/__tests__/route.test.ts +++ b/src/app/api/validate/__tests__/route.test.ts @@ -64,16 +64,5 @@ describe('POST /api/validate', () => { expect(body.message).toContain('zeronet'); }); - it.each([ - ['upgradeId', { upgradeId: '../2025-01-01-upgrade-example', network: 'mainnet', userType: 'base-sc' }], - ['userType', { upgradeId: '2025-01-01-upgrade-example', network: 'mainnet', userType: '../base-sc' }], - ])('rejects unsafe path segments in %s', async (_field, requestBody) => { - const res = await POST(createRequest(requestBody)); - 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 c4c1607..7a17e09 100644 --- a/src/app/api/validate/route.ts +++ b/src/app/api/validate/route.ts @@ -1,7 +1,6 @@ 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 { @@ -26,16 +25,6 @@ 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( { From b2de6107642de4d4dd8ea3703c4f4b36882ad7ac Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 6 Jul 2026 14:27:42 -0400 Subject: [PATCH 08/20] =?UTF-8?q?refactor:=20remove=20isSafePathSegment=20?= =?UTF-8?q?=E2=80=94=20assertWithinDir=20already=20covers=20traversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertWithinDir resolves and bounds-checks every user-supplied path segment, so the extra alphanumeric guard was redundant. isSafePathSegment was not present before this PR; removing it and its definition in path-validation.ts. Generated with Claude Code Co-Authored-By: Claude --- src/lib/path-validation.ts | 6 ------ src/lib/validation-service.ts | 8 +------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/lib/path-validation.ts b/src/lib/path-validation.ts index 680366a..4e932a2 100644 --- a/src/lib/path-validation.ts +++ b/src/lib/path-validation.ts @@ -13,9 +13,3 @@ 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/validation-service.ts b/src/lib/validation-service.ts index c5f041e..c2f54d3 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, isSafePathSegment } from './path-validation'; +import { assertWithinDir } from './path-validation'; import { StateDiffClient } from './state-diff'; import { verifyTaskOrigin } from './task-origin-validate'; import { @@ -50,12 +50,6 @@ async function withValidationLock(fn: () => Promise): Promise { async function getConfigData( opts: ValidationServiceOpts ): Promise<{ cfg: TaskConfig; scriptPath: 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 From df96ae6e5eeb8d0f612552d4e24521256bdeadf9 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 6 Jul 2026 16:35:59 -0400 Subject: [PATCH 09/20] =?UTF-8?q?revert:=20restore=20route.test.ts=20to=20?= =?UTF-8?q?main=20=E2=80=94=20out=20of=20scope=20for=20this=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with Claude Code Co-Authored-By: Claude --- src/app/api/validate/__tests__/route.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index cea59a5..44f8985 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-01-01-upgrade-example', + upgradeId: '2025-08-01-upgrade-qux', network: 'zeronet', userType: 'base-sc', }) @@ -41,7 +41,7 @@ describe('POST /api/validate', () => { expect(res.status).toBe(200); expect(mockValidateUpgrade).toHaveBeenCalledWith({ - upgradeId: '2025-01-01-upgrade-example', + upgradeId: '2025-08-01-upgrade-qux', 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-01-01-upgrade-example', + upgradeId: '2025-08-01-upgrade-qux', network: 'hoodi', userType: 'base-sc', }) @@ -63,6 +63,4 @@ describe('POST /api/validate', () => { expect(body.message).toMatch(/unsupported network/i); expect(body.message).toContain('zeronet'); }); - - }); From a4756416646d3ff06dc8c850e075c6ae973b3f97 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 6 Jul 2026 16:44:55 -0400 Subject: [PATCH 10/20] chore: run prettier Generated with Claude Code Co-Authored-By: Claude --- __tests__/genTaskOriginSig.test.ts | 1 - src/lib/validation-service.ts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/__tests__/genTaskOriginSig.test.ts b/__tests__/genTaskOriginSig.test.ts index 412ad13..9a2e864 100644 --- a/__tests__/genTaskOriginSig.test.ts +++ b/__tests__/genTaskOriginSig.test.ts @@ -396,7 +396,6 @@ async function writeFixtureLeafCertificate(bundlePath: string, directory: string return certificatePath; } - // 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/src/lib/validation-service.ts b/src/lib/validation-service.ts index c2f54d3..dea21a3 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -92,10 +92,7 @@ async function getConfigData( return { cfg: parsedConfig.config, scriptPath, - signatureDir: assertWithinDir( - path.join(taskPath, 'signatures'), - CONTRACT_DEPLOYMENTS_ROOT - ), + signatureDir: assertWithinDir(path.join(taskPath, 'signatures'), CONTRACT_DEPLOYMENTS_ROOT), }; } From 6231405477cae0e8024cbce5c7e08edab13e3d15 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Mon, 6 Jul 2026 20:45:12 -0400 Subject: [PATCH 11/20] fix: pass taskPath instead of scriptPath to runTaskOriginValidation Signing active/evm/ (the entire EVM tree) meant any file change anywhere would invalidate all task signatures. The tarball should cover only the specific task folder: active/evm/tasks/. taskPath was already computed in getConfigData but not returned; add it to the return type and thread it through validateUpgrade. Update README examples to use the concrete active/evm/tasks/ path. Generated with Claude Code Co-Authored-By: Claude --- README.md | 12 ++++++------ src/lib/validation-service.ts | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 535694c..7ef3a34 100644 --- a/README.md +++ b/README.md @@ -359,8 +359,8 @@ npx tsx scripts/genTaskOriginSig.ts --help ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder // \ - --signature-path //signatures/ + --task-folder active/evm/tasks/2025-06-04-upgrade-foo \ + --signature-path active/evm/tasks/2025-06-04-upgrade-foo/signatures ``` **Sign as Base facilitator:** @@ -368,8 +368,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder // \ - --signature-path //signatures/ \ + --task-folder active/evm/tasks/2025-06-04-upgrade-foo \ + --signature-path active/evm/tasks/2025-06-04-upgrade-foo/signatures \ --facilitator base ``` @@ -378,8 +378,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts verify \ - --task-folder // \ - --signature-path //signatures/ \ + --task-folder active/evm/tasks/2025-06-04-upgrade-foo \ + --signature-path active/evm/tasks/2025-06-04-upgrade-foo/signatures \ --common-name alice@example.com ``` diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index dea21a3..f8f1b09 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -49,7 +49,7 @@ async function withValidationLock(fn: () => Promise): Promise { async function getConfigData( opts: ValidationServiceOpts -): Promise<{ cfg: TaskConfig; scriptPath: string; signatureDir: string }> { +): Promise<{ cfg: TaskConfig; scriptPath: string; taskPath: string; signatureDir: string }> { const scriptPath = assertWithinDir( path.join(CONTRACT_DEPLOYMENTS_ROOT, 'active', 'evm'), CONTRACT_DEPLOYMENTS_ROOT @@ -92,6 +92,7 @@ async function getConfigData( return { cfg: parsedConfig.config, scriptPath, + taskPath, signatureDir: assertWithinDir(path.join(taskPath, 'signatures'), CONTRACT_DEPLOYMENTS_ROOT), }; } @@ -262,7 +263,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise { console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); - const { cfg, scriptPath, signatureDir } = await getConfigData(opts); + const { cfg, scriptPath, taskPath, signatureDir } = await getConfigData(opts); // Determine task origin validation state let taskOriginValidation: TaskOriginValidation; @@ -283,7 +284,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise Date: Mon, 6 Jul 2026 20:48:12 -0400 Subject: [PATCH 12/20] Revert "fix: pass taskPath instead of scriptPath to runTaskOriginValidation" This reverts commit 6231405477cae0e8024cbce5c7e08edab13e3d15. --- README.md | 12 ++++++------ src/lib/validation-service.ts | 7 +++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7ef3a34..535694c 100644 --- a/README.md +++ b/README.md @@ -359,8 +359,8 @@ npx tsx scripts/genTaskOriginSig.ts --help ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder active/evm/tasks/2025-06-04-upgrade-foo \ - --signature-path active/evm/tasks/2025-06-04-upgrade-foo/signatures + --task-folder // \ + --signature-path //signatures/ ``` **Sign as Base facilitator:** @@ -368,8 +368,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder active/evm/tasks/2025-06-04-upgrade-foo \ - --signature-path active/evm/tasks/2025-06-04-upgrade-foo/signatures \ + --task-folder // \ + --signature-path //signatures/ \ --facilitator base ``` @@ -378,8 +378,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts verify \ - --task-folder active/evm/tasks/2025-06-04-upgrade-foo \ - --signature-path active/evm/tasks/2025-06-04-upgrade-foo/signatures \ + --task-folder // \ + --signature-path //signatures/ \ --common-name alice@example.com ``` diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index f8f1b09..dea21a3 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -49,7 +49,7 @@ async function withValidationLock(fn: () => Promise): Promise { async function getConfigData( opts: ValidationServiceOpts -): Promise<{ cfg: TaskConfig; scriptPath: string; taskPath: string; signatureDir: string }> { +): Promise<{ cfg: TaskConfig; scriptPath: string; signatureDir: string }> { const scriptPath = assertWithinDir( path.join(CONTRACT_DEPLOYMENTS_ROOT, 'active', 'evm'), CONTRACT_DEPLOYMENTS_ROOT @@ -92,7 +92,6 @@ async function getConfigData( return { cfg: parsedConfig.config, scriptPath, - taskPath, signatureDir: assertWithinDir(path.join(taskPath, 'signatures'), CONTRACT_DEPLOYMENTS_ROOT), }; } @@ -263,7 +262,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise { console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); - const { cfg, scriptPath, taskPath, signatureDir } = await getConfigData(opts); + const { cfg, scriptPath, signatureDir } = await getConfigData(opts); // Determine task origin validation state let taskOriginValidation: TaskOriginValidation; @@ -284,7 +283,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise Date: Tue, 7 Jul 2026 10:14:36 -0400 Subject: [PATCH 13/20] refactor: scope task-origin tarball to network config dir, remove cleanup --- __tests__/fixtures/valid-task/cache/test.json | 0 __tests__/fixtures/valid-task/out/Test.sol | 0 __tests__/task-origin-validate.test.ts | 28 -- src/lib/task-origin-validate.ts | 242 ++++++++---------- src/lib/validation-service.ts | 18 +- 5 files changed, 126 insertions(+), 162 deletions(-) delete mode 100644 __tests__/fixtures/valid-task/cache/test.json delete mode 100644 __tests__/fixtures/valid-task/out/Test.sol diff --git a/__tests__/fixtures/valid-task/cache/test.json b/__tests__/fixtures/valid-task/cache/test.json deleted file mode 100644 index e69de29..0000000 diff --git a/__tests__/fixtures/valid-task/out/Test.sol b/__tests__/fixtures/valid-task/out/Test.sol deleted file mode 100644 index e69de29..0000000 diff --git a/__tests__/task-origin-validate.test.ts b/__tests__/task-origin-validate.test.ts index cb265ad..3071f46 100644 --- a/__tests__/task-origin-validate.test.ts +++ b/__tests__/task-origin-validate.test.ts @@ -88,34 +88,6 @@ describe('createDeterministicTarball', () => { expect(entries).toContain('test.txt'); }); - 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'); - - const tarballPath = await createDeterministicTarball(tempDir); - createdTarballs.push(tarballPath); - - const entries = await listTarEntries(tarballPath); - - // Verify included file is present - expect(entries).toContain('included.txt'); - - // Verify excluded directories' contents are not present - 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 () => { // Create some files diff --git a/src/lib/task-origin-validate.ts b/src/lib/task-origin-validate.ts index 920c6ca..a6db23a 100644 --- a/src/lib/task-origin-validate.ts +++ b/src/lib/task-origin-validate.ts @@ -35,18 +35,10 @@ async function getAllFilesRecursively( const entries = await fs.readdir(currentDir, { withFileTypes: true }); const files: string[] = []; - // Exclude generated folders from the signed tarball. In the active EVM layout, - // signatures are stored under tasks//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); if (entry.isDirectory()) { - // Skip excluded folders - if (excludedFolders.includes(entry.name)) { - continue; - } files.push(...(await getAllFilesRecursively(fullPath, baseDir, allowedDir))); } else if (entry.isFile()) { files.push(path.relative(baseDir, fullPath)); @@ -113,10 +105,6 @@ 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}`); @@ -134,128 +122,122 @@ export async function buildAndValidateSignature(options: TaskOriginVerifyOptions // Regenerate the tarball from the provided task folder const tarballPath = await createDeterministicTarball(taskFolderPath, allowedDir); - 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 - 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; - }), + 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 - 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)}` - ); - } + const baseCerts = ca.certChain.certificates.map((cert: any) => ({ + rawBytes: Buffer.from(cert.rawBytes, 'base64'), + })); - // @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'}` - ); - } + // Build the certificate chain: base certs + runtime intermediate + root + const certChain = [...baseCerts]; + if (runtimeIntermediate) certChain.push(runtimeIntermediate); + if (rootCert) certChain.push(rootCert); - console.log('✅ Verification successful!'); - } finally { - await cleanupTarball(tarballPath); + // 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) => { + // 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'}` + ); + } + + console.log('✅ Verification successful!'); } export async function verifyTaskOrigin(options: TaskOriginVerifyOptions): Promise { diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index dea21a3..fd97ed7 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -49,7 +49,12 @@ async function withValidationLock(fn: () => Promise): Promise { async function getConfigData( opts: ValidationServiceOpts -): Promise<{ cfg: TaskConfig; scriptPath: string; signatureDir: string }> { +): Promise<{ + cfg: TaskConfig; + scriptPath: string; + networkConfigDir: string; + signatureDir: string; +}> { const scriptPath = assertWithinDir( path.join(CONTRACT_DEPLOYMENTS_ROOT, 'active', 'evm'), CONTRACT_DEPLOYMENTS_ROOT @@ -58,8 +63,12 @@ async function getConfigData( path.join(scriptPath, 'tasks', opts.upgradeId), CONTRACT_DEPLOYMENTS_ROOT ); + const networkConfigDir = assertWithinDir( + path.join(taskPath, 'config', opts.network), + CONTRACT_DEPLOYMENTS_ROOT + ); const configDir = assertWithinDir( - path.join(taskPath, 'config', opts.network, 'validations'), + path.join(networkConfigDir, 'validations'), CONTRACT_DEPLOYMENTS_ROOT ); const configFileName = `${opts.taskConfigFileName}.json`; @@ -92,6 +101,7 @@ async function getConfigData( return { cfg: parsedConfig.config, scriptPath, + networkConfigDir, signatureDir: assertWithinDir(path.join(taskPath, 'signatures'), CONTRACT_DEPLOYMENTS_ROOT), }; } @@ -262,7 +272,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise { console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); - const { cfg, scriptPath, signatureDir } = await getConfigData(opts); + const { cfg, scriptPath, networkConfigDir, signatureDir } = await getConfigData(opts); // Determine task origin validation state let taskOriginValidation: TaskOriginValidation; @@ -283,7 +293,7 @@ export async function validateUpgrade(opts: ValidationServiceOpts): Promise Date: Tue, 7 Jul 2026 10:19:15 -0400 Subject: [PATCH 14/20] reformat --- __tests__/task-origin-validate.test.ts | 1 - src/lib/validation-service.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/__tests__/task-origin-validate.test.ts b/__tests__/task-origin-validate.test.ts index 3071f46..d3217ef 100644 --- a/__tests__/task-origin-validate.test.ts +++ b/__tests__/task-origin-validate.test.ts @@ -88,7 +88,6 @@ describe('createDeterministicTarball', () => { expect(entries).toContain('test.txt'); }); - it('produces deterministic output with identical hash', async () => { // Create some files await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content 1'); diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index fd97ed7..ea34856 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -47,9 +47,7 @@ async function withValidationLock(fn: () => Promise): Promise { } } -async function getConfigData( - opts: ValidationServiceOpts -): Promise<{ +async function getConfigData(opts: ValidationServiceOpts): Promise<{ cfg: TaskConfig; scriptPath: string; networkConfigDir: string; From d13a86fc23017a4f4a85c9e3b5454fc6ecfa0661 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Tue, 7 Jul 2026 10:58:16 -0400 Subject: [PATCH 15/20] remove unnessary change --- __tests__/task-origin-validate.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/__tests__/task-origin-validate.test.ts b/__tests__/task-origin-validate.test.ts index d3217ef..1f819bf 100644 --- a/__tests__/task-origin-validate.test.ts +++ b/__tests__/task-origin-validate.test.ts @@ -38,14 +38,6 @@ 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[] = []; @@ -63,7 +55,7 @@ describe('createDeterministicTarball', () => { // Clean up any created tarballs for (const tarball of createdTarballs) { try { - await cleanupCreatedTarball(tarball); + await fs.unlink(tarball); } catch { // Ignore errors if file doesn't exist } @@ -102,7 +94,7 @@ describe('createDeterministicTarball', () => { // Delete the tarball directory before creating the second one to verify // determinism does not depend on the temporary output path. - await cleanupCreatedTarball(tarball1); + await fs.unlink(tarball1); createdTarballs = createdTarballs.filter(t => t !== tarball1); // Create second tarball and compute hash From d99a7fe1c42a4e478e73aaab2ef95c7d7ddf4ba9 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Tue, 7 Jul 2026 11:37:47 -0400 Subject: [PATCH 16/20] test: restructure task-origin fixtures to prod network-config layout Move signed task content into config/chain1/ (the network config dir the tarball is scoped to) and keep cache/ and out/ build artifacts at the task root, outside the tarball scope. This mirrors the real active/evm/tasks/[task-id]/config/[network] structure so cache/out are excluded by directory scope rather than by deleting fixture files. Tarball content is unchanged (relative paths from the config dir), so the existing signatures still verify without regeneration. Generated with Claude Code Co-Authored-By: Claude --- .../{ => config/chain1}/README.md | 0 .../{ => config/chain1}/script/Test.s.sol | 0 .../{lib/dep/TestLib.sol => cache/test.json} | 0 .../valid-task/{ => config/chain1}/README.md | 0 .../chain1/lib/dep/TestLib.sol} | 0 .../config/chain1/script/Test.s.sol | 0 __tests__/fixtures/valid-task/out/Test.sol | 0 __tests__/genTaskOriginSig.test.ts | 2 +- __tests__/task-origin-validate.test.ts | 20 +++++-------------- 9 files changed, 6 insertions(+), 16 deletions(-) rename __tests__/fixtures/modified-task/{ => config/chain1}/README.md (100%) rename __tests__/fixtures/modified-task/{ => config/chain1}/script/Test.s.sol (100%) rename __tests__/fixtures/valid-task/{lib/dep/TestLib.sol => cache/test.json} (100%) rename __tests__/fixtures/valid-task/{ => config/chain1}/README.md (100%) rename __tests__/fixtures/valid-task/{script/Test.s.sol => config/chain1/lib/dep/TestLib.sol} (100%) create mode 100644 __tests__/fixtures/valid-task/config/chain1/script/Test.s.sol create mode 100644 __tests__/fixtures/valid-task/out/Test.sol diff --git a/__tests__/fixtures/modified-task/README.md b/__tests__/fixtures/modified-task/config/chain1/README.md similarity index 100% rename from __tests__/fixtures/modified-task/README.md rename to __tests__/fixtures/modified-task/config/chain1/README.md diff --git a/__tests__/fixtures/modified-task/script/Test.s.sol b/__tests__/fixtures/modified-task/config/chain1/script/Test.s.sol similarity index 100% rename from __tests__/fixtures/modified-task/script/Test.s.sol rename to __tests__/fixtures/modified-task/config/chain1/script/Test.s.sol diff --git a/__tests__/fixtures/valid-task/lib/dep/TestLib.sol b/__tests__/fixtures/valid-task/cache/test.json similarity index 100% rename from __tests__/fixtures/valid-task/lib/dep/TestLib.sol rename to __tests__/fixtures/valid-task/cache/test.json diff --git a/__tests__/fixtures/valid-task/README.md b/__tests__/fixtures/valid-task/config/chain1/README.md similarity index 100% rename from __tests__/fixtures/valid-task/README.md rename to __tests__/fixtures/valid-task/config/chain1/README.md diff --git a/__tests__/fixtures/valid-task/script/Test.s.sol b/__tests__/fixtures/valid-task/config/chain1/lib/dep/TestLib.sol similarity index 100% rename from __tests__/fixtures/valid-task/script/Test.s.sol rename to __tests__/fixtures/valid-task/config/chain1/lib/dep/TestLib.sol diff --git a/__tests__/fixtures/valid-task/config/chain1/script/Test.s.sol b/__tests__/fixtures/valid-task/config/chain1/script/Test.s.sol new file mode 100644 index 0000000..e69de29 diff --git a/__tests__/fixtures/valid-task/out/Test.sol b/__tests__/fixtures/valid-task/out/Test.sol new file mode 100644 index 0000000..e69de29 diff --git a/__tests__/genTaskOriginSig.test.ts b/__tests__/genTaskOriginSig.test.ts index 9a2e864..a72410b 100644 --- a/__tests__/genTaskOriginSig.test.ts +++ b/__tests__/genTaskOriginSig.test.ts @@ -23,7 +23,7 @@ import { createDeterministicTarball } from '../src/lib/task-origin-validate'; // Fixture paths const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES_DIR = path.resolve(__dirname, 'fixtures'); -const VALID_TASK_FOLDER = path.join(FIXTURES_DIR, 'valid-task'); +const VALID_TASK_FOLDER = path.join(FIXTURES_DIR, 'valid-task', 'config', 'chain1'); const VALID_SIGNATURES_DIR = path.join(FIXTURES_DIR, 'signatures/valid'); const INVALID_SIGNATURES_DIR = path.join(FIXTURES_DIR, 'signatures/invalid'); const MISSING_SIGNATURES_DIR = path.join(FIXTURES_DIR, 'signatures/missing'); diff --git a/__tests__/task-origin-validate.test.ts b/__tests__/task-origin-validate.test.ts index 1f819bf..dde6ba7 100644 --- a/__tests__/task-origin-validate.test.ts +++ b/__tests__/task-origin-validate.test.ts @@ -14,8 +14,10 @@ import type { TaskOriginRole } from '../src/lib/types'; // Fixture paths const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES_DIR = path.resolve(__dirname, 'fixtures'); -const VALID_TASK_FOLDER = path.join(FIXTURES_DIR, 'valid-task'); -const MODIFIED_TASK_FOLDER = path.join(FIXTURES_DIR, 'modified-task'); +// The tarball is scoped to a single network config dir; cache/ and out/ build +// artifacts live at the task root, outside this leaf, so they are never signed. +const VALID_TASK_FOLDER = path.join(FIXTURES_DIR, 'valid-task', 'config', 'chain1'); +const MODIFIED_TASK_FOLDER = path.join(FIXTURES_DIR, 'modified-task', 'config', 'chain1'); const VALID_SIGNATURES_DIR = path.join(FIXTURES_DIR, 'signatures/valid'); // Task creator email @@ -92,8 +94,7 @@ describe('createDeterministicTarball', () => { createdTarballs.push(tarball1); const hash1 = await computeFileHash(tarball1); - // Delete the tarball directory before creating the second one to verify - // determinism does not depend on the temporary output path. + // Delete the tarball await fs.unlink(tarball1); createdTarballs = createdTarballs.filter(t => t !== tarball1); @@ -106,17 +107,6 @@ 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 }); From 36d2e45e466033688e145e0b478a37a8b53b6feb Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Wed, 8 Jul 2026 16:57:17 -0400 Subject: [PATCH 17/20] =?UTF-8?q?remove=20withValidationLock=20=E2=80=94?= =?UTF-8?q?=20single=20UI=20per=20folder,=20no=20concurrent=20validation?= =?UTF-8?q?=20possible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with Claude Code Co-Authored-By: Claude --- src/lib/validation-service.ts | 113 ++++++++++++++-------------------- 1 file changed, 47 insertions(+), 66 deletions(-) diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index ea34856..67f7eda 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -30,23 +30,6 @@ 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; @@ -267,62 +250,60 @@ async function runTaskOriginValidation( * Main validation flow that orchestrates script extraction, simulation, and config parsing. */ export async function validateUpgrade(opts: ValidationServiceOpts): Promise { - return withValidationLock(async () => { - console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); + console.log(`🚀 Starting validation for ${opts.upgradeId} on ${opts.network}`); - const { cfg, scriptPath, networkConfigDir, signatureDir } = await getConfigData(opts); + const { cfg, scriptPath, networkConfigDir, 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( - networkConfigDir, - signatureDir, - cfg.taskOriginConfig - ); - } - - // Check if task origin validation failed - if so, skip simulation - const hasTaskOriginFailure = - taskOriginValidation.enabled && taskOriginValidation.results.some(r => !r.success); + // 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( + networkConfigDir, + signatureDir, + cfg.taskOriginConfig + ); + } - if (hasTaskOriginFailure) { - console.log('❌ Task origin validation failed - skipping simulation'); - const expected = getExpectedData(cfg); - return { - expected, - actual: { - stateOverrides: [], - stateChanges: [], - balanceChanges: [], - }, - taskOriginValidation, - }; - } + // Check if task origin validation failed - if so, skip simulation + const hasTaskOriginFailure = + taskOriginValidation.enabled && taskOriginValidation.results.some(r => !r.success); - // Run the task simulation + if (hasTaskOriginFailure) { + console.log('❌ Task origin validation failed - skipping simulation'); const expected = getExpectedData(cfg); - const actual = await runStateDiffSimulation(scriptPath, cfg); - return { expected, - actual, + actual: { + stateOverrides: [], + stateChanges: [], + balanceChanges: [], + }, taskOriginValidation, }; - }); + } + + // Run the task simulation + const expected = getExpectedData(cfg); + const actual = await runStateDiffSimulation(scriptPath, cfg); + + return { + expected, + actual, + taskOriginValidation, + }; } From 56bba9946673d1c7e177e01e2bc4b42cbe4c1dda Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Wed, 8 Jul 2026 17:09:53 -0400 Subject: [PATCH 18/20] =?UTF-8?q?revert=20tarball=20to=20cwd=20=E2=80=94?= =?UTF-8?q?=20tmp=20dir=20adds=20no=20value,=20remaining=20file=20is=20use?= =?UTF-8?q?ful=20for=20debugging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with Claude Code Co-Authored-By: Claude --- scripts/genTaskOriginSig.ts | 72 ++++++++++++++++----------------- src/lib/task-origin-validate.ts | 4 +- 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/scripts/genTaskOriginSig.ts b/scripts/genTaskOriginSig.ts index f666897..554b534 100644 --- a/scripts/genTaskOriginSig.ts +++ b/scripts/genTaskOriginSig.ts @@ -229,52 +229,48 @@ export async function signTaskWithCert( const tarballPath = await createDeterministicTarball(taskFolderPath); console.log(` Tarball: ${tarballPath}`); - try { - const keyPem = await fs.readFile(keyPath, 'utf8'); - const certificateChainPem = await fs.readFile(certPath, 'utf8'); + 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; - } finally { - await fs.rm(path.dirname(tarballPath), { recursive: true, force: true }); - } + console.log(` Signature: ${signatureFileOut}`); + return signatureFileOut; } export function facilitatorToRole(facilitator: FacilitatorType | undefined): TaskOriginRole { diff --git a/src/lib/task-origin-validate.ts b/src/lib/task-origin-validate.ts index a6db23a..811ba62 100644 --- a/src/lib/task-origin-validate.ts +++ b/src/lib/task-origin-validate.ts @@ -1,5 +1,4 @@ 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'; @@ -60,8 +59,7 @@ export async function createDeterministicTarball( } const folderName = path.basename(resolvedTaskFolderPath); - const tarballDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-origin-')); - const tarballPath = path.join(tarballDir, `${folderName}.tar`); + const tarballPath = path.resolve(process.cwd(), `${folderName}.tar`); // Check if lib/ folder exists for reproducibility const libPath = path.join(resolvedTaskFolderPath, 'lib'); From 2cd0ada4e6b2bd1383b5f926e8341dcc8c890724 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Wed, 8 Jul 2026 17:12:56 -0400 Subject: [PATCH 19/20] style: prettier Generated with Claude Code Co-Authored-By: Claude --- scripts/genTaskOriginSig.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/genTaskOriginSig.ts b/scripts/genTaskOriginSig.ts index 554b534..5e5a257 100644 --- a/scripts/genTaskOriginSig.ts +++ b/scripts/genTaskOriginSig.ts @@ -242,11 +242,7 @@ export async function signTaskWithCert( const { nodeDigest, sigstoreAlgorithm } = resolveSignatureHash(keyPem); const bundler = new MessageSignatureBundleBuilder({ - signer: new DeviceCertificateSigner( - keyPem, - pemUtils.fromDER(certificateChain[0]), - nodeDigest - ), + signer: new DeviceCertificateSigner(keyPem, pemUtils.fromDER(certificateChain[0]), nodeDigest), witnesses: [new TSAWitness({ tsaBaseURL: TSA_BASE_URL })], }); From 55dcb39bc0d1c9a44cad3c4c36befd2199752cbf Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Wed, 8 Jul 2026 17:32:25 -0400 Subject: [PATCH 20/20] feat: nest task-origin signatures per-network under signatures/ Signatures are signed over the per-network config dir but were stored in a single signatures/ folder with fixed per-role filenames, so a task spanning multiple networks would collide. Nest them under signatures// to mirror config//. Update README to the new active/evm/tasks layout. Generated with Claude Code Co-Authored-By: Claude --- README.md | 102 ++++++++++++++++------------------ src/lib/validation-service.ts | 5 +- 2 files changed, 52 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 535694c..539926e 100644 --- a/README.md +++ b/README.md @@ -32,43 +32,37 @@ git clone https://github.com/base/task-signing-tool.git ### Expected Directory Layout -Place this repository at the root of your task repository. Network folders (e.g., `mainnet`, `sepolia`, `zeronet`) must live alongside it, and each task must be a date-prefixed folder inside a network folder. +Place this repository at the root of your task repository. Tasks live under `active/evm/tasks/`, each a date-prefixed folder (`YYYY-MM-DD-task-name`) holding a per-network `config//` directory (Foundry project + validation configs) and a per-network `signatures//` directory. ```12:40:root-of-your-task-repo contract-deployments/ # your task repo root (example) ├─ task-signing-tool/ # this repo cloned here │ ├─ src/ │ └─ ... -├─ mainnet/ # network directory -│ ├─ 2025-06-04-upgrade-foo/ # task directory (YYYY-MM-DD-task-name) -│ │ ├─ README.md # optional, used for status parsing -│ │ ├─ validations/ -│ │ │ ├─ base-sc.json # config for "Base SC" user type -│ │ │ ├─ coinbase.json # config for "Coinbase" user type -│ │ │ └─ op.json # config for "OP" user type -│ │ └─ foundry-project/ # directory where you run Foundry scripts -│ │ └─ ... -│ ├─ 2025-07-12-upgrade-bar/ -│ │ └─ ... -│ └─ signatures/ # signatures directory (separate from tasks) -│ ├─ 2025-06-04-upgrade-foo/ # matches task directory name -│ │ ├─ creator-signature.json -│ │ ├─ base-facilitator-signature.json -│ │ └─ base-sc-facilitator-signature.json -│ └─ 2025-07-12-upgrade-bar/ -│ └─ ... -├─ sepolia/ -│ ├─ 2025-05-10-upgrade-baz/ -│ │ └─ ... -│ └─ signatures/ -│ └─ 2025-05-10-upgrade-baz/ -│ └─ ... -└─ zeronet/ - ├─ 2025-08-01-upgrade-qux/ - │ └─ ... - └─ signatures/ - └─ 2025-08-01-upgrade-qux/ - └─ ... +└─ active/ + └─ evm/ + └─ tasks/ + ├─ 2025-06-04-upgrade-foo/ # task directory (YYYY-MM-DD-task-name) + │ ├─ config/ + │ │ ├─ mainnet/ # per-network config + Foundry project + │ │ │ ├─ README.md # optional, used for status parsing + │ │ │ ├─ validations/ + │ │ │ │ ├─ base-sc.json # config for "Base SC" user type + │ │ │ │ ├─ coinbase.json # config for "Coinbase" user type + │ │ │ │ └─ op.json # config for "OP" user type + │ │ │ ├─ script/ # Foundry scripts + │ │ │ └─ lib/ # Foundry dependencies + │ │ └─ sepolia/ + │ │ └─ ... + │ └─ signatures/ # signatures (separate from config, per-network) + │ ├─ mainnet/ + │ │ ├─ creator-signature.json + │ │ ├─ base-facilitator-signature.json + │ │ └─ base-sc-facilitator-signature.json + │ └─ sepolia/ + │ └─ ... + └─ 2025-07-12-upgrade-bar/ + └─ ... ``` Key requirements and notes: @@ -79,13 +73,13 @@ Key requirements and notes: - "Base SC" → `base-sc.json` - "Coinbase" → `coinbase.json` - "OP" → `op.json` -- **Task origin signatures**: By default, tasks require three signature files stored in `/signatures//`: `creator-signature.json`, `base-facilitator-signature.json`, and `base-sc-facilitator-signature.json`. Signatures are stored separately from task directories to avoid changing the tarball content. Use the `genTaskOriginSig.ts` script to generate these. Set `skipTaskOriginValidation: true` in the validation config to opt out. -- **Script execution**: The tool executes Foundry from the task directory root (`//`). Ensure your Foundry project or script context is available under that path; the tool will run the `cmd` specified in your validation config. Temporary outputs like `temp-script-output.txt` will be written there. -- **Optional README parsing**: If `//README.md` exists, the tool may parse it to display status and execution links. +- **Task origin signatures**: By default, tasks require three signature files stored per-network in `tasks//signatures//`: `creator-signature.json`, `base-facilitator-signature.json`, and `base-sc-facilitator-signature.json`. Signatures are stored separately from the config directory (which is what the tarball is signed over) so signing does not change the signed content. Use the `genTaskOriginSig.ts` script to generate these. Set `skipTaskOriginValidation: true` in the validation config to opt out. +- **Script execution**: The tool executes Foundry from `active/evm`. Ensure your Foundry project or script context is available under the per-network config directory (`tasks//config//`); the tool will run the `cmd` specified in your validation config. Temporary outputs like `temp-script-output.txt` will be written there. +- **Optional README parsing**: If `tasks//config//README.md` exists, the tool may parse it to display status and execution links. ### Task README structure -When present, each task's `README.md` is parsed to populate the UI. Place it at `//README.md` and follow these rules: +When present, each task's `README.md` is parsed to populate the UI. Place it at `active/evm/tasks//config//README.md` and follow these rules: - **Status line (required for status display)**: Include a line containing `Status:` within the first 20 lines. Recognized values are `PENDING`, `READY TO SIGN`, and `EXECUTED` (case-insensitive). If `EXECUTED` is not present and `READY TO SIGN` is not present, the status is treated as `PENDING`. Supported formats: - **Single-line with link (any one of these)**: @@ -156,7 +150,7 @@ Executed upgrade; links include both onchain transaction and governance proposal ### Validation file structure -Validation configs live under each task directory at `//validations/` and are selected by user type: +Validation configs live under each task directory at `active/evm/tasks//config//validations/` and are selected by user type: - `base-sc.json` for **Base SC** - `coinbase.json` for **Coinbase** @@ -196,7 +190,7 @@ Notes: - Sorting is not required; the tool sorts by address and storage slot for comparison. - The tool reads `rpcUrl` and `ledgerId` directly from this file. -- When task origin validation is enabled, three signature files must exist in `/signatures//` (see **Task Origin Signing** below). +- When task origin validation is enabled, three signature files must exist in `tasks//signatures//` (see **Task Origin Signing** below). Minimal example (`validations/base-sc.json`): @@ -274,9 +268,9 @@ General usage (tsx): npm ci npx tsx scripts/genValidationFile.ts \ --rpc-url https://mainnet.example \ - --workdir / \ + --workdir active/evm \ --forge-cmd "forge script : --sig 'run()' --sender 0xabc..." \ - --out //validations/.json + --out active/evm/tasks//config//validations/.json ``` Alternative (bun): @@ -286,9 +280,9 @@ Alternative (bun): npm ci bun run scripts/genValidationFile.ts \ --rpc-url https://mainnet.example \ - --workdir / \ + --workdir active/evm \ --forge-cmd "forge script : --sig 'run()' --sender 0xabc..." \ - --out //validations/.json + --out active/evm/tasks//config//validations/.json ``` Example from a Makefile (variables expanded in your environment): @@ -297,15 +291,15 @@ Example from a Makefile (variables expanded in your environment): cd "$SIGNER_TOOL_PATH" && \ npm ci && \ bun run scripts/genValidationFile.ts --rpc-url "$L1_RPC_URL" \ - --workdir .. \ + --workdir ../active/evm \ --forge-cmd 'forge script --rpc-url "$L1_RPC_URL" SwapOwner --sig "sign()" --sender "$SENDER"' \ - --out ../validations/test.json + --out ../active/evm/tasks//config//validations/test.json ``` Notes: - Quote the entire `--forge-cmd` so that inner quotes for `--sig` are preserved by your shell. On macOS/Linux, prefer single quotes around the whole command and double quotes inside for signatures/addresses. -- `--workdir` typically points to the task directory (e.g., `mainnet/2025-06-04-upgrade-foo`). If you keep this repo inside the task repo root, `..` will refer to the task directory when running from `task-signing-tool/`. +- `--workdir` points to the forge script root, `active/evm`. If you keep this repo inside the task repo root, `../active/evm` refers to it when running from `task-signing-tool/`. - If `--out` is omitted, the JSON is printed to stdout. ### Task Origin Signing @@ -314,7 +308,7 @@ Use `scripts/genTaskOriginSig.ts` to sign task folders for origin validation. Ta #### Signature files -When task origin validation is enabled, three signature files must exist in `/signatures//`: +When task origin validation is enabled, three signature files must exist in `tasks//signatures//`: - **Task Creator**: `creator-signature.json` — common name is the email of the task author (e.g., `alice@example.com`) - **Base Facilitator**: `base-facilitator-signature.json` — common name is `base-facilitators` (group) @@ -322,7 +316,7 @@ When task origin validation is enabled, three signature files must exist in `/` directory, separate from the `config//` directory that the tarball is signed over, to ensure the signed content remains unchanged after signing. #### Requirements @@ -359,8 +353,8 @@ npx tsx scripts/genTaskOriginSig.ts --help ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder // \ - --signature-path //signatures/ + --task-folder /tasks//config/ \ + --signature-path /tasks//signatures/ ``` **Sign as Base facilitator:** @@ -368,8 +362,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder // \ - --signature-path //signatures/ \ + --task-folder /tasks//config/ \ + --signature-path /tasks//signatures/ \ --facilitator base ``` @@ -378,8 +372,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts verify \ - --task-folder // \ - --signature-path //signatures/ \ + --task-folder /tasks//config/ \ + --signature-path /tasks//signatures/ \ --common-name alice@example.com ``` @@ -423,12 +417,12 @@ When you enable `--estimate-l2-gas`: ```bash npx 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/2025-06-04-my-l2-deposit/config//validations/base-sc.json ``` #### Validation File Output diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts index 67f7eda..3c9f101 100644 --- a/src/lib/validation-service.ts +++ b/src/lib/validation-service.ts @@ -83,7 +83,10 @@ async function getConfigData(opts: ValidationServiceOpts): Promise<{ cfg: parsedConfig.config, scriptPath, networkConfigDir, - signatureDir: assertWithinDir(path.join(taskPath, 'signatures'), CONTRACT_DEPLOYMENTS_ROOT), + signatureDir: assertWithinDir( + path.join(taskPath, 'signatures', opts.network), + CONTRACT_DEPLOYMENTS_ROOT + ), }; }