From b5270db667d841be6369912396876745c279ffbd Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Thu, 30 Jul 2026 09:53:10 +0700 Subject: [PATCH 1/2] Add ChipRegistryV2: a registration gate, with tests, for the next deployment The deployed ChipRegistry accepts any caller with any 32-byte value, checked only against a constant published in the contract itself. Its own documentation says this is deliberate - it exposes the gate-free path for testnet bring-up - but MiningPool's registration check is described externally as device identity, and on that registry it is not one. It establishes that a registration transaction happened. This cannot be retrofitted. MiningPool's ownership was renounced at deployment, so setChipRegistry can never be called and the registry it consults can never be replaced. This targets the next deployment. The gate requires a signature from the chip over a digest binding five things: the identifier being claimed, the address submitting the transaction, this registry, this chain, and a nonce. Each closes a distinct replay - a captured signature cannot be submitted by a third party, replayed onto another deployment, replayed onto a fork or another network, or replayed here. Nonces are scoped per chip so that concurrent registrations cannot invalidate each other. The identifier is the chip's own address, left zero-padded. That makes it self-authenticating: a signature recovers to an address and the identifier being registered must be that address. The alternative - an arbitrary value plus a separate signing key - lets anyone bind any identifier to a key they hold, which reintroduces the problem the gate exists to solve. The phi-anchor check is retained but demoted in the documentation to a format assertion. A constant published in the contract cannot gate anything, since anyone who can read the contract can supply it. What this proves: whoever registered an identifier held the key it derives from, at registration, for this registry, on this chain, once. What it does not prove: that the key lives on a die rather than in a file. No on-chain check establishes that. So the floor rises from anyone may claim to be any chip to only the holder of a chip's key may register that chip, and that is the whole claim. Thirteen tests, all passing, including a fuzz run over attacker and victim keys. One of them is a demonstration against the deployed registry rather than this one, because a gate is only worth having if the thing it replaces is open. This has not been reviewed by anyone who did not write it, and it sits in front of the token supply. It should not be deployed on that basis. Co-Authored-By: Claude Opus 5 --- src/ChipRegistryV2.sol | 234 ++++++++++++++++++++++++++++++++++++++ test/ChipRegistryV2.t.sol | 183 +++++++++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 src/ChipRegistryV2.sol create mode 100644 test/ChipRegistryV2.t.sol diff --git a/src/ChipRegistryV2.sol b/src/ChipRegistryV2.sol new file mode 100644 index 0000000..7931ff9 --- /dev/null +++ b/src/ChipRegistryV2.sol @@ -0,0 +1,234 @@ +// 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 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..50edd52 --- /dev/null +++ b/test/ChipRegistryV2.t.sol @@ -0,0 +1,183 @@ +// 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"); + } + + // ── 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); + } +} From 0a51fe07510434e825f84ec749fd33923aa647c7 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Thu, 30 Jul 2026 14:50:02 +0700 Subject: [PATCH 2/2] State the enrolment policy, and pin it with a test 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 key generator built on a physically unclonable function needs a debiasing step, since the responses are biased. Of the debiasing methods in the literature three of four are explicitly not reusable: enrolling the same device twice leaks more than one enrolment does, because the debiasing step is stochastic and bit errors between enrolments shift which response pairs are retained. The one reusable method requires an inner repetition code, and a repetition code multiplies the code length while leaving its dimension alone - so it cannot carry the information a 128-bit key needs at any response entropy. The reusable option is not expensive here; it does not exist. That leaves one enrolment per device as the only constructible policy, and this contract must never allow a second one. Two paths are already closed: a registered chip is refused by the AlreadyRegistered check, and a slashed chip is refused by the same check, because slashing sets a flag without clearing registeredAt. 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. The new test refuses re-registration after a slash, with a fresh nonce and a valid signature, and from a different submitter in case the record were ever keyed on the attestor. Injecting the tidy-up - clearing registeredAt on slash - makes it fail, which is the point of having it. 14 tests pass. Co-Authored-By: Claude Opus 5 --- src/ChipRegistryV2.sol | 29 +++++++++++++++++++++++++++++ test/ChipRegistryV2.t.sol | 30 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/ChipRegistryV2.sol b/src/ChipRegistryV2.sol index 7931ff9..9fd6ff7 100644 --- a/src/ChipRegistryV2.sol +++ b/src/ChipRegistryV2.sol @@ -37,6 +37,35 @@ import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; * 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 diff --git a/test/ChipRegistryV2.t.sol b/test/ChipRegistryV2.t.sol index 50edd52..a8b5cc9 100644 --- a/test/ChipRegistryV2.t.sol +++ b/test/ChipRegistryV2.t.sol @@ -163,6 +163,36 @@ contract ChipRegistryV2Test is Test { 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(