EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-7931: Hybrid Governance Documents Management

Hybrid governance document management for on-chain organizations built upon off-chain legal frameworks

Authors
Created2025-04-10
Discussion Linkhttps://ethereum-magicians.org/t/new-erc-hybrid-governance-document-management-hgdm/23444

Markdown

https://raw.githubusercontent.com/Borderless-compa...

EIP-GPT summary

Contents
AbstractMotivationSpecificationInterfaceStructItemLocationEncryptedItemEncryptedItemInputFunctionupdateChaptersetEphemeralSaltsetGovernancesetTokengetVersiongetEncryptedItemgetVersionRootisEphemeralSaltUsedverifyDecryptionKeyHashverifyDecryptionKeyHashWithSaltHashEventsChapterUpdatedParameters:EphemeralSaltMarkedUsedGovernanceUpdatedTokenUpdatedRationaleWhy Use AES-256-GCM?Can Other Encryption Methods Be Used?Why Register Articles Divided Into Chapters, Sections, and Items?Integrity Verification with Merkle RootReason for Registering Executor and Voter AddressesFlow[1] Encryption of Articles by Administrators (Off-chain)[2] Derivation of Decryption Key by User (NFT Holder) (Off-chain)[3] Verify Decryption Key Integrity On-chain[4] Decryption (Off-chain)[5] Updating Articles of Incorporation (On-chain)EIP-712Backwards CompatibilityTest CasesReference ImplementationSecurity ConsiderationsCollision of ephemeralSalt (Lack of Randomness)Leakage of masterSaltHash Collision Attack (plaintextHash)Intentional Reuse of PlaintextUser Private Key Protection (Decryption Process)Inconsistency Between On-chain and Off-chainCopyright

Abstract

This ERC defines a standard for on-chain storage and access control of organizational governance documents using NFT-based permissions and hierarchical document structures. The standard supports encrypted storage with cryptographic integrity verification, version tracking, and signature preservation to enable legally compliant governance for on-chain organizations operating within off-chain legal frameworks, such as decentralized autonomous organization limited liability companies (DAO LLCs).

Motivation

Current blockchain infrastructure lacks standardized methods for managing hierarchical governance documents that require selective access controls. Many DAO LLCs and similar entities need to store critical documents, such as articles of incorporation and bylaws, on-chain to ensure transparency and immutability. However, these documents often contain sensitive information meant only for authorized members, making plaintext storage on public blockchains impractical.

Key challenges include:

  • Selective access control: There is no standardized mechanism to store sensitive governance documents on-chain while restricting access to authorized participants, such as NFT-holding members.
  • Hierarchical document structure: Governance documents often follow a hierarchical structure (e.g., articles, sections, clauses) and require regular partial amendments. Efficiently managing these updates while preserving document integrity remains a challenge.
  • Gas efficiency: Minimizing gas costs when updating specific portions of documents, such as re-encrypting only the modified sections, is critical for scalability.
  • Key management: Distributing and managing individual decryption keys for each member is complex and impractical in large organizations.
  • Off-chain decryption with on-chain verification: Ensuring that decryption occurs securely off-chain while maintaining clear, on-chain records of decryption rights is a critical gap.
  • Consensus and approval tracking: Verifiable systems for documenting consensus and approvals from multiple board members or stakeholders are needed to establish trust in governance processes.

This ERC proposes a document management system that addresses these challenges by combining privacy-preserving encryption techniques with blockchain's transparency and permanence. By providing a standardized framework for managing governance documents on-chain, this proposal aims to enhance document integrity, improve operational efficiency, and expand the possibilities for decentralized governance.

Specification

Smart contracts complying with this standard MUST implement the following interface:

Interface

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

interface IAOI {
    // ************************************************
    // *                     EVENTS                   *
    // ************************************************

    /**
     * @dev Chapter updated
     * @param versionId Version ID
     * @param versionRoot Version root
     * @param finalSigner Final signer
     * @param signers Signers
     * @param updatedLocations Updated locations
     */
    event ChapterUpdated(
        uint256 versionId,
        bytes32 versionRoot,
        address finalSigner,
        address[] signers,
        ItemLocation[] updatedLocations
    );

    /**
     * @dev Ephemeral salt marked used
     * @param ephemeralSalt Ephemeral salt
     */
    event EphemeralSaltMarkedUsed(bytes32 ephemeralSalt);

    /**
     * @dev Governance updated
     * @param governance Governance
     */
    event GovernanceUpdated(address governance);

    /**
     * @dev Token updated
     * @param token Token
     */
    event TokenUpdated(address token);

    // ************************************************
    // *                     ERRORS                   *
    // ************************************************

    /**
     * @dev Length mismatch
     */
    error LengthMismatch();

    /**
     * @dev Not governance
     */
    error NotGovernance(address sender, address governance);

    /**
     * @dev Invalid articleId
     */
    error InvalidArticleId(uint256 articleId);

    /**
     * @dev Invalid signature
     */
    error InvalidSignature(address recovered, address signer);

    /**
     * @dev Ephemeral salt already used
     */
    error EphemeralSaltAlreadyUsed(
        bytes32 ephemeralSalt
    );

    // ************************************************
    // *                    STRUCTS                   *
    // ************************************************

    /**
     * @dev Item location
     * @param articleId Article ID
     * @param paragraphId Paragraph ID
     * @param itemId Item ID
     */
    struct ItemLocation {
        uint256 articleId;
        uint256 paragraphId;
        uint256 itemId;
    }

    /**
     * @dev Encrypted item
     * @param encryptedData Encrypted data
     * @param plaintextHash Plaintext hash
     * @param masterSaltHash Master salt hash
     */
    struct EncryptedItem {
        bytes encryptedData;
        bytes32 plaintextHash;
        bytes32 masterSaltHash;
    }

    /**
     * @dev Encrypted item input
     * @param location Item location
     * @param encryptedData Encrypted data
     * @param plaintextHash Plaintext hash
     * @param masterSaltHash Master salt hash
     */
    struct EncryptedItemInput {
        ItemLocation location;
        bytes encryptedData;
        bytes32 plaintextHash;
        bytes32 masterSaltHash;
    }

    // ************************************************
    // *           EXTERNAL WRITE FUNCTIONS           *
    // ************************************************

    /**
     * @notice Update chapter
     * @param versionRoot Version root
     * @param signers Signers
     * @param signatures Signatures
     * @param finalSignature Final signature
     * @param version Version
     * @param items Items
     */
    function updateChapter(
        bytes32 versionRoot,
        address[] calldata signers,
        bytes[] calldata signatures,
        bytes calldata finalSignature,
        string calldata version,
        EncryptedItemInput[] calldata items
    ) external;

    /**
     * @notice Set ephemeral salt as used
     * @param ephemeralSalt Ephemeral salt
     */
    function setEphemeralSalt(bytes32 ephemeralSalt) external;

    /**
     * @notice Set governance
     * @param governance Governance
     */
    function setGovernance(address governance) external;

    /**
     * @notice Set token
     * @param token Token
     */
    function setToken(address token) external;

    // ************************************************
    // *            EXTERNAL READ FUNCTIONS           *
    // ************************************************

    /**
     * @notice Get version
     * @return Version
     */
    function getVersion() external view returns (string memory);

    /**
     * @notice Get encrypted item
     * @param location Item location
     * @return Encrypted item
     */
    function getEncryptedItem(
        ItemLocation calldata location
    ) external view returns (EncryptedItem memory);

    /**
     * @notice Get version root
     * @param versionId Version ID
     * @return Version root
     */
    function getVersionRoot(uint256 versionId) external view returns (bytes32);

    function isEphemeralSaltUsed(
        bytes32 ephemeralSalt
    ) external view returns (bool);

    /**
     * @notice Verify decryption key hash
     * @param location Item location
     * @param expectedHash Expected hash
     * @return True if the hash is valid
     */
    function verifyDecryptionKeyHash(
        ItemLocation calldata location,
        bytes32 expectedHash
    ) external view returns (bool);

    /**
     * @notice Verify decryption key hash with salt hash
     * @param location Item location
     * @param ephemeralSalt Ephemeral salt
     * @param masterSaltHash Master salt hash
     * @param holder Holder
     * @return True if the hash is valid
     */
    function verifyDecryptionKeyHashWithSaltHash(
        ItemLocation calldata location,
        bytes32 ephemeralSalt,
        bytes32 masterSaltHash,
        address holder
    ) external view returns (bool);

    /**
     * @notice Get governance
     * @return Governance
     */
    function getGovernance() external view returns (address);

    /**
     * @notice Get token
     * @return Token
     */
    function getToken() external view returns (address);
}

Struct

ItemLocation

struct ItemLocation {
    uint256 articleId;
    uint256 paragraphId;
    uint256 itemId;
}

A struct that identifies the hierarchical structure of articles of incorporation as "chapter, section, item."

Used to point to a specific section in the articles of incorporation.

This structure allows flexible specification as follows:

  • {1, 0, 0} → All of Article 1

  • {1, 1, 0} → Article 1, Section 1

  • {1, 1, 1} → Article 1, Section 1, Item 1

  • articleId

    • ID representing a "chapter" (top-level hierarchy).
    • MUST be 1 or greater.
    • "1" indicates "Article 1", "2" indicates "Article 2".
  • paragraphId

    • ID representing a section.
    • "0" means no section specified.
    • If itemId is "1" or greater, this value MUST also be "1" or greater.
  • itemId

    • ID representing an item.
    • "0" means no item specified.

EncryptedItem

struct EncryptedItem {
    bytes encryptedData;
    bytes32 plaintextHash;
    bytes32 masterSaltHash;
}

A struct representing an encrypted item (chapter, section, or item) of the articles of incorporation registered on-chain.

  • encryptedData
    • Content of the articles encrypted with AES or similar (requiring a key for decryption).
  • plaintextHash
    • Hash value of the original plaintext (for verification after decryption).
  • masterSaltHash
    • Hash value of masterSalt (used for decryption key derivation verification).

EncryptedItemInput

struct EncryptedItemInput {
    ItemLocation location;
    bytes encryptedData;
    bytes32 plaintextHash;
    bytes32 masterSaltHash;
}

A struct used when registering or updating an encrypted item (chapter, section, or item) of the articles of incorporation.

  • location
    • ItemLocation pointing to the target chapter (article, section, item).
  • encryptedData
    • Encrypted chapter of the articles (article, section, item).
  • plaintextHash
    • Hash value for verifying integrity after decryption.
  • masterSaltHash
    • Hash value of masterSalt to verify the legitimacy of the decryption key.

Function

updateChapter

function updateChapter(
    bytes32 versionRoot,
    address[] calldata signers,
    bytes[] calldata signatures,
    bytes calldata finalSignature,
    string calldata version,
    EncryptedItemInput[] calldata items
) external;

Function executed based on votes in the Governance contract to batch update chapters (articles, sections, items) of the articles of incorporation. Receives addresses that participated in the vote in the Governance contract and the Executor's address. Registers the specified version. After signature verification, it updates the articles data and emits the ChapterUpdated event.

An error SHOULD be returned in the following cases:

  • When versionRoot is 0 bytes.
  • When the signers array is empty.
  • When the signatures array is empty.
  • When finalSignature is 0 bytes.
  • When the length of version is 0.
  • When the items array is empty.

Parameters

  • versionRoot
    • Root hash indicating the version of the articles.
  • signers
    • Array of addresses that participated in the vote in the Governance contract.
  • signatures
    • Array of signatures corresponding to signers.
  • finalSignature
    • Executor's signature.
  • items
    • Array of EncryptedItemInput structures of chapters to be updated.

setEphemeralSalt

function setEphemeralSalt(bytes32 ephemeralSalt) external;

Function to mark a specific temporary ephemeralSalt (unique value for each decryption session) as "used". Used to prevent reuse of decryption keys. Returns an EphemeralSaltAlreadyUsed error if already used.

An error SHOULD be returned in the following case:

  • When ephemeralSalt is 0 bytes.

Parameters

  • ephemeralSalt
    • Identification hash of the decryption session to mark as used.

setGovernance

function setGovernance(address governance) external;

Function to update the Governance contract address. Emits a GovernanceUpdated event.

An error SHOULD be returned in the following case:

  • When governance is the zero address.

  • governance

    • Updated Governance contract address.

setToken

function setToken(address token) external;

Function to update the NFT contract address. Emits a TokenUpdated event.

An error SHOULD be returned in the following case:

  • When token is the zero address.

  • token

    • Updated NFT contract address.

getVersion

function getVersion() external view returns (string memory);

Function to retrieve the current version of the articles of incorporation.

getEncryptedItem

function getEncryptedItem(ItemLocation calldata location) external view returns (EncryptedItem memory);

Function to retrieve the articles data (encrypted) of the specified chapter, section, or item.

Parameters

  • location
    • Position of the target chapter (article, section, item).

Returns

  • Encrypted articles information (EncryptedItem struct).

getVersionRoot

function getVersionRoot(uint256 versionId) external view returns (bytes32);

Function to retrieve the versionRoot corresponding to a version ID.

Parameters

  • versionId
    • Arbitrary version identification number (uint256 conversion of versionRoot).

Returns

  • versionRoot
    • Hash indicating the Merkle root of the articles.

isEphemeralSaltUsed

function isEphemeralSaltUsed(bytes32 ephemeralSalt) external view returns (bool);

Function to check if the specified ephemeralSalt has been used.

Parameters

  • ephemeralSalt
    • Temporary hash that serves as a session identifier.

Returns

  • true if used, false if unused.

verifyDecryptionKeyHash

function verifyDecryptionKeyHash(
    ItemLocation calldata location,
    bytes32 expectedHash
) external view returns (bool);

Function to verify if the plaintext hash of the specified chapter matches.

Parameters

  • location
    • Position of the target chapter (article, section, item).
  • expectedHash
    • Plaintext hash derived after decryption.

Returns

  • true if the hash matches.

verifyDecryptionKeyHashWithSaltHash

function verifyDecryptionKeyHashWithSaltHash(
    ItemLocation calldata location,
    bytes32 ephemeralSalt,
    bytes32 masterSaltHash,
    address holder
) external view returns (bool);

Function to verify the ephemeralSalt, masterSaltHash, and NFT ownership used to derive the decryption key. Returns false if the ephemeralSalt has been used. Returns false if the masterSaltHash differs from the value registered in the contract. Returns false if the address is not an NFT holder.

Parameters

  • location
    • Position of the target chapter (article, section, item).
  • ephemeralSalt
    • Temporary session value (used for decryption).
  • masterSaltHash
    • Hash of the pre-registered master salt.

Returns

  • true if all match.

Events

ChapterUpdated

event ChapterUpdated(
    bytes32 versionRoot,
    ItemLocation[] updatedLocations
);

Event emitted when new chapter (article, paragraph, item) data is registered or updated. This is emitted when the AOI contract is deployed or when executed by the Executor following a vote in the Governance contract.

Parameters:

  • versionId
    • ID converted from versionRoot to uint256.
    • Used as an identifier for version management.
  • versionRoot
    • Root hash of the articles being signed.
  • finalSigner
    • Address of the Executor.
  • signers
    • Array of addresses that participated in the vote in the Governance contract.
  • updatedLocations: Array of positions (ItemLocation) of updated chapters.

EphemeralSaltMarkedUsed

event EphemeralSaltMarkedUsed(bytes32 ephemeralSalt);

Event emitted when a user marks a specific ephemeralSalt (temporary session identifier) as used to prevent reuse of decryption keys. Emitted when setEphemeralSalt() is executed.

  • ephemeralSalt
    • Temporary salt value marked as used.
    • Cannot be reused.

GovernanceUpdated

event GovernanceUpdated(address governance);

Event emitted when the Governance contract address is updated.

  • governance
    • Updated Governance contract address.

TokenUpdated

event TokenUpdated(address token);

Event emitted when the NFT contract address is updated.

  • token
    • Updated NFT contract address.

Rationale

Why Use AES-256-GCM?

AES (Advanced Encryption Standard) is a widely used symmetric key encryption method. It's a system where encryption and decryption are performed with a single key, allowing information to be encrypted quickly and securely.

AES-256-GCM refers to using this AES with a 256-bit key length, operating in the GCM (Galois/Counter Mode). GCM provides not only encryption but also authentication functionality (MAC: Message Authentication Code), making it an "authenticated encryption" that can detect data tampering.

The main reasons for adopting this method are:

  • High Security

    Using a 256-bit key provides very robust encryption.

  • Tamper Detection

    GCM automatically checks if encrypted data has been altered.

  • Fast Performance

    Optimized at the CPU level, it's very fast especially on Intel-based machines with hardware acceleration.

  • Wide Compatibility

    Supported by many platforms and libraries, reducing implementation and operational burden.

  • Simple Key Management

    Using a single secret key for both encryption and decryption makes key management relatively simple.

In a design like this project where all encryption is done off-chain and only hash values are verified on-chain, encryption methods like GCM that allow integrity verification are suitable. This prevents plaintext tampering off-chain and enables lightweight verification of its legitimacy on-chain.

Can Other Encryption Methods Be Used?

Yes.

The contract proposed in this standard does not depend on the encryption method. The contract deals only with the following information:

  • Encrypted byte sequence (encryptedData)
  • Corresponding plaintext hash (plaintextHash)

Therefore, even if the encryption method is changed from AES-256-GCM to something else, verification is still possible as long as the hash of the plaintext obtained after decryption matches.

Here are examples of other encryption methods that could be adopted:

  • ChaCha20-Poly1305

    A lightweight, fast encryption method that performs particularly well in mobile environments. It provides authenticated encryption like AES-GCM. In terms of security, it's sometimes rated equal to or better than AES-GCM.

  • AES-CTR + HMAC

    An approach that uses AES in CTR (counter) mode for encryption and HMAC (Hash-based Message Authentication Code) for tamper detection. It's sometimes adopted as an alternative in environments where GCM cannot be used.

  • RSA-OAEP

    One of the asymmetric encryption (public key encryption) methods. It uses a key pair (public key and private key) for encryption and decryption. While secure, it's heavy processing, tends to increase file size, and is not suitable for frequent communications or storage.

  • ECIES (Elliptic Curve Integrated Encryption Scheme)

    A hybrid method based on Elliptic Curve Cryptography (ECC) that allows processing combining both encryption and signatures. It works well with ECDSA and is used in contexts such as ZK (Zero-Knowledge Proofs) and confidential tokens.

Why Register Articles Divided Into Chapters, Sections, and Items?

Registering an entire articles of incorporation document at once MAY NOT be possible if it's too long for a smart contract.

Additionally, longer documents increase gas costs.

To avoid this risk, the articles are divided and registered on the smart contract.

When part of the articles needs to be amended, only the changed sections need to be updated on the smart contract, saving gas fees.

Token holders who want to view only part of the articles can retrieve just the relevant portion from the contract.

Integrity Verification with Merkle Root

We calculate and store a versionRoot based on all plaintextHash values.

This is effective for checking whether the entire articles have been tampered with, not just individual chapters (articles, sections, items).

When changes are made, updateChapter() is executed and a new versionRoot is recorded.

Reason for Registering Executor and Voter Addresses

This is not mandatory.

However, recording this information on the smart contract makes it possible to know who served as Executor and who participated in voting, which can be useful in situations where this information is needed.

Flow

The following explains the flow from encryption of articles of incorporation to decryption by NFT holders.

[1] Encryption of Articles by Administrators (Off-chain)

  • Administrators prepare each chapter (article, section, item) of the articles of incorporation.
  • For each chapter, the following process is executed:
    • Determine the plaintext.
    • Randomly generate a masterSalt (different for each chapter).
    • Generate a decryption key:
key = keccak256(encode([location, masterSalt, userAddress, ephemeralSalt]))
  • At this stage, the decryptor's address is set to the administrator's address.
  • Encrypt using AES-256-GCM to obtain encryptedData.
  • plaintextHash = keccak256(plaintext).
  • masterSaltHash = keccak256(masterSalt).
  • Deploy the AOI contract and pass the information prepared earlier.

[2] Derivation of Decryption Key by User (NFT Holder) (Off-chain)

  • User specifies the chapter (location) of the articles they want to view and generates an ephemeralSalt (disposable random value).
  • Derive the decryption key by combining their own userAddress with location, masterSalt, and ephemeralSalt.
key = keccak256(encode([location, masterSalt, userAddress, ephemeralSalt]))

[3] Verify Decryption Key Integrity On-chain

  • Verify using one of the following functions with the masterSalt and ephemeralSalt derived off-chain:

    • verifyDecryptionKeyForHolder(location, ephemeralSalt, masterSalt)

      → Check if the hash calculated from masterSalt matches the registered masterSaltHash.

      → Check if the ephemeralSalt is not already used.

      → Check if the address is an NFT holder.

  • After successful verification, execute setEphemeralSalt(ephemeralSalt) to the contract to invalidate that ephemeralSalt (preventing reuse).

[4] Decryption (Off-chain)

  • Retrieve encryptedData using getEncryptedItem(location).
  • Decrypt using AES-256-GCM with the derived key and ephemeralSalt.
  • Compare the decryption result with plaintextHash (stored on-chain).
    • If they match, it confirms that the data is legitimate and unaltered.

[5] Updating Articles of Incorporation (On-chain)

  • Conduct a vote in the Governance contract to execute the following process:
    • Update the articles information by passing an array of structures containing the following information through the updateChapter() function:
      • location (structured identifier of article, section, item)
      • encryptedData
      • plaintextHash
      • masterSaltHash
  • Generate a Merkle Root from all plaintextHash values and save it as versionRoot.
  • The ChapterUpdated event is emitted, transparently recording the change history.

EIP-712

EIP-712 can also be used for Executor verification. You can use any signature verification method you prefer.

Backwards Compatibility

No backwards compatibility issues were identified.

Test Cases

Sample test code is available and running on Github.

Reference Implementation

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {IAOI} from "./interfaces/IAOI.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";

contract AOI is IAOI, AccessControl {
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    // ************************************************
    // *                   STORAGES                   *
    // ************************************************

    address private _governance;
    address private _token;
    string private _version;

    /** 
     * @dev articleId => paragraphId => itemId => EncryptedItem
     */
    mapping(uint256 => mapping(uint256 => mapping(uint256 => EncryptedItem)))
        public encryptedItems;

    /**
     * @dev versionId => versionRoot
     */
    mapping(uint256 => bytes32) public versionRoots;

    /**
     * @dev ephemeralSalt => used
     */
    mapping(bytes32 => bool) public usedEphemeralSalts;

    // ************************************************
    // *                 MODIFIERS                    *
    // ************************************************

    modifier onlyGovernance() {
        require(
            msg.sender == _governance,
            NotGovernance(msg.sender, _governance)
        );
        _;
    }

    // ************************************************
    // *                 CONSTRUCTOR                  *
    // ************************************************

    constructor(
        address admin,
        address governance,
        address token,
        EncryptedItemInput[] memory items
    ) {
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _governance = governance;
        _token = token;

        bytes32[] memory leaves = new bytes32[](items.length);

        for (uint256 i = 0; i < items.length; i++) {
            _setEncryptedItem(items[i]);
            leaves[i] = items[i].plaintextHash;
        }

        bytes32 initialVersionRoot = _computeMerkleRoot(leaves);
        versionRoots[0] = initialVersionRoot;
        _version = "1.0.0";

        _emitChapterUpdated(
            0,
            initialVersionRoot,
            address(0),
            new address[](0),
            items
        );
    }

    // ************************************************
    // *           EXTERNAL WRITE FUNCTIONS           *
    // ************************************************

    /**
     * @dev Only Governance can call this function
     */
    function updateChapter(
        bytes32 versionRoot,
        address[] calldata signers,
        bytes[] calldata signatures,
        bytes calldata finalSignature,
        string calldata version,
        EncryptedItemInput[] calldata items
    ) external override onlyGovernance {
        (address finalSigner, ) = _verifyFinalAndAllSigners(
            versionRoot,
            signers,
            signatures,
            finalSignature
        );

        for (uint256 i = 0; i < items.length; i++) {
            _setEncryptedItem(items[i]);
        }

        uint256 versionId = uint256(versionRoot);
        versionRoots[versionId] = versionRoot;
        _version = version;

        _emitChapterUpdated(
            versionId,
            versionRoot,
            finalSigner,
            signers,
            items
        );
    }

    /**
     * @dev Only DefaultAdminRole can call this function
     */
    function setEphemeralSalt(
        bytes32 ephemeralSalt
    ) external override onlyRole(DEFAULT_ADMIN_ROLE) {
        require(
            !usedEphemeralSalts[ephemeralSalt],
            EphemeralSaltAlreadyUsed(ephemeralSalt)
        );
        usedEphemeralSalts[ephemeralSalt] = true;
        emit EphemeralSaltMarkedUsed(ephemeralSalt);
    }

    /**
     * @dev Only DefaultAdminRole can call this function
     */
    function setGovernance(
        address governance
    ) external override onlyRole(DEFAULT_ADMIN_ROLE) {
        _governance = governance;
        emit GovernanceUpdated(governance);
    }

    /**
     * @dev Only DefaultAdminRole can call this function
     */
    function setToken(
        address token
    ) external override onlyRole(DEFAULT_ADMIN_ROLE) {
        _token = token;
        emit TokenUpdated(token);
    }

    // ************************************************
    // *            INTERNAL WRITE FUNCTIONS          *
    // ************************************************

    function _setEncryptedItem(EncryptedItemInput memory item) internal {
        require(
            item.location.articleId >= 1,
            InvalidArticleId(item.location.articleId)
        );
        encryptedItems[item.location.articleId][item.location.paragraphId][
            item.location.itemId
        ] = EncryptedItem(
            item.encryptedData,
            item.plaintextHash,
            item.masterSaltHash
        );
    }

    function _computeMerkleRoot(
        bytes32[] memory leaves
    ) internal pure returns (bytes32) {
        while (leaves.length > 1) {
            uint256 len = leaves.length;
            uint256 newLen = (len + 1) / 2;
            bytes32[] memory next = new bytes32[](newLen);
            for (uint256 i = 0; i < len; i += 2) {
                next[i / 2] = (i + 1 < len)
                    ? keccak256(abi.encodePacked(leaves[i], leaves[i + 1]))
                    : leaves[i];
            }
            leaves = next;
        }
        return leaves[0];
    }

    function _emitChapterUpdated(
        uint256 versionId,
        bytes32 versionRoot,
        address finalSigner,
        address[] memory signers,
        EncryptedItemInput[] memory items
    ) internal {
        ItemLocation[] memory locations = new ItemLocation[](items.length);
        for (uint256 i = 0; i < items.length; i++) {
            locations[i] = items[i].location;
        }
        emit ChapterUpdated(
            versionId,
            versionRoot,
            finalSigner,
            signers,
            locations
        );
    }

    // ************************************************
    // *            EXTERNAL READ FUNCTIONS           *
    // ************************************************

    function getVersion() external view override returns (string memory) {
        return _version;
    }

    function getEncryptedItem(
        ItemLocation calldata location
    ) external view returns (EncryptedItem memory) {
        return
            encryptedItems[location.articleId][location.paragraphId][
                location.itemId
            ];
    }

    function getVersionRoot(uint256 versionId) external view returns (bytes32) {
        return versionRoots[versionId];
    }

    function isEphemeralSaltUsed(
        bytes32 ephemeralSalt
    ) external view returns (bool) {
        return usedEphemeralSalts[ephemeralSalt];
    }

    function verifyDecryptionKeyHash(
        ItemLocation calldata location,
        bytes32 expectedHash
    ) external view returns (bool) {
        return
            encryptedItems[location.articleId][location.paragraphId][
                location.itemId
            ].plaintextHash == expectedHash;
    }

    function verifyDecryptionKeyHashWithSaltHash(
        ItemLocation calldata location,
        bytes32 ephemeralSalt,
        bytes32 masterSaltHash,
        address holder
    ) external view returns (bool) {
        return (!usedEphemeralSalts[ephemeralSalt] &&
            encryptedItems[location.articleId][location.paragraphId][
                location.itemId
            ].masterSaltHash ==
            masterSaltHash &&
            IERC721(_token).balanceOf(holder) > 0);
    }

    function getGovernance() external view override returns (address) {
        return _governance;
    }

    function getToken() external view override returns (address) {
        return _token;
    }

    // ************************************************
    // *            INTERNAL READ FUNCTIONS           *
    // ************************************************

    function _verifyFinalAndAllSigners(
        bytes32 versionRoot,
        address[] memory signers,
        bytes[] memory signatures,
        bytes memory finalSignature
    )
        internal
        pure
        returns (address finalSigner, address[] memory recoveredSigners)
    {
        require(signers.length == signatures.length, LengthMismatch());
        recoveredSigners = new address[](signers.length);

        for (uint256 i = 0; i < signers.length; i++) {
            bytes32 hash = keccak256(abi.encodePacked(versionRoot))
                .toEthSignedMessageHash();
            address recovered = hash.recover(signatures[i]);
            require(
                recovered == signers[i],
                InvalidSignature(recovered, signers[i])
            );
            recoveredSigners[i] = recovered;
        }

        bytes32 metaHash = keccak256(
            abi.encode(versionRoot, signers, signatures)
        ).toEthSignedMessageHash();
        finalSigner = metaHash.recover(finalSignature);
    }
}

Security Considerations

Collision of ephemeralSalt (Lack of Randomness)

ephemeralSalt is treated as a one-time random number, but if it's not sufficiently random, there's a risk that someone else could use the same salt to generate a decryption key.

Countermeasures include:

  • Generate salt using a cryptographically secure random generator (CSPRNG) of at least 32 bytes.
  • Recommend using low-collision generation methods like crypto.randomBytes(32) rather than UUID v4.

Leakage of masterSalt

masterSalt is part of the material for the decryption key. If this leaks externally, there's a possibility that a third party could know the decryption key if a user's address and ephemeralSalt match.

Countermeasures include:

  • Keep masterSalt itself strictly confidential (e.g., in secure key storage like KMS, HSM) and don't pass it to clients.
  • Frequent regeneration/rotation of masterSalt.

Hash Collision Attack (plaintextHash)

While keccak256 used for plaintextHash is a powerful hash, if collisions become feasible in the future, there's a danger that malicious third parties could give the same hash to tampered data.

Countermeasures include:

  • keccak256 is currently considered secure and no issues exist.
  • Design to allow upgrading the hash algorithm in anticipation of future security degradation, or make the contract upgradeable.

Intentional Reuse of Plaintext

Using the same plaintext in multiple locations can lead to reuse of encryptedData or plaintextHash, potentially enabling pattern guessing or similarity analysis (e.g., if Article 1 and Article 2 have the same text).

Countermeasures include:

  • Always use different IVs (initialization vectors) during encryption (mandatory with AES-GCM).
  • Ensure encryption results differ each time even if the plaintext is the same.

User Private Key Protection (Decryption Process)

The decryption process is performed with a derived key using the user's address, but because the decryption process depends on the client side, it is subject to device security and key management status.

Countermeasures include:

  • Perform decryption processing locally.
  • For decryption in browsers or mobile apps, utilize secure environments (e.g., Secure Enclave).
  • Minimize the time keys or decryption processes remain in memory.

Inconsistency Between On-chain and Off-chain

Since the contract only holds hash values, if the off-chain encryption/decryption logic deviates from the specification, there's a risk of verification failure, inability to view content, or believing incorrect content.

Countermeasures include:

  • Standardize off-chain processing through libraries and shared code.
  • Manage versionId when changing specifications to prevent confusion with old versions.

Copyright and related rights waived via CC0

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth