From eebecb14793eb4e540e469413232edc6c4f8f8b9 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 9 Jun 2026 18:22:33 +0200 Subject: [PATCH] import remaining e2ee functionality --- .gitignore | 1 + .woodpecker.star | 36 ++- packages/web-app-files/src/HandleUpload.ts | 84 +++-- .../src/components/AppBar/CreateAndUpload.vue | 32 +- .../components/SideBar/ActivitiesPanel.vue | 61 +++- .../components/SideBar/Shares/FileLinks.vue | 7 + .../src/composables/actions/files/index.ts | 1 + .../actions/files/useFileActionsCreateLink.ts | 8 + .../files/useFileActionsCreateNewFile.ts | 49 +-- .../files/useFileActionsCreateNewFolder.ts | 17 +- .../useFileActionsCreateSpaceFromResource.ts | 2 +- .../files/useFileActionsDownloadArchive.ts | 6 + .../actions/files/useFileActionsRename.ts | 14 + .../actions/files/useFileActionsVaultLock.ts | 145 +++++++++ .../composables/extensions/useFileActions.ts | 24 +- .../extensions/useFolderVaultIndicator.ts} | 30 +- .../useKeyboardFileSpaceActions.ts | 14 +- packages/web-app-files/src/extensions.ts | 3 + .../src/views/spaces/GenericSpace.vue | 20 ++ .../tests/unit/HandleUpload.spec.ts | 26 ++ .../components/AppBar/CreateAndUpload.spec.ts | 18 +- .../SideBar/ActivitiesPanel.spec.ts | 66 +++- .../SideBar/Details/FileDetails.spec.ts | 1 + .../SideBar/Shares/FileLinks.spec.ts | 9 +- .../files/useFileActionsCreateLink.spec.ts | 13 +- .../files/useFileActionsRename.spec.ts | 21 ++ .../files/useFileActionsVaultLock.spec.ts | 125 ++++++++ .../useFolderVaultIndicator.spec.ts | 67 ++++ .../useKeyboardFileSpaceActions.spec.ts | 98 ++++++ packages/web-app-preview/src/App.vue | 16 +- .../web-app-preview/tests/unit/app.spec.ts | 42 ++- packages/web-app-rclone-crypt/package.json | 2 +- .../web-app-rclone-crypt/src/crypto/engine.ts | 113 ++----- .../src/extensions/folderVault.ts | 4 +- .../src/extensions/lockVault.ts | 119 ------- packages/web-app-rclone-crypt/src/index.ts | 18 +- .../web-app-rclone-crypt/src/resolveVault.ts | 43 +-- .../web-app-rclone-crypt/src/vaultPath.ts | 29 +- .../src/views/UnlockVault.vue | 64 +++- .../tests/unit/crypto/engine.spec.ts | 109 +++++++ .../tests/unit/resolveVault.spec.ts | 54 ++++ .../components/AppTemplates/AppWrapper.vue | 138 +-------- .../src/components/SearchBarFilter.vue | 4 +- .../actions/files/useFileActions.ts | 4 +- .../actions/files/useFileActionsRestore.ts | 31 +- .../actions/files/useFileActionsUndoDelete.ts | 30 +- .../helpers/useFileActionsDeleteResources.ts | 44 +-- .../appDefaults/useAppFileHandling.ts | 29 +- .../appDefaults/useAppFolderHandling.ts | 41 +-- .../composables/download/useDownloadFile.ts | 49 ++- .../piniaStores/extensionRegistry/types.ts | 60 +++- .../composables/piniaStores/folderVault.ts | 93 +++--- .../composables/resources/useLoadPreview.ts | 7 + .../src/composables/search/useSearch.ts | 34 +- packages/web-pkg/src/helpers/folderVault.ts | 181 ++++++----- packages/web-pkg/src/helpers/index.ts | 1 + packages/web-pkg/src/helpers/vaultEngine.ts | 51 +++ packages/web-pkg/src/helpers/vaultMimeType.ts | 40 +++ .../web-pkg/src/services/client/client.ts | 7 +- .../src/services/client/vaultWebDav.ts | 269 ++++++++++++++++ .../src/services/folder/folderService.ts | 4 +- .../folder/loaders/loaderFavorites.ts | 29 +- .../services/folder/loaders/loaderSpace.ts | 39 +-- .../web-pkg/src/services/uppy/uppyService.ts | 34 ++ .../components/sidebar/FileSideBar.spec.ts | 12 +- .../piniaStores/folderVault.spec.ts | 79 +++++ .../resources/useLoadPreview.spec.ts | 33 +- .../tests/unit/helpers/folderVault.spec.ts | 211 +++++++++++++ .../tests/unit/helpers/vaultEngine.spec.ts | 48 +++ .../tests/unit/services/client.spec.ts | 5 +- .../unit/services/client/vaultWebDav.spec.ts | 293 ++++++++++++++++++ .../web-runtime/src/container/bootstrap.ts | 3 - .../web-runtime/src/container/sse/files.ts | 24 +- .../web-runtime/src/container/sse/types.ts | 2 - packages/web-runtime/src/index.ts | 1 - .../src/router/setupVaultUnlockGuard.ts | 13 +- .../tests/unit/container/sse/files.spec.ts | 3 - .../tests/unit/container/sse/helpers.spec.ts | 3 - .../tests/unit/container/sse/shares.spec.ts | 3 - .../tests/unit/container/sse/spaces.spec.ts | 3 - .../unit/router/setupVaultUnlockGuard.spec.ts | 132 ++++++++ .../src/mocks/defaultComponentMocks.ts | 2 +- tests/e2e/cucumber/environment/index.ts | 22 ++ .../features/rclone-crypt/vault.feature | 66 ++++ tests/e2e/cucumber/steps/rcloneCrypt.ts | 199 +++++++++++- tests/e2e/support/utils/dragDrop.ts | 107 +++++-- tests/woodpecker/config-opencloud.json | 3 +- 87 files changed, 3183 insertions(+), 914 deletions(-) create mode 100644 packages/web-app-files/src/composables/actions/files/useFileActionsVaultLock.ts rename packages/{web-app-rclone-crypt/src/extensions/resourceIndicator.ts => web-app-files/src/composables/extensions/useFolderVaultIndicator.ts} (67%) create mode 100644 packages/web-app-files/tests/unit/composables/actions/files/useFileActionsVaultLock.spec.ts create mode 100644 packages/web-app-files/tests/unit/composables/extensions/useFolderVaultIndicator.spec.ts create mode 100644 packages/web-app-files/tests/unit/composables/keyboardActions/useKeyboardFileSpaceActions.spec.ts delete mode 100644 packages/web-app-rclone-crypt/src/extensions/lockVault.ts create mode 100644 packages/web-app-rclone-crypt/tests/unit/crypto/engine.spec.ts create mode 100644 packages/web-app-rclone-crypt/tests/unit/resolveVault.spec.ts create mode 100644 packages/web-pkg/src/helpers/vaultEngine.ts create mode 100644 packages/web-pkg/src/helpers/vaultMimeType.ts create mode 100644 packages/web-pkg/src/services/client/vaultWebDav.ts create mode 100644 packages/web-pkg/tests/unit/composables/piniaStores/folderVault.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/folderVault.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/vaultEngine.spec.ts create mode 100644 packages/web-pkg/tests/unit/services/client/vaultWebDav.spec.ts create mode 100644 packages/web-runtime/tests/unit/router/setupVaultUnlockGuard.spec.ts diff --git a/.gitignore b/.gitignore index 1534439eeeb..20d5431c4c3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ reports tests/opencloud tests/testing-app playwright-report +test-results /webpack.json /webpack-build-log.json diff --git a/.woodpecker.star b/.woodpecker.star index f74cbef4f07..bbf7ca1876a 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -97,6 +97,14 @@ config = { "app-store", ], }, + "rclone-crypt": { + "earlyFail": True, + "skip": False, + "rcloneNeeded": True, + "suites": [ + "rclone-crypt", + ], + }, "a11y": { "earlyFail": True, "skip": False, @@ -538,6 +546,31 @@ def unitTests(ctx): ], }] +def installRclone(params): + # The rclone-crypt e2e suite shells out to the rclone CLI to build encrypted + # vault fixtures on the backend and to verify decryption against the + # reference implementation. rclone is a single static binary; pin the + # version (crypt format stays reproducible) AND verify the archive against a + # pinned SHA256 before extracting, so a tampered or corrupted download fails + # the step instead of installing an unverified binary. + if not params["rcloneNeeded"]: + return [] + version = "v1.74.0" + archive = "rclone-%s-linux-amd64.zip" % version + + # SHA256 of the official archive, from https://downloads.rclone.org/v1.74.0/SHA256SUMS + sha256 = "61de0a78d8776fe3e080f8385ebe96d817f2ee6a6003fe36b2d9f3b49d3e36ea" + url = "https://downloads.rclone.org/%s/%s" % (version, archive) + return [ + "command -v unzip || (apt-get update && apt-get install -y unzip)", + "curl -fsSL %s -o /tmp/rclone.zip" % url, + # abort the step on any checksum mismatch, before we trust the archive + "echo '%s /tmp/rclone.zip' | sha256sum -c -" % sha256, + "unzip -j /tmp/rclone.zip '*/rclone' -d /usr/local/bin", + "chmod +x /usr/local/bin/rclone", + "rclone version", + ] + def e2eTests(ctx): default = { "skip": False, @@ -547,6 +580,7 @@ def e2eTests(ctx): "suites": [], "features": [], "tikaNeeded": False, + "rcloneNeeded": False, "federationServer": False, "failOnUncaughtConsoleError": False, "extraServerEnvironment": {}, @@ -658,7 +692,7 @@ def e2eTests(ctx): "name": "e2e-tests", "image": OC_CI_NODEJS, "environment": environment, - "commands": [ + "commands": installRclone(params) + [ command, ], }] + \ diff --git a/packages/web-app-files/src/HandleUpload.ts b/packages/web-app-files/src/HandleUpload.ts index 629b6672f41..e709c37964c 100644 --- a/packages/web-app-files/src/HandleUpload.ts +++ b/packages/web-app-files/src/HandleUpload.ts @@ -20,6 +20,7 @@ import { OcUppyMeta, OcUppyBody, resolveFolderVault, + encryptVaultPath, streamToBlob } from '@opencloud-eu/web-pkg' import { locationSpacesGeneric, UppyService } from '@opencloud-eu/web-pkg' @@ -304,8 +305,7 @@ export class HandleUpload extends BasePlugin async createDirectoryTree( filesToUpload: OcUppyFile[], uploadFolder: Resource, - mergedFolders: string[] = [], - vaultEngine: FolderVaultEngine | null = null + mergedFolders: string[] = [] ): Promise<{ filesToUpload: OcUppyFile[]; folderFiles: OcUppyFile[] }> { const { webdav } = this.clientService const space = unref(this.space) @@ -374,16 +374,8 @@ export class HandleUpload extends BasePlugin this.uppyService.publish('addedForUpload', [uppyFile]) try { - // Inside a vault every path segment we create on the server must - // be ciphertext. The directoryTree itself stays in cleartext for - // tracking purposes; we only translate the path right before the - // MKCOL call. - const cleartextPath = urlJoin(currentFolderPath, path) - const mkcolPath = vaultEngine - ? await vaultEngine.encryptPath(cleartextPath) - : cleartextPath const folder = await webdav.createFolder(space, { - path: mkcolPath, + path: urlJoin(currentFolderPath, path), fetchFolder: isRoot // FIXME: remove once we get the fileId from the server here }) this.uppyService.publish('uploadSuccess', { @@ -445,20 +437,6 @@ export class HandleUpload extends BasePlugin const uploadFolder = this.getUploadFolder(uploadId) let filesToUpload = this.prepareFiles(files, uploadFolder) - // Vault-aware upload: when the target folder lives inside a vault, swap - // every file's content for its ciphertext and rewrite the upload endpoint - // to the encrypted server path before Uppy starts pushing bytes. Folder - // creation (MKCOL) inside createDirectoryTree is vault-aware too — it - // pulls the engine out of the same registry. - const vaultEngine = resolveFolderVault( - this.extensionRegistry, - unref(this.space), - uploadFolder?.path - ) - if (vaultEngine) { - filesToUpload = await this.applyVaultEncryption(filesToUpload, uploadFolder, vaultEngine) - } - if (!this.directoryTreeCreateEnabled) { // if directory tree creation is disabled, we need to remove all folder files // from the upload queue (both locally and from Uppy's state to prevent upload attempts) @@ -511,15 +489,29 @@ export class HandleUpload extends BasePlugin } } + // Vault-aware upload: when the target folder lives inside a vault, swap + // every file's content for its ciphertext and rewrite the upload endpoint + // to the encrypted server path before Uppy starts pushing bytes. Folder + // creation (MKCOL) inside createDirectoryTree is vault-aware too - it + // pulls the engine out of the same registry. + // + // This MUST run *after* conflict handling: getConflicts compares file + // names against the (cleartext) resource store, so encrypting names any + // earlier would hide every real name collision and silently overwrite. + // It also encrypts the cleartext names produced by a "keep both" choice. + const vaultEngine = await resolveFolderVault( + this.extensionRegistry, + unref(this.space), + uploadFolder?.path + ) + if (vaultEngine) { + filesToUpload = await this.applyVaultEncryption(filesToUpload, uploadFolder, vaultEngine) + } + this.uppyService.publish('uploadStarted') let folderFiles: OcUppyFile[] = [] if (this.directoryTreeCreateEnabled) { - const result = await this.createDirectoryTree( - filesToUpload, - uploadFolder, - mergedFolders, - vaultEngine - ) + const result = await this.createDirectoryTree(filesToUpload, uploadFolder, mergedFolders) filesToUpload = result.filesToUpload folderFiles = result.folderFiles } @@ -544,7 +536,7 @@ export class HandleUpload extends BasePlugin * Replace each file's content + name with their encrypted forms and rewrite * the upload endpoint so Uppy/Tus pushes ciphertext to the encrypted * server path. Sub-paths inside a drag-drop are walked segment-by-segment - * — every segment of `relativeFolder` ends up as ciphertext on the wire, + * - every segment of `relativeFolder` ends up as ciphertext on the wire, * even though we keep the original cleartext tree on `file.meta` for * progress UI / directory creation tracking. */ @@ -554,29 +546,25 @@ export class HandleUpload extends BasePlugin vaultEngine: FolderVaultEngine ): Promise { const space = unref(this.space) - const encryptedFolderPath = await vaultEngine.encryptPath(uploadFolder.path) + const encryptedFolderPath = await encryptVaultPath(vaultEngine, uploadFolder.path) const updated: Record = {} for (const file of filesToUpload) { if (file.type === 'directory') { - // Folder entries don't get an HTTP payload of their own — the MKCOLs + // Folder entries don't get an HTTP payload of their own - the MKCOLs // happen in createDirectoryTree (which is also vault-aware). Skip // here so we don't try to encrypt a non-existent content stream. continue } - // Walk the cleartext relativeFolder segment-by-segment so each part - // is encrypted independently (rclone-crypt's filename EME operates - // per segment) and join the ciphertext segments back into a path the - // server understands. + // relativeFolder is already relative to the upload target, so encrypt it + // as one vault-relative path (the engine handles its own segments). The + // filename is just a one-segment path. const clearRelative = file.meta.relativeFolder || '' - const encryptedRelativeSegments: string[] = [] - for (const segment of clearRelative.split('/').filter(Boolean)) { - encryptedRelativeSegments.push(await vaultEngine.encryptName(segment, uploadFolder.path)) - } - const encryptedRelativeFolder = encryptedRelativeSegments.join('/') - - const encryptedName = await vaultEngine.encryptName(file.name, uploadFolder.path) + const encryptedRelativeFolder = clearRelative + ? await vaultEngine.encryptPath(clearRelative) + : '' + const encryptedName = await vaultEngine.encryptPath(file.name) // Feed the engine the Blob's native stream instead of materialising the // whole plaintext first. The engine internals still collect today @@ -585,11 +573,11 @@ export class HandleUpload extends BasePlugin // rclone-crypt blocks straight onto nacl can be swapped in without // touching this call site. // - // FIXME(poc-vault): the output is still collected into a Blob because + // TODO(vault): the output is still collected into a Blob because // Uppy + tus-js-client require `file.data` to be `Blob`-shaped with a // working `.slice()` for chunked uploads. End-to-end streaming would // need either a stream-aware uppy plugin or replacing the transport; - // both are out of PoC scope. The engine API stays streaming so that + // both are out of scope for now. The engine API stays streaming so that // change lands as a pure replacement here. // Drive the engine end-to-end with streams. Collection only happens // because Uppy + tus need a sliceable Blob for file.data, not because @@ -609,7 +597,7 @@ export class HandleUpload extends BasePlugin } // Mutate in-place so existing meta (uploadId, spaceId, …) is preserved. - // We keep meta.relativeFolder in cleartext on purpose — directoryTree + // We keep meta.relativeFolder in cleartext on purpose - directoryTree // creation reads it to decide which folders to MKCOL and translates // the path itself at the very last step. file.data = cipherBlob diff --git a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue index e8ad00fa0ab..2c4002fec89 100644 --- a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue +++ b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue @@ -41,15 +41,7 @@ import { import { computed, onMounted, onBeforeUnmount, unref, watch } from 'vue' import { SpaceResource, isPublicSpaceResource } from '@opencloud-eu/web-client' -import { - decryptResourceInPlace, - markVaultStatus, - resolveFolderVault, - useService, - useUpload, - UppyService, - UploadResult -} from '@opencloud-eu/web-pkg' +import { useService, useUpload, UppyService, UploadResult } from '@opencloud-eu/web-pkg' import { HandleUpload } from '../../HandleUpload' import { useGettext } from 'vue3-gettext' import { storeToRefs } from 'pinia' @@ -152,28 +144,13 @@ const onUploadComplete = async (result: UploadResult) => { return } - // Vault-aware refresh: the cleartext currentFolder path means nothing to - // the server, so encrypt before listing and decrypt the children back to - // cleartext before they enter the store. Without this the upload would - // pop up with the encrypted blob name. - const clearPath = unref(currentFolder).path - const vaultEngine = resolveFolderVault(extensionRegistry, unref(computedSpace), clearPath) - const listPath = vaultEngine ? await vaultEngine.encryptPath(clearPath) : clearPath - const { children } = await clientService.webdav.listFiles(unref(computedSpace), { - path: listPath + path: unref(currentFolder).path }) const existingIds = new Set(resourcesStore.resources.map((r) => r.id)) const newResources = children.filter((child) => !existingIds.has(child.id)) - // Decrypt only the resources we're actually about to upsert — children - // already in the store are already cleartext. Parallel because - // decryptResourceInPlace mutates only the resource it's given. - if (vaultEngine && newResources.length) { - await Promise.all(newResources.map((r) => decryptResourceInPlace(vaultEngine, r))) - } - markVaultStatus(extensionRegistry, unref(computedSpace), newResources) resourcesStore.upsertResources(newResources) } @@ -192,7 +169,10 @@ const isMovingIntoSameFolder = computed(() => { }) const isPasteHereButtonDisabled = computed(() => { - return !unref(canUpload) || unref(isMovingIntoSameFolder) + // Pasting into a vault isn't supported (the worker can't re-encrypt the moved + // content), so the button is disabled inside one - matching the context-menu + // paste action, which the "Paste here" button bypasses. + return !unref(canUpload) || unref(isMovingIntoSameFolder) || unref(currentFolder)?.isInVault }) const pasteHereButtonTooltip = computed(() => { diff --git a/packages/web-app-files/src/components/SideBar/ActivitiesPanel.vue b/packages/web-app-files/src/components/SideBar/ActivitiesPanel.vue index 21b0283aed9..00366d78abe 100644 --- a/packages/web-app-files/src/components/SideBar/ActivitiesPanel.vue +++ b/packages/web-app-files/src/components/SideBar/ActivitiesPanel.vue @@ -40,7 +40,16 @@