Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions __tests__/genTaskOriginSig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
}
});
});
Expand Down Expand Up @@ -398,6 +400,14 @@ async function writeFixtureLeafCertificate(bundlePath: string, directory: string
return certificatePath;
}

async function cleanupCreatedTarball(tarballPath: string): Promise<void> {
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): {
Expand Down
66 changes: 28 additions & 38 deletions __tests__/task-origin-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ async function listTarEntries(tarballPath: string): Promise<string[]> {
return entries;
}

async function cleanupCreatedTarball(tarballPath: string): Promise<void> {
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[] = [];
Expand All @@ -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
}
Expand All @@ -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');
Expand All @@ -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 () => {
Expand All @@ -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
Expand All @@ -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 });
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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(
Expand All @@ -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(
Expand Down
71 changes: 41 additions & 30 deletions scripts/genTaskOriginSig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions scripts/genValidationFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<task-id>/config/<network>/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/<task-id>/config/<network>/validations/base-sc.json
`;
console.log(msg);
}
Expand Down
38 changes: 35 additions & 3 deletions src/app/api/validate/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ 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',
})
);

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',
});
Expand All @@ -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',
})
Expand All @@ -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);
});
});
Loading
Loading