diff --git a/src/ChipRegistryV2.sol b/src/ChipRegistryV2.sol new file mode 100644 index 0000000..9fd6ff7 --- /dev/null +++ b/src/ChipRegistryV2.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/** + * @title ChipRegistryV2 + * @notice Registration gate for the chip registry, for a future deployment. + * + * The deployed ChipRegistry accepts any caller with any 32-byte value, + * subject only to a check against a constant published in the contract + * itself. Its own documentation says so - it exposes the gate-free path + * deliberately, for testnet bring-up - and MiningPool's registration + * check therefore establishes that a registration transaction happened + * rather than that hardware exists. This contract closes that. + * + * It cannot be retrofitted to the current deployment: MiningPool's + * ownership was renounced at deployment, so the registry it consults can + * never be replaced. This targets the next one. + * + * @dev What the gate proves, and what it does not. + * + * Proves: whoever registered a chip identifier held the private key that + * identifier is derived from, at the time of registration, for this + * registry, on this chain, once. + * + * Does not prove: that the key lives on a die rather than in a file. No + * on-chain check can establish that. Establishing it needs a challenge the + * hardware answers under a constraint software cannot meet, which is a + * separate problem and is recorded as partially solved at best - a timing + * deadline was tested and refuted, and a parallel-width challenge holds + * against a general-purpose processor and fails against a many-lane + * accelerator. + * + * So this raises the floor from "anyone may claim to be any chip" to "only + * the holder of a chip's key may register that chip". That is the whole + * claim being made here. + * + * @dev Enrolment policy: one enrolment per device, permanently. + * + * This was already the behaviour and is now stated as a requirement, because + * it turns out to be load-bearing outside the contract. + * + * A physically unclonable function's responses are biased, and a key generator + * built on biased responses needs a debiasing step. Of the debiasing methods + * in the literature, three of four are explicitly not reusable: enrolling the + * same device a second time leaks more than one enrolment does, because the + * debiasing step is stochastic and bit errors between enrolments shift which + * response pairs are retained. Only pair-output von Neumann with erasures is + * reusable, and it requires an inner repetition code that cannot carry the + * information a 128-bit key needs - not at any response entropy, since a + * repetition code multiplies the code length while leaving its dimension + * alone. So the reusable option is not merely expensive here; it does not + * exist. + * + * That leaves one enrolment per device as the only constructible policy, and + * this contract must therefore never allow a second one. Two paths are closed: + * + * - a registered chip cannot register again, by the AlreadyRegistered check + * - a slashed chip cannot register again either, because slashing sets a + * flag and does not clear registeredAt, so the same check still fires + * + * The second is easy to break by a well-meaning change - clearing the record + * on slash, or adding an unregister path, would read like tidying up and would + * silently make the key generator insecure. `test_slashedChipCanNeverReRegister` + * exists to stop that. + * + * @dev Identifier scheme. The chip identifier is the chip's own address, left + * zero-padded into a bytes32. This makes the identifier self-authenticating: + * a signature recovers to an address, and the identifier being registered + * must be that address. The alternative - an arbitrary 32-byte value plus a + * separate signing address - lets anyone bind any identifier to a key they + * hold, which reintroduces the problem the gate exists to solve. + */ +contract ChipRegistryV2 { + + using ECDSA for bytes32; + + // ───────────────────────────────────────────────────────────────────────── + // Constants + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Canonical phi-anchor. Retained from the deployed registry, but + /// demoted in meaning: it is a format assertion, not a gate. A + /// constant published in this contract cannot gate anything, since + /// anyone who can read the contract can supply it. + uint16 public constant PHI_ANCHOR = 0x47C0; + + uint8 public constant FAMILY_PHI = 1; + uint8 public constant FAMILY_EULER = 2; + uint8 public constant FAMILY_GAMMA = 3; + + /// @notice Domain tag, so a signature for this purpose cannot be reused for + /// another one that happens to hash the same fields. + string public constant REGISTER_DOMAIN = "trinity-chip-registration-v2"; + + // ───────────────────────────────────────────────────────────────────────── + // Storage + // ───────────────────────────────────────────────────────────────────────── + + struct ChipRecord { + uint8 family; + uint16 phiAnchor; + uint32 registeredAt; + bool slashed; + address attestor; + } + + mapping(bytes32 => ChipRecord) private _chips; + + /// @notice Consumed registration nonces, per chip. A nonce is scoped to the + /// chip rather than global so that concurrent registrations of + /// different chips cannot invalidate each other. + mapping(bytes32 => mapping(uint256 => bool)) public nonceUsed; + + mapping(uint8 => uint256) public chipCountByFamily; + uint256 public totalChips; + + // ───────────────────────────────────────────────────────────────────────── + // Events + // ───────────────────────────────────────────────────────────────────────── + + event ChipRegistered(bytes32 indexed chipPubkey, uint8 family, address attestor); + event ChipSlashed(bytes32 indexed chipPubkey, string reason); + + // ───────────────────────────────────────────────────────────────────────── + // Errors + // ───────────────────────────────────────────────────────────────────────── + + error ZeroPubkey(); + error AlreadyRegistered(); + error InvalidFamily(); + error PhiAnchorMismatch(); + error ChipNotFound(); + error AlreadySlashed(); + error NotAttestor(); + error NonceAlreadyUsed(); + error SignatureNotFromChip(); + error IdentifierNotAnAddress(); + + // ───────────────────────────────────────────────────────────────────────── + // Registration + // ───────────────────────────────────────────────────────────────────────── + + /** + * @notice The message a chip must sign in order to be registered. + * + * Four bindings, each closing a distinct replay: + * + * chipPubkey the identity being claimed + * registrant the address submitting the transaction, so a + * captured signature cannot be submitted by a third + * party + * address(this) this registry, so it cannot be replayed onto + * another deployment + * block.chainid this chain, so it cannot be replayed onto a fork + * or another network + * nonce once, so it cannot be replayed here + */ + function registrationDigest( + bytes32 chipPubkey, + address registrant, + uint256 nonce + ) public view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + keccak256(bytes(REGISTER_DOMAIN)), + chipPubkey, + registrant, + address(this), + block.chainid, + nonce + ) + ); + return MessageHashUtils.toEthSignedMessageHash(structHash); + } + + /** + * @notice Register a chip, proving possession of its key. + * @param chipPubkey The chip's address, left zero-padded into bytes32. + * @param family 1=Phi, 2=Euler, 3=Gamma. + * @param phiAnchorOut Format assertion; must equal PHI_ANCHOR. + * @param nonce Registration nonce, consumed on success. + * @param signature Signature by the chip over registrationDigest. + */ + function registerChip( + bytes32 chipPubkey, + uint8 family, + uint16 phiAnchorOut, + uint256 nonce, + bytes calldata signature + ) external { + if (chipPubkey == bytes32(0)) revert ZeroPubkey(); + if (_chips[chipPubkey].registeredAt != 0) revert AlreadyRegistered(); + if (family < FAMILY_PHI || family > FAMILY_GAMMA) revert InvalidFamily(); + if (phiAnchorOut != PHI_ANCHOR) revert PhiAnchorMismatch(); + if (nonceUsed[chipPubkey][nonce]) revert NonceAlreadyUsed(); + + // The identifier must be an address in the low 160 bits and nothing in + // the high 96, or it cannot be the address a signature recovers to. + if (uint256(chipPubkey) >> 160 != 0) revert IdentifierNotAnAddress(); + + address recovered = ECDSA.recover( + registrationDigest(chipPubkey, msg.sender, nonce), + signature + ); + if (recovered != address(uint160(uint256(chipPubkey)))) revert SignatureNotFromChip(); + + nonceUsed[chipPubkey][nonce] = true; + + _chips[chipPubkey] = ChipRecord({ + family: family, + phiAnchor: phiAnchorOut, + registeredAt: uint32(block.timestamp), + slashed: false, + attestor: msg.sender + }); + + chipCountByFamily[family] += 1; + totalChips += 1; + + emit ChipRegistered(chipPubkey, family, msg.sender); + } + + // ───────────────────────────────────────────────────────────────────────── + // Slashing — unchanged from the deployed registry + // ───────────────────────────────────────────────────────────────────────── + + function slashChip(bytes32 chipPubkey, string calldata reason) external { + ChipRecord storage r = _chips[chipPubkey]; + if (r.registeredAt == 0) revert ChipNotFound(); + if (r.slashed) revert AlreadySlashed(); + if (msg.sender != r.attestor) revert NotAttestor(); + + r.slashed = true; + emit ChipSlashed(chipPubkey, reason); + } + + // ───────────────────────────────────────────────────────────────────────── + // Views — same surface MiningPool depends on + // ───────────────────────────────────────────────────────────────────────── + + function isRegistered(bytes32 chipPubkey) external view returns (bool) { + ChipRecord storage r = _chips[chipPubkey]; + return r.registeredAt != 0 && !r.slashed; + } + + function chipInfo(bytes32 chipPubkey) + external + view + returns ( + uint8 family, + uint16 phiAnchor, + uint32 registeredAt, + bool slashed, + address attestor + ) + { + ChipRecord storage r = _chips[chipPubkey]; + return (r.family, r.phiAnchor, r.registeredAt, r.slashed, r.attestor); + } +} diff --git a/test/ChipRegistryV2.t.sol b/test/ChipRegistryV2.t.sol new file mode 100644 index 0000000..a8b5cc9 --- /dev/null +++ b/test/ChipRegistryV2.t.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/ChipRegistryV2.sol"; +import "../src/ChipRegistry.sol"; + +/// @dev Every test here corresponds to a way the deployed registry can be +/// fooled, or to a replay the gate is supposed to close. The first test is +/// a demonstration against the deployed contract rather than the new one, +/// because a gate is only worth having if the thing it replaces is open. +contract ChipRegistryV2Test is Test { + + ChipRegistryV2 registry; + + uint256 chipKey = 0xA11CE; + address chipAddr; + bytes32 chipId; + + uint256 otherKey = 0xB0B; + + address registrant = address(0xBEEF); + address stranger = address(0xCAFE); + + function setUp() public { + registry = new ChipRegistryV2(); + chipAddr = vm.addr(chipKey); + chipId = bytes32(uint256(uint160(chipAddr))); + } + + function _sign(uint256 key, bytes32 id, address who, uint256 nonce) + internal view returns (bytes memory) + { + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(key, registry.registrationDigest(id, who, nonce)); + return abi.encodePacked(r, s, v); + } + + // ── the problem being solved ──────────────────────────────────────────── + + function test_deployedRegistryAcceptsAnyoneWithAnyIdentifier() public { + ChipRegistry old = new ChipRegistry(); + vm.prank(stranger); + old.registerChip(bytes32(uint256(0xDEADBEEF)), 1, 0x47C0); + assertTrue( + old.isRegistered(bytes32(uint256(0xDEADBEEF))), + "the deployed registry should accept an invented identifier" + ); + } + + // ── happy path ────────────────────────────────────────────────────────── + + function test_registersWithChipSignature() public { + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.prank(registrant); + registry.registerChip(chipId, 1, 0x47C0, 1, sig); + + assertTrue(registry.isRegistered(chipId)); + assertEq(registry.totalChips(), 1); + (, , , , address attestor) = registry.chipInfo(chipId); + assertEq(attestor, registrant, "submitter becomes attestor"); + } + + // ── the gate ──────────────────────────────────────────────────────────── + + function test_rejectsSignatureFromAnotherKey() public { + bytes memory sig = _sign(otherKey, chipId, registrant, 1); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.SignatureNotFromChip.selector); + registry.registerChip(chipId, 1, 0x47C0, 1, sig); + } + + function test_rejectsIdentifierThatIsNotAnAddress() public { + bytes32 wide = bytes32(uint256(1) << 200); + bytes memory sig = _sign(chipKey, wide, registrant, 1); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.IdentifierNotAnAddress.selector); + registry.registerChip(wide, 1, 0x47C0, 1, sig); + } + + // ── replays the bindings are supposed to close ────────────────────────── + + function test_signatureIsBoundToTheSubmitter() public { + // Signed for `registrant`, submitted by `stranger`. + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.prank(stranger); + vm.expectRevert(ChipRegistryV2.SignatureNotFromChip.selector); + registry.registerChip(chipId, 1, 0x47C0, 1, sig); + } + + function test_signatureIsBoundToThisRegistry() public { + ChipRegistryV2 other = new ChipRegistryV2(); + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(chipKey, other.registrationDigest(chipId, registrant, 1)); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.SignatureNotFromChip.selector); + registry.registerChip(chipId, 1, 0x47C0, 1, abi.encodePacked(r, s, v)); + } + + function test_signatureIsBoundToThisChain() public { + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.chainId(block.chainid + 1); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.SignatureNotFromChip.selector); + registry.registerChip(chipId, 1, 0x47C0, 1, sig); + } + + function test_nonceCannotBeReusedAfterSlashingFrees_theIdentifier() public { + // Register, then attempt the same nonce again. AlreadyRegistered fires + // first, so drive the nonce path with a second chip sharing a nonce + // value - nonces are per chip, which this also demonstrates. + bytes memory sig = _sign(chipKey, chipId, registrant, 7); + vm.prank(registrant); + registry.registerChip(chipId, 1, 0x47C0, 7, sig); + assertTrue(registry.nonceUsed(chipId, 7)); + + address second = vm.addr(otherKey); + bytes32 secondId = bytes32(uint256(uint160(second))); + bytes memory sig2 = _sign(otherKey, secondId, registrant, 7); + vm.prank(registrant); + registry.registerChip(secondId, 2, 0x47C0, 7, sig2); + assertEq(registry.totalChips(), 2, "nonce 7 is free for a different chip"); + } + + // ── checks retained from the deployed registry ─────────────────────────── + + function test_stillRejectsWrongAnchor() public { + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.PhiAnchorMismatch.selector); + registry.registerChip(chipId, 1, 0x0000, 1, sig); + } + + function test_stillRejectsBadFamily() public { + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.InvalidFamily.selector); + registry.registerChip(chipId, 4, 0x47C0, 1, sig); + } + + function test_stillRejectsDoubleRegistration() public { + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.prank(registrant); + registry.registerChip(chipId, 1, 0x47C0, 1, sig); + + bytes memory sig2 = _sign(chipKey, chipId, registrant, 2); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.AlreadyRegistered.selector); + registry.registerChip(chipId, 1, 0x47C0, 2, sig2); + } + + function test_onlyAttestorMaySlash() public { + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.prank(registrant); + registry.registerChip(chipId, 1, 0x47C0, 1, sig); + + vm.prank(stranger); + vm.expectRevert(ChipRegistryV2.NotAttestor.selector); + registry.slashChip(chipId, "not yours"); + + vm.prank(registrant); + registry.slashChip(chipId, "caught cheating"); + assertFalse(registry.isRegistered(chipId), "slashed chip stops counting"); + } + + // ── the enrolment policy, which is load-bearing outside this contract ─── + + /// @dev One enrolment per device, permanently - including after a slash. The + /// key generator that sits behind this registry needs a debiasing step, + /// and the debiasing methods that survive a second enrolment cannot carry + /// a 128-bit key. So a second enrolment is not a policy preference here; + /// it is a hole in the key generator. Clearing the record on slash, or + /// adding an unregister path, would look like tidying up and would open it. + function test_slashedChipCanNeverReRegister() public { + bytes memory sig = _sign(chipKey, chipId, registrant, 1); + vm.prank(registrant); + registry.registerChip(chipId, 1, 0x47C0, 1, sig); + + vm.prank(registrant); + registry.slashChip(chipId, "caught cheating"); + assertFalse(registry.isRegistered(chipId), "slashed chip is not registered"); + + // A fresh nonce and a valid signature must still be refused. + bytes memory sig2 = _sign(chipKey, chipId, registrant, 2); + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.AlreadyRegistered.selector); + registry.registerChip(chipId, 1, 0x47C0, 2, sig2); + + // And by a different submitter, in case the record were keyed on attestor. + bytes memory sig3 = _sign(chipKey, chipId, stranger, 3); + vm.prank(stranger); + vm.expectRevert(ChipRegistryV2.AlreadyRegistered.selector); + registry.registerChip(chipId, 1, 0x47C0, 3, sig3); + } + + // ── fuzz: no identifier registers without its own key ─────────────────── + + function testFuzz_arbitraryKeyCannotRegisterArbitraryChip( + uint256 attackerKey, + uint256 victimKey + ) public { + attackerKey = bound(attackerKey, 1, type(uint128).max); + victimKey = bound(victimKey, 1, type(uint128).max); + vm.assume(attackerKey != victimKey); + + bytes32 victimId = bytes32(uint256(uint160(vm.addr(victimKey)))); + bytes memory sig = _sign(attackerKey, victimId, registrant, 1); + + vm.prank(registrant); + vm.expectRevert(ChipRegistryV2.SignatureNotFromChip.selector); + registry.registerChip(victimId, 1, 0x47C0, 1, sig); + } +}