diff --git a/README.md b/README.md index baba345..e6883e9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Encrypted Dataroom Management (eDRM) -A trustless, encrypted data room where access is enforced on-chain via FHE (Fully Homomorphic Encryption). Nobody can see who has access to what. Documents live on Filecoin via Storacha. +Trustless, encrypted filesharing where access is enforced via FHE (Fully Homomorphic Encryption). Nobody can see who has access to what. Documents live on Filecoin via Storacha. ## The Problem Data rooms are essential to every investment deal, yet the market (\$3-4B TAM, led by Intralinks and Datasite) charges \$15-50k+ per deal for what is fundamentally access control on a file share. -Often we need to rely on trust assumptions of the vendors and the members that operate on these data rooms. +Often we need to rely on trust assumptions of the vendors and the members that operate on these data rooms. + +**Alternatively, it's a way to securely share documents with your friends that no-one else can access!** ## The Solution @@ -38,6 +40,7 @@ For the full encryption flow, key hierarchy, and contract interface, see [Techni - [Technical Architecture](docs/ARCHITECTURE.md): encryption flow, key hierarchy, storage model, privacy guarantees - [Feature Set](docs/FEATURES.md): planned and shipped features +- [Architecture Decision Records](docs/ADRs/): technical decisions and trade-offs ## Getting Started @@ -80,12 +83,12 @@ This runs: 3. **TypeChain**: pulls ABIs from the build artifacts and generates typed bindings 4. **Vite**: starts the dapp dev server -#### Storacha setup (file storage) -If it's too much effort to connect storacha ask for keys from: petros@obolos.io +#### Storacha setup +If it's too much effort to setup Storacha ask for keys from: petros@obolos.io The dapp encrypts files client-side and uploads them to Filecoin via [Storacha](https://storacha.network). -You need an agent key and a delegation proof: +Make an agent key and a delegation proof: ```bash # 1. Install the CLI @@ -119,8 +122,7 @@ VITE_STORACHA_PROOF=mAY… #### Chain / RPC (optional) ```env -# 31337 = Anvil (default), 11155111 = Sepolia, 1 = Mainnet -VITE_CHAIN_ID=31337 +VITE_CHAIN_ID=31337 # anvil, 11155111 = Sepolia VITE_RPC_URL=http://127.0.0.1:8545 ``` diff --git a/contracts/src/EncryptedDataRoom.sol b/contracts/src/EncryptedDataRoom.sol index 48b2e67..1cbc4e7 100644 --- a/contracts/src/EncryptedDataRoom.sol +++ b/contracts/src/EncryptedDataRoom.sol @@ -19,6 +19,8 @@ contract EncryptedDataRoom is ZamaEthereumConfig { error NotParentRoom(); error IsParentRoom(); error CannotNestDeeper(); + error BatchTooLarge(); + error InvalidAddress(); // Types struct Room { @@ -39,6 +41,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { } uint256 public constant NO_PARENT = type(uint256).max; + uint256 public constant MAX_BATCH_SIZE = 100; uint256 public roomCount; mapping(uint256 => Room) public rooms; mapping(uint256 => mapping(uint256 => Document)) internal _documents; @@ -74,6 +77,11 @@ contract EncryptedDataRoom is ZamaEthereumConfig { _; } + modifier roomExists(uint256 roomId) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); + _; + } + // Room Management /// @notice Create a new parent room @@ -153,6 +161,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { notParentRoom(roomId) { if (cids.length != names.length || cids.length != wrappedKeys.length) revert LengthMismatch(); + if (cids.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < cids.length; i++) { uint256 docIndex = rooms[roomId].documentCount++; _documents[roomId][docIndex] = @@ -174,6 +183,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { notParentRoom(roomId) { if (docIndices.length != newWrappedKeys.length) revert LengthMismatch(); + if (docIndices.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < docIndices.length; i++) { _documents[roomId][docIndices[i]].wrappedKey = newWrappedKeys[i]; documentKeyVersion[roomId][docIndices[i]] = roomKeyVersion[roomId]; @@ -190,12 +200,24 @@ contract EncryptedDataRoom is ZamaEthereumConfig { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; + if (user == address(0)) revert InvalidAddress(); if (_isMember[roomId][user]) revert AlreadyMember(); - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + // Only allocate a new slot if user was never in this folder before + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) { + existingSlot = true; + break; + } + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; FHE.allow(_roomKey[roomId], user); @@ -216,6 +238,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; if (!_isMember[roomId][user]) revert NotMember(); @@ -234,6 +257,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { /// @param parentId The parent room ID. /// @param user Address of the user to grant access. function grantAccessToAllFolders(uint256 parentId, address user) external onlyRoomOwner(parentId) { + if (user == address(0)) revert InvalidAddress(); Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -241,8 +265,17 @@ contract EncryptedDataRoom is ZamaEthereumConfig { uint256 roomId = _children[parentId][i]; if (_isMember[roomId][user]) continue; - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) { + existingSlot = true; + break; + } + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; FHE.allow(_roomKey[roomId], user); @@ -302,15 +335,21 @@ contract EncryptedDataRoom is ZamaEthereumConfig { // Views + /// @notice Check if the caller has access to a folder + /// @param roomId The folder to check access for. + function hasAccess(uint256 roomId) external view roomExists(roomId) returns (bool) { + return _isMember[roomId][msg.sender]; + } + /// @notice Get your encrypted access flag for a folder. /// @param roomId The folder to check access for. - function validateAccess(uint256 roomId) external view returns (ebool) { + function validateAccess(uint256 roomId) external view roomExists(roomId) returns (ebool) { return _access[roomId][msg.sender]; } /// @notice Get the encrypted folder key handle. Only decryptable if granted FHE access. /// @param roomId The folder to get the key for. - function getRoomKey(uint256 roomId) external view notParentRoom(roomId) returns (euint256) { + function getRoomKey(uint256 roomId) external view roomExists(roomId) notParentRoom(roomId) returns (euint256) { if (!_isMember[roomId][msg.sender]) revert Unauthorized(); return _roomKey[roomId]; } @@ -323,11 +362,12 @@ contract EncryptedDataRoom is ZamaEthereumConfig { view returns (string memory cid, string memory name, uint256 createdAt, uint256 keyVersion, bytes memory wrappedKey) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Document storage doc = _documents[roomId][docIndex]; return (doc.cid, doc.name, doc.createdAt, documentKeyVersion[roomId][docIndex], doc.wrappedKey); } - /// @notice Get room/folder info. + /// @notice Get room/folder info. memberCount reflects only active (non-revoked) members. /// @param roomId The room or folder to query. function getRoom(uint256 roomId) external @@ -342,14 +382,21 @@ contract EncryptedDataRoom is ZamaEthereumConfig { uint256 childCount ) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Room storage room = rooms[roomId]; - return - (room.owner, room.name, room.documentCount, room.memberCount, room.isParent, room.parentId, room.childCount); + uint256 active = 0; + uint256 total = room.memberCount; + for (uint256 i = 0; i < total; i++) { + if (_isMember[roomId][_members[roomId][i]]) { + active++; + } + } + return (room.owner, room.name, room.documentCount, active, room.isParent, room.parentId, room.childCount); } /// @notice Get all folder IDs under a parent room. /// @param parentId The parent room ID. - function getFolders(uint256 parentId) external view returns (uint256[] memory) { + function getFolders(uint256 parentId) external view roomExists(parentId) returns (uint256[] memory) { Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -362,13 +409,14 @@ contract EncryptedDataRoom is ZamaEthereumConfig { /// @notice Get the parent room ID for a folder. /// @param roomId The folder ID. - function getParentRoom(uint256 roomId) external view returns (uint256) { + function getParentRoom(uint256 roomId) external view roomExists(roomId) returns (uint256) { return rooms[roomId].parentId; } /// @notice Get active members of a folder. /// @param roomId The folder to query. function getMembers(uint256 roomId) external view returns (address[] memory) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); if (rooms[roomId].owner != msg.sender) revert Unauthorized(); uint256 count = rooms[roomId].memberCount; diff --git a/contracts/src/MockEncryptedDataRoom.sol b/contracts/src/MockEncryptedDataRoom.sol index 31e8a03..d8a853a 100644 --- a/contracts/src/MockEncryptedDataRoom.sol +++ b/contracts/src/MockEncryptedDataRoom.sol @@ -14,6 +14,8 @@ contract MockEncryptedDataRoom { error NotParentRoom(); error IsParentRoom(); error CannotNestDeeper(); + error BatchTooLarge(); + error InvalidAddress(); struct Room { address owner; @@ -33,6 +35,7 @@ contract MockEncryptedDataRoom { } uint256 public constant NO_PARENT = type(uint256).max; + uint256 public constant MAX_BATCH_SIZE = 100; uint256 public roomCount; @@ -67,6 +70,11 @@ contract MockEncryptedDataRoom { _; } + modifier roomExists(uint256 roomId) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); + _; + } + // ─── Room Management /// @notice Create a new parent room (deal-level container). No key, no members, no documents. @@ -139,6 +147,7 @@ contract MockEncryptedDataRoom { notParentRoom(roomId) { if (cids.length != names.length || cids.length != wrappedKeys.length) revert LengthMismatch(); + if (cids.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < cids.length; i++) { uint256 docIndex = rooms[roomId].documentCount++; _documents[roomId][docIndex] = @@ -160,6 +169,7 @@ contract MockEncryptedDataRoom { notParentRoom(roomId) { if (docIndices.length != newWrappedKeys.length) revert LengthMismatch(); + if (docIndices.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < docIndices.length; i++) { _documents[roomId][docIndices[i]].wrappedKey = newWrappedKeys[i]; documentKeyVersion[roomId][docIndices[i]] = roomKeyVersion[roomId]; @@ -176,12 +186,23 @@ contract MockEncryptedDataRoom { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; + if (user == address(0)) revert InvalidAddress(); if (_isMember[roomId][user]) revert AlreadyMember(); - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) { + existingSlot = true; + break; + } + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; _access[roomId][user] = true; } @@ -196,6 +217,7 @@ contract MockEncryptedDataRoom { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; if (!_isMember[roomId][user]) revert NotMember(); @@ -210,6 +232,7 @@ contract MockEncryptedDataRoom { /// @param parentId The parent room ID. /// @param user Address of the user to grant access. function grantAccessToAllFolders(uint256 parentId, address user) external onlyRoomOwner(parentId) { + if (user == address(0)) revert InvalidAddress(); Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -217,8 +240,17 @@ contract MockEncryptedDataRoom { uint256 roomId = _children[parentId][i]; if (_isMember[roomId][user]) continue; - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) { + existingSlot = true; + break; + } + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; _access[roomId][user] = true; @@ -257,15 +289,21 @@ contract MockEncryptedDataRoom { // ─── Views + /// @notice Check if the caller has access to a folder + /// @param roomId The folder to check access for. + function hasAccess(uint256 roomId) external view roomExists(roomId) returns (bool) { + return _isMember[roomId][msg.sender]; + } + /// @notice Check your access flag for a folder. /// @param roomId The folder to check access for. - function validateAccess(uint256 roomId) external view returns (bytes32) { + function validateAccess(uint256 roomId) external view roomExists(roomId) returns (bytes32) { return _access[roomId][msg.sender] ? bytes32(uint256(1)) : bytes32(0); } /// @notice Get the folder key. Only accessible to members. /// @param roomId The folder to get the key for. - function getRoomKey(uint256 roomId) external view notParentRoom(roomId) returns (bytes32) { + function getRoomKey(uint256 roomId) external view roomExists(roomId) notParentRoom(roomId) returns (bytes32) { if (!_access[roomId][msg.sender]) revert Unauthorized(); return _roomKey[roomId]; } @@ -278,11 +316,12 @@ contract MockEncryptedDataRoom { view returns (string memory cid, string memory name, uint256 createdAt, uint256 keyVersion, bytes memory wrappedKey) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Document storage doc = _documents[roomId][docIndex]; return (doc.cid, doc.name, doc.createdAt, documentKeyVersion[roomId][docIndex], doc.wrappedKey); } - /// @notice Get room/folder info. + /// @notice Get room/folder info. memberCount reflects only active (non-revoked) members. /// @param roomId The room or folder to query. function getRoom(uint256 roomId) external @@ -297,14 +336,21 @@ contract MockEncryptedDataRoom { uint256 childCount ) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Room storage room = rooms[roomId]; - return - (room.owner, room.name, room.documentCount, room.memberCount, room.isParent, room.parentId, room.childCount); + uint256 active = 0; + uint256 total = room.memberCount; + for (uint256 i = 0; i < total; i++) { + if (_isMember[roomId][_members[roomId][i]]) { + active++; + } + } + return (room.owner, room.name, room.documentCount, active, room.isParent, room.parentId, room.childCount); } /// @notice Get all folder IDs under a parent room. /// @param parentId The parent room ID. - function getFolders(uint256 parentId) external view returns (uint256[] memory) { + function getFolders(uint256 parentId) external view roomExists(parentId) returns (uint256[] memory) { Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -317,13 +363,14 @@ contract MockEncryptedDataRoom { /// @notice Get the parent room ID for a folder. /// @param roomId The folder ID. - function getParentRoom(uint256 roomId) external view returns (uint256) { + function getParentRoom(uint256 roomId) external view roomExists(roomId) returns (uint256) { return rooms[roomId].parentId; } /// @notice Get active members of a folder. /// @param roomId The folder to query. function getMembers(uint256 roomId) external view returns (address[] memory) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); if (rooms[roomId].owner != msg.sender) revert Unauthorized(); uint256 count = rooms[roomId].memberCount; diff --git a/contracts/test/EncryptedDataRoom.fhe.ts b/contracts/test/EncryptedDataRoom.fhe.ts index fee509f..89cf34b 100644 --- a/contracts/test/EncryptedDataRoom.fhe.ts +++ b/contracts/test/EncryptedDataRoom.fhe.ts @@ -4,7 +4,6 @@ import hre from "hardhat"; const { ethers, fhevm } = hre; -/** Mock wrapped key (60 bytes: 12 IV + 32 ciphertext + 16 tag). */ function mockWrappedKey(seed: number): string { return ethers.hexlify(ethers.randomBytes(60)); } @@ -188,12 +187,12 @@ describe("contract EncryptedDataRoom", function () { .to.emit(room, "MembershipChanged") .withArgs(folderId); - // member can decrypt access flag + // can decrypt access flag const encryptedAccess = await room.connect(member).validateAccess(folderId); const decryptedAccess = await fhevm.userDecryptEbool(encryptedAccess, roomAddress, member); expect(decryptedAccess).to.equal(true); - // member can decrypt folder key + // can decrypt folder key const encryptedKey = await room.connect(member).getRoomKey(folderId); const decryptedKey = await fhevm.userDecryptEuint( FhevmType.euint256, @@ -268,7 +267,6 @@ describe("contract EncryptedDataRoom", function () { const { owner, member, outsider, room, folderId } = await roomWithFolderFixture(); await room.connect(owner).grantAccess(folderId, [member.address]); - // outsider is valid, member is duplicate : should revert await expect(room.connect(owner).grantAccess(folderId, [outsider.address, member.address])) .to.be.revertedWithCustomError(room, "AlreadyMember"); @@ -299,7 +297,6 @@ describe("contract EncryptedDataRoom", function () { await room.connect(owner).grantAccessToAllFolders(parentId, member.address); - // Check member is in all 3 folders for (const fId of [1n, 2n, 3n]) { const members = await room.connect(owner).getMembers(fId); expect(members.length).to.equal(2); @@ -525,7 +522,6 @@ describe("contract EncryptedDataRoom", function () { const doc1 = await room.getDocument(folderId, 1n); expect(doc1.keyVersion).to.equal(1n); - // first doc still has old version const doc0After = await room.getDocument(folderId, 0n); expect(doc0After.keyVersion).to.equal(0n); }); @@ -614,6 +610,112 @@ describe("contract EncryptedDataRoom", function () { }); }); + describe("hasAccess()", function () { + it("returns true for members and false for non-members", async function () { + const { owner, member, outsider, room, folderId } = await roomWithFolderFixture(); + + // owner has access + expect(await room.connect(owner).hasAccess(folderId)).to.equal(true); + + // non-member does not + expect(await room.connect(outsider).hasAccess(folderId)).to.equal(false); + + // grant + await room.connect(owner).grantAccess(folderId, [member.address]); + expect(await room.connect(member).hasAccess(folderId)).to.equal(true); + + // revoke + await room.connect(owner).revokeAccess(folderId, [member.address]); + expect(await room.connect(member).hasAccess(folderId)).to.equal(false); + }); + }); + + describe("re-grant after revoke (H-1 fix)", function () { + it("re-granting a revoked member does not create duplicate entries", async function () { + const { owner, member, room, folderId } = await roomWithFolderFixture(); + const roomAddress = await room.getAddress(); + + // Grant + await room.connect(owner).grantAccess(folderId, [member.address]); + expect((await room.connect(owner).getMembers(folderId)).length).to.equal(2); + + // Revoke + await room.connect(owner).revokeAccess(folderId, [member.address]); + expect((await room.connect(owner).getMembers(folderId)).length).to.equal(1); + + // Re-grant: reuse existing slot + await room.connect(owner).grantAccess(folderId, [member.address]); + + const members = await room.connect(owner).getMembers(folderId); + expect(members.length).to.equal(2); // owner + member, no duplicate + + const info = await room.getRoom(folderId); + expect(info.memberCount).to.equal(2n); + + expect(await room.connect(member).hasAccess(folderId)).to.equal(true); + + // member can still decrypt folder key + const encryptedKey = await room.connect(member).getRoomKey(folderId); + const decryptedKey = await fhevm.userDecryptEuint( + FhevmType.euint256, + encryptedKey, + roomAddress, + member, + ); + expect(decryptedKey).to.not.equal(0n); + }); + + it("multiple revoke/re-grant cycles produce correct counts", async function () { + const { owner, member, room, folderId } = await roomWithFolderFixture(); + + for (let cycle = 0; cycle < 3; cycle++) { + await room.connect(owner).grantAccess(folderId, [member.address]); + + const members = await room.connect(owner).getMembers(folderId); + expect(members.length).to.equal(2, `cycle ${cycle}: members after grant`); + + const info = await room.getRoom(folderId); + expect(info.memberCount).to.equal(2n, `cycle ${cycle}: memberCount after grant`); + + expect(await room.connect(member).hasAccess(folderId)).to.equal(true); + + await room.connect(owner).revokeAccess(folderId, [member.address]); + + const membersAfter = await room.connect(owner).getMembers(folderId); + expect(membersAfter.length).to.equal(1, `cycle ${cycle}: members after revoke`); + + expect(await room.connect(member).hasAccess(folderId)).to.equal(false); + } + }); + + it("grantAccessToAllFolders re-grant after revoke produces no duplicates", async function () { + const { owner, member, room, parentId } = await roomWithFolderFixture(); + await room.connect(owner).createFolder(parentId, "Financials"); + + await room.connect(owner).grantAccessToAllFolders(parentId, member.address); + for (const fId of [1n, 2n]) { + expect((await room.connect(owner).getMembers(fId)).length).to.equal(2); + } + + await room.connect(owner).revokeAccessFromAllFolders(parentId, member.address); + for (const fId of [1n, 2n]) { + expect((await room.connect(owner).getMembers(fId)).length).to.equal(1); + } + + // Re-grant == no duplicates + await room.connect(owner).grantAccessToAllFolders(parentId, member.address); + for (const fId of [1n, 2n]) { + const members = await room.connect(owner).getMembers(fId); + expect(members.length).to.equal(2); + + const info = await room.getRoom(fId); + expect(info.memberCount).to.equal(2n); + + expect(await room.connect(member).hasAccess(fId)).to.equal(true); + } + }); + }); + describe("CRITICAL PATH: Key Isolation", function () { it("each folder gets a different FHE key", async function () { const { owner, room, parentId } = await roomWithFolderFixture(); @@ -636,4 +738,78 @@ describe("contract EncryptedDataRoom", function () { expect(key1).to.not.equal(key2); }); }); + + describe("M-1: room existence checks", function () { + it("getRoom reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getRoom(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getDocument reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getDocument(999n, 0n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("hasAccess reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.hasAccess(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("validateAccess reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.validateAccess(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getRoomKey reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getRoomKey(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getFolders reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getFolders(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getParentRoom reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getParentRoom(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getMembers reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getMembers(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + }); + + describe("M-3: address(0) rejected", function () { + it("grantAccess rejects address(0)", async function () { + const { owner, room, folderId } = await roomWithFolderFixture(); + + await expect(room.connect(owner).grantAccess(folderId, [ethers.ZeroAddress])) + .to.be.revertedWithCustomError(room, "InvalidAddress"); + }); + + it("grantAccessToAllFolders rejects address(0)", async function () { + const { owner, room, parentId } = await roomWithFolderFixture(); + + await expect(room.connect(owner).grantAccessToAllFolders(parentId, ethers.ZeroAddress)) + .to.be.revertedWithCustomError(room, "InvalidAddress"); + }); + }); }); diff --git a/contracts/test/MockEncryptedDataRoom.t.sol b/contracts/test/MockEncryptedDataRoom.t.sol index 1ec613c..bbcbbbb 100644 --- a/contracts/test/MockEncryptedDataRoom.t.sol +++ b/contracts/test/MockEncryptedDataRoom.t.sol @@ -568,4 +568,188 @@ contract MockEncryptedDataRoomTest is Test { assertEq(kv0, 1); // bumped to current version assertEq(kv1, 1); } + + // ─── hasAccess view ─────────────────────────────────────── + + function test_hasAccess() public { + dr.createRoom("P"); + uint256 fId = dr.createFolder(0, "HasAccess"); + + // owner has access + assertTrue(dr.hasAccess(fId)); + + // non-member has no access + vm.prank(user); + assertFalse(dr.hasAccess(fId)); + + // grant then check + dr.grantAccess(fId, _toArray(user)); + vm.prank(user); + assertTrue(dr.hasAccess(fId)); + + // revoke then check + dr.revokeAccess(fId, _toArray(user)); + vm.prank(user); + assertFalse(dr.hasAccess(fId)); + } + + // ─── Re-grant after revoke (H-1 fix) ───────────────────── + + function test_grantAccess_reGrantAfterRevoke_noDuplicate() public { + dr.createRoom("P"); + uint256 fId = dr.createFolder(0, "ReGrant"); + + // Grant user + dr.grantAccess(fId, _toArray(user)); + assertEq(dr.getMembers(fId).length, 2); // owner + user + + // Revoke user + dr.revokeAccess(fId, _toArray(user)); + assertEq(dr.getMembers(fId).length, 1); // owner only + + // Re-grant user — should reuse existing slot, not create a duplicate + dr.grantAccess(fId, _toArray(user)); + + address[] memory members = dr.getMembers(fId); + assertEq(members.length, 2); // owner + user (no duplicate) + assertEq(members[0], owner); + assertEq(members[1], user); + + // getRoom memberCount should also be 2 + (,,, uint256 memberCount,,,) = dr.getRoom(fId); + assertEq(memberCount, 2); + + // hasAccess should be true + vm.prank(user); + assertTrue(dr.hasAccess(fId)); + + // user can get key + vm.prank(user); + bytes32 key = dr.getRoomKey(fId); + assertNotEq(key, bytes32(0)); + } + + function test_grantAccess_multipleRevokeCycles() public { + dr.createRoom("P"); + uint256 fId = dr.createFolder(0, "Cycles"); + + // 3 cycles of grant/revoke/re-grant + for (uint256 cycle = 0; cycle < 3; cycle++) { + dr.grantAccess(fId, _toArray(user)); + + address[] memory members = dr.getMembers(fId); + assertEq(members.length, 2, "members should be 2 after grant"); + + (,,, uint256 memberCount,,,) = dr.getRoom(fId); + assertEq(memberCount, 2, "getRoom memberCount should be 2"); + + vm.prank(user); + assertTrue(dr.hasAccess(fId)); + + dr.revokeAccess(fId, _toArray(user)); + + members = dr.getMembers(fId); + assertEq(members.length, 1, "members should be 1 after revoke"); + + (,,, memberCount,,,) = dr.getRoom(fId); + assertEq(memberCount, 1, "getRoom memberCount should be 1"); + + vm.prank(user); + assertFalse(dr.hasAccess(fId)); + } + } + + function test_grantAccessToAllFolders_reGrantAfterRevoke_noDuplicate() public { + dr.createRoom("P"); + uint256 f1 = dr.createFolder(0, "Legal"); + uint256 f2 = dr.createFolder(0, "Fin"); + + // Grant to all folders + dr.grantAccessToAllFolders(0, user); + assertEq(dr.getMembers(f1).length, 2); + assertEq(dr.getMembers(f2).length, 2); + + // Revoke from all folders + dr.revokeAccessFromAllFolders(0, user); + assertEq(dr.getMembers(f1).length, 1); + assertEq(dr.getMembers(f2).length, 1); + + // Re-grant to all folders — no duplicates + dr.grantAccessToAllFolders(0, user); + + assertEq(dr.getMembers(f1).length, 2); + assertEq(dr.getMembers(f2).length, 2); + + // Verify getRoom memberCount is correct + (,,, uint256 mc1,,,) = dr.getRoom(f1); + (,,, uint256 mc2,,,) = dr.getRoom(f2); + assertEq(mc1, 2); + assertEq(mc2, 2); + + // User has access to both + vm.prank(user); + assertTrue(dr.hasAccess(f1)); + vm.prank(user); + assertTrue(dr.hasAccess(f2)); + } + + // ─── M-1: Room existence checks ────────────────────────── + + function test_getRoom_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getRoom(999); + } + + function test_getDocument_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getDocument(999, 0); + } + + function test_hasAccess_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.hasAccess(999); + } + + function test_validateAccess_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.validateAccess(999); + } + + function test_getRoomKey_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getRoomKey(999); + } + + function test_getFolders_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getFolders(999); + } + + function test_getParentRoom_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getParentRoom(999); + } + + function test_getMembers_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getMembers(999); + } + + // ─── M-3: address(0) rejected ──────────────────────────── + + function test_grantAccess_addressZero_reverts() public { + dr.createRoom("P"); + dr.createFolder(0, "F"); + + vm.expectRevert(MockEncryptedDataRoom.InvalidAddress.selector); + dr.grantAccess(1, _toArray(address(0))); + } + + function test_grantAccessToAllFolders_addressZero_reverts() public { + dr.createRoom("P"); + dr.createFolder(0, "F"); + + vm.expectRevert(MockEncryptedDataRoom.InvalidAddress.selector); + dr.grantAccessToAllFolders(0, address(0)); + } } diff --git a/dapp/src/assets/31337.contracts.json b/dapp/src/assets/31337.contracts.json index 58203fe..57f41d9 100644 --- a/dapp/src/assets/31337.contracts.json +++ b/dapp/src/assets/31337.contracts.json @@ -1,3 +1,3 @@ { - "EncryptedDataRoom": "0xf4b146fba71f41e0592668ffbf264f1d186b2ca8" + "EncryptedDataRoom": "0xd84379ceae14aa33c123af12424a37803f885889" } \ No newline at end of file diff --git a/dapp/src/components/Layout.tsx b/dapp/src/components/Layout.tsx index 829750a..b6e18c0 100644 --- a/dapp/src/components/Layout.tsx +++ b/dapp/src/components/Layout.tsx @@ -1,8 +1,54 @@ +import { useState } from "react"; import { Outlet, Link } from "react-router-dom"; import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useChainId } from "wagmi"; +import { sepolia } from "wagmi/chains"; import ThemeToggle from "./ThemeToggle"; import { BrandMark } from "./BrandMark"; +const FAUCETS = [ + { name: "Google Cloud", url: "https://cloud.google.com/application/web3/faucet/ethereum/sepolia" }, + { name: "Alchemy", url: "https://www.alchemy.com/faucets/ethereum-sepolia" }, + { name: "Infura", url: "https://www.infura.io/faucet/sepolia" }, +]; + +function SepoliaFaucetBanner() { + const chainId = useChainId(); + const [dismissed, setDismissed] = useState(false); + + if (chainId !== sepolia.id || dismissed) return null; + + return ( +
+

+ Sepolia testnet + + Need gas? + {FAUCETS.map((f, i) => ( + + {i > 0 && · } + + {f.name} + + + ))} +

+ +
+ ); +} + export default function Layout() { return (
@@ -22,7 +68,7 @@ export default function Layout() { to="/investor" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]" > - Investor View + Shared with me @@ -78,6 +124,7 @@ export default function Layout() { +
diff --git a/dapp/src/contracts/EncryptedDataRoom.json b/dapp/src/contracts/EncryptedDataRoom.json new file mode 100644 index 0000000..d28f514 --- /dev/null +++ b/dapp/src/contracts/EncryptedDataRoom.json @@ -0,0 +1,668 @@ +[ + { + "type": "function", + "name": "NO_PARENT", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "addDocuments", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cids", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "names", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "wrappedKeys", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "confidentialProtocolId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "createFolder", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createRoom", + "inputs": [ + { + "name": "name", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "documentKeyVersion", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getDocument", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "docIndex", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "cid", + "type": "string", + "internalType": "string" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "createdAt", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "keyVersion", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "wrappedKey", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getFolders", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getMembers", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getParentRoom", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoom", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "documentCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memberCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "isParent", + "type": "bool", + "internalType": "bool" + }, + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "childCount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoomKey", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "euint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "users", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "grantAccessToAllFolders", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rekeyRoom", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "users", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeAccessFromAllFolders", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "roomCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "roomKeyVersion", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rooms", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "documentCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memberCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "isParent", + "type": "bool", + "internalType": "bool" + }, + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "childCount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "updateDocumentKeys", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "docIndices", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "newWrappedKeys", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "validateAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "ebool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "DocumentAdded", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "docIndex", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FolderCreated", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MembershipChanged", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoomCreated", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoomRekeyed", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "newVersion", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AlreadyMember", + "inputs": [] + }, + { + "type": "error", + "name": "BatchTooLarge", + "inputs": [] + }, + { + "type": "error", + "name": "CannotNestDeeper", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidAddress", + "inputs": [] + }, + { + "type": "error", + "name": "IsParentRoom", + "inputs": [] + }, + { + "type": "error", + "name": "LengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "NotMember", + "inputs": [] + }, + { + "type": "error", + "name": "NotParentRoom", + "inputs": [] + }, + { + "type": "error", + "name": "NotRoomOwner", + "inputs": [] + }, + { + "type": "error", + "name": "RoomNotFound", + "inputs": [] + }, + { + "type": "error", + "name": "Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "ZamaProtocolUnsupported", + "inputs": [] + } +] \ No newline at end of file diff --git a/dapp/src/hooks/dataroom/index.ts b/dapp/src/hooks/dataroom/index.ts new file mode 100644 index 0000000..b6dbbf5 --- /dev/null +++ b/dapp/src/hooks/dataroom/index.ts @@ -0,0 +1,17 @@ +export { ZERO_BYTES32 } from "./shared"; +export { useRoomCount } from "./useRoomCount"; +export { useRoom } from "./useRoom"; +export { useAccessibleFolders } from "./useAccessibleFolders"; +export { useDocument } from "./useDocument"; +export { useRoomMembers } from "./useRoomMembers"; +export { useRoomKey, useRoomKeyHandle } from "./useRoomKey"; +export { useHasAccessToAnyFolder } from "./useHasAccessToAnyFolder"; +export { useCreateRoom } from "./useCreateRoom"; +export { useCreateFolder } from "./useCreateFolder"; +export { useAddDocuments } from "./useAddDocuments"; +export { useGrantAccess } from "./useGrantAccess"; +export { useRevokeAccess } from "./useRevokeAccess"; +export { useGrantAccessToAllFolders } from "./useGrantAccessToAllFolders"; +export { useRevokeAccessFromAllFolders } from "./useRevokeAccessFromAllFolders"; +export { useRekeyAndRewrap, RekeyPhase } from "./useRekeyAndRewrap"; +export type { RekeyProgress } from "./useRekeyAndRewrap"; diff --git a/dapp/src/hooks/dataroom/shared.ts b/dapp/src/hooks/dataroom/shared.ts new file mode 100644 index 0000000..08b1e3d --- /dev/null +++ b/dapp/src/hooks/dataroom/shared.ts @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import type { JsonRpcSigner } from "ethers"; +import { DATAROOM_ADDRESS, CHAIN_ID } from "@/contracts"; +import { EncryptedDataRoom__factory } from "@/types"; +import type { EncryptedDataRoom } from "@/types"; +import { useEthersProvider, useEthersSigner } from "@/lib/ethers-adapter"; + +export { CHAIN_ID, DATAROOM_ADDRESS }; +export { EncryptedDataRoom__factory }; +export type { EncryptedDataRoom }; +export { useEthersSigner }; + +export const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +/** Read-only contract (no wallet needed). */ +export function usePublicContract(): EncryptedDataRoom | null { + const provider = useEthersProvider({ chainId: CHAIN_ID }); + return useMemo(() => { + if (!provider) return null; + return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, provider); + }, [provider]); +} + +/** Signer-backed typed contract (needs connected wallet). */ +export async function getSignerContract(signerPromise: Promise) { + const signer = await signerPromise; + return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer); +} diff --git a/dapp/src/hooks/dataroom/useAccessibleFolders.ts b/dapp/src/hooks/dataroom/useAccessibleFolders.ts new file mode 100644 index 0000000..bd46f00 --- /dev/null +++ b/dapp/src/hooks/dataroom/useAccessibleFolders.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useAccessibleFolders(parentId: bigint | undefined, isOwner: boolean) { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + return useQuery({ + queryKey: ["dataroom", "accessibleFolders", parentId?.toString(), isOwner], + queryFn: async () => { + const contract = await getSignerContract(signerPromise!); + const folderIds = await contract.getFolders(parentId!); + if (isOwner || folderIds.length === 0) return folderIds; + const flags = await Promise.all(folderIds.map((fId) => contract.hasAccess(fId))); + return folderIds.filter((_, i) => flags[i]); + }, + enabled: !!signerPromise && parentId !== undefined, + structuralSharing: false, + }); +} diff --git a/dapp/src/hooks/dataroom/useAddDocuments.ts b/dapp/src/hooks/dataroom/useAddDocuments.ts new file mode 100644 index 0000000..f323d11 --- /dev/null +++ b/dapp/src/hooks/dataroom/useAddDocuments.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useAddDocuments() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + + const addDocuments = useCallback(async (roomId: bigint, cids: string[], names: string[], wrappedKeys: string[]) => { + if (!signerPromise) return; + setIsPending(true); + setError(null); + try { + const contract = await getSignerContract(signerPromise); + const tx = await contract.addDocuments(roomId, cids, names, wrappedKeys); + setIsPending(false); + setIsConfirming(true); + await tx.wait(); + setIsConfirming(false); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + setIsPending(false); + setIsConfirming(false); + setError(e as Error); + } + }, [signerPromise, queryClient]); + + return { addDocuments, isPending, isConfirming, error }; +} diff --git a/dapp/src/hooks/dataroom/useCreateFolder.ts b/dapp/src/hooks/dataroom/useCreateFolder.ts new file mode 100644 index 0000000..3c38a5a --- /dev/null +++ b/dapp/src/hooks/dataroom/useCreateFolder.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useCreateFolder() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + + const createFolder = useCallback(async (parentId: bigint, name: string) => { + if (!signerPromise) return; + setIsPending(true); + setError(null); + try { + const contract = await getSignerContract(signerPromise); + const tx = await contract.createFolder(parentId, name); + setIsPending(false); + setIsConfirming(true); + await tx.wait(); + setIsConfirming(false); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + setIsPending(false); + setIsConfirming(false); + setError(e as Error); + } + }, [signerPromise, queryClient]); + + return { createFolder, isPending, isConfirming, error }; +} diff --git a/dapp/src/hooks/dataroom/useCreateRoom.ts b/dapp/src/hooks/dataroom/useCreateRoom.ts new file mode 100644 index 0000000..0df93ef --- /dev/null +++ b/dapp/src/hooks/dataroom/useCreateRoom.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useCreateRoom() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + + const createRoom = useCallback(async (name: string) => { + if (!signerPromise) return; + setIsPending(true); + setError(null); + try { + const contract = await getSignerContract(signerPromise); + const tx = await contract.createRoom(name); + setIsPending(false); + setIsConfirming(true); + await tx.wait(); + setIsConfirming(false); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + setIsPending(false); + setIsConfirming(false); + setError(e as Error); + } + }, [signerPromise, queryClient]); + + return { createRoom, isPending, isConfirming, error }; +} diff --git a/dapp/src/hooks/dataroom/useDocument.ts b/dapp/src/hooks/dataroom/useDocument.ts new file mode 100644 index 0000000..33714dd --- /dev/null +++ b/dapp/src/hooks/dataroom/useDocument.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { usePublicContract } from "./shared"; + +export function useDocument(roomId: bigint | undefined, docIndex: bigint | undefined) { + const contract = usePublicContract(); + return useQuery({ + queryKey: ["dataroom", "document", roomId?.toString(), docIndex?.toString()], + queryFn: () => contract!.getDocument(roomId!, docIndex!), + enabled: !!contract && roomId !== undefined && docIndex !== undefined, + structuralSharing: false, + }); +} diff --git a/dapp/src/hooks/dataroom/useGrantAccess.ts b/dapp/src/hooks/dataroom/useGrantAccess.ts new file mode 100644 index 0000000..2e53aff --- /dev/null +++ b/dapp/src/hooks/dataroom/useGrantAccess.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useGrantAccess() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + + const grantAccess = useCallback(async (roomId: bigint, user: string) => { + if (!signerPromise) return; + setIsPending(true); + setError(null); + try { + const contract = await getSignerContract(signerPromise); + const tx = await contract.grantAccess(roomId, [user]); + setIsPending(false); + setIsConfirming(true); + await tx.wait(); + setIsConfirming(false); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + setIsPending(false); + setIsConfirming(false); + setError(e as Error); + } + }, [signerPromise, queryClient]); + + return { grantAccess, isPending, isConfirming, error }; +} diff --git a/dapp/src/hooks/dataroom/useGrantAccessToAllFolders.ts b/dapp/src/hooks/dataroom/useGrantAccessToAllFolders.ts new file mode 100644 index 0000000..443362a --- /dev/null +++ b/dapp/src/hooks/dataroom/useGrantAccessToAllFolders.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useGrantAccessToAllFolders() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + + const grantAccessToAllFolders = useCallback(async (parentId: bigint, user: string) => { + if (!signerPromise) return; + setIsPending(true); + setError(null); + try { + const contract = await getSignerContract(signerPromise); + const tx = await contract.grantAccessToAllFolders(parentId, user); + setIsPending(false); + setIsConfirming(true); + await tx.wait(); + setIsConfirming(false); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + setIsPending(false); + setIsConfirming(false); + setError(e as Error); + } + }, [signerPromise, queryClient]); + + return { grantAccessToAllFolders, isPending, isConfirming, error }; +} diff --git a/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts b/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts new file mode 100644 index 0000000..419826f --- /dev/null +++ b/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useHasAccessToAnyFolder(parentId: bigint | undefined) { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + return useQuery({ + queryKey: ["dataroom", "hasAccessToAnyFolder", parentId?.toString()], + queryFn: async () => { + const contract = await getSignerContract(signerPromise!); + const folderIds = await contract.getFolders(parentId!); + if (folderIds.length === 0) return false; + const flags = await Promise.all(folderIds.map((fId) => contract.hasAccess(fId))); + return flags.some((flag) => flag); + }, + enabled: !!signerPromise && parentId !== undefined, + }); +} diff --git a/dapp/src/hooks/dataroom/useRekeyAndRewrap.ts b/dapp/src/hooks/dataroom/useRekeyAndRewrap.ts new file mode 100644 index 0000000..c3e69b6 --- /dev/null +++ b/dapp/src/hooks/dataroom/useRekeyAndRewrap.ts @@ -0,0 +1,98 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; +import { deriveAesKey, hexToBytes, unwrapKey, wrapKey, bytesToHex } from "@/lib/crypto"; +import { decryptRoomKey } from "@/lib/fhe"; + +export const RekeyPhase = { + Idle: "idle", + Rekeying: "rekeying", + Rewrapping: "rewrapping", + Updating: "updating", + Done: "done", + Error: "error", +} as const; + +export type RekeyPhase = (typeof RekeyPhase)[keyof typeof RekeyPhase]; + +export type RekeyProgress = { + phase: RekeyPhase; + current: number; + total: number; + error?: string; +}; + +export function useRekeyAndRewrap() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [progress, setProgress] = useState({ + phase: RekeyPhase.Idle, + current: 0, + total: 0, + }); + + const rekeyAndRewrap = useCallback( + async ( + folderId: bigint, + documentCount: number, + oldRoomKeyHex: string, + ) => { + if (!signerPromise) return; + + try { + const contract = await getSignerContract(signerPromise); + + // Phase 1: Rekey on-chain + setProgress({ phase: RekeyPhase.Rekeying, current: 0, total: 0 }); + const tx = await contract.rekeyRoom(folderId); + await tx.wait(); + + // If no documents, we're done + if (documentCount === 0) { + setProgress({ phase: RekeyPhase.Done, current: 0, total: 0 }); + queryClient.invalidateQueries({ refetchType: "all" }); + return; + } + + const signer = await signerPromise!; + const newKeyHandle = await contract.getRoomKey(folderId); + const newKeyHex = await decryptRoomKey(newKeyHandle, signer); + + // Phase 2: Re-wrap all document CEKs locally (no Storacha I/O) + setProgress({ phase: RekeyPhase.Rewrapping, current: 0, total: documentCount }); + const oldWrappingKey = await deriveAesKey(hexToBytes(oldRoomKeyHex)); + const newWrappingKey = await deriveAesKey(hexToBytes(newKeyHex)); + const newWrappedKeys: string[] = []; + + for (let i = 0; i < documentCount; i++) { + setProgress({ phase: RekeyPhase.Rewrapping, current: i + 1, total: documentCount }); + const doc = await contract.getDocument(folderId, BigInt(i)); + const wrappedCek = hexToBytes(doc.wrappedKey); + const cek = await unwrapKey(wrappedCek, oldWrappingKey); + const newWrapped = await wrapKey(cek, newWrappingKey); + newWrappedKeys.push(bytesToHex(newWrapped)); + } + + // Phase 3: Update all wrapped keys on-chain in a single transaction + setProgress({ phase: RekeyPhase.Updating, current: 0, total: documentCount }); + const docIndices = Array.from({ length: documentCount }, (_, i) => BigInt(i)); + const updateTx = await contract.updateDocumentKeys(folderId, docIndices, newWrappedKeys); + await updateTx.wait(); + setProgress({ phase: RekeyPhase.Updating, current: documentCount, total: documentCount }); + + setProgress({ phase: RekeyPhase.Done, current: documentCount, total: documentCount }); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + const message = e instanceof Error ? e.message : "Unknown error"; + setProgress((prev) => ({ ...prev, phase: RekeyPhase.Error, error: message })); + } + }, + [signerPromise, queryClient] + ); + + const reset = useCallback(() => { + setProgress({ phase: RekeyPhase.Idle, current: 0, total: 0 }); + }, []); + + return { rekeyAndRewrap, progress, reset }; +} diff --git a/dapp/src/hooks/dataroom/useRevokeAccess.ts b/dapp/src/hooks/dataroom/useRevokeAccess.ts new file mode 100644 index 0000000..c3c306d --- /dev/null +++ b/dapp/src/hooks/dataroom/useRevokeAccess.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useRevokeAccess() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + + const revokeAccess = useCallback(async (roomId: bigint, user: string) => { + if (!signerPromise) return; + setIsPending(true); + setError(null); + try { + const contract = await getSignerContract(signerPromise); + const tx = await contract.revokeAccess(roomId, [user]); + setIsPending(false); + setIsConfirming(true); + await tx.wait(); + setIsConfirming(false); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + setIsPending(false); + setIsConfirming(false); + setError(e as Error); + } + }, [signerPromise, queryClient]); + + return { revokeAccess, isPending, isConfirming, error }; +} diff --git a/dapp/src/hooks/dataroom/useRevokeAccessFromAllFolders.ts b/dapp/src/hooks/dataroom/useRevokeAccessFromAllFolders.ts new file mode 100644 index 0000000..b7860bc --- /dev/null +++ b/dapp/src/hooks/dataroom/useRevokeAccessFromAllFolders.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useRevokeAccessFromAllFolders() { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + + const revokeAccessFromAllFolders = useCallback(async (parentId: bigint, user: string) => { + if (!signerPromise) return; + setIsPending(true); + setError(null); + try { + const contract = await getSignerContract(signerPromise); + const tx = await contract.revokeAccessFromAllFolders(parentId, user); + setIsPending(false); + setIsConfirming(true); + await tx.wait(); + setIsConfirming(false); + queryClient.invalidateQueries({ refetchType: "all" }); + } catch (e) { + setIsPending(false); + setIsConfirming(false); + setError(e as Error); + } + }, [signerPromise, queryClient]); + + return { revokeAccessFromAllFolders, isPending, isConfirming, error }; +} diff --git a/dapp/src/hooks/dataroom/useRoom.ts b/dapp/src/hooks/dataroom/useRoom.ts new file mode 100644 index 0000000..d97f2d2 --- /dev/null +++ b/dapp/src/hooks/dataroom/useRoom.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { usePublicContract } from "./shared"; + +export function useRoom(roomId: bigint | undefined) { + const contract = usePublicContract(); + return useQuery({ + queryKey: ["dataroom", "room", roomId?.toString()], + queryFn: () => contract!.getRoom(roomId!), + enabled: !!contract && roomId !== undefined, + structuralSharing: false, + }); +} diff --git a/dapp/src/hooks/dataroom/useRoomCount.ts b/dapp/src/hooks/dataroom/useRoomCount.ts new file mode 100644 index 0000000..60f245b --- /dev/null +++ b/dapp/src/hooks/dataroom/useRoomCount.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { usePublicContract } from "./shared"; + +export function useRoomCount() { + const contract = usePublicContract(); + return useQuery({ + queryKey: ["dataroom", "roomCount"], + queryFn: () => contract!.roomCount(), + enabled: !!contract, + structuralSharing: false, + }); +} diff --git a/dapp/src/hooks/dataroom/useRoomKey.ts b/dapp/src/hooks/dataroom/useRoomKey.ts new file mode 100644 index 0000000..1aaf643 --- /dev/null +++ b/dapp/src/hooks/dataroom/useRoomKey.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { EncryptedDataRoom__factory, DATAROOM_ADDRESS, useEthersSigner, CHAIN_ID } from "./shared"; +import { decryptRoomKey } from "@/lib/fhe"; + +export function useRoomKey(roomId: bigint | undefined, enabled = true) { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + return useQuery({ + queryKey: ["dataroom", "roomKey", roomId?.toString()], + queryFn: async () => { + const signer = await signerPromise!; + const contract = EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer); + const handle = await contract.getRoomKey(roomId!); + return decryptRoomKey(handle, signer); + }, + enabled: !!signerPromise && roomId !== undefined && enabled, + staleTime: Infinity, + retry: false, + structuralSharing: false, + }); +} + +export function useRoomKeyHandle(roomId: bigint | undefined) { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + return useQuery({ + queryKey: ["dataroom", "roomKeyHandle", roomId?.toString()], + queryFn: async () => { + const signer = await signerPromise!; + const contract = EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer); + return contract.getRoomKey(roomId!); + }, + enabled: !!signerPromise && roomId !== undefined, + staleTime: Infinity, + retry: false, + structuralSharing: false, + }); +} diff --git a/dapp/src/hooks/dataroom/useRoomMembers.ts b/dapp/src/hooks/dataroom/useRoomMembers.ts new file mode 100644 index 0000000..b18214e --- /dev/null +++ b/dapp/src/hooks/dataroom/useRoomMembers.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; + +export function useRoomMembers(roomId: bigint | undefined, enabled = true) { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); + return useQuery({ + queryKey: ["dataroom", "members", roomId?.toString()], + queryFn: async () => { + const contract = await getSignerContract(signerPromise!); + return contract.getMembers(roomId!); + }, + enabled: !!signerPromise && roomId !== undefined && enabled, + structuralSharing: false, + }); +} diff --git a/dapp/src/hooks/useDataRoom.ts b/dapp/src/hooks/useDataRoom.ts deleted file mode 100644 index ff3d291..0000000 --- a/dapp/src/hooks/useDataRoom.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { useMemo, useState, useCallback } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { JsonRpcSigner } from "ethers"; -import { DATAROOM_ADDRESS, CHAIN_ID } from "@/contracts"; -import { EncryptedDataRoom__factory } from "@/types"; -import type { EncryptedDataRoom } from "@/types"; -import { useEthersProvider, useEthersSigner } from "@/lib/ethers-adapter"; -import { deriveAesKey, hexToBytes, unwrapKey, wrapKey, bytesToHex } from "@/lib/crypto"; -import { decryptRoomKey } from "@/lib/fhe"; - -export const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; - -/** Read-only contract (no wallet needed). */ -function usePublicContract(): EncryptedDataRoom | null { - const provider = useEthersProvider({ chainId: CHAIN_ID }); - return useMemo(() => { - if (!provider) return null; - return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, provider); - }, [provider]); -} - -/** Signer-backed typed contract (needs connected wallet). */ -async function getSignerContract(signerPromise: Promise) { - const signer = await signerPromise; - return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer); -} - -export function useRoomCount() { - const contract = usePublicContract(); - return useQuery({ - queryKey: ["dataroom", "roomCount"], - queryFn: () => contract!.roomCount(), - enabled: !!contract, - structuralSharing: false, - }); -} - -export function useRoom(roomId: bigint | undefined) { - const contract = usePublicContract(); - return useQuery({ - queryKey: ["dataroom", "room", roomId?.toString()], - queryFn: () => contract!.getRoom(roomId!), - enabled: !!contract && roomId !== undefined, - structuralSharing: false, - }); -} - -export function useFolders(parentId: bigint | undefined) { - const contract = usePublicContract(); - return useQuery({ - queryKey: ["dataroom", "folders", parentId?.toString()], - queryFn: () => contract!.getFolders(parentId!), - enabled: !!contract && parentId !== undefined, - structuralSharing: false, - }); -} - -export function useDocument(roomId: bigint | undefined, docIndex: bigint | undefined) { - const contract = usePublicContract(); - return useQuery({ - queryKey: ["dataroom", "document", roomId?.toString(), docIndex?.toString()], - queryFn: () => contract!.getDocument(roomId!, docIndex!), - enabled: !!contract && roomId !== undefined && docIndex !== undefined, - structuralSharing: false, - }); -} - -export function useRoomMembers(roomId: bigint | undefined, enabled = true) { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - return useQuery({ - queryKey: ["dataroom", "members", roomId?.toString()], - queryFn: async () => { - const contract = await getSignerContract(signerPromise!); - return contract.getMembers(roomId!); - }, - enabled: !!signerPromise && roomId !== undefined && enabled, - structuralSharing: false, - }); -} - -export function useRoomKey(roomId: bigint | undefined, enabled = true) { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - return useQuery({ - queryKey: ["dataroom", "roomKey", roomId?.toString()], - queryFn: async () => { - const signer = await signerPromise!; - const contract = EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer); - const handle = await contract.getRoomKey(roomId!); - return decryptRoomKey(handle, signer); - }, - enabled: !!signerPromise && roomId !== undefined && enabled, - staleTime: Infinity, - structuralSharing: false, - }); -} - - -export function useHasAccessToAnyFolder(parentId: bigint | undefined) { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - return useQuery({ - queryKey: ["dataroom", "hasAccessToAnyFolder", parentId?.toString()], - queryFn: async () => { - const contract = await getSignerContract(signerPromise!); - const folderIds = await contract.getFolders(parentId!); - if (folderIds.length === 0) return false; - const flags = await Promise.all(folderIds.map((fId) => contract.validateAccess(fId))); - return flags.some((flag) => flag !== ZERO_BYTES32); - }, - enabled: !!signerPromise && parentId !== undefined, - }); -} - -export function useCreateRoom() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [isPending, setIsPending] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [error, setError] = useState(null); - - const createRoom = useCallback(async (name: string) => { - if (!signerPromise) return; - setIsPending(true); - setError(null); - try { - const contract = await getSignerContract(signerPromise); - const tx = await contract.createRoom(name); - setIsPending(false); - setIsConfirming(true); - await tx.wait(); - setIsConfirming(false); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - setIsPending(false); - setIsConfirming(false); - setError(e as Error); - } - }, [signerPromise, queryClient]); - - return { createRoom, isPending, isConfirming, error }; -} - -export function useCreateFolder() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [isPending, setIsPending] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [error, setError] = useState(null); - - const createFolder = useCallback(async (parentId: bigint, name: string) => { - if (!signerPromise) return; - setIsPending(true); - setError(null); - try { - const contract = await getSignerContract(signerPromise); - const tx = await contract.createFolder(parentId, name); - setIsPending(false); - setIsConfirming(true); - await tx.wait(); - setIsConfirming(false); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - setIsPending(false); - setIsConfirming(false); - setError(e as Error); - } - }, [signerPromise, queryClient]); - - return { createFolder, isPending, isConfirming, error }; -} - -export function useAddDocuments() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [isPending, setIsPending] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [error, setError] = useState(null); - - const addDocuments = useCallback(async (roomId: bigint, cids: string[], names: string[], wrappedKeys: string[]) => { - if (!signerPromise) return; - setIsPending(true); - setError(null); - try { - const contract = await getSignerContract(signerPromise); - const tx = await contract.addDocuments(roomId, cids, names, wrappedKeys); - setIsPending(false); - setIsConfirming(true); - await tx.wait(); - setIsConfirming(false); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - setIsPending(false); - setIsConfirming(false); - setError(e as Error); - } - }, [signerPromise, queryClient]); - - return { addDocuments, isPending, isConfirming, error }; -} - -export function useGrantAccess() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [isPending, setIsPending] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [error, setError] = useState(null); - - const grantAccess = useCallback(async (roomId: bigint, user: string) => { - if (!signerPromise) return; - setIsPending(true); - setError(null); - try { - const contract = await getSignerContract(signerPromise); - const tx = await contract.grantAccess(roomId, [user]); - setIsPending(false); - setIsConfirming(true); - await tx.wait(); - setIsConfirming(false); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - setIsPending(false); - setIsConfirming(false); - setError(e as Error); - } - }, [signerPromise, queryClient]); - - return { grantAccess, isPending, isConfirming, error }; -} - -export function useRevokeAccess() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [isPending, setIsPending] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [error, setError] = useState(null); - - const revokeAccess = useCallback(async (roomId: bigint, user: string) => { - if (!signerPromise) return; - setIsPending(true); - setError(null); - try { - const contract = await getSignerContract(signerPromise); - const tx = await contract.revokeAccess(roomId, [user]); - setIsPending(false); - setIsConfirming(true); - await tx.wait(); - setIsConfirming(false); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - setIsPending(false); - setIsConfirming(false); - setError(e as Error); - } - }, [signerPromise, queryClient]); - - return { revokeAccess, isPending, isConfirming, error }; -} - -export function useGrantAccessToAllFolders() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [isPending, setIsPending] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [error, setError] = useState(null); - - const grantAccessToAllFolders = useCallback(async (parentId: bigint, user: string) => { - if (!signerPromise) return; - setIsPending(true); - setError(null); - try { - const contract = await getSignerContract(signerPromise); - const tx = await contract.grantAccessToAllFolders(parentId, user); - setIsPending(false); - setIsConfirming(true); - await tx.wait(); - setIsConfirming(false); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - setIsPending(false); - setIsConfirming(false); - setError(e as Error); - } - }, [signerPromise, queryClient]); - - return { grantAccessToAllFolders, isPending, isConfirming, error }; -} - -export function useRevokeAccessFromAllFolders() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [isPending, setIsPending] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [error, setError] = useState(null); - - const revokeAccessFromAllFolders = useCallback(async (parentId: bigint, user: string) => { - if (!signerPromise) return; - setIsPending(true); - setError(null); - try { - const contract = await getSignerContract(signerPromise); - const tx = await contract.revokeAccessFromAllFolders(parentId, user); - setIsPending(false); - setIsConfirming(true); - await tx.wait(); - setIsConfirming(false); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - setIsPending(false); - setIsConfirming(false); - setError(e as Error); - } - }, [signerPromise, queryClient]); - - return { revokeAccessFromAllFolders, isPending, isConfirming, error }; -} - -export const RekeyPhase = { - Idle: "idle", - Rekeying: "rekeying", - Rewrapping: "rewrapping", - Updating: "updating", - Done: "done", - Error: "error", -} as const; - -export type RekeyPhase = (typeof RekeyPhase)[keyof typeof RekeyPhase]; - -export type RekeyProgress = { - phase: RekeyPhase; - current: number; - total: number; - error?: string; -}; - -export function useRekeyAndRewrap() { - const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); - const queryClient = useQueryClient(); - const [progress, setProgress] = useState({ - phase: RekeyPhase.Idle, - current: 0, - total: 0, - }); - - const rekeyAndRewrap = useCallback( - async ( - folderId: bigint, - documentCount: number, - oldRoomKeyHex: string, - ) => { - if (!signerPromise) return; - - try { - const contract = await getSignerContract(signerPromise); - - // Phase 1: Rekey on-chain - setProgress({ phase: RekeyPhase.Rekeying, current: 0, total: 0 }); - const tx = await contract.rekeyRoom(folderId); - await tx.wait(); - - // If no documents, we're done - if (documentCount === 0) { - setProgress({ phase: RekeyPhase.Done, current: 0, total: 0 }); - queryClient.invalidateQueries({ refetchType: "all" }); - return; - } - - const signer = await signerPromise!; - const newKeyHandle = await contract.getRoomKey(folderId); - const newKeyHex = await decryptRoomKey(newKeyHandle, signer); - - // Phase 2: Re-wrap all document CEKs locally (no Storacha I/O) - setProgress({ phase: RekeyPhase.Rewrapping, current: 0, total: documentCount }); - const oldWrappingKey = await deriveAesKey(hexToBytes(oldRoomKeyHex)); - const newWrappingKey = await deriveAesKey(hexToBytes(newKeyHex)); - const newWrappedKeys: string[] = []; - - for (let i = 0; i < documentCount; i++) { - setProgress({ phase: RekeyPhase.Rewrapping, current: i + 1, total: documentCount }); - const doc = await contract.getDocument(folderId, BigInt(i)); - const wrappedCek = hexToBytes(doc.wrappedKey); - const cek = await unwrapKey(wrappedCek, oldWrappingKey); - const newWrapped = await wrapKey(cek, newWrappingKey); - newWrappedKeys.push(bytesToHex(newWrapped)); - } - - // Phase 3: Update all wrapped keys on-chain in a single transaction - setProgress({ phase: RekeyPhase.Updating, current: 0, total: documentCount }); - const docIndices = Array.from({ length: documentCount }, (_, i) => BigInt(i)); - const updateTx = await contract.updateDocumentKeys(folderId, docIndices, newWrappedKeys); - await updateTx.wait(); - setProgress({ phase: RekeyPhase.Updating, current: documentCount, total: documentCount }); - - setProgress({ phase: RekeyPhase.Done, current: documentCount, total: documentCount }); - queryClient.invalidateQueries({ refetchType: "all" }); - } catch (e) { - const message = e instanceof Error ? e.message : "Unknown error"; - setProgress((prev) => ({ ...prev, phase: RekeyPhase.Error, error: message })); - } - }, - [signerPromise, queryClient] - ); - - const reset = useCallback(() => { - setProgress({ phase: RekeyPhase.Idle, current: 0, total: 0 }); - }, []); - - return { rekeyAndRewrap, progress, reset }; -} diff --git a/dapp/src/lib/crypto.ts b/dapp/src/lib/crypto.ts index f2ffc30..4ba6760 100644 --- a/dapp/src/lib/crypto.ts +++ b/dapp/src/lib/crypto.ts @@ -88,6 +88,7 @@ export async function wrapKey(cek: Uint8Array, wrappingKey: CryptoKey): Promise< /** Unwrap (decrypt) a wrapped CEK. Input is IV‖ciphertext‖tag. */ export async function unwrapKey(wrapped: Uint8Array, wrappingKey: CryptoKey): Promise { + if (wrapped.length < IV_BYTES + 16) throw new Error("Wrapped key too short"); const iv = wrapped.slice(0, IV_BYTES); const ciphertext = wrapped.slice(IV_BYTES); const plaintext = await crypto.subtle.decrypt({ name: ALGO, iv }, wrappingKey, ciphertext); diff --git a/dapp/src/lib/fhe.ts b/dapp/src/lib/fhe.ts index 7c53780..bd4360e 100644 --- a/dapp/src/lib/fhe.ts +++ b/dapp/src/lib/fhe.ts @@ -1,5 +1,3 @@ -import { createInstance, SepoliaConfig, MainnetConfig } from "@zama-fhe/relayer-sdk/web"; -import type { FhevmInstance } from "@zama-fhe/relayer-sdk/web"; import type { JsonRpcSigner } from "ethers"; import { CHAIN_ID, DATAROOM_ADDRESS } from "@/contracts"; @@ -12,26 +10,47 @@ const rpcUrls: Record = { [1]: import.meta.env.VITE_RPC_URL || "https://eth.llamarpc.com", }; -const networkConfigs: Record = { - [11155111]: SepoliaConfig, - [1]: MainnetConfig, -}; +// Load the relayer SDK UMD bundle from Zama's CDN. This handles WASM init internally +const SDK_CDN_URL = "https://cdn.zama.org/relayer-sdk-js/0.4.2/relayer-sdk-js.umd.cjs"; +function loadSDKScript(): Promise { + return new Promise((resolve, reject) => { + const w = window as unknown as Record; + if (w.relayerSDK) { resolve(); return; } + const script = document.createElement("script"); + script.src = SDK_CDN_URL; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load relayer SDK from ${SDK_CDN_URL}`)); + document.head.appendChild(script); + }); +} -let instancePromise: Promise | null = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let instancePromise: Promise | null = null; -function getInstance(): Promise { +async function getInstance() { if (!instancePromise) { - const config = networkConfigs[CHAIN_ID]; - const network = rpcUrls[CHAIN_ID]; - if (!config || !network) { - throw new Error(`No FHE config for chain ${CHAIN_ID}. Supported: Sepolia (11155111), Mainnet (1).`); - } - instancePromise = createInstance({ ...config, network }); + instancePromise = (async () => { + await loadSDKScript(); + const { initSDK, createInstance, SepoliaConfig, MainnetConfig } = + await import("@zama-fhe/relayer-sdk/bundle"); + + const presets: Record = { + [11155111]: SepoliaConfig, + [1]: MainnetConfig, + }; + const preset = presets[CHAIN_ID]; + const network = rpcUrls[CHAIN_ID]; + if (!preset || !network) { + throw new Error(`No FHE config for chain ${CHAIN_ID}. Supported: Sepolia (11155111), Mainnet (1).`); + } + + await initSDK(); + return createInstance({ ...preset, network }); + })(); } return instancePromise; } - const CACHE_PREFIX = "fhe:"; function getCached(handle: string): string | null { @@ -42,6 +61,10 @@ function getCached(handle: string): string | null { } } +export function hasCachedKey(handle: string): boolean { + return getCached(handle) !== null; +} + function setCached(handle: string, hex: string): void { try { sessionStorage.setItem(CACHE_PREFIX + handle.toLowerCase(), hex); @@ -97,11 +120,6 @@ export async function decryptEuint256( return hex; } -/** - * Convenience: decrypt the room key for a folder. - * On mock chains (Anvil), this is a no-op — the handle IS the key. - * On FHE chains, performs the full user decryption flow. - */ export async function decryptRoomKey( handle: string, signer: JsonRpcSigner, diff --git a/dapp/src/pages/Dashboard/components/RoomCard.tsx b/dapp/src/pages/Dashboard/components/RoomCard.tsx index c0d9e43..5175f85 100644 --- a/dapp/src/pages/Dashboard/components/RoomCard.tsx +++ b/dapp/src/pages/Dashboard/components/RoomCard.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router-dom"; import { ChevronRight, FolderOpen } from "lucide-react"; -import { useRoom } from "@/hooks/useDataRoom"; +import { useRoom } from "@/hooks/dataroom"; import { CopyableAddress } from "@/components/CopyableAddress"; export function RoomCard({ roomId }: { roomId: bigint }) { diff --git a/dapp/src/pages/Dashboard/index.tsx b/dapp/src/pages/Dashboard/index.tsx index d9fdf6c..b1f4313 100644 --- a/dapp/src/pages/Dashboard/index.tsx +++ b/dapp/src/pages/Dashboard/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useAccount } from "wagmi"; import { Plus, FolderLock, Loader2 } from "lucide-react"; -import { useRoomCount, useCreateRoom } from "@/hooks/useDataRoom"; +import { useRoomCount, useCreateRoom } from "@/hooks/dataroom"; import { RoomCard } from "./components/RoomCard"; export default function Dashboard() { @@ -74,6 +74,13 @@ export default function Dashboard() {
)} + {(isPending || isConfirming) && count > 0 && ( +
+ + {isPending ? "Waiting for signature..." : "Confirming transaction..."} +
+ )} + {(isPending || isConfirming) && count === 0 ? (
diff --git a/dapp/src/pages/InvestorView/components/InvestorRoomCard.tsx b/dapp/src/pages/InvestorView/components/InvestorRoomCard.tsx index e45e2db..3ef1408 100644 --- a/dapp/src/pages/InvestorView/components/InvestorRoomCard.tsx +++ b/dapp/src/pages/InvestorView/components/InvestorRoomCard.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router-dom"; import { ChevronRight, FolderOpen } from "lucide-react"; -import { useRoom, useHasAccessToAnyFolder } from "@/hooks/useDataRoom"; +import { useRoom, useHasAccessToAnyFolder } from "@/hooks/dataroom"; export function InvestorRoomCard({ roomId }: { roomId: bigint }) { const { data: roomData } = useRoom(roomId); diff --git a/dapp/src/pages/InvestorView/index.tsx b/dapp/src/pages/InvestorView/index.tsx index b322db5..16f75a3 100644 --- a/dapp/src/pages/InvestorView/index.tsx +++ b/dapp/src/pages/InvestorView/index.tsx @@ -1,6 +1,6 @@ import { useAccount } from "wagmi"; import { Eye, FolderLock } from "lucide-react"; -import { useRoomCount } from "@/hooks/useDataRoom"; +import { useRoomCount } from "@/hooks/dataroom"; import { InvestorRoomCard } from "./components/InvestorRoomCard"; export default function InvestorView() { diff --git a/dapp/src/pages/Room/components/DocumentRow.tsx b/dapp/src/pages/Room/components/DocumentRow.tsx index eef9511..4e41cb8 100644 --- a/dapp/src/pages/Room/components/DocumentRow.tsx +++ b/dapp/src/pages/Room/components/DocumentRow.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { FileText, Download, Loader2 } from "lucide-react"; -import { useDocument } from "@/hooks/useDataRoom"; +import { useDocument } from "@/hooks/dataroom"; export function DocumentRow({ roomId, diff --git a/dapp/src/pages/Room/components/FolderCard.tsx b/dapp/src/pages/Room/components/FolderCard.tsx index 17717cb..d0ba617 100644 --- a/dapp/src/pages/Room/components/FolderCard.tsx +++ b/dapp/src/pages/Room/components/FolderCard.tsx @@ -1,5 +1,5 @@ import { FolderOpen, FileText, Users } from "lucide-react"; -import { useRoom, useRoomMembers } from "@/hooks/useDataRoom"; +import { useRoom, useRoomMembers } from "@/hooks/dataroom"; export function FolderCard({ folderId, isOwner, onSelect }: { folderId: bigint; isOwner: boolean; onSelect: () => void }) { const { data } = useRoom(folderId); diff --git a/dapp/src/pages/Room/components/FolderPanel.tsx b/dapp/src/pages/Room/components/FolderPanel.tsx index 1f2157f..0f0068d 100644 --- a/dapp/src/pages/Room/components/FolderPanel.tsx +++ b/dapp/src/pages/Room/components/FolderPanel.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useAccount } from "wagmi"; +import { useQueryClient } from "@tanstack/react-query"; import { Upload, UserPlus, @@ -22,9 +23,11 @@ import { useRevokeAccess, useRekeyAndRewrap, useRoomKey, + useRoomKeyHandle, ZERO_BYTES32, RekeyPhase, -} from "@/hooks/useDataRoom"; +} from "@/hooks/dataroom"; +import { isFheChain, hasCachedKey } from "@/lib/fhe"; import { useStoracha } from "@/hooks/useStoracha"; import { CopyableAddress } from "@/components/CopyableAddress"; import { DocumentRow } from "./DocumentRow"; @@ -47,15 +50,17 @@ function getUploadButtonText( export function FolderPanel({ folderId }: { folderId: bigint }) { const { address } = useAccount(); + const queryClient = useQueryClient(); const { data: folderData } = useRoom(folderId); - const isOwner = - !!folderData && !!address && folderData.owner.toLowerCase() === address.toLowerCase(); + const isOwner = !!folderData + && !!address + && folderData.owner.toLowerCase() === address.toLowerCase(); const { data: membersData } = useRoomMembers(folderId, isOwner); const { addDocuments, isPending: isAddingDoc, isConfirming: isConfirmingDoc } = useAddDocuments(); const { grantAccess, isPending: isGranting, isConfirming: isConfirmingGrant } = useGrantAccess(); - const { revokeAccess, isPending: isRevoking } = useRevokeAccess(); + const { revokeAccess, isPending: isRevoking, isConfirming: isConfirmingRevoke } = useRevokeAccess(); const { rekeyAndRewrap, progress: rekeyProgress } = useRekeyAndRewrap(); const { @@ -67,7 +72,11 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { isReady: storachaReady, } = useStoracha(); - const { data: roomKeyData } = useRoomKey(folderId); + const { data: roomKeyHandle } = useRoomKeyHandle(folderId); + const alreadyCached = !!roomKeyHandle && hasCachedKey(roomKeyHandle); + const [userRequestedKey, setUserRequestedKey] = useState(false); + const keyRequested = !isFheChain() || userRequestedKey || alreadyCached; + const { data: roomKeyData, error: roomKeyError, isLoading: isLoadingKey } = useRoomKey(folderId, keyRequested); const [newMember, setNewMember] = useState(""); const [selectedFiles, setSelectedFiles] = useState([]); @@ -79,6 +88,8 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { const roomKeyHex = !!roomKeyData && roomKeyData !== ZERO_BYTES32 ? roomKeyData : null; const hasAccess = !!roomKeyHex || isOwner; + const showDecryptPrompt = isFheChain() && !keyRequested; + const showDecrypting = isFheChain() && keyRequested && isLoadingKey && !roomKeyData; if (!folderData) { return

Loading folder...

; @@ -169,7 +180,56 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
- {!hasAccess && ( + {showDecryptPrompt && ( +
+ +

Folder is Encrypted

+

+ Sign a message to decrypt the folder and access documents. +

+ +
+ )} + + {showDecrypting && ( +
+ +

Decrypting Folder

+

+ Please sign the message to decrypt folder... +

+
+ )} + + {!showDecryptPrompt && !showDecrypting && roomKeyError && ( +
+ +

Decryption Failed

+

+ {roomKeyError.message?.includes("user rejected") || roomKeyError.message?.includes("ACTION_REJECTED") + ? "Signature request was rejected. Please try again to access this folder." + : "Could not decrypt the folder key. You may not have access, or the network may be unavailable."} +

+ +
+ )} + + {!showDecryptPrompt && !showDecrypting && !roomKeyError && !hasAccess && (

Access Restricted

@@ -179,9 +239,8 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
)} - {hasAccess && ( + {!showDecryptPrompt && !showDecrypting && !roomKeyError && hasAccess && (
- {/* Documents */}

@@ -267,7 +326,6 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { )}

- {/* Access Group */}

@@ -294,6 +352,16 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {

+ {(isGranting || isConfirmingGrant || isRevoking || isConfirmingRevoke) && ( +
+ + {isGranting && "Granting access..."} + {!isGranting && isConfirmingGrant && "Confirming grant..."} + {isRevoking && "Revoking access..."} + {!isRevoking && isConfirmingRevoke && "Confirming revoke..."} +
+ )} +
{members.map((member: string) => (
@@ -306,7 +374,7 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { {member.toLowerCase() !== folderData.owner.toLowerCase() && (
)} + {(isCreatingFolder || isConfirmingFolder) && folders.length > 0 && ( +
+ + {isCreatingFolder ? "Waiting for signature..." : "Confirming transaction..."} +
+ )} + {(isCreatingFolder || isConfirmingFolder) && folders.length === 0 ? (
diff --git a/dapp/src/types/EncryptedDataRoom.ts b/dapp/src/types/EncryptedDataRoom.ts index 3380074..55a6517 100644 --- a/dapp/src/types/EncryptedDataRoom.ts +++ b/dapp/src/types/EncryptedDataRoom.ts @@ -28,6 +28,7 @@ export interface EncryptedDataRoomInterface extends Interface { nameOrSignature: | "NO_PARENT" | "addDocuments" + | "confidentialProtocolId" | "createFolder" | "createRoom" | "documentKeyVersion" @@ -39,6 +40,7 @@ export interface EncryptedDataRoomInterface extends Interface { | "getRoomKey" | "grantAccess" | "grantAccessToAllFolders" + | "hasAccess" | "rekeyRoom" | "revokeAccess" | "revokeAccessFromAllFolders" @@ -63,6 +65,10 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "addDocuments", values: [BigNumberish, string[], string[], BytesLike[]] ): string; + encodeFunctionData( + functionFragment: "confidentialProtocolId", + values?: undefined + ): string; encodeFunctionData( functionFragment: "createFolder", values: [BigNumberish, string] @@ -104,6 +110,10 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "grantAccessToAllFolders", values: [BigNumberish, AddressLike] ): string; + encodeFunctionData( + functionFragment: "hasAccess", + values: [BigNumberish] + ): string; encodeFunctionData( functionFragment: "rekeyRoom", values: [BigNumberish] @@ -136,6 +146,10 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "addDocuments", data: BytesLike ): Result; + decodeFunctionResult( + functionFragment: "confidentialProtocolId", + data: BytesLike + ): Result; decodeFunctionResult( functionFragment: "createFolder", data: BytesLike @@ -165,6 +179,7 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "grantAccessToAllFolders", data: BytesLike ): Result; + decodeFunctionResult(functionFragment: "hasAccess", data: BytesLike): Result; decodeFunctionResult(functionFragment: "rekeyRoom", data: BytesLike): Result; decodeFunctionResult( functionFragment: "revokeAccess", @@ -310,6 +325,8 @@ export interface EncryptedDataRoom extends BaseContract { "nonpayable" >; + confidentialProtocolId: TypedContractMethod<[], [bigint], "view">; + createFolder: TypedContractMethod< [parentId: BigNumberish, name: string], [bigint], @@ -374,6 +391,8 @@ export interface EncryptedDataRoom extends BaseContract { "nonpayable" >; + hasAccess: TypedContractMethod<[roomId: BigNumberish], [boolean], "view">; + rekeyRoom: TypedContractMethod<[roomId: BigNumberish], [void], "nonpayable">; revokeAccess: TypedContractMethod< @@ -439,6 +458,9 @@ export interface EncryptedDataRoom extends BaseContract { [void], "nonpayable" >; + getFunction( + nameOrSignature: "confidentialProtocolId" + ): TypedContractMethod<[], [bigint], "view">; getFunction( nameOrSignature: "createFolder" ): TypedContractMethod< @@ -514,6 +536,9 @@ export interface EncryptedDataRoom extends BaseContract { [void], "nonpayable" >; + getFunction( + nameOrSignature: "hasAccess" + ): TypedContractMethod<[roomId: BigNumberish], [boolean], "view">; getFunction( nameOrSignature: "rekeyRoom" ): TypedContractMethod<[roomId: BigNumberish], [void], "nonpayable">; diff --git a/dapp/src/types/factories/EncryptedDataRoom__factory.ts b/dapp/src/types/factories/EncryptedDataRoom__factory.ts index 543d22c..88f6928 100644 --- a/dapp/src/types/factories/EncryptedDataRoom__factory.ts +++ b/dapp/src/types/factories/EncryptedDataRoom__factory.ts @@ -50,6 +50,19 @@ const _abi = [ outputs: [], stateMutability: "nonpayable", }, + { + type: "function", + name: "confidentialProtocolId", + inputs: [], + outputs: [ + { + name: "", + type: "uint256", + internalType: "uint256", + }, + ], + stateMutability: "view", + }, { type: "function", name: "createFolder", @@ -281,7 +294,7 @@ const _abi = [ { name: "", type: "bytes32", - internalType: "bytes32", + internalType: "euint256", }, ], stateMutability: "view", @@ -322,6 +335,25 @@ const _abi = [ outputs: [], stateMutability: "nonpayable", }, + { + type: "function", + name: "hasAccess", + inputs: [ + { + name: "roomId", + type: "uint256", + internalType: "uint256", + }, + ], + outputs: [ + { + name: "", + type: "bool", + internalType: "bool", + }, + ], + stateMutability: "view", + }, { type: "function", name: "rekeyRoom", @@ -489,7 +521,7 @@ const _abi = [ { name: "", type: "bytes32", - internalType: "bytes32", + internalType: "ebool", }, ], stateMutability: "view", @@ -628,6 +660,11 @@ const _abi = [ name: "Unauthorized", inputs: [], }, + { + type: "error", + name: "ZamaProtocolUnsupported", + inputs: [], + }, ] as const; export class EncryptedDataRoom__factory { diff --git a/dapp/vite.config.ts b/dapp/vite.config.ts index 8f33a9c..a89e277 100644 --- a/dapp/vite.config.ts +++ b/dapp/vite.config.ts @@ -21,4 +21,10 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + optimizeDeps: { + exclude: ["@zama-fhe/relayer-sdk"], + }, + build: { + target: "esnext", + }, }); diff --git a/docs/ADRs/001-fhe-access-control.md b/docs/ADRs/001-fhe-access-control.md new file mode 100644 index 0000000..7093f27 --- /dev/null +++ b/docs/ADRs/001-fhe-access-control.md @@ -0,0 +1,20 @@ +# ADR-001: Use FHE for On-Chain Access Control + +**Date**: 2026-03-08 + +**Status**: Accepted + +## Context + +We need a mechanism to control who can decrypt documents in the data room. The key management must be trustless — no centralized server or intermediary should be able to grant or revoke access. + +Options considered: + +1. **Zama (FHE)**: Room key stored as `euint256` in contract storage. Only addresses granted `FHE.allow()` can decrypt. The key never exists in plaintext on-chain. +2. **Lit Protocol**: Skip. clashes with our current solution. One more integration concern. +3. **MPC / Shamir**: Off-chain key sharing among participants. Requires coordination infrastructure. +4. **Pure client-side**: Walkaway test. + +## Decision + +Use Zama. The room key lives as an FHE-encrypted `euint256` in the smart contract. `FHE.allow(key, user)` grants decryption rights at the EVM level. Users decrypt via the Zama Relayer using EIP-712 signed requests. diff --git a/docs/ADRs/002-per-document-cek-wrapping.md b/docs/ADRs/002-per-document-cek-wrapping.md new file mode 100644 index 0000000..78ac573 --- /dev/null +++ b/docs/ADRs/002-per-document-cek-wrapping.md @@ -0,0 +1,27 @@ +# ADR-002: Per-Document CEK Wrapping + +**Date**: 2026-03-08 + +**Status**: Accepted + +## Context + +When a member is revoked, the room key must rotate so the revoked user cannot decrypt future content. The question is how to handle existing encrypted documents during key rotation. + +**Option A: Re-encrypt blobs** — Download each encrypted document from Storacha, decrypt with the old key, re-encrypt with the new key, re-upload. O(n) Storacha round-trips. + +## Decision +Each document is encrypted with its own random Content Encryption Key (CEK). The CEK is then wrapped (encrypted) with the room key and stored on-chain. On rekey, only the wrapped CEKs need re-wrapping. Blobs never change. + +Upload flow: +``` +file -> [AES-GCM + random CEK] -> encrypted blob -> Storacha (CID) +CEK -> [AES-GCM + room key] -> wrappedKey -> on-chain +``` + +Rekey flow: +``` +for each doc: unwrap CEK (old key) -> re-wrap CEK (new key) +batch updateDocumentKeys() on-chain +blobs on Storacha are never touched +``` diff --git a/docs/ADRs/003-room-folder-hierarchy.md b/docs/ADRs/003-room-folder-hierarchy.md new file mode 100644 index 0000000..dda1106 --- /dev/null +++ b/docs/ADRs/003-room-folder-hierarchy.md @@ -0,0 +1,29 @@ +# ADR-003: Room/Folder Two-Level Hierarchy + +**Date**: 2026-03-08 + +**Status**: Accepted + +## Context + +Data rooms in due diligence typically have organizational structure: a deal (room) contains folders like "Legal", "Financials", "IP", each with different access permissions. Some investors see legal docs but not financials. + + + A "parent room" is a deal-level container with no key/members/docs. "Folders" are children of a parent room, each with their own key, members, and documents. Max one level of nesting. + +## Decision + +Two-level hierarchy. Parent rooms are organizational containers. Folders hold the actual keys, members, and documents. + +- `createRoom("Series A")` -> parent (id 0) +- `createFolder(0, "Legal")` -> folder (id 1) with its own FHE key +- `createFolder(0, "Financials")` -> folder (id 2) with its own FHE key +- `grantAccessToAllFolders(0, investor)` -> bulk grant across all folders + +## Consequences + +- Simple model that maps well to real-world data room structure +- Per-folder key isolation: revoking from "Legal" doesn't affect "Financials" +- `grantAccessToAllFolders` / `revokeAccessFromAllFolders` for convenience +- No deep nesting complexity (max 1 level enforced by `CannotNestDeeper`) +- Owner is always the parent room creator — no per-folder ownership delegation diff --git a/docs/ADRs/004-skip-hkdf-key-derivation.md b/docs/ADRs/004-skip-hkdf-key-derivation.md new file mode 100644 index 0000000..4f6623e --- /dev/null +++ b/docs/ADRs/004-skip-hkdf-key-derivation.md @@ -0,0 +1,28 @@ +# ADR-004: Skip HKDF Key Derivation + +**Date**: 2026-03-09 + +**Status**: Accepted as out of scope. + +## Context + +The 32-byte FHE-decrypted room key is imported directly as an AES-256-GCM key via `crypto.subtle.importKey('raw', ...)`. Cryptographic best practice says raw key material should go through a Key Derivation Function (HKDF) with domain-separated info strings before use. + +The proper approach: +```typescript +const baseKey = await crypto.subtle.importKey('raw', roomKeyBytes, 'HKDF', false, ['deriveKey']); +return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: encode(purpose) }, + baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] +); +``` + +## Decision + +Skip HKDF as an accepted drawback. Use the raw FHE-decrypted bytes directly as the AES key. + +## Rationale + +- The room key is only used for one purpose: wrapping per-document CEKs via AES-GCM +- Each wrapping operation uses a unique random 12-byte IV from `crypto.getRandomValues()` +- AES-256-GCM with random IVs is secure for up to ~2^32 operations per key diff --git a/docs/ADRs/005-relayer-sdk-cdn-loading.md b/docs/ADRs/005-relayer-sdk-cdn-loading.md new file mode 100644 index 0000000..3cf2a4c --- /dev/null +++ b/docs/ADRs/005-relayer-sdk-cdn-loading.md @@ -0,0 +1,26 @@ +# ADR-005: Load Zama Relayer SDK via CDN Script Injection + +**Date**: 2026-03-09 + +**Status**: Accepted as a workaround + +## Context + +The `@zama-fhe/relayer-sdk` npm package (v0.4.2) does not work as a standard ESM import in Vite/React apps: + +1. The package bundles WASM internally and is distributed as a UMD module only +2. Vite's `optimizeDeps` fails to pre-bundle it — requires `exclude: ["@zama-fhe/relayer-sdk"]` +3. Direct `import` fails at build time even with the exclusion +4. No TypeScript type declarations (`.d.ts`) are shipped — everything is `any` + +## Decision + +Load the UMD bundle at runtime via `