Skip to content

feat: collaborative encrypted drives with ACT-based access control#71

Open
misaakidis wants to merge 3 commits into
GasperX93:developfrom
misaakidis:shared-drive
Open

feat: collaborative encrypted drives with ACT-based access control#71
misaakidis wants to merge 3 commits into
GasperX93:developfrom
misaakidis:shared-drive

Conversation

@misaakidis

Copy link
Copy Markdown

What this adds

A new collaborative drive type that allows multiple users to read from and write to a shared encrypted drive on Swarm, with the creator managing who has access.

This is distinct from the existing shared drive flow, where a drive owner grants their Bee node's ACT key to specific readers. The new model is fully wallet-derived and works across any device — no Bee node key sharing involved.


How it works

Drive creation

When a user creates a collaborative drive, three things are set up on Swarm:

  1. ACT (Access Control Trie) — created via act-js, a browser-side ACT client. The creator's wallet-derived key is the initial grantee.
  2. Drive feed — a Swarm SOC feed whose topic is keccak256("nook:drive:<creatorAddr>:<driveId>"). Each feed entry points to the current encrypted manifest (file list). The feed is signed by a write key rather than the wallet key directly.
  3. Write key — derived deterministically from the creator's Nook signing key: HMAC-SHA256(creatorSigningKey, "nook:write:<driveId>:v<N>"). This key controls who can push new entries to the drive feed.

The manifest (a JSON file listing all drive files) is ACT-encrypted so only current grantees can read it. The feed entry stores the encrypted manifest reference alongside the ACT history ref needed to decrypt it.

Roles

Role Can read files Can push to feed Holds write key
Creator Yes Yes Yes (derived)
Writer Yes Yes Yes (encrypted copy)
Reader Yes No No

Readers get ACT access to the encrypted manifest. Writers additionally receive the write key, encrypted with their wallet public key (ECIES via @swarm-notify/sdk), so they can post new feed entries.

Who pays for storage

Each participant uploads to their own postage stamp. Writers and readers select which of their own drives to use when uploading a file — they are not uploading onto the creator's stamp. The drive feed and ACT state live on the creator's stamp; file data lives on whoever uploaded it.

Granting and revoking access

The creator uses a new Share modal (ShareModalV2) to manage members:

  • Grant: calls ActClient.patchGrantees({ add: [writerPublicKey] }) to extend ACT access, then sends the grantee a share link via Nook messaging. Writer links include the write key encrypted specifically for that recipient's wallet public key.
  • Revoke reader: removes them from the ACT grantee list. The manifest is re-encrypted with the updated history.
  • Revoke writer: removes ACT access, rotates the write key (version bump), re-derives it, and re-encrypts it for all remaining writers. Old write key holders can no longer push valid feed entries.

Member state is tracked in a signed member list document (creator's secp256k1 signature over canonical JSON) uploaded to Swarm and referenced in the feed entry. This lets any member verify the current access state without trusting a centralised server.

Receiving a shared drive

Share links use the existing nook://drive-share scheme, extended with v2 params (driveId, role, writeKey). The presence of driveId distinguishes v2 links from legacy ones.

useInboxPolling (which already runs in the background at the Layout level) now also scans incoming messages for v2 drive-share links and auto-imports the drive into localStorage — no manual paste needed.


New files

File Purpose
src/crypto/drive.ts Write key derivation, drive feed topic, ECIES encrypt/decrypt for write key
src/api/driveFeed.ts Read and write drive feed entries (SOC feed via bee-js)
src/api/createSharedDrive.ts Create ACT + initial manifest + feed entry
src/api/sharedDriveUpload.ts Upload a file to a collaborative drive (decrypt manifest → append → re-encrypt → write feed)
src/api/memberList.ts Create, sign, verify, and update the member list document
src/hooks/useSharedDrives.ts Extended with SharedDriveV2 type, useSharedDrivesV2 hook, v2 link parsing/building
src/hooks/useActiveDrive.ts Resolve active drive from route, expose write key bytes and canWrite flag
src/hooks/useMemberList.ts Fetch and verify member list from Swarm on mount
src/components/ShareModalV2.tsx Grant/revoke UI, member list with role badges, notify-all flow

Changed files

File Change
src/components/AddSharedDriveModal.tsx Handle v2 share links — decrypt write key if present, save to v2 store
src/hooks/useInboxPolling.ts Auto-import v2 drives from incoming drive-share messages
src/pages/Drive.tsx "Shared with me" tab shows v2 drives with role badge; "New collaborative" button + creation modal; Share button opens ShareModalV2

Dependency

Adds @nook/act-js (github:misaakidis/act-js) — a browser-side ACT client that wraps Bee's ACT chunk operations. It exposes ActClient.create(), patchGrantees(), encryptRef(), decryptRef(), and reencryptRef(). The package builds on install via its prepare script.

Implements the browser-side ACT shared drive stack on top of @nook/act-js:

crypto/drive.ts: deriveWriteKey, driveFeedTopicHex, ECIES writeKey
encrypt/decrypt helpers via @swarm-notify/sdk crypto module.

api/driveFeed.ts: readLatestEntry / writeEntry for the driveFeed SOC
(topic + writeKey-signed feed, JSON payload with historyRef + encryptedRef).

hooks/useSharedDrives.ts: SharedDriveV2 type + useSharedDrivesV2 hook
(nook-shared-drives-v2 localStorage key, legacy key untouched).
hooks/useActiveDrive.ts: route-param → SharedDriveV2 + canWrite flag.

api/createSharedDrive.ts: creates ACT + empty manifest + initial
driveFeed entry; returns a SharedDriveV2 ready to persist.

api/sharedDriveUpload.ts: decrypts manifest via ActClient.decryptRef,
appends file, re-encrypts via reencryptRef, writes new feed entry.

api/memberList.ts: creator-signed MemberListDoc (sign/verify/update/
fetch). hooks/useMemberList.ts: fetches + signature-verifies on mount.

hooks/useInboxPolling.ts: extended to detect v2 drive-share messages
(link contains driveId) and auto-add to nook-shared-drives-v2, with
optional writeKey decryption (falls back to reader on failure).

hooks/useSharedDrives.ts: parseShareLinkTyped (discriminated union
v1/v2), buildV2ShareLink. components/AddSharedDriveModal.tsx: v2
branch decrypts writeKey and calls addDriveV2 / onAddV2 prop; legacy
v1 path untouched. Both branches handle optional sender contact import.

Dependencies: adds @nook/act-js from github:misaakidis/act-js.
- Add CreateCollaborativeDriveModal: name + stamp selector, calls
  createSharedDrive() API, saves to sharedDrivesV2 on success
- Add "New collaborative" button in Shared tab header alongside
  existing "Add shared drive" button
- Render ShareModalV2 when a v2 drive's Share button is clicked
- Pass onAddV2 to AddSharedDriveModal so v2 share links import
  correctly into sharedDrivesV2 store
- Drop unused creatingSharedDrive / createSharedError state
- Import Bee from @ethersphere/bee-js for drive creation
Auto-fixed by eslint --fix: import reflows, blank lines, prettier
multi-line formatting. No logic changes.
@misaakidis misaakidis requested a review from GasperX93 as a code owner April 23, 2026 06:08
@misaakidis

Copy link
Copy Markdown
Author

Protocol diagrams

Read-only ACT vs Read/Write ACT

The existing sharing model uses ACT tied to the Bee node's keypair, encrypts individual files, and has no write access concept. The new model lifts ACT into the browser, narrows it to encrypting the manifest (the file index), and adds a separate write-key layer for feed signing.

Read-only ACT (existing)               Read/Write ACT (this PR)
────────────────────────               ────────────────────────────────────
Bee node keypair                       Wallet-derived keypair (HMAC-SHA256)

[File upload + swarm-act header]       ┌─ Write Key ────────────────────┐
→ ACT history ref per file             │  HMAC(signingKey, "nook:write:  │
  chain must be preserved              │  <driveId>:v<N>") · rotatable   │
                                       │  controls who can write to feed │
[Feed] entry:                          └────────────────────────────────┘
  { ref: encryptedFileRef,
    history: historyRef }              ┌─ ACT (browser-side, act-js) ───┐
                                       │  controls who can read manifest │
Grantees identified by                 │  grantees: wallet pubkeys       │
Bee node public key                    └────────────────────────────────┘

Read access only                       ┌─ SOC Feed ─────────────────────┐
                                       │  topic: keccak256(addr+driveId) │
                                       │  entry: { historyRef,           │
                                       │           encryptedRef }        │
                                       └────────────────────────────────┘

                                       ┌─ Manifest (ACT-encrypted) ──────┐
                                       │  { files: [{name,ref,size}] }   │
                                       │  files are plain bytes;         │
                                       │  ACT guards the index only      │
                                       └────────────────────────────────┘

What lives where

IN BROWSER                          ON SWARM
─────────────────────────           ────────────────────────────────────

signingKey  (HMAC of wallet sig)
    │
    ├──► writeKey.priv ───────────► [SOC FEED]
    │    HMAC(signingKey,               topic:  keccak256(addr+driveId)
    │    "nook:write:<id>:v<N>")        owner:  writeKey.address
    │    rotatable v1, v2 …             entry:  { historyRef,
    │                                             encryptedRef,
    │                                             memberListRef? }
    │
    └──► actSigner ───────────────► [ACT HISTORY]
         wraps signingKey               grantee pubkeys → encrypted
         for act-js (browser)           lookup table
              │
              │  encryptRef(manifestRef, historyRef)
              ▼
         encryptedRef ───────────► [MANIFEST]  (plain bytes on Swarm)
                                       { files: [{ name, ref, size }] }
                                       ref only recoverable by grantees

                                   [FILE CHUNKS]  (plain bytes on Swarm)
                                       accessible to anyone with the ref
                                       ref only discoverable via manifest

Read path (any grantee)

knows: driveId, creatorAddr, own signingKey
    │
    1. compute feed topic
       keccak256("nook:drive:<creatorAddr>:<driveId>")
    │
    2. read latest SOC feed entry ─────────────► [SOC FEED]
       ← { historyRef, encryptedRef }
    │
    3. actClient.decryptRef(
         encryptedRef,
         { signer: actSigner, historyRef }
       )
       ← plainManifestRef
       (fails if not a grantee)
    │
    4. download manifest ──────────────────────► [MANIFEST]
       ← { files: [{ name, ref, size }, ...] }
    │
    5. download file ──────────────────────────► [FILE CHUNK at ref]
       plain GET /bytes/<ref>

Write path (writer uploads a file)

has: actSigner (own signingKey) + writeKey.priv
    │
    1. read SOC feed entry ────────────────────► [SOC FEED]
       ← { historyRef, encryptedRef }
    │
    2. actClient.decryptRef(encryptedRef, historyRef)
       ← plainManifestRef
    │
    3. download + parse manifest ──────────────► [MANIFEST (current)]
       ← { files: [...] }
    │
    4. upload file bytes ──────────────────────► [FILE CHUNK] (new)
       to own postage batch                       plain bytes
       ← newFileRef
    │
    5. build new manifest
       { files: [...old, { name, newFileRef, size }] }
    │
    6. upload new manifest bytes ──────────────► [MANIFEST (new)]
       ← newManifestRef
    │
    7. actClient.reencryptRef(
         encryptedRef,        ← old encrypted ref
         newManifestRef,      ← new content to encrypt
         { signer, historyRef }
       )
       ← newEncryptedRef
       (historyRef unchanged — no grantee change)
    │
    8. write new SOC entry ────────────────────► [SOC FEED]
       signed by writeKey.priv                    new entry
       { historyRef, newEncryptedRef }

Granting access

Grant reader                            Grant writer (additional step)
────────────────────────                ───────────────────────────────

actClient.patchGrantees(                same as reader, plus:
  { add: [alicePubkey] },
  { signer, historyRef }                encryptWriteKeyForWriter(
)                                         writeKey.priv,
← newHistoryRef ────────► [ACT]           aliceWalletPubkey   ← ECIES
                           new grantee  )
                           lookup       ← writeKeyBlob

reencryptRef(                           include writeKeyBlob in share link
  encryptedRef, manifestRef,
  { signer, newHistoryRef }
)
← newEncryptedRef

write new SOC entry ─────► [SOC FEED]
{ newHistoryRef,
  newEncryptedRef }

Revoking access

Revoke reader                           Revoke writer (additional step)
────────────────────────                ───────────────────────────────

actClient.patchGrantees(                same ACT patch, plus:
  { revoke: [bobPubkey] },
  { signer, historyRef }                rotate write key
)                                         v1 → v2
← newHistoryRef ────────► [ACT]           writeKey_v2 = HMAC(
                           Bob's             signingKey,
                           lookup            "nook:write:<id>:v2"
                           removed          )

reencryptRef(               re-encrypt writeKey_v2 for all
  encryptedRef,             remaining writers
  manifestRef,
  { signer, newHistoryRef }
)                           Bob holds writeKey_v1 →
← newEncryptedRef           his signed entries are rejected
                            (feed owner address changed to
write new SOC entry ─────► [SOC FEED]    writeKey_v2.address)
{ newHistoryRef,
  newEncryptedRef }
(signed by writeKey_v2)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant