Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
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;
use OCA\EndToEndEncryption\Middleware\CanUseAppMiddleware;
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;
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 5 additions & 6 deletions lib/Connector/Sabre/PropFindPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
}
Expand All @@ -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';
});
}

/**
Expand Down
4 changes: 3 additions & 1 deletion lib/E2EEPublicShareTemplateProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions lib/Listener/PublicShareListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\EndToEndEncryption\Listener;

use OCA\EndToEndEncryption\AppInfo\Application;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;

/**
* @template-implements IEventListener<BeforeTemplateRenderedEvent>
*/
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');
}
}
68 changes: 68 additions & 0 deletions src/main-public-share.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 16 additions & 0 deletions src/services/filesSharingSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
30 changes: 21 additions & 9 deletions src/services/webDavProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
}

Expand All @@ -53,10 +59,16 @@ function wrapInterceptor(middleware: BaseMiddleware<FetchContext>, 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()
}

Expand Down
7 changes: 6 additions & 1 deletion src/store/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -23,7 +25,7 @@ export interface IStoreMetadata {
path: string
}

const currentUser = getCurrentUser()?.uid
const currentUser = getCurrentUser()?.uid ?? `s:${getSharingToken()!}`
const metadataCache = new Map<string, Omit<IStoreMetadata, 'path'>>()

/**
Expand Down Expand Up @@ -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))
Expand Down
125 changes: 125 additions & 0 deletions src/views/FilesSharingSidebarSectionExternal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import type { INode } from '@nextcloud/files'
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { RootMetadata } from '../models/RootMetadata.ts'

import { mdiPlus } from '@mdi/js'
import axios from '@nextcloud/axios'
import { Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import { X509CertificateGenerator } from '@peculiar/x509'
import stringify from 'safe-stable-stringify'
import { ref, toRaw, useId, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import * as api from '../services/api.ts'
import { bufferToBase64 } from '../services/bufferUtils.ts'
import { exportRSAKey } from '../services/crypto.ts'
import logger from '../services/logger.ts'
import { generatePrivateKey } from '../services/privateKeyUtils.ts'
import { ensureKeyUsage } from '../services/rsaUtils.ts'
import * as keyStore from '../store/keys.ts'
import * as metadataStore from '../store/metadata.ts'

const props = defineProps<{
node: INode
}>()

const idHeading = useId()

const rootMetadata = ref<RootMetadata>()
watch(() => props.node, async () => {
rootMetadata.value = await metadataStore.getRootMetadata(props.node.path)
}, { immediate: true })

const shares = ref()
watch(rootMetadata, loadShares, { immediate: true })

/**
* Create a new end-to-end encrypted external share
*/
async function createShare() {
logger.debug('Creating end-to-end external share')
const metadata = toRaw(rootMetadata.value)
if (!metadata) {
throw new Error('No metadata available for the current folder')
}

await keyStore.loadPublicKey()
await keyStore.loadPrivateKey()

const { path, id } = metadataStore.getRootFolder(metadata)
const { data } = await axios.post(generateOcsUrl('/apps/files_sharing/api/v1/shares'), {
path: decodeURI(path),
permissions: Permission.READ,
shareType: ShareType.Link,
})

const publicKeys = await generatePrivateKey()
const cert = await X509CertificateGenerator.createSelfSigned({
keys: {
privateKey: await ensureKeyUsage(publicKeys.privateKey, 'sign'),
publicKey: await ensureKeyUsage(publicKeys.publicKey, 'verify'),
},
name: `CN=s:${data.ocs.data.token}`,
})
window.prompt('The private key:', bufferToBase64(await exportRSAKey(publicKeys.privateKey)))

metadata.addUser(`s:${data.ocs.data.token}`, cert)
const lockToken = await api.lockFolder(id, metadata.counter)
try {
const metadataRaw = await metadata.export(await keyStore.getCertificate())
await api.updateMetadata(id, stringify(metadataRaw.metadata), lockToken, metadataRaw.signature)
} finally {
await api.unlockFolder(id, lockToken)
}
}

/**
* Handle loading shares for the root metadata
*/
async function loadShares() {
const metadata = toRaw(rootMetadata.value)
if (!metadata) {
logger.debug('No metadata available, skipping loading shares')
return
}

let { path } = metadataStore.getRootFolder(metadata)
path = decodeURI(path)
logger.debug(`Loading shares for path: ${path}`)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data } = await axios.get<OCSResponse<any[]>>(generateOcsUrl('/apps/files_sharing/api/v1/shares'), {
params: {
path,
},
})

logger.debug(`Loaded ${data.ocs.data.length} shares for path: ${path}`, { shares: data.ocs.data })
shares.value = data.ocs.data.filter(({ share_type: shareType, permissions }) => shareType === ShareType.Link && (permissions & Permission.READ) !== 0)
}
</script>

<template>
<section>
<h5 :id="idHeading">
{{ t('end_to_end_encryption', 'End-to-end encrypted external share') }}
</h5>
<ul :aria-labelledby="idHeading">
<li>todo</li>
</ul>
<NcButton @click="createShare">
<template #icon>
<NcIconSvgWrapper :path="mdiPlus" />
</template>
{{ t('end_to_end_encryption', 'New external share') }}
</NcButton>
</section>
</template>
Loading
Loading