Skip to content

feat: rclone-crypt folder vault PoC#2566

Draft
dschmidt wants to merge 6 commits into
mainfrom
feat/e2ee
Draft

feat: rclone-crypt folder vault PoC#2566
dschmidt wants to merge 6 commits into
mainfrom
feat/e2ee

Conversation

@dschmidt
Copy link
Copy Markdown
Contributor

@dschmidt dschmidt commented May 20, 2026

⚠️ This PR explores feasibility. Nothing here is set in stone.
Function names, type names, extension point IDs, file layout, the shape of
the public surface, all of it is subject to change. DRY was explicitly not
an objective for this PoC, the goal was wiring the vault story end to end
to learn where the seams actually fall. Treat this as a proposal, not a
proposed merge.

Summary

Adds a folderVault extension type and the first implementation
(web-app-rclone-crypt) that surfaces rclone-crypt
compatible vaults in the OpenCloud Web UI under cleartext names. End users see
a *.vault folder, unlock it once per session via a passphrase, and read,
edit, upload and delete content as if it were unencrypted, all crypto happens
in the browser.

Plugin surface

  • FolderVaultExtension, pluggable filename/path/content (de)cryption engine
  • ResourceIndicatorExtension, pluggable per resource status indicators
  • in memory passphrase store (lost on reload, by design)
  • router level unlock guard mirroring the public link flow
  • empty vault create passphrase UX
  • lock vault / unlock vault context actions

Cross cutting integrations

Vault awareness wired into listing (loaderSpace), AppWrapper (load/save),
single file upload, drag drop directory tree upload, create file,
create folder, delete, SSE postprocessing, route guard.

Test coverage

Cucumber scenarios under tests/e2e/cucumber/features/rclone-crypt/ drive an
rclone CLI fixture to verify read, edit save, single file upload and drag
drop upload, all decrypt back to expected cleartext via the rclone CLI as a
ground truth oracle.

Notes

Known FIXMEs scattered:

  • content encrypt/decrypt buffers the entire payload before emitting
  • vault awareness lives in every caller rather than behind a single
    decorator layer
  • hardcoded foobar is gone but the unlock flow could grow more checks

With the touch points now known, the right helpers and abstractions (likely a
webdav decorator or folder service decorator layer that absorbs the path /
content translation) will be evaluated next.

dschmidt added 2 commits May 20, 2026 12:46
Introduces a `folderVault` extension type so apps can transparently encrypt
and decrypt the names and contents of folders flagged as vaults, plus the
first implementation (`web-app-rclone-crypt`) that surfaces rclone-crypt
compatible vaults under cleartext names.

Wires vault-awareness into listing, AppWrapper, single-file and drag-drop
upload, create-file, create-folder and delete. Adds a runtime-level unlock
guard that mirrors the public-link flow, an in-memory passphrase store,
empty-vault create-passphrase UX and a lock-vault context action. Covered
by cucumber e2e scenarios that drive an rclone CLI fixture.
dschmidt added 4 commits May 20, 2026 12:58
Replaces the hand-rolled collectStream loops and single-emit ReadableStream
wrappings in every vault encrypt/decrypt call site with two small helpers
(streamToArrayBuffer, streamToBlob) plus Blob.stream() for input. The
engine's streaming contract stays unchanged; only the call sites get tidier
and the future "swap engine for real block streaming" lands without
touching them.
- useResourceIndicators: tolerate test mocks that omit requestExtensions
- useFileActions: silently bail in openEditor when no route exists
  (instead of filtering the action out, which broke route-null tests)
- useFileActionsCreateNewFile: null-safe appFileExtension access
- useFileActionsDeleteResources: drop the sync/async zalgo path, always
  return Promises; matching tests now await
- web-test-helpers defaultComponentMocks: hasRoute defaults to true so
  the overwhelming majority of suites get the expected behaviour
- web-app-preview: only opt out of preview service on explicit
  hasPreview() === false, preserve legacy behaviour for missing method
- web-app-rclone-crypt: drop unused imports
…c DragEvent

Headless Chrome silently swallows `new DragEvent(...).dispatchEvent(...)`
fired from inside `page.evaluate`, so the drag-drop upload scenario
worked headed and failed headless. Switching the helper to
`locator.dispatchEvent('drop', { dataTransfer })` routes the event
through CDP, where both modes behave the same way.

dataTransfer is built in the page via `evaluateHandle(new DataTransfer())`
so File objects and webkitRelativePath stay intact.
Comment on lines +166 to +170
if (vaultEngine) {
for (const child of children) {
await decryptResourceInPlace(vaultEngine, child)
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this happen in parallel? And I guess it should be done one the filtered newResources array below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, true! The api should handle a list, depending on the encryption implementation we can implement it much more efficiently that way

router.push({
name: 'files-spaces-generic',
params: { driveAliasAndItem },
query: resource.fileId ? { fileId: resource.fileId as string } : undefined
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
query: resource.fileId ? { fileId: resource.fileId as string } : undefined
query: resource.fileId ? { fileId: resource.fileId } : undefined

Comment on lines +11 to +15
// FIXME(poc-vault): this lookup currently lives next to the loaders and the
// AppWrapper. Once we lift vault-aware translation onto a higher layer (e.g.
// a webdav/client decorator or a folderService decorator), callers stop
// needing to resolve the engine themselves.
export function resolveFolderVault(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only resolves unlocked vaults, right? Could you clarify this in the doc string?

handler: ({ resources }: FileActionOptions) => {
const resource = resources?.[0]
if (!resource || !isVaultRoot(resource)) return
const spaceId = resource.storageId as string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const spaceId = resource.storageId as string
const spaceId = resource.storageId

isVisible: ({ resources }: FileActionOptions) => {
const resource = resources?.[0]
if (!resource || !isVaultRoot(resource)) return false
return vaultStore.isUnlocked(resource.storageId as string, resource.path)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return vaultStore.isUnlocked(resource.storageId as string, resource.path)
return vaultStore.isUnlocked(resource.storageId, resource.path)

if (!password) {
return null
}
return createEngine(space.id as string, vaultRoot, password)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return createEngine(space.id as string, vaultRoot, password)
return createEngine(space.id, vaultRoot, password)

return null
}
const vaultStore = useFolderVaultStore()
const password = vaultStore.getSecret<string>(space.id as string, vaultRoot)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const password = vaultStore.getSecret<string>(space.id as string, vaultRoot)
const password = vaultStore.getSecret<string>(space.id, vaultRoot)

Comment on lines +128 to +130
for (const child of children) {
await decryptResourceInPlace(vaultEngine, child)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parallel possible?

Comment on lines +118 to +129
// The editor's view may already be torn down by tiptap's own
// onBeforeUnmount hook by the time we run, depending on hook
// ordering. In that case `serialize` blows up reading a null schema.
// Flush the pending update if it's still safe, otherwise drop it —
// throwing here would break the rest of Vue's teardown.
const inst = editor.value as unknown as { view?: { state?: unknown } } | null
if (options.onUpdate && editor.value && inst?.view?.state) {
try {
options.onUpdate(strategy.serialize(editor.value))
} catch (e) {
console.warn('[useTextEditor] dropped pending update during destroy', e)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been fixed on current main meanwhile, I belive.

Comment on lines +88 to +90
for (const child of resources) {
yield* call(decryptResourceInPlace(vaultEngine, child))
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parallel possible?

@JammingBen
Copy link
Copy Markdown
Member

List of to-dos:

  • Disable move/copy/paste for vaults
  • Conflict handling inside vaults does't work (conflict dialog never pops up)
  • Change "New file.value" to "New vault.vault" when creating a vault
  • Disable external apps like Collabora in vaults
  • Make Rename possible
  • Resource Name in activities need to be decrypted
  • Disable favorites in vaults
  • Disable "Create space from resource" in vaults and for vaults
  • Delete undo inside a vault needs a reload for the resource to be decrypted and displayed correctly
  • "Current folder" search options in vaults doesn't make sense since search doesn't work in general for vault resources
  • Sharing a vault? Disable for now?

@butonic
Copy link
Copy Markdown
Member

butonic commented May 26, 2026

Can we split this into a part with the changes to web and move the rest into a dedicated extension?

@dschmidt
Copy link
Copy Markdown
Contributor Author

Can we split this into the a part with the changes to web and move the rest into a dedicated extension?

We can, but I don't recommend it at this point. I would strongly suggest to keep it in web while it matures

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants