diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 0d3011673..2f7887120 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -20,6 +20,7 @@ use OCA\EndToEndEncryption\KeyStorage; use OCA\EndToEndEncryption\Listener\AllowBlobMediaInCSPListener; use OCA\EndToEndEncryption\Listener\LoadAdditionalListener; +use OCA\EndToEndEncryption\Listener\PublicShareListener; use OCA\EndToEndEncryption\Listener\UserDeletedListener; use OCA\EndToEndEncryption\MetaDataStorage; use OCA\EndToEndEncryption\MetaDataStorageV1; @@ -27,6 +28,7 @@ use OCA\EndToEndEncryption\Middleware\ClientHasCapabilityMiddleware; use OCA\EndToEndEncryption\Middleware\UserAgentCheckMiddleware; use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Files_Trashbin\Events\MoveToTrashEvent; use OCA\Files_Versions\Events\CreateVersionEvent; use OCP\AppFramework\App; @@ -64,6 +66,7 @@ public function register(IRegistrationContext $context): void { $context->registerServiceAlias(IMetaDataStorage::class, MetaDataStorage::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class); + $context->registerEventListener(BeforeTemplateRenderedEvent::class, PublicShareListener::class); $context->registerEventListener(AddContentSecurityPolicyEvent::class, AllowBlobMediaInCSPListener::class); $context->registerPublicShareTemplateProvider(E2EEPublicShareTemplateProvider::class); } diff --git a/lib/Connector/Sabre/PropFindPlugin.php b/lib/Connector/Sabre/PropFindPlugin.php index 66c0565ff..2ed5fa378 100644 --- a/lib/Connector/Sabre/PropFindPlugin.php +++ b/lib/Connector/Sabre/PropFindPlugin.php @@ -67,8 +67,9 @@ public function setE2EEProperties(PropFind $propFind, \Sabre\DAV\INode $node) { $propFind->handle(self::E2EE_METADATA_PROPERTYNAME, function () use ($node) { if ($this->isE2EEnabledPath($node)) { + $user = $this->userSession->getUser() ?? $node->getNode()->getOwner(); return $this->metaDataStorage->getMetaData( - $this->userSession->getUser()->getUID(), + $user->getUID(), $node->getId(), ); } @@ -82,11 +83,9 @@ public function setE2EEProperties(PropFind $propFind, \Sabre\DAV\INode $node) { } // This property was introduced to expose encryption status for both files and folders. - if ($this->userFolder !== null) { - $propFind->handle(self::E2EE_IS_ENCRYPTED, function () use ($node) { - return $this->isE2EEnabledPath($node) ? '1' : '0'; - }); - } + $propFind->handle(self::E2EE_IS_ENCRYPTED, function () use ($node) { + return $this->isE2EEnabledPath($node) ? '1' : '0'; + }); } /** diff --git a/lib/E2EEPublicShareTemplateProvider.php b/lib/E2EEPublicShareTemplateProvider.php index d0305deba..f62f060a7 100644 --- a/lib/E2EEPublicShareTemplateProvider.php +++ b/lib/E2EEPublicShareTemplateProvider.php @@ -38,7 +38,9 @@ public function __construct( public function shouldRespond(IShare $share): bool { $node = $share->getNode(); - return $node->getType() === FileInfo::TYPE_FOLDER && $node->isEncrypted(); + return $node->getType() === FileInfo::TYPE_FOLDER + && $node->isEncrypted() + && ($share->getPermissions() & \OCP\Constants::PERMISSION_READ) === 0; } protected function getMetadata(IShare $share): array { diff --git a/lib/Listener/PublicShareListener.php b/lib/Listener/PublicShareListener.php new file mode 100644 index 000000000..b3e7b9059 --- /dev/null +++ b/lib/Listener/PublicShareListener.php @@ -0,0 +1,38 @@ + + */ +class PublicShareListener implements IEventListener { + + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + if ($event->getScope() === BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH) { + return; + } + + if (!$event->getShare()->getNode()->isEncrypted()) { + return; + } + + Util::addStyle(Application::APP_ID, Application::APP_ID . '-public-share'); + Util::addInitScript(Application::APP_ID, Application::APP_ID . '-public-share'); + } +} diff --git a/src/main-public-share.ts b/src/main-public-share.ts new file mode 100644 index 000000000..d521282a2 --- /dev/null +++ b/src/main-public-share.ts @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node, View } from '@nextcloud/files' + +import { getFileActions, registerFileAction } from '@nextcloud/files' +import { registerDavProperty } from '@nextcloud/files/dav' +import downloadUnencryptedAction from './files_actions/downloadUnencryptedAction.ts' +import { base64ToBuffer } from './services/bufferUtils.ts' +import logger from './services/logger.ts' +import { setupWebDavProxy } from './services/webDavProxy.ts' +import * as keyStore from './store/keys.ts' + +const browserSupportsWebCrypto = typeof window.crypto !== 'undefined' && typeof window.crypto.subtle !== 'undefined' + +if (browserSupportsWebCrypto) { + setupWebDavProxy() + // Register DAV properties used for E2EE + registerDavProperty('nc:e2ee-is-encrypted', { nc: 'http://nextcloud.org/ns' }) + registerDavProperty('nc:e2ee-metadata', { nc: 'http://nextcloud.org/ns' }) + registerDavProperty('nc:e2ee-metadata-signature', { nc: 'http://nextcloud.org/ns' }) + // Register file integrations + registerFileAction(downloadUnencryptedAction) + disableFileAction('download') + + document.addEventListener('DOMContentLoaded', async () => { + // ensure we have the user's private key loaded + while (!keyStore.hasPrivateKey()) { + const key = window.prompt('Please enter your private key:') + if (!key) { + logger.debug('No private key provided, retrying...') + continue + } + try { + logger.debug('Importing private key') + const privateKey = await globalThis.crypto.subtle.importKey('pkcs8', base64ToBuffer(key), { name: 'RSA-OAEP', hash: 'SHA-256' }, true, ['decrypt']) + keyStore.setPrivateKey(privateKey) + } catch (error) { + logger.debug('Failed to import private key', { error }) + } + } + }) +} else { + logger.error('End-to-end encryption in the browser is not supported by your browser or you are not using a secure connection (HTTPS).') +} + +/** + * Disable a file action by monkey patching a custom enabled function. + * + * @param actionId - The ID of the action to disable + */ +function disableFileAction(actionId: string) { + logger.debug(`Inhibiting ${actionId} actions for e2ee files`) + const actions = getFileActions() + + const action = actions.find((action) => action.id === actionId) as unknown as { _action: { enabled: (nodes: Node[], view: View) => boolean } } + const originalEnabled = action._action.enabled + + action._action.enabled = (nodes: Node[], view: View) => { + if (nodes.some((node) => node.attributes['e2ee-is-encrypted'] === 1)) { + return false + } + + return originalEnabled(nodes, view) + } +} diff --git a/src/services/filesSharingSection.ts b/src/services/filesSharingSection.ts index 6172644ea..a06da10dc 100644 --- a/src/services/filesSharingSection.ts +++ b/src/services/filesSharingSection.ts @@ -28,6 +28,22 @@ export function registerSharingSidebarSection() { element: id, }) + const idExternal = 'oca__end_to_end_encryption__sharing-sections-external' + const FilesSharingSidebarSectionsExternal = defineCustomElement( + defineAsyncComponent(() => import('../views/FilesSharingSidebarSectionExternal.vue')), + { shadowRoot: false }, + ) + window.customElements.define(idExternal, FilesSharingSidebarSectionsExternal) + + registerSidebarSection({ + id: 'end_to_end_encryption:external', + order: 52, + enabled(node: INode) { + return node.attributes['e2ee-is-encrypted'] === 1 + }, + element: idExternal, + }) + const idFiledrop = 'oca__end_to_end_encryption__sharing-sections-filedrop' const FilesSharingSidebarSectionsFiledrop = defineCustomElement( defineAsyncComponent(() => import('../views/FilesSharingSidebarSectionFiledrop.vue')), diff --git a/src/services/webDavProxy.ts b/src/services/webDavProxy.ts index 31078858c..0c6c6b4cc 100644 --- a/src/services/webDavProxy.ts +++ b/src/services/webDavProxy.ts @@ -5,6 +5,7 @@ import type { BaseMiddleware, FetchContext } from '@rxliuli/vista' +import { isPublicShare } from '@nextcloud/sharing/public' import { interceptFetch, interceptXHR, Vista } from '@rxliuli/vista' import { useCopyInterceptor } from '../middleware/useCopyInterceptor.ts' import { useDeleteInterceptor } from '../middleware/useDeleteInterceptor.ts' @@ -24,13 +25,18 @@ export function setupWebDavProxy() { logger.debug('Setting up WebDAV proxy') vistaInstance = new Vista([interceptFetch, interceptXHR]) - .use(wrapInterceptor(useCopyInterceptor, 'COPY')) - .use(wrapInterceptor(useDeleteInterceptor, 'DELETE')) .use(wrapInterceptor(useGetInterceptor, 'GET')) - .use(wrapInterceptor(useMkcolInterceptor, 'MKCOL')) - .use(wrapInterceptor(useMoveInterceptor, 'MOVE')) - .use(wrapInterceptor(usePutInterceptor, 'PUT')) .use(wrapInterceptor(usePropFindInterceptor, 'PROPFIND')) + + if (!isPublicShare()) { + vistaInstance + .use(wrapInterceptor(useCopyInterceptor, 'COPY')) + .use(wrapInterceptor(useDeleteInterceptor, 'DELETE')) + .use(wrapInterceptor(useMkcolInterceptor, 'MKCOL')) + .use(wrapInterceptor(useMoveInterceptor, 'MOVE')) + .use(wrapInterceptor(usePutInterceptor, 'PUT')) + } + vistaInstance.intercept() } @@ -53,10 +59,16 @@ function wrapInterceptor(middleware: BaseMiddleware, method: strin if (context.req.method !== method) { return next() } - if (context.req.headers.get('X-E2EE-SUPPORTED') === 'true' - || !context.req.url.includes('/remote.php/dav/files/') - ) { - logger.debug(`Pass through ${context.req.method} ${context.req.url}`) + + // ignore requests already handled by middleware + if (context.req.headers.get('X-E2EE-SUPPORTED') === 'true') { + return next() + } + + // check proper webdav context + if (isPublicShare() && !context.req.url.includes('/public.php/dav/files/')) { + return next() + } else if (!isPublicShare() && !context.req.url.includes('/remote.php/dav/files/')) { return next() } diff --git a/src/store/metadata.ts b/src/store/metadata.ts index 75e65d393..9c66be275 100644 --- a/src/store/metadata.ts +++ b/src/store/metadata.ts @@ -8,6 +8,8 @@ import type { IRawMetadata } from '../models/metadata.d.ts' import { getCurrentUser } from '@nextcloud/auth' import { defaultRemoteURL, defaultRootPath } from '@nextcloud/files/dav' import { dirname } from '@nextcloud/paths' +import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' +import { X509Certificate } from '@peculiar/x509' import { Metadata } from '../models/Metadata.ts' import { RootMetadata } from '../models/RootMetadata.ts' import * as api from '../services/api.ts' @@ -23,7 +25,7 @@ export interface IStoreMetadata { path: string } -const currentUser = getCurrentUser()?.uid +const currentUser = getCurrentUser()?.uid ?? `s:${getSharingToken()!}` const metadataCache = new Map>() /** @@ -133,6 +135,9 @@ export async function setRawMetadata(path: string, id: string, rawMetadata: stri if (!await validateMetadataSignature(metadataRaw, signature, rootMetadata.rawUsers)) { throw new Error('Root metadata signature verification failed') } + if (isPublicShare()) { + keyStore.setCertificate(new X509Certificate(rootMetadata.rawUsers.find((u) => u.userId === currentUser)!.certificate)) + } metadata = rootMetadata } else { const rootMetadata = await getRootMetadata(dirname(path)) diff --git a/src/views/FilesSharingSidebarSectionExternal.vue b/src/views/FilesSharingSidebarSectionExternal.vue new file mode 100644 index 000000000..1cc522327 --- /dev/null +++ b/src/views/FilesSharingSidebarSectionExternal.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/tests/stub.phpstub b/tests/stub.phpstub index db97ae229..57265881e 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -836,6 +836,18 @@ namespace OCA\Files_Sharing { } } +namespace OCA\Files_Sharing\Event { + class BeforeTemplateRenderedEvent extends \OCP\EventDispatcher\Event { + public const SCOPE_PUBLIC_SHARE_AUTH = 'publicShareAuth'; + + public function getShare(): \OCP\Share\IShare { + } + + public function getScope(): ?string { + } + } +} + namespace OCA\Files_Trashbin\Events { use OCP\EventDispatcher\Event; diff --git a/vite.config.ts b/vite.config.ts index b09c6b3c8..3a966a4d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,7 @@ declare const __dirname: string export default createAppConfig({ files: join(__dirname, 'src', 'main-files.ts'), filedrop: join(__dirname, 'src', 'main-filedrop.js'), + 'public-share': join(__dirname, 'src', 'main-public-share.ts'), 'settings-admin': join(__dirname, 'src', 'settings-admin.js'), 'settings-personal': join(__dirname, 'src', 'settings-user.js'), }, {