Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions test/CertManagerPackedLayout.t.sol
Original file line number Diff line number Diff line change
@@ -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];
}
}