Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ reports
tests/opencloud
tests/testing-app
playwright-report
test-results

/webpack.json
/webpack-build-log.json
Expand Down
36 changes: 35 additions & 1 deletion .woodpecker.star
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ config = {
"app-store",
],
},
"rclone-crypt": {
"earlyFail": True,
"skip": False,
"rcloneNeeded": True,
"suites": [
"rclone-crypt",
],
},
"a11y": {
"earlyFail": True,
"skip": False,
Expand Down Expand Up @@ -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,
Expand All @@ -547,6 +580,7 @@ def e2eTests(ctx):
"suites": [],
"features": [],
"tikaNeeded": False,
"rcloneNeeded": False,
"federationServer": False,
"failOnUncaughtConsoleError": False,
"extraServerEnvironment": {},
Expand Down Expand Up @@ -658,7 +692,7 @@ def e2eTests(ctx):
"name": "e2e-tests",
"image": OC_CI_NODEJS,
"environment": environment,
"commands": [
"commands": installRclone(params) + [
command,
],
}] + \
Expand Down
84 changes: 36 additions & 48 deletions packages/web-app-files/src/HandleUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
OcUppyMeta,
OcUppyBody,
resolveFolderVault,
encryptVaultPath,
streamToBlob
} from '@opencloud-eu/web-pkg'
import { locationSpacesGeneric, UppyService } from '@opencloud-eu/web-pkg'
Expand Down Expand Up @@ -304,8 +305,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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)
Expand Down Expand Up @@ -374,16 +374,8 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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', {
Expand Down Expand Up @@ -445,20 +437,6 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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)
Expand Down Expand Up @@ -511,15 +489,29 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
}
}

// 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
}
Expand All @@ -544,7 +536,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
* 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.
*/
Expand All @@ -554,29 +546,25 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
vaultEngine: FolderVaultEngine
): Promise<OcUppyFile[]> {
const space = unref(this.space)
const encryptedFolderPath = await vaultEngine.encryptPath(uploadFolder.path)
const encryptedFolderPath = await encryptVaultPath(vaultEngine, uploadFolder.path)

const updated: Record<string, OcUppyFile> = {}
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
Expand All @@ -585,11 +573,11 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
// 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
Expand All @@ -609,7 +597,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
}

// 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
Expand Down
32 changes: 6 additions & 26 deletions packages/web-app-files/src/components/AppBar/CreateAndUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}

Expand All @@ -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(() => {
Expand Down
61 changes: 59 additions & 2 deletions packages/web-app-files/src/components/SideBar/ActivitiesPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@
<script setup lang="ts">
import { computed, inject, Ref, ref, unref, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import { formatDateFromDateTime, useClientService, UserAvatar } from '@opencloud-eu/web-pkg'
import {
FolderVaultEngine,
formatDateFromDateTime,
getVaultClaim,
resolveFolderVault,
useClientService,
useExtensionRegistry,
useGetMatchingSpace,
UserAvatar
} from '@opencloud-eu/web-pkg'
import { useTask } from 'vue-concurrency'
import { call, Resource } from '@opencloud-eu/web-client'
import { DateTime } from 'luxon'
Expand All @@ -49,10 +58,42 @@ import escape from 'lodash-es/escape'

const { $ngettext, current: currentLanguage } = useGettext()
const clientService = useClientService()
const extensionRegistry = useExtensionRegistry()
const { getMatchingSpace } = useGetMatchingSpace()
const resource = inject<Ref<Resource>>('resource')
const activities = ref<Activity[]>([])
const activitiesLimit = 200

// Inside an unlocked vault the activity feed carries the encrypted blob names
// the server stores. Decrypt every resource-name variable in place so the
// feed reads like the user's cleartext file names. user/sharee are people and
// never encrypted; cleartext segments (the vault root, a parent folder) make
// decryptPath throw on the base32 decode, so we keep them as-is.
const decryptActivityNames = async (items: Activity[], engine: FolderVaultEngine) => {
for (const activity of items) {
const variables = activity.template?.variables as Record<string, any> | undefined
if (!variables) {
continue
}
for (const [key, value] of Object.entries(variables)) {
if (key === 'user' || key === 'sharee' || !value) {
continue
}
for (const field of ['name', 'displayName'] as const) {
if (typeof value[field] !== 'string') {
continue
}
try {
// a resource name is a one-segment relative path
value[field] = await engine.decryptPath(value[field])
} catch {
// not a vault-encrypted segment - leave it untouched
}
}
}
}
}

const activitiesFooterText = computed(() => {
return $ngettext(
'Showing %{activitiesCount} activity',
Expand All @@ -65,12 +106,28 @@ const activitiesFooterText = computed(() => {
})

const loadActivitiesTask = useTask(function* (signal) {
activities.value = yield* call(
const loaded = yield* call(
clientService.graphAuthenticated.activities.listActivities(
`itemid:${unref(resource).fileId} AND limit:${activitiesLimit} AND sort:desc`,
{ signal }
)
)

// Only resolve the (possibly index-loading) engine and decrypt names when
// the vault scheme actually encrypts names. A content-only scheme leaves
// activity names in clear text, so there is nothing to decrypt and no need
// to touch the engine at all. claimsPath is a cheap, sync, engine-free check.
const res = unref(resource)
const space = res ? getMatchingSpace(res) : null
const claim = space ? getVaultClaim(extensionRegistry, space, res.path) : null
if (claim?.encryptsNames) {
const vaultEngine = yield* call(resolveFolderVault(extensionRegistry, space, res.path))
if (vaultEngine) {
yield* call(decryptActivityNames(loaded, vaultEngine))
}
}

activities.value = loaded
}).restartable()

const isLoading = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ const canCreateLinks = computed(() => {
if (!ability.can('create-all', 'PublicLink')) {
return false
}
// Public links rebase the path at the link target, dropping the vault's
// cleartext `.vault` anchor so the share can no longer be claimed and
// unlocked. Block link creation inside or at a vault root; collaborator
// shares stay available (see markVaultStatus).
if (unref(resource)?.isInVault) {
return false
}
return canShare({ space: unref(space), resource: unref(resource) })
})

Expand Down
Loading