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
4 changes: 1 addition & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ env:
FLUTTER_VERSION: '3.44.2'

jobs:

analyze:
runs-on: ubuntu-latest
if: github.event.repository.private == false
Expand Down Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Visit the package at [pub.dev](https://pub.dev/packages/solidpod).

## 1.0

+ Preserve encryption state on overwrite [1.0.16 20260811 jesscmoore]
+ Fix deleting a large file stored as a single chunk [1.0.15 20260809 gjw]
+ Remove debug output when reading a large file [1.0.14 20260809 gjw]
+ Add load test to the example app [1.0.13 20260702 tonypioneer]
Expand All @@ -34,8 +35,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]
Expand Down
1 change: 1 addition & 0 deletions lib/solidpod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export 'src/solid/utils/exceptions.dart'
CssEmailAlreadyRegisteredException,
CssWrongCredentialsException,
NotLoggedInException,
PublicShareEncryptionConflictException,
RecipientNotReadyException,
ResourceNotDecryptableException,
ResourceNotExistException,
Expand Down
22 changes: 22 additions & 0 deletions lib/src/solid/utils/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,25 @@ class CssEmailAlreadyRegisteredException implements Exception {
@override
String toString() => 'CssEmailAlreadyRegisteredException: $message';
}

/// Thrown by [writePod] when a write would encrypt a resource whose ACL
/// still grants the Public or Authenticated User agent class access — a
/// grant that only works while the resource stays plaintext (those agent
/// classes cannot be issued an individual decryption key). This usually
/// means the resource was deliberately decrypted in place for sharing (see
/// `decryptFileInPlace`) and the caller passed an explicit `encrypted: true`
/// (or `inheritKeyFrom`) without meaning to undo that sharing grant.
///
/// To fix: call `revokePermission` to remove the Public/Authenticated grant
/// first (it re-encrypts the resource as part of revocation), or omit
/// `encrypted` / pass `encrypted: false` to preserve the current plaintext
/// state.

class PublicShareEncryptionConflictException implements Exception {
final String message;

PublicShareEncryptionConflictException(this.message);

@override
String toString() => 'PublicShareEncryptionConflictException: $message';
}
34 changes: 34 additions & 0 deletions lib/src/solid/utils/permission.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import 'dart:convert';
import 'package:rdflib/rdflib.dart' show URIRef, Namespace;

import 'package:solidpod/src/solid/api/rest_api.dart';
import 'package:solidpod/src/solid/constants/common.dart'
show ResourceStatus, authAgent, pubAgent;
import 'package:solidpod/src/solid/constants/web_acl.dart';
import 'package:solidpod/src/solid/utils/authdata_manager.dart';
import 'package:solidpod/src/solid/utils/misc.dart' show getResAclFile;
Expand Down Expand Up @@ -184,6 +186,38 @@ Future<Map<dynamic, dynamic>> readAcl(
return parseACL(aclContent);
}

/// Whether [resourceUrl]'s ACL currently grants read access to the Public
/// or Authenticated User agent class.
///
/// Those agent classes cannot be issued an individual decryption key (see
/// `decryptFileInPlace`), so a `true` result means the resource's bytes are
/// expected to stay plaintext at rest for as long as the grant exists —
/// [writePod] uses this to avoid silently re-encrypting a resource that a
/// prior `grantPermission` call deliberately decrypted for sharing.
///
/// Returns `false` when the resource has no dedicated ACL file yet (nothing
/// to check).

Future<bool> hasPublicOrAuthUserGrant(
String resourceUrl, {
bool isFile = true,
}) async {
final aclFileUrl = getResAclFile(resourceUrl, isFile);
if (await checkResourceStatus(aclFileUrl) != ResourceStatus.exist) {
return false;
}

final aclMap = await readAcl(resourceUrl, isFile);
for (final predicates in aclMap.values) {
final agentClasses = (predicates as Map)['agentClass'];
if (agentClasses is List &&
(agentClasses.contains(pubAgent) || agentClasses.contains(authAgent))) {
return true;
}
}
return false;
}

/// Retrieves the list of WebIDs defined in a ttl file as a vcard:Group
///
/// Returns a Future that completes with a List containing the list of WebIDs.
Expand Down
63 changes: 59 additions & 4 deletions lib/src/solid/write_external_pod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,42 @@ 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';
import 'package:solidpod/src/solid/utils/get_url_helper.dart';
import 'package:solidpod/src/solid/utils/key_inheritance.dart';
import 'package:solidpod/src/solid/utils/key_manager.dart' show KeyManager;
import 'package:solidpod/src/solid/utils/misc.dart';
import 'package:solidpod/src/solid/utils/permission.dart'
show hasPublicOrAuthUserGrant;

/// 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.
///
/// Passing `true`/`false` explicitly overrides the mirroring above and
/// forces that encryption state. Leave [encrypted] unset whenever you're
/// only touching content and not intentionally changing whether it's
/// encrypted. If forcing encryption would break an active
/// Public/Authenticated sharing grant on the resource (i.e. its ACL still
/// grants that class access — readable only when the caller happens to hold
/// acl:Control on it, which a recipient with mere Read/Write access
/// typically does not), the call throws
/// [PublicShareEncryptionConflictException] instead of silently stranding
/// the grant. The resource owner should call `revokePermission` to remove
/// the grant first (it handles re-encrypting the resource itself).
///
/// The encryption boilerplate shared with [writePod] is factored out into
/// [getEncTTLStrWithRandomIV], and the "own POD vs external POD" routing is
Expand All @@ -57,7 +82,7 @@ Future<void> writeExternalPod(
String fileUrl,
String fileContent,
String fileOwnerWebId, {
bool encrypted = true,
bool? encrypted,
bool overwrite = true,
String? inheritKeyFrom,
}) async {
Expand Down Expand Up @@ -89,9 +114,39 @@ Future<void> 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) {
// Refuse to write ciphertext over a resource whose ACL still grants
// the Public or Authenticated User agent class access — that grant
// only works while the resource stays plaintext. This only fires when
// [wantEncrypted] was forced by an explicit `encrypted: true`; the
// auto-detect path above already mirrors the resource's actual
// current state, so a resource that's genuinely still plaintext never
// trips it. (When the caller lacks acl:Control on the resource, the
// ACL read fails closed to "no grant found" rather than blocking the
// write — see [hasPublicOrAuthUserGrant].)

if (encrypted == true &&
(key != null || hasInheritedKey(remoteFileContent, fileUrl)) &&
await hasPublicOrAuthUserGrant(fileUrl)) {
throw PublicShareEncryptionConflictException(
'Refusing to write encrypted content to "$fileUrl": its ACL '
'grants Public/Authenticated User access, which requires the '
'resource to stay plaintext. Ask the resource owner to call '
'revokePermission() to remove that grant (it re-encrypts the '
'resource as part of revocation), or omit "encrypted" (or pass '
'encrypted: false) to preserve its current plaintext state.',
);
}

if (wantEncrypted && key != null) {
// Get file path
// final filePath =
// fileUrl.replaceAll(fileOwnerWebId.replaceAll(profCard, ''), '');
Expand All @@ -108,7 +163,7 @@ Future<void> 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, ''), '');
Expand Down
81 changes: 76 additions & 5 deletions lib/src/solid/write_pod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,24 @@

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';
import 'package:solidpod/src/solid/utils/io_helper.dart';
import 'package:solidpod/src/solid/utils/key_inheritance.dart';
import 'package:solidpod/src/solid/utils/misc.dart';
import 'package:solidpod/src/solid/utils/permission.dart' show genAclTurtle;
import 'package:solidpod/src/solid/utils/permission.dart'
show genAclTurtle, hasPublicOrAuthUserGrant;
import 'package:solidpod/src/solid/write_external_pod.dart'
show writeExternalPod;

Expand All @@ -58,7 +63,28 @@ 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.
///
/// Passing `true`/`false` explicitly (or setting [inheritKeyFrom])
/// overrides the mirroring above and forces that encryption state,
/// because some callers genuinely need to — e.g. toggling a resource's
/// privacy, or restoring a backed-up encryption state. Leave [encrypted]
/// unset whenever you're only touching content and not intentionally
/// changing whether it's encrypted. If forcing encryption would break an
/// active Public/Authenticated sharing grant (i.e. the resource's ACL
/// still grants that class access), the call throws
/// [PublicShareEncryptionConflictException] instead of silently
/// stranding the grant — call `revokePermission` to remove the grant
/// first (it handles re-encrypting the resource itself).
/// - [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])
Expand All @@ -80,7 +106,7 @@ import 'package:solidpod/src/solid/write_external_pod.dart'
Future<void> writePod(
String filePath,
String fileContent, {
bool encrypted = true,
bool? encrypted,
bool createAcl = true,
bool overwrite = false,
PathType pathType = PathType.relativeToData,
Expand Down Expand Up @@ -133,6 +159,51 @@ Future<void> 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Jess, thanks for the changes. As I briefly mentioned in the standup. A developer will be able to skip the check of a file's current encryption state by calling writePod() with arguments:

  • encrypted=true
  • overwrite=true

I wonder if any feedback is necessary in such cases (e.g., a warning message, an error or exception)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @cdawei I've added an exception to catch the scenario of encrypted = true, overwrite=true on an auth/public shared resource.

}

// Refuse to write ciphertext over a resource whose ACL still grants the
// Public or Authenticated User agent class access. That grant only works
// while the resource stays plaintext (those agent classes cannot be
// issued an individual decryption key), so a resource in this state was
// deliberately decrypted in place for sharing (see `decryptFileInPlace`).
// This only fires when [resolvedEncrypted] was forced to `true` by an
// explicit `encrypted: true` or by [inheritKeyFrom] — the auto-detect
// path above already mirrors the resource's actual current state, so it
// never trips this for a resource that's genuinely still plaintext.

if (overwrite &&
status == ResourceStatus.exist &&
(resolvedEncrypted || inheritKeyFrom != null) &&
await hasPublicOrAuthUserGrant(fileUrl)) {
throw PublicShareEncryptionConflictException(
'Refusing to write encrypted content to "$filePath": its ACL grants '
'Public/Authenticated User access, which requires the resource to '
'stay plaintext. Call revokePermission() to remove that grant '
'(it re-encrypts the resource as part of revocation), or omit '
'"encrypted" (or pass encrypted: false) to preserve its current '
'plaintext state.',
);
}

Key? encKey;
String? inheritKeyUrl;
if (inheritKeyFrom != null) {
Expand All @@ -143,7 +214,7 @@ Future<void> writePod(
);
}

if (encrypted || inheritKeyFrom != null) {
if (resolvedEncrypted || inheritKeyFrom != null) {
if (!fileUrl.endsWith('.ttl')) {
throw Exception(
'Encrypted text file should be in turtle format, '
Expand All @@ -154,7 +225,7 @@ Future<void> writePod(
encKey = await configureEncKey(fileUrl, inheritKeyUrl: inheritKeyUrl);
}

switch (await checkResourceStatus(fileUrl)) {
switch (status) {
case ResourceStatus.exist:
if (overwrite) {
debugPrint('NOTE: Overwriting existing file "$filePath"');
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: solidpod
description: Support access to private data from PODs on Solid servers.
version: 1.0.15
version: 1.0.16
homepage: https://github.com/anusii/solidpod

environment:
Expand Down
Loading