diff --git a/docs/Management.md b/docs/Management.md index a747753..24cafe0 100644 --- a/docs/Management.md +++ b/docs/Management.md @@ -20,6 +20,7 @@ This document describes how to manage deployed `Scribe` and `ScribeOptimistic` i - [`IToll::kiss`](#itollkiss) - [`IToll::diss`](#itolldiss) - [Offboarding](#offboarding) + - [Offboard via `ScribeOffboarder`](#offboard-via-scribeoffboarder) - [Deactivation](#deactivation) - [Fund Rescue](#fund-resuce) - [Killing](#killing) @@ -286,6 +287,39 @@ Offboarding a Scribe(Optimistic) instance simply means _Chronicle Protocol_ is n However, to ensure an offboarded Scribe(Optimistic) instance may not behave unexpectedly it needs to be deactivated. Furthermore, if the contract is a ScribeOptimistic instance ETH held by the contract may need to be rescued. If its certain the contract will never be used again it is recommended to kill it. +A Scribe(Optimistic) instance can be deactivated in two ways: + +- In a single transaction via a deployed [`ScribeOffboarder`](#offboard-via-scribeoffboarder) contract that holds `auth` on the instance. +- Via two distinct `forge script` [deactivation](#deactivation) commands signed by an `auth`'ed EOA. + +### Offboard via `ScribeOffboarder` + +Offboarding through a `ScribeOffboarder` instance drops every currently-lifted feed, sets `bar` to `1`, and pokes value `0` in a single transaction. After the call, `read()` reverts and no feed is lifted, so no future poke can verify. + +Expects: + +- `OFFBOARDER` is a deployed `ScribeOffboarder` instance that holds `auth` on `SCRIBE`. +- The caller holds `auth` on `OFFBOARDER`. + +Set the additional environment variable: + +- `OFFBOARDER`: The `ScribeOffboarder` instance to offboard `SCRIBE` with. + +Run: + +```bash +$ forge script \ + --keystore "$KEYSTORE" \ + --password "$KEYSTORE_PASSWORD" \ + --broadcast \ + --rpc-url "$RPC_URL" \ + --sig $(cast calldata "offboard(address,address)" "$OFFBOARDER" "$SCRIBE") \ + -vvv \ + script/${SCRIBE_FLAVOUR}.s.sol:${SCRIBE_FLAVOUR}Script +``` + +Note that `auth`'ed addresses on `SCRIBE` are untouched by `offboard` — to make the deactivation permanent, follow up with [`kill`](#killing). + ### Deactivation Deactivating a Scribe(Optimistic) instance means its value is set to zero, leading all `read()` calls to revert/fail, no feeds are lifted, and `bar` is set to `255`. diff --git a/script/Scribe.s.sol b/script/Scribe.s.sol index 6592440..e328cfb 100644 --- a/script/Scribe.s.sol +++ b/script/Scribe.s.sol @@ -17,6 +17,8 @@ import {LibSecp256k1} from "src/libs/LibSecp256k1.sol"; import {LibRandom} from "./libs/LibRandom.sol"; import {LibFeed} from "./libs/LibFeed.sol"; +import {ScribeOffboarder} from "./offboard/ScribeOffboarder.sol"; + /** * @notice Scribe Management Script */ @@ -269,6 +271,27 @@ contract ScribeScript is Script { // -- Offboarding Functions -- + /// @dev Offboards instance `scribe` via `ScribeOffboarder` instance + /// `offboarder` in a single transaction. + /// + /// @dev Offboarding drops every currently-lifted feed, sets bar to 1, + /// and pokes value 0. After the call `scribe.read()` reverts and + /// no feed is lifted, so no future poke can verify. + /// + /// @dev Expects: + /// - `offboarder` to hold `auth` on `scribe` + /// - `msg.sender` to hold `auth` on `offboarder` + /// + /// @dev Note that `auth`'ed addresses on `scribe` are untouched. To make + /// the deactivation permanent, follow up with `kill(scribe)`. + function offboard(address offboarder, address scribe) public { + vm.startBroadcast(); + ScribeOffboarder(offboarder).offboard(scribe); + vm.stopBroadcast(); + + console2.log("Offboarded", scribe); + } + /// @dev Step 1 of deactivating instance `self`. /// /// @dev Deactivating an instance means: diff --git a/script/offboard/ScribeOffboarder.sol b/script/offboard/ScribeOffboarder.sol new file mode 100644 index 0000000..48eced5 --- /dev/null +++ b/script/offboard/ScribeOffboarder.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Auth} from "chronicle-std/auth/Auth.sol"; +import {IAuth} from "chronicle-std/auth/IAuth.sol"; + +import {IScribe} from "../../src/IScribe.sol"; + +import {LibSecp256k1} from "../../src/libs/LibSecp256k1.sol"; + +/** + * @title ScribeOffboarder + * + * @notice Permanently disables a `Scribe` oracle in a single transaction. + * The contract must be granted `auth` on the target scribe. + * + * `offboard(scribe)` enumerates every currently-lifted feed, drops + * them, sets bar = 1, lifts the hardcoded offboarder feed, pokes + * val = 0, drops the offboarder feed, and raises bar = 255. + * + * @dev Security note: the offboarder feed's private key is hardcoded as a + * `constant` in the bytecode and is therefore publicly readable. This + * is intentional; the feed is only lifted inside an offboarder call + * and dropped again before the transaction returns, so any signatures + * craftable from the leaked key require the attacker to already hold + * `auth` on the scribe and re-lift the feed (at which point they could + * poke arbitrary values directly anyway). + */ +contract ScribeOffboarder is Auth { + // ----------------------------------------------------------------------------- + // Hardcoded Offboarder Feed + // + // All four values below are derived from a single string seed: + // + // SEED = "Chronicle.ScribeOffboarder.v1" + // FEED_PRIV_KEY = uint(keccak256(SEED)) mod Q + // pubKey = vm.createWallet(FEED_PRIV_KEY).{publicKeyX, publicKeyY} + // ECDSA(v,r,s) = vm.sign(FEED_PRIV_KEY, FEED_REGISTRATION_MESSAGE) + // + // where Q is the order of secp256k1 and + // + // FEED_REGISTRATION_MESSAGE = + // keccak256( + // "\x19Ethereum Signed Message:\n32" ‖ + // keccak256("Chronicle Feed Registration") + // ) + // + // FEED_REGISTRATION_MESSAGE is a Scribe protocol constant (see + // `Scribe.feedRegistrationMessage`), so the ECDSA signature below is + // valid against every Scribe deployment. + + /// @dev `keccak256("Chronicle.ScribeOffboarder.v1") mod Q`. + uint private constant FEED_PRIV_KEY = + 0xf067ac0350b6ab5e39cf9138ae16508404c05af8b58676e07ae14e9a510a600e; + + uint private constant FEED_PUBKEY_X = + 0xc2f3bfebb984817bbcaaf2b8e185259b09493f6db4b2c30d7b4feca89f7a64b2; + + uint private constant FEED_PUBKEY_Y = + 0x53a0dc54c1d22a62fbb1b3724737486c7e61c26080a44817eef7f5992a521a40; + + /// @notice Feed id is the highest-order byte of the ethereum address + /// derived from the offboarder feed's public key + /// (= 0x03da1Db2b7875106395548682B068a7C7b296902 => id = 0x03). + uint8 private constant FEED_ID = 0x03; + + // ECDSA signature over FEED_REGISTRATION_MESSAGE by FEED_PRIV_KEY. + uint8 private constant LIFT_V = 28; + bytes32 private constant LIFT_R = + 0xc9988947c96dd6899cc097e3979b1e4d9228e09b4bc7cf53a9e0fe2ff395a77c; + bytes32 private constant LIFT_S = + 0x76ec989c8765878f18daceb7893ea2c24b1a87401c847e240a1301c3565f8dd7; + + // ----------------------------------------------------------------------------- + // secp256k1 Constants + + /// @dev x-coordinate of the secp256k1 generator G. + uint private constant G_X = + 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798; + + /// @dev Order of the secp256k1 group. + uint private constant Q = + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + + /// @dev `v` value corresponding to G (G_y is even, so parity = 0). + uint8 private constant G_V = 27; + + // ----------------------------------------------------------------------------- + // Events + + event Offboarded(address indexed caller, address indexed scribe); + + // ----------------------------------------------------------------------------- + // Constructor + + constructor(address initialAuthed) Auth(initialAuthed) {} + + // ----------------------------------------------------------------------------- + // Offboard + + /// @notice Offboards `scribe` in a single transaction: enumerates the + /// currently-lifted feeds, drops them, sets bar = 1, lifts the + /// hardcoded offboarder feed, pokes val = 0, drops the + /// offboarder feed, and raises bar = 255. + function offboard(address scribe) external auth { + require(IAuth(scribe).authed(address(this))); + + IScribe target = IScribe(scribe); + + // Drop every currently-lifted feed. + address[] memory currentFeeds = target.feeds(); + if (currentFeeds.length != 0) { + uint8[] memory ids = new uint8[](currentFeeds.length); + for (uint i; i < currentFeeds.length; i++) { + ids[i] = uint8(uint(uint160(currentFeeds[i])) >> 152); + } + target.drop(ids); + } + + // Set bar to one and lift the offboarder feed. + target.setBar(1); + target.lift( + LibSecp256k1.Point({x: FEED_PUBKEY_X, y: FEED_PUBKEY_Y}), + IScribe.ECDSAData({v: LIFT_V, r: LIFT_R, s: LIFT_S}) + ); + + // Poke val = 0 via the offboarder feed's 1-of-1 Schnorr signature. + uint32 pokeAge = uint32(block.timestamp); + bytes32 message = target.constructPokeMessage( + IScribe.PokeData({val: 0, age: pokeAge}) + ); + (bytes32 signature, address commitment) = _signSchnorr(message); + target.poke( + IScribe.PokeData({val: 0, age: pokeAge}), + IScribe.SchnorrData({ + signature: signature, + commitment: commitment, + feedIds: abi.encodePacked(FEED_ID) + }) + ); + + target.drop(FEED_ID); + + // Set bar to max. + target.setBar(type(uint8).max); + + // Deny offboarder. + IAuth(scribe).deny(address(this)); + + emit Offboarded(msg.sender, scribe); + } + + // ----------------------------------------------------------------------------- + // Internal — Schnorr signing + + /// @dev Produces a 1-of-1 Schnorr signature over `message` using the + /// hardcoded offboarder feed. + /// + /// The ethereum address `R_e = address(k·G)` is computed via the + /// ecrecover precompile trick rather than a full secp256k1 scalar + /// multiplication: with `h = 0`, `r = G_x`, `v = G_v`, + /// `s = k·G_x mod Q`, ecrecover returns exactly `address(k·G)` — + /// which is all the signing scheme needs as the commitment. + /// + /// See the scribe repo's `docs/Schnorr.md` for the scheme this + /// implements. + function _signSchnorr(bytes32 message) + internal + pure + returns (bytes32 signature, address commitment) + { + // k = H(privKey ‖ m) mod Q + uint nonce = + uint(keccak256(abi.encodePacked(FEED_PRIV_KEY, message))) % Q; + + // R_e = address(k·G) via the ecrecover trick. + commitment = ecrecover( + bytes32(0), G_V, bytes32(G_X), bytes32(mulmod(nonce, G_X, Q)) + ); + require(commitment != address(0), "ScribeOffboarder: bad commitment"); + + // e = H(Pₓ ‖ Pₚ ‖ m ‖ R_e) mod Q + uint challenge = uint( + keccak256( + abi.encodePacked( + FEED_PUBKEY_X, uint8(FEED_PUBKEY_Y & 1), message, commitment + ) + ) + ) % Q; + + // s = k + e·x mod Q + signature = + bytes32(addmod(nonce, mulmod(challenge, FEED_PRIV_KEY, Q), Q)); + } +} diff --git a/script/rescue/Rescuer.sol b/script/rescue/Rescuer.sol index b867bde..d8d242c 100644 --- a/script/rescue/Rescuer.sol +++ b/script/rescue/Rescuer.sol @@ -105,6 +105,9 @@ contract Rescuer is Auth { // Compute amount of ETH received as challenge reward. uint amount = address(this).balance - balanceBefore; + // Deny rescuer on opScribe. + IAuth(opScribe).deny(address(this)); + // Emit event. emit Recovered(msg.sender, opScribe, amount); } diff --git a/test/offboard/ScribeOffboarder.t.sol b/test/offboard/ScribeOffboarder.t.sol new file mode 100644 index 0000000..851d6d1 --- /dev/null +++ b/test/offboard/ScribeOffboarder.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Test, Vm} from "forge-std/Test.sol"; + +import {IAuth} from "chronicle-std/auth/IAuth.sol"; +import {IToll} from "chronicle-std/toll/IToll.sol"; + +import {IScribe} from "../../src/IScribe.sol"; +import {Scribe} from "../../src/Scribe.sol"; +import {LibSecp256k1} from "../../src/libs/LibSecp256k1.sol"; + +import {ScribeOffboarder} from "../../script/offboard/ScribeOffboarder.sol"; + +contract ScribeOffboarderTest is Test { + bytes32 internal constant WAT = bytes32("ETH/USD"); + + /// @dev Must match `feedId` in `ScribeOffboarder.sol`. + uint8 internal constant OFFBOARDER_FEED_ID = 0x03; + + Scribe internal scribe; + ScribeOffboarder internal offboarder; + + function setUp() public { + scribe = new Scribe({initialAuthed: address(this), wat_: WAT}); + IToll(address(scribe)).kiss(address(this)); + + offboarder = new ScribeOffboarder({initialAuthed: address(this)}); + IAuth(address(scribe)).rely(address(offboarder)); + + vm.warp(1_700_000_000); + } + + // ----------------------------------------------------------------------------- + // Offboard + + function test_offboard() public { + _liftRandomFeeds(3, 0xBEEF); + + offboarder.offboard(address(scribe)); + + assertEq(scribe.feeds().length, 0); + assertEq(scribe.bar(), type(uint8).max); + (bool ok,) = scribe.tryRead(); + assertFalse(ok); + } + + function testFuzz_offboard( + uint8 nFeeds, + uint64 timeAdvance, + uint64 seedSalt + ) public { + nFeeds = uint8(bound(nFeeds, 0, 32)); + timeAdvance = uint64(bound(timeAdvance, 1, 365 days)); + + _liftRandomFeeds(nFeeds, uint(seedSalt) | 1); + vm.warp(block.timestamp + timeAdvance); + + offboarder.offboard(address(scribe)); + + assertEq(scribe.feeds().length, 0); + assertEq(scribe.bar(), type(uint8).max); + (bool ok,) = scribe.tryRead(); + assertFalse(ok); + + assertFalse(IAuth(address(scribe)).authed(address(offboarder))); + } + + // ----------------------------------------------------------------------------- + // Auth Protection + + function testFuzz_offboard_isAuthProtected(address caller) public { + vm.assume(!IAuth(address(offboarder)).authed(caller)); + + vm.prank(caller); + vm.expectRevert( + abi.encodeWithSelector(IAuth.NotAuthorized.selector, caller) + ); + offboarder.offboard(address(scribe)); + } + + // ----------------------------------------------------------------------------- + // Value zeroing + + function testFuzz_offboard_zeroesValue(uint8 nFeeds, uint64 seedSalt) + public + { + nFeeds = uint8(bound(nFeeds, 0, 32)); + _liftRandomFeeds(nFeeds, uint(seedSalt) | 1); + + offboarder.offboard(address(scribe)); + + (bool ok, uint val) = scribe.tryRead(); + assertFalse(ok, "tryRead should report invalid"); + assertEq(val, 0, "scribe stored value must be zero"); + + vm.expectRevert(); + scribe.read(); + } + + // ----------------------------------------------------------------------------- + // Helpers + + function _feedId(address who) internal pure returns (uint8) { + return uint8(uint(uint160(who)) >> 152); + } + + /// @dev Lifts up to `n` feeds derived from sequential private keys + /// starting at `seed`. Skips collisions with already-lifted feed + /// ids and with the offboarder's reserved id. + function _liftRandomFeeds(uint8 n, uint seed) + internal + returns (uint lifted) + { + if (n == 0) { + return 0; + } + + bytes32 reg = scribe.feedRegistrationMessage(); + + uint bloom; + uint pk = seed; + uint tried; + while (lifted < n && tried < 1024) { + tried++; + Vm.Wallet memory w = vm.createWallet(pk++); + uint8 id = _feedId(w.addr); + + if (bloom & (1 << id) != 0) { + continue; + } + bloom |= 1 << id; + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(w.privateKey, reg); + scribe.lift( + LibSecp256k1.Point({x: w.publicKeyX, y: w.publicKeyY}), + IScribe.ECDSAData({v: v, r: r, s: s}) + ); + lifted++; + } + } +} diff --git a/test/rescue/RescuerTest.sol b/test/rescue/RescuerTest.sol index 326e8e9..0c4592c 100644 --- a/test/rescue/RescuerTest.sol +++ b/test/rescue/RescuerTest.sol @@ -77,6 +77,9 @@ contract RescuerTest is Test { // Verify feed got kicked. assertFalse(opScribe.feeds(feed.pubKey.toAddress())); + + // Verify rescuer renounced auth on opScribe. + assertFalse(IAuth(address(opScribe)).authed(address(rescuer))); } function testFuzz_suck_FailsIf_RescuerNotAuthedOnOpScribe(uint privKeySeed)