Skip to content
Draft
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ node_modules/
# Ignores development broadcast logs
!/broadcast
/broadcast/*/31337/
/broadcast/*/11155111/
/broadcast/**/dry-run/

# Docs
Expand Down
535 changes: 535 additions & 0 deletions broadcast/DeployOnchainID.s.sol/11155111/run-1776971155727.json

Large diffs are not rendered by default.

535 changes: 535 additions & 0 deletions broadcast/DeployOnchainID.s.sol/11155111/run-latest.json

Large diffs are not rendered by default.

24 changes: 6 additions & 18 deletions contracts/ClaimIssuer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ contract ClaimIssuer is IClaimIssuer, Identity, UUPSUpgradeable {
override
initializer
{
__UUPSUpgradeable_init();
_getClaimStorage().identityType = IdentityTypes.CLAIM_ISSUER;
__Identity_init(initialManagementKey);
}
Expand Down Expand Up @@ -73,7 +72,7 @@ contract ClaimIssuer is IClaimIssuer, Identity, UUPSUpgradeable {
bytes memory sig;
bytes memory data;

(foundClaimTopic, scheme, issuer, sig, data,) = Identity(_identity).getClaim(_claimId);
(foundClaimTopic, scheme, issuer, sig, data,) = Identity(payable(_identity)).getClaim(_claimId);

require(!revokedClaims[sig], Errors.ClaimAlreadyRevoked());

Expand Down Expand Up @@ -108,30 +107,19 @@ contract ClaimIssuer is IClaimIssuer, Identity, UUPSUpgradeable {

/**
* @dev See {IClaimIssuer-isClaimValid}.
* @notice Extends Identity's isClaimValid with claim revocation check.
*/
function isClaimValid(IIdentity _identity, uint256 claimTopic, bytes memory sig, bytes memory data)
public
view
override(Identity, IClaimIssuer)
returns (bool claimValid)
{
bytes32 dataHash = keccak256(abi.encode(_identity, claimTopic, data));
// Use abi.encodePacked to concatenate the message prefix and the message to sign.
bytes32 prefixedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
// 1. Check if the claim signature has been revoked by this issuer.
if (isClaimRevoked(sig)) return false;

// Recover address of data signer using OpenZeppelin's ECDSA
(address recovered, ECDSA.RecoverError error,) = ECDSA.tryRecover(prefixedHash, sig);

// If recovery failed, return false
if (error != ECDSA.RecoverError.NoError) {
return false;
}

// Take hash of recovered address
bytes32 hashedAddr = keccak256(abi.encode(recovered));

// Check if the recovered address has CLAIM_SIGNER purpose (CLAIM_ADDER cannot sign claims)
return keyHasPurpose(hashedAddr, KeyPurposes.CLAIM_SIGNER) && !isClaimRevoked(sig);
// 2. Delegate to Identity.isClaimValid for EIP-712 digest + SignatureChecker verification.
return super.isClaimValid(_identity, claimTopic, sig, data);
}

/**
Expand Down
84 changes: 60 additions & 24 deletions contracts/Identity.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.27;

import { KeyManager } from "./KeyManager.sol";
import { SmartAccount } from "./SmartAccount.sol";
import { IClaimIssuer } from "./interface/IClaimIssuer.sol";
import { IERC734 } from "./interface/IERC734.sol";
import { IERC735 } from "./interface/IERC735.sol";
Expand All @@ -11,7 +11,8 @@ import { KeyPurposes } from "./libraries/KeyPurposes.sol";
import { Structs } from "./storage/Structs.sol";
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import { MulticallUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/MulticallUpgradeable.sol";
import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

Expand All @@ -34,7 +35,7 @@ import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableS
* @custom:security This contract uses ERC-7201 storage slots to prevent storage collision attacks
* in upgradeable contracts.
*/
contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable {
contract Identity is Initializable, IIdentity, SmartAccount, MulticallUpgradeable {

using EnumerableSet for EnumerableSet.Bytes32Set;

Expand Down Expand Up @@ -62,15 +63,19 @@ contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable
abi.encode(uint256(keccak256(bytes("onchainid.identity.claim.storage"))) - 1)
) & ~bytes32(uint256(0xff));

/// @dev EIP-712 typehash for claim signing: Claim(address identity,uint256 topic,bytes data)
bytes32 internal constant _CLAIM_TYPEHASH = keccak256("Claim(address identity,uint256 topic,bytes data)");

// Key management functionality is inherited from KeyManager contract

// ========= Modifiers =========

/// @notice requires claim key (CLAIM_SIGNER or CLAIM_ADDER) to call this function, or internal call
modifier onlyClaimKey() {
require(
msg.sender == address(this) || keyHasPurpose(keccak256(abi.encode(msg.sender)), KeyPurposes.CLAIM_SIGNER)
|| keyHasPurpose(keccak256(abi.encode(msg.sender)), KeyPurposes.CLAIM_ADDER),
msg.sender == address(this)
|| keyHasPurpose(keccak256(abi.encodePacked(msg.sender)), KeyPurposes.CLAIM_SIGNER)
|| keyHasPurpose(keccak256(abi.encodePacked(msg.sender)), KeyPurposes.CLAIM_ADDER),
Errors.SenderDoesNotHaveClaimSignerKey()
);
_;
Expand All @@ -80,7 +85,8 @@ contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable
/// @dev CLAIM_ADDER keys are excluded — they can add but not remove claims
modifier onlyClaimSignerKey() {
require(
msg.sender == address(this) || keyHasPurpose(keccak256(abi.encode(msg.sender)), KeyPurposes.CLAIM_SIGNER),
msg.sender == address(this)
|| keyHasPurpose(keccak256(abi.encodePacked(msg.sender)), KeyPurposes.CLAIM_SIGNER),
Errors.SenderDoesNotHaveClaimSignerKey()
);
_;
Expand All @@ -94,7 +100,7 @@ contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable
* @param _isLibrary boolean value stating if the contract is library or not
* calls __Identity_init if contract is not library
*/
constructor(address initialManagementKey, bool _isLibrary) {
constructor(address initialManagementKey, bool _isLibrary) EIP712("OnchainID", "1") {
if (!_isLibrary) {
__Identity_init(initialManagementKey);
} else {
Expand All @@ -110,9 +116,15 @@ contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable
*/
function initialize(address initialManagementKey, uint256 _identityType) external virtual initializer {
_getClaimStorage().identityType = _identityType;
__AccountERC7579_init();
__Identity_init(initialManagementKey);
}

/// @dev See {IERC7579AccountConfig-accountId}.
function accountId() public view virtual override returns (string memory) {
return "trex.onchainid.identity.v3.0.0";
}

/**
* @dev See {IERC735-getClaimIdsByTopic}.
* * @notice Implementation of the getClaimIdsByTopic function from the ERC-735 standard.
Expand Down Expand Up @@ -160,7 +172,7 @@ contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable
* Claim validation:
* - If the issuer is not the identity itself, the claim must be validated by the issuer
* - Self-issued claims are automatically valid
* - The signature must follow the structure: keccak256(abi.encode(identityHolder_address, topic, data))
* - The signature must be over the EIP-712 typed data hash produced by `getClaimHash(identity, topic, data)`
*
* Access control: Only CLAIM_SIGNER keys can add claims.
*
Expand Down Expand Up @@ -277,12 +289,18 @@ contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable
}

/**
* @dev Checks if a claim is valid. Claims issued by the identity are self-attested claims. They do not have a
* built-in revocation mechanism and are considered valid as long as their signature is valid and they are still
* stored by the identity contract.
* @dev Checks if a claim is valid via unified ERC-7913 signature verification.
*
* All signature schemes (ECDSA, WebAuthn, RSA, etc.) use the same format:
* `sig = abi.encode(signer, actualSignature)`
*
* The claim digest is an EIP-712 typed data hash, allowing EOA wallets to use
* `signTypedData` (readable prompts) and WebAuthn/passkey signers to use the
* digest as an opaque challenge — both verify against the same hash.
*
* @param _identity the identity contract related to the claim
* @param claimTopic the claim topic of the claim
* @param sig the signature of the claim
* @param sig the signature: abi.encode(signer, actualSignature)
* @param data the data field of the claim
* @return claimValid true if the claim is valid, false otherwise
*/
Expand All @@ -293,25 +311,43 @@ contract Identity is Initializable, IIdentity, KeyManager, MulticallUpgradeable
override
returns (bool claimValid)
{
// Step 1: Create the data hash that was signed
bytes32 dataHash = keccak256(abi.encode(_identity, claimTopic, data));
// 1. Build the EIP-712 struct hash. `data` is dynamic, so it must be hashed
// per EIP-712 encodeData rules.
bytes32 structHash = keccak256(abi.encode(_CLAIM_TYPEHASH, address(_identity), claimTopic, keccak256(data)));

// Step 2: Add Ethereum signature prefix for EIP-191 compliance
bytes32 prefixedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash));
// 2. Wrap with this contract's domain separator to produce the digest the
// issuer actually signed (matches eth_signTypedData_v4 output).
bytes32 digest = _hashTypedDataV4(structHash);

// Step 3: Recover the signer's address from the signature using OpenZeppelin's ECDSA
(address recovered, ECDSA.RecoverError error,) = ECDSA.tryRecover(prefixedHash, sig);
// 3. Decode the unified ERC-7913 signature format.
(bytes memory signer, bytes memory actualSig) = abi.decode(sig, (bytes, bytes));

// If recovery failed, return false
if (error != ECDSA.RecoverError.NoError) {
// 4. Verify the signer is registered as a CLAIM_SIGNER on this identity.
if (!keyHasPurpose(keccak256(signer), KeyPurposes.CLAIM_SIGNER)) {
return false;
}

// Step 4: Hash the recovered address for key lookup
bytes32 hashedAddr = keccak256(abi.encode(recovered));
// 5. Dispatch through SignatureChecker:
// - 20-byte signer -> ECDSA recover (EIP-712 prompt in MetaMask) or ERC-1271
// - >20-byte signer -> ERC-7913 verifier (WebAuthn / RSA / etc.)
return SignatureChecker.isValidSignatureNow(signer, digest, actualSig);
}

// Step 5: Check if the recovered address has CLAIM_SIGNER purpose (CLAIM_ADDER cannot sign claims)
return keyHasPurpose(hashedAddr, KeyPurposes.CLAIM_SIGNER);
/**
* @dev Computes the EIP-712 claim digest for off-chain signing.
*
* Frontend computes the same hash using `signTypedData` (EOA) or passes it as the
* WebAuthn challenge (passkey). Mirrors the pattern of `getOperationHash` in SmartAccount.
*
* @param _identity The identity address the claim is for
* @param _topic The claim topic
* @param _data The claim data
* @return The EIP-712 typed data hash
*/
function getClaimHash(address _identity, uint256 _topic, bytes memory _data) public view returns (bytes32) {
// EIP-712 struct hash: dynamic `_data` is hashed per encodeData rules.
bytes32 structHash = keccak256(abi.encode(_CLAIM_TYPEHASH, _identity, _topic, keccak256(_data)));
return _hashTypedDataV4(structHash);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion contracts/IdentityUtilities.sol
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.27;
pragma solidity ^0.8.27;

import { IClaimIssuer } from "./interface/IClaimIssuer.sol";
import { IIdentity } from "./interface/IIdentity.sol";
Expand Down
Loading
Loading