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:
commit
f0b1096404
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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) | 0–7KB |
|
||||||
|
| 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).
|
||||||
|
|
@ -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:** 1–2 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:** 3–5 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:** 3–5 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:** 5–7 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 | 1–2 days | None |
|
||||||
|
| 2. IPFS storage | 3–5 days | Phase 1 |
|
||||||
|
| 3. Collab server | 3–5 days | None (parallel with Phase 2) |
|
||||||
|
| 4. dSheet module | 5–7 days | Phase 3 |
|
||||||
|
| 5. UCAN auth | 5+ days | Phases 2, 3 |
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue