diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..771af87 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + contracts: + name: Contracts (forge) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + # lib/ is gitignored (not vendored, not submodules), so CI fetches the + # Solidity deps at the versions foundry.toml's remappings expect. + # OZ is pinned to v5.6.1; forge-std tracks its default branch (test-only + # scaffolding, stable cheatcode API). + - name: Fetch Solidity dependencies + run: | + git clone --depth 1 --branch v5.6.1 https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + git clone --depth 1 --branch v5.6.1 https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable + git clone --depth 1 https://github.com/foundry-rs/forge-std.git lib/forge-std + + - name: Build + run: forge build --sizes + + - name: Test (ci profile — 5000 fuzz runs) + env: + FOUNDRY_PROFILE: ci + run: forge test -vvv + + sdk: + name: SDK (vitest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/sdk + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run build + - run: npm test + + oracle: + name: Oracle (node --test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: oracle + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: node --test diff --git a/Countersig_Build_Document.md b/Countersig_Build_Document.md index 01a8519..0ff9786 100644 --- a/Countersig_Build_Document.md +++ b/Countersig_Build_Document.md @@ -4,7 +4,7 @@ The Countersig Network is a decentralized identity and trust protocol designed explicitly for autonomous AI agents. As AI agents evolve into independent economic actors, the lack of verifiable Non-Human Identity (NHI) presents a systemic risk, enabling Sybil attacks, reputation manipulation, and unaccountable execution. -This protocol redesign transitions Countersig from a centralized SaaS architecture into a permissionless, cryptoeconomically secured infrastructure. The design philosophy centers on **verifiability, interoperability, and economic accountability**. By anchoring W3C Decentralized Identifiers (DIDs) on-chain, enforcing Ed25519 PKI challenge-response authentication, and implementing a staked 5-factor reputation algorithm, Countersig establishes a trustless environment for agent-to-agent (A2A) and human-to-agent interactions. +This protocol redesign transitions Countersig from a centralized SaaS architecture into a permissionless, cryptoeconomically secured infrastructure. The design philosophy centers on **verifiability, interoperability, and economic accountability**. By anchoring W3C Decentralized Identifiers (DIDs) on-chain, enforcing Ed25519 PKI challenge-response authentication, and implementing a staked 6-factor reputation algorithm, Countersig establishes a trustless environment for agent-to-agent (A2A) and human-to-agent interactions. **Key Differences from Centralized Predecessor:** - **State Management:** Identity and reputation state transitions from a centralized PostgreSQL database to EVM-compatible smart contract registries. @@ -49,7 +49,7 @@ The Countersig Network operates across three layers: the On-Chain State Layer, t ``` **Core Components:** -- **On-Chain State:** Three primary EVM registries manage the lifecycle of agent identities, compute their 5-factor reputation scores, and store verifiable credential hashes. The Staking Core enforces economic security. +- **On-Chain State:** Three primary EVM registries manage the lifecycle of agent identities, compute their 6-factor reputation scores, and store verifiable credential hashes. The Staking Core enforces economic security. - **Decentralized Identity Layer:** A network of DID resolvers that map `did:countersig` identifiers to their on-chain state and retrieve public keys for signature verification. - **Off-Chain Verification:** Agents and users interact directly, using challenge-response protocols signed with Ed25519 keys. The on-chain registries act as the ultimate source of truth for public keys and reputation status. @@ -67,7 +67,7 @@ Anchors the agent's identity and maps it to the operator's address and the agent - **Key Interface:** `registerAgent(bytes32 didHash, bytes32 ed25519PubKey)` ### Registry 2: Trust/Reputation Registry -Maintains the 5-factor reputation score for each agent. +Maintains the 6-factor reputation score for each agent. - **Storage Layout:** - `mapping(bytes32 => ReputationData) public reputations;` @@ -123,21 +123,25 @@ Agents authenticate off-chain using their Ed25519 keys, verified against the on- 3. **Sign:** Agent A signs the payload using its Ed25519 private key. 4. **Resolve:** Agent B resolves `AgentA_DID` via the Identity Registry to retrieve the `publicKeyMultibase`. 5. **Verify:** Agent B verifies the signature using the retrieved public key. If valid, authentication succeeds. -## 5. Reputation System: 5-Factor Algorithm +## 5. Reputation System: 6-Factor Algorithm -The Countersig reputation system computes a deterministic score (0-100) using a 5-factor model. This score is stored in the Reputation Registry and determines the agent's trust tier. +The Countersig reputation system computes a deterministic score (0-100) using a 6-factor model. This score is stored in the Reputation Registry and determines the agent's trust tier. -### The 5 Factors and Weights -1. **Fee Activity (30%):** Measures economic utility. Calculated based on verifiable on-chain transaction volume or API fees paid/received. +### The 6 Factors and Weights +1. **Fee Activity (30%):** Measures economic utility. Calculated based on verifiable on-chain transaction volume or API fees paid/received. - *Scoring:* `min(30, floor(totalFeesUSD / 100))` 2. **Success Rate (25%):** Measures operational reliability. Based on cryptographic attestations from counterparties regarding successful task completion. - *Scoring:* `floor((successfulTasks / totalTasks) * 25)` -3. **Registration Age (20%):** Measures longevity and persistence. - - *Scoring:* `min(20, daysSinceRegistration)` +3. **Registration Age (20%):** Measures longevity and persistence. Logarithmic, so it can't be farmed simply by waiting. + - *Scoring:* `min(20, floor(log2(daysSinceRegistration + 1) * 4))` 4. **External Trust (15%):** Measures cross-platform validity. Integrates scores from external registries (e.g., SAID Protocol, Gitcoin Passport). - *Scoring:* `floor((externalScore / 100) * 15)` -5. **Community Verification (10%):** Measures behavioral safety. Starts at 10, reduced by community flags or minor slashing events. - - *Scoring:* `10 - (activeFlags * 5)` (Min 0) +5. **Community Verification (5%):** Measures behavioral safety. Starts at 5, reduced by community flags. + - *Scoring:* `max(0, 5 - (activeFlags * 2))` +6. **Trust Propagation (5%):** Inherited trust from high-reputation agents that vouch for or delegate to this agent. + - *Scoring:* oracle-computed from the attestation/vouch graph. + +> **Phase note:** External Trust (4) and Trust Propagation (6) are stubbed to 0 until the Phase 2 oracle network is live; the on-chain caps and the other four factors are active today. This matches `docs/reputation-model.md`, `CountersigReputation.sol`, and `oracle/scoring.js`. ### On-Chain Scoring Mechanics To optimize gas, the Reputation Registry does not compute the score on every transaction. Instead, an off-chain decentralized oracle network aggregates the raw data, computes the score, and submits a state update to the registry periodically (e.g., every 24 hours) or upon a significant deviation threshold. diff --git a/oracle/http-helpers.js b/oracle/http-helpers.js index d08e9b5..c2826ac 100644 --- a/oracle/http-helpers.js +++ b/oracle/http-helpers.js @@ -56,4 +56,31 @@ function parseScorePath(pathname) { return match ? match[1] : null; } -module.exports = { MAX_BODY_SIZE, json, readBody, isAuthorized, parseScorePath }; +// Simple in-memory fixed-window rate limiter, keyed by an arbitrary string +// (caller passes the client IP). Testnet-grade: single-process and resets on +// restart, but it blunts bursts of writes to /attest, /flag, and /epoch on top +// of the bearer-token gate — a stolen token can no longer flood scores/gas. +const RATE_WINDOW_MS = 60_000; +const RATE_MAX = 60; // requests per key per window +const _rateBuckets = new Map(); + +function rateLimited(key, now = Date.now(), max = RATE_MAX, windowMs = RATE_WINDOW_MS) { + const bucket = _rateBuckets.get(key); + if (!bucket || now >= bucket.reset) { + _rateBuckets.set(key, { count: 1, reset: now + windowMs }); + return false; + } + bucket.count++; + return bucket.count > max; +} + +module.exports = { + MAX_BODY_SIZE, + RATE_WINDOW_MS, + RATE_MAX, + json, + readBody, + isAuthorized, + parseScorePath, + rateLimited, +}; diff --git a/oracle/http-helpers.test.js b/oracle/http-helpers.test.js index 2834b62..38385ea 100644 --- a/oracle/http-helpers.test.js +++ b/oracle/http-helpers.test.js @@ -3,7 +3,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { EventEmitter } = require('node:events'); -const { readBody, isAuthorized, parseScorePath } = require('./http-helpers'); +const { readBody, isAuthorized, parseScorePath, rateLimited, RATE_MAX } = require('./http-helpers'); // Minimal fake matching the subset of http.IncomingMessage that readBody uses: // an EventEmitter with data/end/error events plus a destroy() method. @@ -113,3 +113,31 @@ test('parseScorePath: missing 0x prefix returns null', () => { test('parseScorePath: unrelated path returns null', () => { assert.equal(parseScorePath('/health'), null); }); + +// ------------------------------------------------------------------------- +// rateLimited +// ------------------------------------------------------------------------- + +test('rateLimited: allows up to the max, then blocks within the window', () => { + const key = 'ip-a'; + const now = 1_000_000; + for (let i = 0; i < RATE_MAX; i++) { + assert.equal(rateLimited(key, now), false, `request ${i + 1} should pass`); + } + assert.equal(rateLimited(key, now), true, 'the (max+1)th request is blocked'); +}); + +test('rateLimited: window reset clears the count', () => { + const key = 'ip-b'; + const now = 2_000_000; + for (let i = 0; i < RATE_MAX; i++) rateLimited(key, now); + assert.equal(rateLimited(key, now), true, 'blocked at the cap'); + assert.equal(rateLimited(key, now + 60_001), false, 'allowed again after the window'); +}); + +test('rateLimited: separate keys have independent buckets', () => { + const now = 3_000_000; + for (let i = 0; i < RATE_MAX; i++) rateLimited('ip-c', now); + assert.equal(rateLimited('ip-c', now), true, 'ip-c is capped'); + assert.equal(rateLimited('ip-d', now), false, 'ip-d is unaffected'); +}); diff --git a/oracle/index.js b/oracle/index.js index 425b52e..970bec9 100644 --- a/oracle/index.js +++ b/oracle/index.js @@ -6,7 +6,12 @@ const http = require('http'); const chain = require('./chain'); const { computeScore } = require('./scoring'); const { decideAction } = require('./epoch-policy'); -const { json, readBody, isAuthorized, parseScorePath } = require('./http-helpers'); +const { json, readBody, isAuthorized, parseScorePath, rateLimited } = require('./http-helpers'); + +// Per-client key for rate limiting. Behind the container's 127.0.0.1 port map all +// requests may share one source IP, so this degrades to a global cap — still a +// useful flood guard for the write endpoints. +const clientKey = req => req.socket?.remoteAddress || 'unknown'; // ---- Config ---------------------------------------------------------------- @@ -165,6 +170,7 @@ const server = http.createServer(async (req, res) => { // Gated: a manual epoch submits on-chain tx's paid from the oracle wallet, so // it must not be triggerable by anyone who can reach the port. if (!isAuthorized(req.headers, cfg.adminToken)) return json(res, 401, { error: 'Unauthorized' }); + if (rateLimited(clientKey(req))) return json(res, 429, { error: 'Rate limited' }); if (epochRunning) return json(res, 409, { error: 'Epoch already running' }); runEpoch().catch(err => console.error('[oracle] manual epoch error:', err.message)); return json(res, 202, { message: 'Epoch started' }); @@ -173,6 +179,7 @@ const server = http.createServer(async (req, res) => { // POST /attest — body: { didHash, success } if (req.method === 'POST' && pathname === '/attest') { if (!isAuthorized(req.headers, cfg.adminToken)) return json(res, 401, { error: 'Unauthorized' }); + if (rateLimited(clientKey(req))) return json(res, 429, { error: 'Rate limited' }); try { const { didHash, success } = await readBody(req); if (!didHash) return json(res, 400, { error: 'didHash required' }); @@ -189,6 +196,7 @@ const server = http.createServer(async (req, res) => { // POST /flag — body: { didHash } if (req.method === 'POST' && pathname === '/flag') { if (!isAuthorized(req.headers, cfg.adminToken)) return json(res, 401, { error: 'Unauthorized' }); + if (rateLimited(clientKey(req))) return json(res, 429, { error: 'Rate limited' }); try { const { didHash } = await readBody(req); if (!didHash) return json(res, 400, { error: 'didHash required' });