Initial research and POC scaffolding for Fileverse + rStack integration

Research docs covering crypto comparison (@fileverse/crypto vs MIT primitives vs
rSpace DocCrypto), Y.js vs Automerge architecture decision, and phased integration
plan. POC scaffolding includes MIT crypto primitives library with ECIES key exchange,
benchmark suite, collab-server Docker Compose for Netcup deployment, and placeholder
structures for IPFS storage and dSheet embed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 16:20:11 -07:00
commit f0b1096404
14 changed files with 1078 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
*.log

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# Fileverse + rNotes Integration
Research and proof-of-concept repo for integrating [Fileverse](https://github.com/fileverse) decentralization primitives into the rStack ecosystem (rNotes, rSpace).
## Goal
Evaluate and prototype how Fileverse's AGPL-licensed components can enhance rNotes/rSpace with:
- **E2E encryption** via `@fileverse/crypto` (or MIT equivalents)
- **IPFS storage** for files/images (eliminating centralized storage)
- **WebRTC P2P sync** for real-time collaboration
- **Decentralized spreadsheets** via `@fileverse-dev/dsheet`
## Current rStack Architecture
| Component | rnotes-online | rspace-online (rNotes module) |
|-----------|--------------|-------------------------------|
| CRDT | None | Automerge 2.2.8 |
| Sync | REST only | WebSocket Automerge sync |
| Offline | No | Full offline-first (IndexedDB) |
| Encryption | TLS only | E2E (AES-256-GCM + HKDF) |
| Real-time Collab | No | Yes (Automerge + Y.js) |
| Editor | TipTap 3.19 | TipTap 3.20 + Y.js |
| Auth | EncryptID | EncryptID + MCP |
| Import | Markdown | 6 formats (Notion, Obsidian, Logseq, Google Docs, Evernote, Roam) |
## Fileverse Ecosystem
| Package | Purpose | License |
|---------|---------|---------|
| [`@fileverse-dev/ddoc`](https://github.com/fileverse/fileverse-ddoc) | TipTap editor + Y.js CRDTs + WebRTC + E2E + IPFS | AGPL-3.0 |
| [`@fileverse/crypto`](https://github.com/fileverse/fileverse-cryptography) | ECIES, NaCl SecretBox, Argon2id, HKDF | AGPL-3.0 |
| [`collaboration-server`](https://github.com/fileverse/collaboration-server) | Y.js WebSocket relay, UCAN auth, ephemeral → IPFS | AGPL-3.0 |
| [`fileverse-storage`](https://github.com/fileverse/fileverse-storage) | UCAN-authorized file uploads to IPFS | AGPL-3.0 |
| [`@fileverse-dev/dsheet`](https://github.com/fileverse/fileverse-dsheet) | Decentralized spreadsheet (Y.js + WebRTC + IndexedDB) | AGPL-3.0 |
## Integration Priorities
See [`docs/integration-plan.md`](docs/integration-plan.md) for the detailed plan.
1. **Crypto primitives evaluation** — Compare `@fileverse/crypto` vs rSpace's existing DocCrypto
2. **IPFS file storage** — POC for encrypted file uploads (resolves rNotes TASK-4)
3. **Collaboration server** — Self-host on Netcup for WebSocket relay
4. **dSheet module** — Prototype as `rsheet-online` in rStack
## Repo Structure
```
fileverse/
├── docs/ # Research and architecture docs
│ ├── integration-plan.md # Detailed integration roadmap
│ ├── crypto-comparison.md # @fileverse/crypto vs DocCrypto analysis
│ └── yjs-vs-automerge.md # CRDT framework decision
├── poc/ # Proof-of-concept implementations
│ ├── crypto-eval/ # Encryption primitive benchmarks
│ ├── ipfs-storage/ # IPFS file upload POC
│ ├── collab-server/ # Self-hosted collaboration server
│ └── dsheet-embed/ # dSheet React component POC
└── README.md
```
## License Considerations
Fileverse repos are AGPL-3.0. Using their npm packages triggers copyleft for the combined work. Since rStack is open source on Gitea/GitHub, this is acceptable. For maximum flexibility, the crypto POC also evaluates MIT-licensed alternatives (Noble, StableLib, TweetNaCl) that provide equivalent primitives without AGPL obligations.

130
docs/crypto-comparison.md Normal file
View File

@ -0,0 +1,130 @@
# Crypto Comparison: @fileverse/crypto vs rSpace DocCrypto vs MIT Primitives
## Overview
Three options for the encryption layer:
1. **Keep DocCrypto** — rSpace's existing AES-256-GCM + HKDF implementation
2. **Adopt @fileverse/crypto** — Drop-in AGPL library with ECIES, NaCl, Argon2id
3. **Build from MIT primitives** — Same capabilities as Fileverse but without AGPL
## Primitive-Level Comparison
### Symmetric Encryption
| | DocCrypto | @fileverse/crypto | MIT Build |
|--|-----------|-------------------|-----------|
| Algorithm | AES-256-GCM | XSalsa20-Poly1305 (NaCl SecretBox) | Either |
| Implementation | Web Crypto API | TweetNaCl.js | Web Crypto or TweetNaCl |
| Key size | 256 bits | 256 bits | 256 bits |
| Nonce | 96 bits (random) | 192 bits (random) | Depends on choice |
| Auth tag | 128 bits | 128 bits (Poly1305) | Depends on choice |
| Bundle size | 0 (native) | ~7KB (tweetnacl) | 07KB |
| Performance | Fastest (hardware) | Fast (pure JS) | Fastest if Web Crypto |
**Analysis:** AES-256-GCM via Web Crypto is faster (hardware acceleration) and adds 0 bundle size. NaCl SecretBox is simpler to use correctly (larger nonce = less collision risk). For rStack, Web Crypto AES-256-GCM is the better choice — it's already battle-tested in DocCrypto.
### Asymmetric Encryption (Gap in DocCrypto)
| | DocCrypto | @fileverse/crypto | MIT Build |
|--|-----------|-------------------|-----------|
| ECIES | Not implemented | ✅ (secp256k1) | Noble curves |
| RSA | Not implemented | ✅ (envelope) | Web Crypto |
| Key exchange | Not implemented | ECDH shared secret | Noble/StableLib x25519 |
**Analysis:** This is the main gap. DocCrypto only does symmetric encryption — it can't share encryption keys between users without a trusted server. ECIES allows encrypting a document key so only a specific collaborator's private key can decrypt it.
### Key Derivation
| | DocCrypto | @fileverse/crypto | MIT Build |
|--|-----------|-------------------|-----------|
| HKDF | ✅ (Web Crypto) | ✅ (StableLib) | Web Crypto |
| Argon2id | Not implemented | ✅ (argon2-browser WASM) | argon2-browser |
| Key hierarchy | Master → Space → Doc | Flat | Custom |
**Analysis:** DocCrypto's key hierarchy is more sophisticated. Fileverse derives keys per-operation. For rStack, keep DocCrypto's hierarchy and add Argon2id for password-protected sharing.
## Recommendation
### Extend DocCrypto with MIT Primitives
```typescript
// Additions to rspace-online/shared/local-first/crypto.ts
import { x25519 } from '@noble/curves/ed25519'
import { hkdf } from '@noble/hashes/hkdf'
import { sha256 } from '@noble/hashes/sha256'
import argon2 from 'argon2-browser'
class DocCrypto {
// ... existing AES-256-GCM + HKDF methods ...
// NEW: Key exchange for multi-user collaboration
static async deriveSharedSecret(
myPrivateKey: Uint8Array,
theirPublicKey: Uint8Array
): Promise<CryptoKey> {
const shared = x25519.getSharedSecret(myPrivateKey, theirPublicKey)
return this.importKey(shared)
}
// NEW: Encrypt document key for a specific collaborator
static async encryptKeyForUser(
docKey: Uint8Array,
recipientPublicKey: Uint8Array,
senderPrivateKey: Uint8Array
): Promise<Uint8Array> {
const sharedSecret = x25519.getSharedSecret(senderPrivateKey, recipientPublicKey)
const wrappingKey = hkdf(sha256, sharedSecret, null, 'rstack-key-wrap', 32)
// Encrypt docKey with wrappingKey using existing AES-256-GCM
return this.encrypt(wrappingKey, docKey)
}
// NEW: Password-protected note sharing
static async deriveKeyFromPassword(
password: string,
salt: Uint8Array
): Promise<CryptoKey> {
const result = await argon2.hash({
pass: password,
salt: salt,
type: argon2.ArgonType.Argon2id,
hashLen: 32,
time: 3,
mem: 65536,
parallelism: 1,
})
return this.importKey(result.hash)
}
}
```
### Dependencies (All MIT)
```json
{
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"argon2-browser": "^1.18.0"
}
```
### Bundle Size Impact
| Package | Size (min+gzip) |
|---------|----------------|
| @noble/curves (x25519 only) | ~8KB |
| @noble/hashes (sha256+hkdf) | ~4KB |
| argon2-browser (WASM) | ~30KB |
| **Total** | **~42KB** |
vs `@fileverse/crypto` full package: ~45KB (similar, but with AGPL)
## Interoperability
If we build from the same MIT primitives Fileverse uses, we maintain the option to:
- Decrypt content encrypted by Fileverse apps
- Share encrypted documents with Fileverse users
- Participate in Fileverse collaboration rooms
The key is using compatible curve parameters (secp256k1 or x25519) and matching the encryption format (nonce || ciphertext || tag).

283
docs/integration-plan.md Normal file
View File

@ -0,0 +1,283 @@
# Fileverse + rStack Integration Plan
## Key Architectural Decision: Y.js vs Automerge
rSpace already uses **both** Automerge and Y.js:
- **Automerge 2.2.8** — Document state, sync protocol, binary wire format
- **Y.js 13.6** — TipTap editor binding (`y-prosemirror`), IndexedDB persistence (`y-indexeddb`)
Fileverse uses **Y.js exclusively** for CRDTs.
### Assessment
rSpace's dual approach is actually well-positioned. The Y.js layer handles editor collaboration (which is what Fileverse's components need), while Automerge handles the broader document state management. We don't need to choose one — the integration points are at the Y.js/TipTap layer.
**Decision: Keep both. Integrate Fileverse at the Y.js/TipTap layer.**
The Automerge sync protocol handles state replication, while Y.js handles real-time editor cursors and awareness. Fileverse's ddoc component and collaboration-server plug into the Y.js side.
---
## Phase 1: Crypto Primitives Evaluation
**Status:** Not started
**Effort:** 12 days
**Target:** Determine whether to use `@fileverse/crypto` or build equivalent from MIT libs
### rSpace DocCrypto (existing)
- AES-256-GCM for document encryption
- HKDF key derivation: Master key → Space key → Doc key
- Key material from EncryptID passkey PRF
- Location: `rspace-online/shared/local-first/crypto.ts`
### @fileverse/crypto (Fileverse)
- ECIES (asymmetric encryption via elliptic curves)
- NaCl SecretBox (symmetric authenticated encryption via TweetNaCl)
- RSA envelope encryption for large messages
- HKDF key derivation
- Argon2id password hashing
### Comparison
| Feature | DocCrypto | @fileverse/crypto |
|---------|-----------|-------------------|
| Symmetric encryption | AES-256-GCM (Web Crypto) | NaCl SecretBox (TweetNaCl) |
| Asymmetric encryption | Not implemented | ECIES + RSA |
| Key derivation | HKDF (Web Crypto) | HKDF + Argon2id |
| Key hierarchy | Master → Space → Doc | Flat (per-operation) |
| Sharing keys between users | Not implemented | ECIES key exchange |
| Security audit | No | No |
| License | Proprietary (rStack) | AGPL-3.0 |
### Gap Analysis
rSpace's DocCrypto lacks:
1. **Asymmetric encryption** — needed to share document keys between collaborators without a central server
2. **Key exchange** — ECIES allows encrypting a doc key for a specific collaborator's public key
3. **Password-derived keys** — Argon2id enables password-protected notes (useful for shared links)
### MIT Alternatives
The underlying primitives in `@fileverse/crypto` are all MIT-licensed:
- **Noble curves** (`@noble/curves`) — ECIES elliptic curve operations
- **StableLib** (`@stablelib/x25519`, `@stablelib/hkdf`) — Key exchange and derivation
- **TweetNaCl** (`tweetnacl`) — SecretBox symmetric encryption
- **argon2-browser** — Argon2id in WASM
**Recommendation:** Build a thin wrapper around MIT libs, matching `@fileverse/crypto`'s API where useful. This avoids AGPL while gaining all the cryptographic capabilities.
### Tasks
- [ ] Benchmark NaCl SecretBox vs AES-256-GCM (performance + bundle size)
- [ ] Implement ECIES key exchange using Noble curves
- [ ] Add Argon2id for password-protected notes
- [ ] Test interop: can we decrypt Fileverse-encrypted content?
---
## Phase 2: IPFS File Storage
**Status:** Not started
**Effort:** 35 days
**Target:** Replace centralized file uploads with IPFS-backed storage
**Resolves:** rNotes TASK-4 (file/image upload)
### Architecture
```
Client Server/IPFS
│ │
├─ Pick file │
├─ Encrypt (SecretBox) │
├─ Upload encrypted blob ───────►│ Store on IPFS
│◄──────── Return CID ──────────┤
├─ Store CID + key in │
│ document metadata │
│ │
├─ To view: fetch CID ─────────►│ Return encrypted blob
│◄──────────────────────────────┤
├─ Decrypt locally │
└─ Display │
```
### Options
#### A. Use Fileverse's storage service
- Requires UCAN tokens for authorization
- Depends on Fileverse infrastructure
- No self-hosting option documented
#### B. Self-host IPFS node on Netcup
- Full control over storage
- `kubo` (go-ipfs) or `helia` (JS)
- Pin encrypted blobs, serve via gateway
- CIDs stored in Automerge documents
#### C. Use Pinata/web3.storage
- Managed IPFS pinning
- Free tiers available
- Less operational overhead
**Recommendation:** Start with option C (managed pinning) for POC, migrate to B (self-hosted) for production.
### Tasks
- [ ] Set up Pinata account and API keys (store in Infisical)
- [ ] Build upload service: encrypt → pin → return CID
- [ ] Build retrieval service: fetch CID → decrypt → serve
- [ ] Add TipTap image extension that uses IPFS CIDs
- [ ] Test with rNotes note editor
---
## Phase 3: Collaboration Server
**Status:** Not started
**Effort:** 35 days
**Target:** Self-host Fileverse's collaboration-server on Netcup
### Requirements
- Node.js runtime
- MongoDB (temporary update storage)
- Redis (session management)
- WebSocket support via Traefik
### Deployment Plan
```yaml
# docker-compose.yml on Netcup
services:
collab-server:
image: node:20-slim
# Build from github.com/fileverse/collaboration-server
environment:
PORT: 5000
MONGODB_URI: mongodb://collab-mongo:27017/collab
CORS_ORIGINS: "https://rnotes.jeffemmett.com,https://rspace.jeffemmett.com"
labels:
- "traefik.enable=true"
- "traefik.http.routers.collab.rule=Host(`collab.jeffemmett.com`)"
- "traefik.http.routers.collab.tls.certresolver=letsencrypt"
depends_on:
- collab-mongo
- collab-redis
collab-mongo:
image: mongo:7
volumes:
- collab-mongo-data:/data/db
collab-redis:
image: redis:7-alpine
volumes:
- collab-redis-data:/data
```
### Integration with rSpace
The collaboration-server uses Y.js sync protocol. rSpace already has Y.js for TipTap. Integration points:
1. Connect TipTap's `y-prosemirror` binding to collab-server WebSocket
2. Use collab-server's awareness protocol for cursor presence
3. Automerge sync continues separately for document state
### UCAN Auth Integration
Fileverse's collab-server uses UCAN tokens. Options:
1. **Adapt:** Generate UCANs from EncryptID DIDs (preferred)
2. **Bypass:** Fork server, replace UCAN auth with EncryptID JWT
3. **Bridge:** Proxy auth through an adapter service
### Tasks
- [ ] Fork collaboration-server, evaluate auth integration
- [ ] Create Docker Compose config for Netcup
- [ ] Deploy with Traefik routing
- [ ] Connect rSpace TipTap editor to collab-server
- [ ] Test real-time collaboration between two clients
---
## Phase 4: dSheet as rSheet Module
**Status:** Not started
**Effort:** 57 days
**Target:** Integrate `@fileverse-dev/dsheet` as a new rStack module
### Architecture
```
rstack-online/
└── modules/
└── rsheet/ # New module
├── schemas.ts # Automerge schemas for spreadsheet metadata
├── components/
│ └── folk-sheet-app.ts # LitElement wrapper around dSheet
├── local-first-client.ts # Sync integration
└── converters/
├── csv.ts # CSV import/export
└── xlsx.ts # Excel import/export
```
### dSheet Component Props
```typescript
<DSheet
isAuthorized={true}
dsheetId="room-id" // Collaboration room
enableWebrtc={true} // P2P sync
enableIndexeddbSync={true} // Offline persistence
isCollaborative={true} // Multi-user
onChange={handleChange} // Data callback
/>
```
### Integration Points
- Use rSpace's EncryptID auth for `isAuthorized`
- Generate `dsheetId` from Automerge document ID
- Route WebRTC signaling through self-hosted collab-server
- Store spreadsheet metadata in Automerge (title, permissions, CID)
- Store spreadsheet data in IndexedDB (via dSheet's built-in support)
### Use Cases for rStack
- DAO treasury tracking (live blockchain data queries)
- Token allocation spreadsheets
- Budget planning with E2E encryption
- Research data tables alongside rNotes
### Tasks
- [ ] Create rSheet module scaffold in rspace-online
- [ ] Wrap dSheet React component in LitElement
- [ ] Wire auth and collaboration
- [ ] Add CSV/XLSX import/export converters
- [ ] Deploy and test
---
## Phase 5: Advanced — UCAN + Decentralized Identity
**Status:** Future
**Effort:** 5+ days
### Goals
- Complement EncryptID with UCAN capability tokens
- Decentralized authorization without central server
- Fine-grained permissions: read, write, share, admin per document
### UCAN Flow
```
User (EncryptID DID) → Mint UCAN → Delegate to collaborator
Collaborator presents UCAN to:
- Collaboration server (real-time sync)
- Storage server (file uploads)
- IPFS gateway (content retrieval)
```
---
## Timeline
| Phase | Duration | Dependencies |
|-------|----------|-------------|
| 1. Crypto evaluation | 12 days | None |
| 2. IPFS storage | 35 days | Phase 1 |
| 3. Collab server | 35 days | None (parallel with Phase 2) |
| 4. dSheet module | 57 days | Phase 3 |
| 5. UCAN auth | 5+ days | Phases 2, 3 |

72
docs/yjs-vs-automerge.md Normal file
View File

@ -0,0 +1,72 @@
# Y.js vs Automerge: Decision for rStack + Fileverse Integration
## Current State
rSpace uses **both** frameworks in a layered architecture:
```
┌─────────────────────────────────────────┐
│ TipTap Editor │
│ ↕ y-prosemirror binding │
│ ↕ Y.js (editor collaboration) │ ← Fileverse integrates HERE
├─────────────────────────────────────────┤
│ Automerge (document state + sync) │ ← State management stays HERE
│ ↕ WebSocket binary sync protocol │
│ ↕ IndexedDB encrypted persistence │
└─────────────────────────────────────────┘
```
## Why This Dual Approach Works
### Y.js Layer (Editor)
- `y-prosemirror`: Official TipTap/ProseMirror binding for real-time editing
- `y-indexeddb`: Client-side persistence for editor state
- Awareness protocol: Cursor positions, user presence
- **This is what Fileverse's ddoc and collaboration-server use**
### Automerge Layer (State)
- Document metadata, permissions, notebook structure
- Binary sync protocol over WebSocket
- Encrypted storage with DocCrypto
- Migration path from PostgreSQL
### Integration Surface
Fileverse components connect at the Y.js layer:
- `collaboration-server` speaks Y.js sync protocol over WebSocket
- `@fileverse-dev/ddoc` uses `y-prosemirror` internally
- `@fileverse-dev/dsheet` uses Y.js for cell sync
The Automerge layer is invisible to Fileverse — it handles higher-level state that Fileverse doesn't need to know about.
## Decision
**Keep the dual architecture.** No migration needed.
### Rationale
1. **No conflict** — Y.js and Automerge operate at different levels
2. **Fileverse compatibility** — Their components plug into the Y.js layer directly
3. **Automerge strengths preserved** — Better merge semantics for structured data, Rust/WASM performance for large documents
4. **Migration cost: zero** — No existing code needs to change
### What This Means for Each Integration
| Integration | CRDT Layer | Notes |
|-------------|-----------|-------|
| E2E encryption | Neither (operates on raw bytes) | Encrypts before CRDT, decrypts after |
| IPFS storage | Neither (file blobs) | CIDs stored in Automerge metadata |
| Collab server | Y.js | Handles editor sync + awareness |
| dSheet | Y.js | Self-contained Y.js-based component |
| UCAN auth | Neither (auth layer) | Wraps transport, not CRDTs |
## Alternative Considered: Full Y.js Migration
If we were starting fresh, using Y.js for everything would be simpler. But the migration cost outweighs the benefit:
- Automerge schemas, storage, and sync code in rSpace would all need rewriting
- The PostgreSQL → Automerge migration tool would be wasted
- Automerge's typed document schemas are more ergonomic than Y.js's untyped maps/arrays
## Conclusion
The existing architecture is already well-positioned for Fileverse integration. No CRDT migration needed. Focus integration efforts on the Y.js/TipTap layer where Fileverse components naturally connect.

View File

@ -0,0 +1,24 @@
# Build from Fileverse collaboration-server source
# https://github.com/fileverse/collaboration-server
FROM node:20-slim AS builder
WORKDIR /app
# Clone and build collaboration-server
RUN apt-get update && apt-get install -y git && \
git clone --depth 1 https://github.com/fileverse/collaboration-server.git . && \
npm ci && \
npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 5000
CMD ["node", "dist/index.js"]

View File

@ -0,0 +1,33 @@
# Self-Hosted Collaboration Server
Fileverse's [collaboration-server](https://github.com/fileverse/collaboration-server) deployed on Netcup for rStack real-time collaboration.
## Architecture
```
rSpace Client ──WebSocket──► Traefik ──► collab-server ──► MongoDB (ephemeral)
──► Redis (sessions)
```
## Deployment
```bash
# On Netcup
cd /opt/apps/collab-server
docker compose up -d
# Verify
curl https://collab.jeffemmett.com/health
```
## Auth Integration
The collaboration-server uses UCAN tokens. For rStack integration, we need to either:
1. Generate UCAN tokens from EncryptID DIDs
2. Fork and replace auth with EncryptID JWT
See `docs/integration-plan.md` Phase 3 for details.
## Status
Not started — Docker Compose is prepared, needs testing after DNS setup.

View File

@ -0,0 +1,65 @@
# Fileverse Collaboration Server — Self-hosted on Netcup
# Y.js WebSocket relay for real-time document collaboration
#
# Deploy: scp to Netcup, docker compose up -d
# Requires: Traefik network, DNS for collab.jeffemmett.com
services:
collab-server:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
environment:
PORT: 5000
HOST: 0.0.0.0
NODE_ENV: production
MONGODB_URI: mongodb://collab-mongo:27017/collab
REDIS_URL: redis://collab-redis:6379
CORS_ORIGINS: "https://rnotes.jeffemmett.com,https://rspace.jeffemmett.com,http://localhost:3000"
# SERVER_DID and other secrets via Infisical
INFISICAL_CLIENT_ID: ${INFISICAL_CLIENT_ID}
INFISICAL_CLIENT_SECRET: ${INFISICAL_CLIENT_SECRET}
networks:
- proxy
- collab-internal
labels:
- "traefik.enable=true"
# HTTP router
- "traefik.http.routers.collab.rule=Host(`collab.jeffemmett.com`)"
- "traefik.http.routers.collab.entrypoints=websecure"
- "traefik.http.routers.collab.tls.certresolver=letsencrypt"
- "traefik.http.services.collab.loadbalancer.server.port=5000"
# WebSocket support
- "traefik.http.middlewares.collab-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.collab.middlewares=collab-headers"
depends_on:
- collab-mongo
- collab-redis
collab-mongo:
image: mongo:7
restart: unless-stopped
volumes:
- collab-mongo-data:/data/db
networks:
- collab-internal
collab-redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- collab-redis-data:/data
networks:
- collab-internal
networks:
proxy:
external: true
collab-internal:
driver: bridge
volumes:
collab-mongo-data:
collab-redis-data:

View File

@ -0,0 +1,25 @@
{
"name": "@rstack/crypto-eval",
"version": "0.1.0",
"private": true,
"description": "Evaluate @fileverse/crypto vs MIT primitives for rStack encryption",
"type": "module",
"scripts": {
"benchmark": "tsx src/benchmark.ts",
"test:fileverse": "tsx src/test-fileverse-crypto.ts",
"test:mit": "tsx src/test-mit-crypto.ts",
"test:interop": "tsx src/test-interop.ts",
"test": "tsx src/run-all-tests.ts"
},
"dependencies": {
"@fileverse/crypto": "^0.0.1",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
"argon2-browser": "^1.18.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,150 @@
/**
* Benchmark: AES-256-GCM (Web Crypto) vs NaCl SecretBox
* Tests encryption/decryption performance across payload sizes
*/
import {
aesEncrypt,
aesDecrypt,
generateSymmetricKey,
generateKeyPair,
encryptForRecipient,
decryptFromSender,
shareDocKey,
receiveDocKey,
} from './mit-crypto.js'
const encoder = new TextEncoder()
const decoder = new TextDecoder()
async function benchmark(name: string, fn: () => Promise<void>, iterations: number = 1000) {
// Warmup
for (let i = 0; i < 10; i++) await fn()
const start = performance.now()
for (let i = 0; i < iterations; i++) await fn()
const elapsed = performance.now() - start
console.log(`${name}: ${(elapsed / iterations).toFixed(3)}ms avg (${iterations} iterations)`)
}
async function main() {
console.log('=== rStack Crypto Primitives Benchmark ===\n')
// ─── Symmetric Encryption ───
console.log('--- Symmetric Encryption (AES-256-GCM) ---')
const key = generateSymmetricKey()
for (const size of [100, 1_000, 10_000, 100_000]) {
const data = new Uint8Array(size)
crypto.getRandomValues(data)
await benchmark(` Encrypt ${size.toLocaleString()}B`, async () => {
await aesEncrypt(key, data)
})
const encrypted = await aesEncrypt(key, data)
await benchmark(` Decrypt ${size.toLocaleString()}B`, async () => {
await aesDecrypt(key, encrypted)
})
}
// ─── Key Exchange ───
console.log('\n--- Key Exchange (x25519 ECDH) ---')
const alice = generateKeyPair()
const bob = generateKeyPair()
await benchmark(' Generate key pair', async () => {
generateKeyPair()
})
// ─── ECIES Encryption ───
console.log('\n--- ECIES Encryption (x25519 + AES-256-GCM) ---')
const message = encoder.encode('This is a secret document encryption key - 32 bytes!')
await benchmark(' Encrypt for recipient', async () => {
await encryptForRecipient(message, bob.publicKey, alice.privateKey)
})
const { ciphertext, ephemeralPublicKey } = await encryptForRecipient(
message, bob.publicKey, alice.privateKey
)
await benchmark(' Decrypt from sender', async () => {
await decryptFromSender(ciphertext, ephemeralPublicKey, bob.privateKey)
})
// ─── Document Key Sharing ───
console.log('\n--- Document Key Sharing ---')
const docKey = generateSymmetricKey()
await benchmark(' Share doc key', async () => {
await shareDocKey(docKey, bob.publicKey, alice.privateKey)
})
const shared = await shareDocKey(docKey, bob.publicKey, alice.privateKey)
await benchmark(' Receive doc key', async () => {
await receiveDocKey(shared.encryptedKey, shared.ephemeralPublicKey, bob.privateKey)
})
// ─── End-to-End Roundtrip ───
console.log('\n--- Full Roundtrip: Note Encryption + Key Sharing ---')
const noteContent = encoder.encode(JSON.stringify({
title: 'Secret Meeting Notes',
content: '<p>Discussion about token allocation...</p>'.repeat(100),
tags: ['dao', 'treasury', 'private'],
}))
await benchmark(' Full encrypt + share flow', async () => {
// 1. Generate doc key
const dk = generateSymmetricKey()
// 2. Encrypt content
const enc = await aesEncrypt(dk, noteContent)
// 3. Share key with collaborator
await shareDocKey(dk, bob.publicKey, alice.privateKey)
})
await benchmark(' Full receive + decrypt flow', async () => {
// Simulated received data
const dk = generateSymmetricKey()
const enc = await aesEncrypt(dk, noteContent)
const sk = await shareDocKey(dk, bob.publicKey, alice.privateKey)
// Actual measured flow
const receivedKey = await receiveDocKey(sk.encryptedKey, sk.ephemeralPublicKey, bob.privateKey)
await aesDecrypt(receivedKey, enc)
})
// ─── Correctness Check ───
console.log('\n--- Correctness Verification ---')
// Symmetric roundtrip
const testData = encoder.encode('Hello, encrypted world!')
const testKey = generateSymmetricKey()
const testEncrypted = await aesEncrypt(testKey, testData)
const testDecrypted = await aesDecrypt(testKey, testEncrypted)
const symOk = decoder.decode(testDecrypted) === 'Hello, encrypted world!'
console.log(` Symmetric roundtrip: ${symOk ? 'PASS' : 'FAIL'}`)
// ECIES roundtrip
const kp1 = generateKeyPair()
const kp2 = generateKeyPair()
const plaintext = encoder.encode('Secret message for key pair 2')
const { ciphertext: ct, ephemeralPublicKey: epk } = await encryptForRecipient(
plaintext, kp2.publicKey, kp1.privateKey
)
const decrypted = await decryptFromSender(ct, epk, kp2.privateKey)
const eciesOk = decoder.decode(decrypted) === 'Secret message for key pair 2'
console.log(` ECIES roundtrip: ${eciesOk ? 'PASS' : 'FAIL'}`)
// Doc key sharing roundtrip
const origKey = generateSymmetricKey()
const shareResult = await shareDocKey(origKey, kp2.publicKey, kp1.privateKey)
const recoveredKey = await receiveDocKey(
shareResult.encryptedKey, shareResult.ephemeralPublicKey, kp2.privateKey
)
const keyOk = origKey.every((b, i) => b === recoveredKey[i])
console.log(` Doc key sharing roundtrip: ${keyOk ? 'PASS' : 'FAIL'}`)
console.log('\nDone.')
}
main().catch(console.error)

View File

@ -0,0 +1,154 @@
/**
* MIT-licensed crypto primitives for rStack
* Equivalent functionality to @fileverse/crypto without AGPL obligations
*
* Dependencies (all MIT):
* - @noble/curves: ECDH key exchange (x25519)
* - @noble/hashes: HKDF key derivation, SHA-256
* - tweetnacl: SecretBox symmetric encryption (for comparison)
* - Web Crypto API: AES-256-GCM (native, zero bundle cost)
*/
import { x25519 } from '@noble/curves/ed25519'
import { hkdf } from '@noble/hashes/hkdf'
import { sha256 } from '@noble/hashes/sha256'
import { randomBytes } from '@noble/hashes/utils'
// ─── Symmetric Encryption (AES-256-GCM via Web Crypto) ───
export async function aesEncrypt(
key: Uint8Array,
plaintext: Uint8Array
): Promise<Uint8Array> {
const iv = randomBytes(12)
const cryptoKey = await crypto.subtle.importKey(
'raw', key, 'AES-GCM', false, ['encrypt']
)
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
plaintext
)
// Format: iv (12) || ciphertext+tag
const result = new Uint8Array(12 + ciphertext.byteLength)
result.set(iv)
result.set(new Uint8Array(ciphertext), 12)
return result
}
export async function aesDecrypt(
key: Uint8Array,
encrypted: Uint8Array
): Promise<Uint8Array> {
const iv = encrypted.slice(0, 12)
const ciphertext = encrypted.slice(12)
const cryptoKey = await crypto.subtle.importKey(
'raw', key, 'AES-GCM', false, ['decrypt']
)
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
ciphertext
)
return new Uint8Array(plaintext)
}
// ─── Key Generation ───
export function generateSymmetricKey(): Uint8Array {
return randomBytes(32)
}
export function generateKeyPair(): { publicKey: Uint8Array; privateKey: Uint8Array } {
const privateKey = randomBytes(32)
const publicKey = x25519.getPublicKey(privateKey)
return { publicKey, privateKey }
}
// ─── ECDH Key Exchange (x25519) ───
export function deriveSharedSecret(
myPrivateKey: Uint8Array,
theirPublicKey: Uint8Array
): Uint8Array {
return x25519.getSharedSecret(myPrivateKey, theirPublicKey)
}
// ─── HKDF Key Derivation ───
export function deriveKey(
ikm: Uint8Array,
salt: Uint8Array | null,
info: string,
length: number = 32
): Uint8Array {
return hkdf(sha256, ikm, salt ?? undefined, info, length)
}
// ─── Key Hierarchy (matches DocCrypto pattern) ───
export function deriveSpaceKey(masterKey: Uint8Array, spaceId: string): Uint8Array {
return deriveKey(masterKey, null, `rstack:space:${spaceId}`)
}
export function deriveDocKey(spaceKey: Uint8Array, docId: string): Uint8Array {
return deriveKey(spaceKey, null, `rstack:doc:${docId}`)
}
// ─── ECIES-like Encryption (encrypt for a specific recipient) ───
export async function encryptForRecipient(
plaintext: Uint8Array,
recipientPublicKey: Uint8Array,
senderPrivateKey: Uint8Array
): Promise<{ ciphertext: Uint8Array; ephemeralPublicKey: Uint8Array }> {
// Generate ephemeral key pair for forward secrecy
const ephemeral = generateKeyPair()
// Derive shared secret from ephemeral private + recipient public
const shared = deriveSharedSecret(ephemeral.privateKey, recipientPublicKey)
const encryptionKey = deriveKey(shared, null, 'rstack:ecies:encrypt')
// Encrypt with derived key
const ciphertext = await aesEncrypt(encryptionKey, plaintext)
return {
ciphertext,
ephemeralPublicKey: ephemeral.publicKey,
}
}
export async function decryptFromSender(
ciphertext: Uint8Array,
ephemeralPublicKey: Uint8Array,
recipientPrivateKey: Uint8Array
): Promise<Uint8Array> {
// Derive same shared secret from ephemeral public + recipient private
const shared = deriveSharedSecret(recipientPrivateKey, ephemeralPublicKey)
const encryptionKey = deriveKey(shared, null, 'rstack:ecies:encrypt')
return aesDecrypt(encryptionKey, ciphertext)
}
// ─── Document Key Sharing ───
export async function shareDocKey(
docKey: Uint8Array,
recipientPublicKey: Uint8Array,
senderPrivateKey: Uint8Array
): Promise<{ encryptedKey: Uint8Array; ephemeralPublicKey: Uint8Array }> {
const { ciphertext, ephemeralPublicKey } = await encryptForRecipient(
docKey,
recipientPublicKey,
senderPrivateKey
)
return { encryptedKey: ciphertext, ephemeralPublicKey }
}
export async function receiveDocKey(
encryptedKey: Uint8Array,
ephemeralPublicKey: Uint8Array,
recipientPrivateKey: Uint8Array
): Promise<Uint8Array> {
return decryptFromSender(encryptedKey, ephemeralPublicKey, recipientPrivateKey)
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,25 @@
# dSheet Embed POC
Prototype for embedding `@fileverse-dev/dsheet` as a new rStack module (rSheet).
## Goal
Wrap Fileverse's decentralized spreadsheet component in a LitElement web component compatible with rSpace's module system.
## Key Integration Points
- Auth: EncryptID → `isAuthorized` prop
- Collaboration: WebRTC via self-hosted collab-server
- Persistence: IndexedDB (`enableIndexeddbSync`)
- Metadata: Stored in Automerge documents
## Use Cases
- DAO treasury tracking
- Token allocation spreadsheets
- Budget planning with E2E encryption
- Research data tables alongside rNotes
## Status
Not started — depends on Phase 3 (collab server deployment).

View File

@ -0,0 +1,36 @@
# IPFS File Storage POC
Proof-of-concept for encrypted file uploads to IPFS, replacing centralized storage for rNotes.
## Flow
```
1. User picks file in TipTap editor
2. Client generates per-file symmetric key
3. Client encrypts file with AES-256-GCM
4. Encrypted blob uploaded to IPFS (via Pinata or self-hosted kubo)
5. CID + encrypted file key stored in Automerge document metadata
6. To view: fetch CID → decrypt with file key → display
```
## Setup
```bash
# Using Pinata (managed IPFS pinning)
# Store API keys in Infisical: infisical-project=fileverse, path=/ipfs
export PINATA_API_KEY=...
export PINATA_SECRET_KEY=...
npm install
npm run test
```
## Files
- `src/ipfs-client.ts` — Upload/download encrypted files to IPFS
- `src/tiptap-image-extension.ts` — TipTap extension for IPFS-backed images
- `src/test.ts` — End-to-end test: encrypt → upload → download → decrypt
## Status
Not started — waiting on Phase 1 crypto evaluation results.