diff --git a/test/CertManagerPackedLayout.t.sol b/test/CertManagerPackedLayout.t.sol new file mode 100644 index 0000000..86acd16 --- /dev/null +++ b/test/CertManagerPackedLayout.t.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {CertManager} from "../src/CertManager.sol"; +import {ICertManager} from "../src/ICertManager.sol"; +import {P384Verifier} from "../src/P384Verifier.sol"; + +contract CertManagerPackedLayoutHarness is CertManager { + constructor() CertManager(new P384Verifier(), msg.sender, msg.sender) {} + + function save(bytes32 certHash, VerifiedCert memory cert) external { + _saveVerified(certHash, cert); + } + + function load(bytes32 certHash) external view returns (VerifiedCert memory) { + return _loadVerified(certHash); + } +} + +/// @notice Pins the `verified[certHash]` packed encoding and the assembly offsets `_loadVerified` +/// uses to read it back. +/// @dev `_saveVerified` writes `abi.encodePacked(ca, notAfter, maxPathLen, subjectHash, pubKey)`, +/// so the payload is `ca(1) || notAfter(8) || maxPathLen(8) || subjectHash(32) || pubKey(48)`. +/// `_loadVerified` reads each field with `mload(add(packed, D + N))`, which lands an N-byte +/// field at payload offset D in the low bytes of the loaded word — 0x1, 0x9, 0x11 and 0x31 +/// for the four fixed-width fields. That idiom is easy to misread as off-by-one against the +/// payload offsets themselves, so these tests assert the encoding and the round trip +/// directly rather than leaving the warm-cache path covered only indirectly. +contract CertManagerPackedLayoutTest is Test { + CertManagerPackedLayoutHarness public harness; + + // ca(1) + notAfter(8) + maxPathLen(8) + subjectHash(32) + pubKey(48) + uint256 internal constant PACKED_LENGTH = 97; + uint256 internal constant PUB_KEY_OFFSET = 0x31; + + function setUp() public { + harness = new CertManagerPackedLayoutHarness(); + } + + function _sampleCert() internal pure returns (ICertManager.VerifiedCert memory) { + return ICertManager.VerifiedCert({ + ca: true, + notAfter: 1893456000, + maxPathLen: -1, + subjectHash: keccak256("subject"), + // 48-byte P-384 public key, built from two literals to keep the line short. + pubKey: abi.encodePacked(keccak256("pubKey-head"), bytes16(keccak256("pubKey-tail"))) + }); + } + + function test_saveVerified_writesDocumentedByteLayout() public { + ICertManager.VerifiedCert memory cert = _sampleCert(); + bytes32 certHash = keccak256("cert"); + harness.save(certHash, cert); + + bytes memory packed = harness.verified(certHash); + assertEq(packed.length, PACKED_LENGTH, "packed length"); + assertEq( + packed, + abi.encodePacked(cert.ca, cert.notAfter, cert.maxPathLen, cert.subjectHash, cert.pubKey), + "packed encoding" + ); + assertEq(uint8(packed[0]), 1, "ca at payload offset 0"); + assertEq(uint64(bytes8(this.slice(packed, 1, 8))), cert.notAfter, "notAfter at payload offset 1"); + assertEq(int64(uint64(bytes8(this.slice(packed, 9, 8)))), cert.maxPathLen, "maxPathLen at payload offset 9"); + assertEq(bytes32(this.slice(packed, 17, 32)), cert.subjectHash, "subjectHash at payload offset 17"); + assertEq(this.slice(packed, PUB_KEY_OFFSET, 48), cert.pubKey, "pubKey at payload offset 49"); + } + + function test_loadVerified_roundTripsEveryField() public { + ICertManager.VerifiedCert memory cert = _sampleCert(); + bytes32 certHash = keccak256("cert"); + harness.save(certHash, cert); + + ICertManager.VerifiedCert memory loaded = harness.load(certHash); + assertEq(loaded.ca, cert.ca, "ca"); + assertEq(loaded.notAfter, cert.notAfter, "notAfter"); + assertEq(loaded.maxPathLen, cert.maxPathLen, "maxPathLen"); + assertEq(loaded.subjectHash, cert.subjectHash, "subjectHash"); + assertEq(loaded.pubKey, cert.pubKey, "pubKey"); + } + + /// @dev `ca` is read out of a word whose upper bytes hold the tail of the length slot, so a + /// `ca == false` entry has to stay false rather than picking up those bytes. + function test_loadVerified_roundTripsNonCaEntry() public { + ICertManager.VerifiedCert memory cert = _sampleCert(); + cert.ca = false; + cert.maxPathLen = 0; + bytes32 certHash = keccak256("leaf"); + harness.save(certHash, cert); + + ICertManager.VerifiedCert memory loaded = harness.load(certHash); + assertEq(loaded.ca, false, "ca"); + assertEq(loaded.maxPathLen, int64(0), "maxPathLen"); + assertEq(loaded.notAfter, cert.notAfter, "notAfter"); + assertEq(loaded.subjectHash, cert.subjectHash, "subjectHash"); + } + + function testFuzz_loadVerified_roundTripsEveryField( + bool ca, + uint64 notAfter, + int64 maxPathLen, + bytes32 subjectHash, + bytes32 pubKeyHead, + bytes16 pubKeyTail + ) public { + ICertManager.VerifiedCert memory cert = ICertManager.VerifiedCert({ + ca: ca, + notAfter: notAfter, + maxPathLen: maxPathLen, + subjectHash: subjectHash, + pubKey: abi.encodePacked(pubKeyHead, pubKeyTail) + }); + bytes32 certHash = keccak256(abi.encode(subjectHash, notAfter)); + harness.save(certHash, cert); + + ICertManager.VerifiedCert memory loaded = harness.load(certHash); + assertEq(loaded.ca, cert.ca, "ca"); + assertEq(loaded.notAfter, cert.notAfter, "notAfter"); + assertEq(loaded.maxPathLen, cert.maxPathLen, "maxPathLen"); + assertEq(loaded.subjectHash, cert.subjectHash, "subjectHash"); + assertEq(loaded.pubKey, cert.pubKey, "pubKey"); + } + + /// @dev The root entry is written by the constructor, so it exercises the same encoding against + /// the pinned constants rather than test-authored values. + function test_loadVerified_returnsRootCaConstants() public view { + ICertManager.VerifiedCert memory root = harness.load(harness.ROOT_CA_CERT_HASH()); + assertEq(root.ca, true, "ca"); + assertEq(root.notAfter, harness.ROOT_CA_CERT_NOT_AFTER(), "notAfter"); + assertEq(root.maxPathLen, harness.ROOT_CA_CERT_MAX_PATH_LEN(), "maxPathLen"); + assertEq(root.subjectHash, harness.ROOT_CA_CERT_SUBJECT_HASH(), "subjectHash"); + assertEq(root.pubKey, harness.ROOT_CA_CERT_PUB_KEY(), "pubKey"); + } + + function test_loadVerified_returnsEmptyCertForUnknownHash() public view { + ICertManager.VerifiedCert memory missing = harness.load(keccak256("never-verified")); + assertEq(missing.ca, false, "ca"); + assertEq(missing.notAfter, uint64(0), "notAfter"); + assertEq(missing.maxPathLen, int64(0), "maxPathLen"); + assertEq(missing.subjectHash, bytes32(0), "subjectHash"); + assertEq(missing.pubKey.length, 0, "pubKey"); + } + + function slice(bytes calldata b, uint256 offset, uint256 length) external pure returns (bytes memory) { + return b[offset:offset + length]; + } +}