Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3d570d1
feat: harden task origin verification
0xth4nh Jun 28, 2026
ec6c691
fix: restore original upgradeId test value in validate route test
0xth4nh Jun 28, 2026
bb79b38
fix: use generic upgradeId in validate test and remove hardcoded example
0xth4nh Jun 29, 2026
92b64c3
fix: use generic upgradeId in path-traversal test
0xth4nh Jun 29, 2026
6fa069e
refactor: address jackchuma review comments
0xth4nh Jul 6, 2026
5557865
refactor: remove assertTarballInTmpDir implementation detail test
0xth4nh Jul 6, 2026
8f47093
refactor: remove redundant isSafePathSegment check from route handler
0xth4nh Jul 6, 2026
b2de610
refactor: remove isSafePathSegment — assertWithinDir already covers t…
0xth4nh Jul 6, 2026
df96ae6
revert: restore route.test.ts to main — out of scope for this PR
0xth4nh Jul 6, 2026
a475641
chore: run prettier
0xth4nh Jul 6, 2026
6231405
fix: pass taskPath instead of scriptPath to runTaskOriginValidation
0xth4nh Jul 7, 2026
d6a8d84
Revert "fix: pass taskPath instead of scriptPath to runTaskOriginVali…
0xth4nh Jul 7, 2026
70e87c9
refactor: scope task-origin tarball to network config dir, remove cle…
0xth4nh Jul 7, 2026
65443d3
reformat
0xth4nh Jul 7, 2026
d13a86f
remove unnessary change
0xth4nh Jul 7, 2026
d99a7fe
test: restructure task-origin fixtures to prod network-config layout
0xth4nh Jul 7, 2026
36d2e45
remove withValidationLock — single UI per folder, no concurrent valid…
0xth4nh Jul 8, 2026
56bba99
revert tarball to cwd — tmp dir adds no value, remaining file is usef…
0xth4nh Jul 8, 2026
2cd0ada
style: prettier
0xth4nh Jul 8, 2026
55dcb39
feat: nest task-origin signatures per-network under signatures/<network>
0xth4nh Jul 8, 2026
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
102 changes: 48 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<network>/` directory (Foundry project + validation configs) and a per-network `signatures/<network>/` 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:
Expand All @@ -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 `<network>/signatures/<task-name>/`: `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 (`<network>/<task>/`). 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 `<network>/<task>/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/<task-name>/signatures/<network>/`: `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/<task-name>/config/<network>/`); 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/<task-name>/config/<network>/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 `<network>/<YYYY-MM-DD-slug>/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/<YYYY-MM-DD-slug>/config/<network>/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)**:
Expand Down Expand Up @@ -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 `<network>/<YYYY-MM-DD-slug>/validations/` and are selected by user type:
Validation configs live under each task directory at `active/evm/tasks/<YYYY-MM-DD-slug>/config/<network>/validations/` and are selected by user type:

- `base-sc.json` for **Base SC**
- `coinbase.json` for **Coinbase**
Expand Down Expand Up @@ -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 `<network>/signatures/<task-name>/` (see **Task Origin Signing** below).
- When task origin validation is enabled, three signature files must exist in `tasks/<task-name>/signatures/<network>/` (see **Task Origin Signing** below).

Minimal example (`validations/base-sc.json`):

Expand Down Expand Up @@ -274,9 +268,9 @@ General usage (tsx):
npm ci
npx tsx scripts/genValidationFile.ts \
--rpc-url https://mainnet.example \
--workdir <network>/<YYYY-MM-DD-task> \
--workdir active/evm \
--forge-cmd "forge script <path>:<Contract> --sig 'run()' --sender 0xabc..." \
--out <network>/<YYYY-MM-DD-task>/validations/<user-type>.json
--out active/evm/tasks/<YYYY-MM-DD-task>/config/<network>/validations/<user-type>.json
```

Alternative (bun):
Expand All @@ -286,9 +280,9 @@ Alternative (bun):
npm ci
bun run scripts/genValidationFile.ts \
--rpc-url https://mainnet.example \
--workdir <network>/<YYYY-MM-DD-task> \
--workdir active/evm \
--forge-cmd "forge script <path>:<Contract> --sig 'run()' --sender 0xabc..." \
--out <network>/<YYYY-MM-DD-task>/validations/<user-type>.json
--out active/evm/tasks/<YYYY-MM-DD-task>/config/<network>/validations/<user-type>.json
```

Example from a Makefile (variables expanded in your environment):
Expand All @@ -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/<YYYY-MM-DD-task>/config/<network>/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
Expand All @@ -314,15 +308,15 @@ 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 `<network>/signatures/<task-name>/`:
When task origin validation is enabled, three signature files must exist in `tasks/<task-name>/signatures/<network>/`:

- **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)
- **Security Council Facilitator**: `base-sc-facilitator-signature.json` — common name is `base-sc-facilitators` (group)

The **common name** is extracted from the signer's X.509 certificate Subject Alternative Name (SAN). For task creators, this is their email address. For facilitators, it is the group name they belong to.

Signatures are stored separately from task directories to ensure the tarball content remains unchanged after signing.
Signatures are stored under a per-network `signatures/<network>/` directory, separate from the `config/<network>/` directory that the tarball is signed over, to ensure the signed content remains unchanged after signing.

#### Requirements

Expand Down Expand Up @@ -359,17 +353,17 @@ npx tsx scripts/genTaskOriginSig.ts --help
```bash
npm ci
npx tsx scripts/genTaskOriginSig.ts sign \
--task-folder <path>/<network>/<task> \
--signature-path <path>/<network>/signatures/<task>
--task-folder <path>/tasks/<task>/config/<network> \
--signature-path <path>/tasks/<task>/signatures/<network>
```

**Sign as Base facilitator:**

```bash
npm ci
npx tsx scripts/genTaskOriginSig.ts sign \
--task-folder <path>/<network>/<task> \
--signature-path <path>/<network>/signatures/<task> \
--task-folder <path>/tasks/<task>/config/<network> \
--signature-path <path>/tasks/<task>/signatures/<network> \
--facilitator base
```

Expand All @@ -378,8 +372,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \
```bash
npm ci
npx tsx scripts/genTaskOriginSig.ts verify \
--task-folder <path>/<network>/<task> \
--signature-path <path>/<network>/signatures/<task> \
--task-folder <path>/tasks/<task>/config/<network> \
--signature-path <path>/tasks/<task>/signatures/<network> \
--common-name alice@example.com
```

Expand Down Expand Up @@ -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/<network>/validations/base-sc.json
```

#### Validation File Output
Expand Down
14 changes: 6 additions & 8 deletions __tests__/genTaskOriginSig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import {
resolveSignatureHash,
FacilitatorType,
} from '../scripts/genTaskOriginSig';
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');
Expand Down Expand Up @@ -295,13 +296,10 @@ 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 expectedDigest = createHash('sha256').update(new Uint8Array(tarball)).digest('base64');
expect(bundle.messageSignature.messageDigest.digest).toBe(expectedDigest);
await fsp.unlink(tarballPath).catch(() => {});
}
const tarballPath = await createDeterministicTarball(taskFolder);
const tarball = await fsp.readFile(tarballPath);
const expectedDigest = createHash('sha256').update(new Uint8Array(tarball)).digest('base64');
expect(bundle.messageSignature.messageDigest.digest).toBe(expectedDigest);
});
});

Expand Down
67 changes: 5 additions & 62 deletions __tests__/task-origin-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,32 +82,6 @@ describe('createDeterministicTarball', () => {
expect(entries).toContain('test.txt');
});

it('excludes cache/, out/, and signer-tool/ 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.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');

// 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);
});

it('produces deterministic output with identical hash', async () => {
// Create some files
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content 1');
Expand Down Expand Up @@ -170,39 +146,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 +171,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 +185,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 +196,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 +210,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 +224,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 +237,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
Loading
Loading