diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 86cc5434..0071c02b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,6 @@ env: FLUTTER_VERSION: '3.44.2' jobs: - analyze: runs-on: ubuntu-latest if: github.event.repository.private == false @@ -103,8 +102,7 @@ jobs: id: lychee uses: lycheeverse/lychee-action@v2 with: # Don't fail for now but then create an issue - useful? - args: - --exclude-file .lycheeignore + args: --exclude-path .lycheeignore --no-progress '*.md' './**/*.dart' diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f69e097..b9bd84cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ Visit the package at [pub.dev](https://pub.dev/packages/solidpod). ## 1.0 ++ Switch public to private sharing [1.0.15 20260728 jesscmoore] ++ Preserve encryption state on overwrite [1.0.14 20260730 jesscmoore] + Add load test to the example app [1.0.13 20260702 tonypioneer] -+ Migrate TEMPALTE to solidui [1.0.12 20260629 tonypioneer] ++ Migrate TEMPLATE to solidui [1.0.12 20260629 tonypioneer] + Support profile editing [1.0.11 20260626 tonypioneer] + Bug fix template for dart run [1.0.10 20260622 tonypioneer] + Add app template for a 'create' experience [1.0.9 20260619 tonypioneer] @@ -32,8 +34,8 @@ Visit the package at [pub.dev](https://pub.dev/packages/solidpod). + Check missing resources [0.12.9 20260520 tonypioneer] + Support checking webID [0.12.8 20260520 tonypioneer] + Update Try Another WebID workflow [0.12.7 20260520 tonypioneer] -+ Bug fix to ttl rdf for special chars #628 [0.12.6 20260518 tonypioneer] -+ Upgrade solidauth and fix key file saving edge cases [0.12.5 20260427 jesscmoore] ++ Bug fix to ttl RDF for special chars #628 [0.12.6 20260518 tonypioneer] ++ Upgrade solid_auth and fix key file saving edge cases [0.12.5 20260427 jesscmoore] + Support user profile. [0.12.4 20260421 tonypioneer] + Key map + paths updates. Update file_picker. [0.12.3 20260420 jesscmoore] + Add silentLogout() [0.12.2 20260325 tonypioneer] diff --git a/lib/src/solid/grant_permission.dart b/lib/src/solid/grant_permission.dart index 4b868b70..68f68022 100644 --- a/lib/src/solid/grant_permission.dart +++ b/lib/src/solid/grant_permission.dart @@ -38,6 +38,10 @@ import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/constants/web_acl.dart'; import 'package:solidpod/src/solid/models/log_entry.dart'; +import 'package:solidpod/src/solid/read_permission.dart' + show getUserClassPermissions; +import 'package:solidpod/src/solid/revoke_permission.dart' + show revokePermission; import 'package:solidpod/src/solid/solid_func_call_status.dart'; import 'package:solidpod/src/solid/utils/exceptions.dart'; import 'package:solidpod/src/solid/utils/get_url_helper.dart'; @@ -70,6 +74,17 @@ import 'package:solidpod/src/solid/utils/permission.dart' show genAclTurtle; /// - [isFile] Optional flag describing whether the resources is a file or /// not. /// - [groupName] - Optional name of the group permission. +/// - [revokePublicAccessOnSpecificGrant] - When [recipientType] is +/// individual or group and the resource currently has a Public or +/// Authenticated User class grant (and is therefore plaintext on the +/// server, per the decryption step this function performs for those +/// recipient classes), revoke that grant and re-encrypt the resource +/// before proceeding. Without this, the resource would end up both still +/// readable by the previously-granted class *and* holding a stale +/// individual key that does not match the (still plaintext) content. +/// Defaults to `true`; set to `false` to keep today's behaviour where +/// granting to a specific recipient never touches an existing +/// public/authUser grant. Future grantPermission({ required String fileName, @@ -81,6 +96,7 @@ Future grantPermission({ bool isFile = true, bool isExternalRes = false, String? groupName, + bool revokePublicAccessOnSpecificGrant = true, }) async { if (!await isUserLoggedIn()) { throw NotLoggedInException( @@ -150,6 +166,45 @@ Future grantPermission({ // if recipient pods have been initialised if (allRecipientsInitialised || !hasSpecificRecipients) { if (resStatus == ResourceStatus.exist) { + // Sharing to a specific individual/group assumes the resource is + // ciphertext under an individual key (see the `fileHasIndKey` + // branch below). If it's currently also granted to the Public or + // Authenticated User class, it's plaintext on the server (that + // class has no key of its own to decrypt with) — so revoke that + // grant and re-encrypt first. Otherwise the resource would end up + // both still openly readable *and* holding a stale individual key + // that doesn't match the (still plaintext) bytes. Must run before + // `setPermissionAcl` below, so `revokePermission`'s own ACL read + // still sees the pre-existing grant. + if (hasSpecificRecipients && revokePublicAccessOnSpecificGrant) { + final existingClassPerms = await getUserClassPermissions( + fileName: resourceUrl, + isFile: isFile, + isFileUrl: true, + isExternalRes: isExternalRes, + ); + for (final classType in existingClassPerms.keys) { + final classAgent = classType == RecipientType.public + ? publicAgent + : authenticatedAgent; + debugPrint( + '[grantPermission] revoking existing $classType access on ' + '"$resourceUrl" before granting to $recipientType', + ); + await revokePermission( + fileName: resourceUrl, + isFileUrl: true, + permissionList: existingClassPerms[classType]!, + recipientIndOrGroupWebId: classAgent.value, + recipientType: classType, + ownerWebId: ownerWebId, + granterWebId: granterWebId, + isFile: isFile, + isExternalRes: isExternalRes, + ); + } + } + // Add the permission line to the relevant ACL file await setPermissionAcl( resourceUrl, diff --git a/lib/src/solid/read_permission.dart b/lib/src/solid/read_permission.dart index 70f8eb50..16bcc76d 100644 --- a/lib/src/solid/read_permission.dart +++ b/lib/src/solid/read_permission.dart @@ -30,6 +30,12 @@ library; import 'dart:core'; +import 'package:rdflib/rdflib.dart'; + +import 'package:solidpod/src/solid/constants/common.dart' + show agentClassPred, agentStr, permStr; +import 'package:solidpod/src/solid/constants/web_acl.dart' + show RecipientType, authenticatedAgent, publicAgent; import 'package:solidpod/src/solid/utils/get_url_helper.dart'; import 'package:solidpod/src/solid/utils/misc.dart'; import 'package:solidpod/src/solid/utils/permission.dart'; @@ -70,3 +76,41 @@ Future> readPermission({ return permMap; } + +/// The Public/Authenticated-User access modes currently granted on +/// [fileName], keyed by [RecipientType.public]/[RecipientType.authUser]. +/// A class with no current grant is omitted from the result. +/// +/// Single source of truth for "does this resource currently have a +/// Public/AuthenticatedUser grant" — used both by [grantPermission] (to +/// decide whether an individual/group grant must first revoke and +/// re-encrypt) and by solidui's confirmation dialog for the same action. + +Future>> getUserClassPermissions({ + required String fileName, + required bool isFile, + bool isFileUrl = false, + bool isExternalRes = false, +}) async { + final permMap = await readPermission( + fileName: fileName, + isFile: isFile, + isFileUrl: isFileUrl, + isExternalRes: isExternalRes, + ); + + final result = >{}; + for (final receiverId in permMap.keys) { + if (receiverId is! String || + permMap[receiverId][agentStr] != agentClassPred) { + continue; + } + final perms = (permMap[receiverId][permStr] as List).cast(); + if (URIRef(receiverId) == publicAgent) { + result[RecipientType.public] = perms; + } else if (URIRef(receiverId) == authenticatedAgent) { + result[RecipientType.authUser] = perms; + } + } + return result; +} diff --git a/lib/src/solid/write_external_pod.dart b/lib/src/solid/write_external_pod.dart index 4ad08166..74970dd6 100644 --- a/lib/src/solid/write_external_pod.dart +++ b/lib/src/solid/write_external_pod.dart @@ -34,6 +34,8 @@ import 'dart:convert'; import 'package:flutter/material.dart' hide Key; import 'package:solidpod/src/solid/api/rest_api.dart'; +import 'package:solidpod/src/solid/check_encryption.dart' + show isContentEncrypted; import 'package:solidpod/src/solid/common_func.dart'; import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/utils/exceptions.dart'; @@ -44,7 +46,17 @@ import 'package:solidpod/src/solid/utils/misc.dart'; /// Write file [fileUrl] with content [fileContent] to an external PODs in the /// data directory (within potential subdirectories encoded in [fileUrl]). -/// The content will be encrypted if the original content is true. +/// +/// [encrypted] defaults to `null`, meaning "not specified by the caller": when +/// overwriting an existing file, the file's *current* at-rest state on the +/// server is mirrored (plaintext stays plaintext, ciphertext stays +/// ciphertext) rather than always re-encrypting just because a shared +/// individual key happens to be on record. This matters for a resource the +/// owner decrypted in place for Public/Authenticated User sharing (see +/// `decryptFileInPlace` in solidpod) — without this, a recipient with write +/// access editing the file would silently re-encrypt it and break that +/// sharing grant. Pass `true`/`false` explicitly to force a specific +/// encryption state regardless of what's currently on the server. /// /// The encryption boilerplate shared with [writePod] is factored out into /// [getEncTTLStrWithRandomIV], and the "own POD vs external POD" routing is @@ -57,7 +69,7 @@ Future writeExternalPod( String fileUrl, String fileContent, String fileOwnerWebId, { - bool encrypted = true, + bool? encrypted, bool overwrite = true, String? inheritKeyFrom, }) async { @@ -89,9 +101,16 @@ Future writeExternalPod( case ResourceStatus.exist: final remoteFileContent = utf8.decode(await getResource(fileUrl)); + // When the caller didn't specify [encrypted], mirror whatever is + // actually on the server right now instead of assuming a shared key + // on record means the file should be (re-)encrypted — the owner may + // have decrypted it in place for Public/Authenticated User sharing. + final wantEncrypted = encrypted ?? + isContentEncrypted(fileUrl: fileUrl, content: remoteFileContent); + final key = await KeyManager.getSharedIndividualKey(fileUrl); - if (key != null) { + if (wantEncrypted && key != null) { // Get file path // final filePath = // fileUrl.replaceAll(fileOwnerWebId.replaceAll(profCard, ''), ''); @@ -108,7 +127,7 @@ Future writeExternalPod( 'but the extension of provided filename "$fileUrl" is not ".ttl"', ); } - } else if (hasInheritedKey(remoteFileContent, fileUrl)) { + } else if (wantEncrypted && hasInheritedKey(remoteFileContent, fileUrl)) { // Get file path // final filePath = // fileUrl.replaceAll(fileOwnerWebId.replaceAll(profCard, ''), ''); diff --git a/lib/src/solid/write_pod.dart b/lib/src/solid/write_pod.dart index e2efbd88..ebeaa604 100644 --- a/lib/src/solid/write_pod.dart +++ b/lib/src/solid/write_pod.dart @@ -28,12 +28,16 @@ library; +import 'dart:convert' show utf8; + import 'package:flutter/foundation.dart' show debugPrint; import 'package:encrypter_plus/encrypter_plus.dart' show Key; import 'package:mime/mime.dart' as mime; import 'package:solidpod/src/solid/api/rest_api.dart'; +import 'package:solidpod/src/solid/check_encryption.dart' + show isContentEncrypted; import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/constants/path_type.dart'; import 'package:solidpod/src/solid/utils/exceptions.dart'; @@ -58,7 +62,18 @@ import 'package:solidpod/src/solid/write_external_pod.dart' /// Arguments: /// - [filePath]: The path (relative to appname/data/) of the file to write /// - [fileContent]: The content to write to the file -/// - [encrypted]: Whether to encrypt the file content (default: true) +/// - [encrypted]: Whether to encrypt the file content. Defaults to `null`, +/// meaning "not specified by the caller": for a new file (or when +/// [overwrite] is false) this behaves as `true`; when [overwrite] is true +/// and the file already exists, the file's *current* at-rest state on the +/// server is mirrored instead (plaintext stays plaintext, ciphertext stays +/// ciphertext). This matters for a resource that was decrypted in place +/// for Public/Authenticated User sharing (see `decryptFileInPlace`) — +/// without this, an unrelated edit would silently re-encrypt it and break +/// that sharing grant, since a class-based ACL grant has no key to +/// decrypt with. Pass `true`/`false` explicitly to override this and +/// force a specific encryption state regardless of what's currently on +/// the server. /// - [createAcl]: Whether to create a separate acl for the resource (default: true) /// - [overwrite]: Whether to overwrite the content of an existing file (default: false) /// - [pathType]: Optional type of relative path (for both [filePath] and [inheritKeyFrom]) @@ -80,7 +95,7 @@ import 'package:solidpod/src/solid/write_external_pod.dart' Future writePod( String filePath, String fileContent, { - bool encrypted = true, + bool? encrypted, bool createAcl = true, bool overwrite = false, PathType pathType = PathType.relativeToData, @@ -133,6 +148,27 @@ Future writePod( ); } + final status = await checkResourceStatus(fileUrl); + + // Resolve the effective encryption flag. When the caller didn't specify + // [encrypted] and this is an overwrite of an existing file, mirror the + // file's current at-rest state instead of assuming `true` — otherwise an + // unrelated edit would silently re-encrypt a file that was deliberately + // decrypted in place for Public/Authenticated User sharing (see + // `decryptFileInPlace`), stranding a resource whose ACL still promises + // open access but whose bytes no longer are. + + var resolvedEncrypted = encrypted ?? true; + if (encrypted == null && + inheritKeyFrom == null && + overwrite && + status == ResourceStatus.exist) { + final currentContent = utf8.decode(await getResource(fileUrl)); + // Determine current encryption state + resolvedEncrypted = + isContentEncrypted(fileUrl: fileUrl, content: currentContent); + } + Key? encKey; String? inheritKeyUrl; if (inheritKeyFrom != null) { @@ -143,7 +179,7 @@ Future writePod( ); } - if (encrypted || inheritKeyFrom != null) { + if (resolvedEncrypted || inheritKeyFrom != null) { if (!fileUrl.endsWith('.ttl')) { throw Exception( 'Encrypted text file should be in turtle format, ' @@ -154,7 +190,7 @@ Future writePod( encKey = await configureEncKey(fileUrl, inheritKeyUrl: inheritKeyUrl); } - switch (await checkResourceStatus(fileUrl)) { + switch (status) { case ResourceStatus.exist: if (overwrite) { debugPrint('NOTE: Overwriting existing file "$filePath"'); diff --git a/pubspec.yaml b/pubspec.yaml index cbdcb728..55864787 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: solidpod description: Support access to private data from PODs on Solid servers. -version: 1.0.13 +version: 1.0.15 homepage: https://github.com/anusii/solidpod environment: