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
8 changes: 8 additions & 0 deletions lib/solidpod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export 'src/solid/utils/rdf.dart' show turtleToTripleMap, tripleMapToTurtle;

export 'src/solid/utils/misc.dart'
show
applyPublicShareDecryptedHookInPlace,
createContainer,
createDir,
isPathInCurrentApp,
Expand Down Expand Up @@ -176,6 +177,13 @@ export 'src/solid/write_pod.dart';

export 'src/solid/grant_permission.dart';

/// Application-level hooks invoked by solidpod when a resource is
/// decrypted in place for public/auth-user sharing, or re-encrypted
/// in place after such sharing is revoked.

export 'src/solid/public_sharing_hooks.dart'
show PublicSharingHooks, PublicSharingContentTransformer;

/// The function to read permissions given to a resource

export 'src/solid/read_permission.dart';
Expand Down
61 changes: 0 additions & 61 deletions lib/src/solid/api/grant_permission_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ library;

import 'dart:convert';

import 'package:encrypter_plus/encrypter_plus.dart';
import 'package:rdflib/rdflib.dart';

import 'package:solidpod/src/solid/api/rest_api.dart';
Expand Down Expand Up @@ -250,63 +249,3 @@ Future<void> copySharedKey(
}
}
}

/// Copy shared individual key, either publicly or for all authenticated users
Future<void> copySharedKeyUserClass(
Key indKey,
String resourceUrl,
List<dynamic> permissionList,
RecipientType recipientType,
) async {
// File contents variables
var userClassIndKeyFileUrl = '';
var aclContentStr = '';

if (recipientType == RecipientType.public) {
// Get the url of the file
userClassIndKeyFileUrl = await getFileUrl(await getPubIndKeyPath());

// Create ACL content for the file
aclContentStr = await genAclTurtle(
userClassIndKeyFileUrl,
ownerAccess: {AccessMode.read, AccessMode.write, AccessMode.control},
publicAccess: {AccessMode.read},
);
} else if (recipientType == RecipientType.authUser) {
// Get the url of the file
userClassIndKeyFileUrl = await getFileUrl(await getAuthUserIndKeyPath());

// Create ACL content for the file
aclContentStr = await genAclTurtle(
userClassIndKeyFileUrl,
ownerAccess: {AccessMode.read, AccessMode.write, AccessMode.control},
authUserAccess: {AccessMode.read},
);
}

// Check if individual key file exists. If not create a file
if (await checkResourceStatus(userClassIndKeyFileUrl, isFile: true) ==
ResourceStatus.notExist) {
// If file does not exist create a ttl file
final userClassIndKeyFileContent = await genUserClassIndKeyTTLStr([
resourceUrl,
indKey.base64,
]);

await createResource(
userClassIndKeyFileUrl,
content: userClassIndKeyFileContent,
);

// Also create a corresponding acl file
await createResource('$userClassIndKeyFileUrl.acl', content: aclContentStr);
} else {
// Update the existing file using a sparql query
final prefix = '${solidTermsNS.prefix}: <$appsTerms>';
final insertQuery =
'PREFIX $prefix INSERT DATA {<$resourceUrl> ${solidTermsNS.prefix}:encryptionKey "${indKey.base64}"};';

// Update the file using the insert query
await updateFileByQuery(userClassIndKeyFileUrl, insertQuery);
}
}
140 changes: 86 additions & 54 deletions lib/src/solid/grant_permission.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,71 +140,103 @@ Future<SolidFunctionCallStatus> grantPermission({
isFile,
);

// Check if the file is encrypted
final fileIsEncrypted = await checkFileEnc(
// Check whether an individual encryption key has been recorded
// for the resource. This is the legacy "is encrypted" signal:
// it tells us the user originally chose to protect the file but
// it does not tell us whether the bytes on the server are still
// encrypted at this moment (see `isFileContentEncrypted`).
final fileHasIndKey = await checkFileEnc(
resourceUrl,
isExternalRes: isExternalRes,
);
debugPrint(
'[grantPermission] resourceUrl="$resourceUrl" '
'recipientType=$recipientType '
'hasSpecificRecipients=$hasSpecificRecipients '
'fileHasIndKey=$fileHasIndKey '
'isExternalRes=$isExternalRes',
);

if (hasSpecificRecipients && fileHasIndKey) {
// Permission granted to specific individuals or groups: share
// the individual encryption key with each recipient via their
// POD so they can decrypt the file content.

// If the file is encrypted then share the individual encryption key
// with the receiver
if (fileIsEncrypted) {
// Get the individual encryption key for the file
final indKey = isExternalRes
? await KeyManager.getSharedIndividualKey(resourceUrl)
: await KeyManager.getIndividualKey(resourceUrl);
assert(indKey != null);

// If permission granted to specific recipients
if (hasSpecificRecipients) {
// For each recipient share the individual encryption key

for (final recipientWebId in recipientWebIdList) {
// Setup recipient's public key
final recipientPubKey = RecipientPubKey(
recipientWebId: recipientWebId as String,
);

// Encrypt individual key
final sharedIndKey = await recipientPubKey.encryptData(
indKey!.base64,
);

// Encrypt resource URL
final sharedResPath = await recipientPubKey.encryptData(
resourceUrl,
);

// Encrypt the list of permissions
permissionList.sort();
final sharedAccessList = await recipientPubKey.encryptData(
permissionList.join(','),
);

// Generate unique ID for the resource being shared
final resUniqueId = getUniqueIdResUrl(
resourceUrl,
recipientWebId,
);

// Copy shared content to recipient's POD
await copySharedKey(
recipientWebId,
resUniqueId,
sharedIndKey,
sharedResPath,
sharedAccessList,
);
}
} else {
// if the recipient type is either public or authenticated agent
// Copy the key to a publicly available or authenticated user accessible file
await copySharedKeyUserClass(
indKey!,
for (final recipientWebId in recipientWebIdList) {
// Setup recipient's public key
final recipientPubKey = RecipientPubKey(
recipientWebId: recipientWebId as String,
);

// Encrypt individual key
final sharedIndKey = await recipientPubKey.encryptData(
indKey!.base64,
);

// Encrypt resource URL
final sharedResPath = await recipientPubKey.encryptData(
resourceUrl,
);

// Encrypt the list of permissions
permissionList.sort();
final sharedAccessList = await recipientPubKey.encryptData(
permissionList.join(','),
);

// Generate unique ID for the resource being shared
final resUniqueId = getUniqueIdResUrl(
resourceUrl,
recipientWebId,
);

// Copy shared content to recipient's POD
await copySharedKey(
recipientWebId,
resUniqueId,
sharedIndKey,
sharedResPath,
sharedAccessList,
);
}
} else if (!hasSpecificRecipients) {
// Permission granted to the Public or Authenticated User class:
// these recipients cannot be issued an individual key (they
// have no POD/private key under our control), so the only way
// for them to actually read the resource by navigating to its
// URL is for the file itself to be plaintext on the server.
//
// Always check the actual bytes on the server here, even when
// `fileHasIndKey` is false: this is more robust against the
// legacy case where the ind-key record might have been
// dropped while the file content is still encrypted.
//
// The individual key is intentionally kept in `ind-keys.ttl`
// so that the file can be re-encrypted later if public or
// authenticated-user access is revoked.
if (await isFileContentEncrypted(resourceUrl)) {
debugPrint('[grantPermission] decrypting "$resourceUrl" for '
'public/authUser sharing');
await decryptFileInPlace(
resourceUrl,
permissionList,
recipientType,
isExternalRes: isExternalRes,
);
} else {
debugPrint('[grantPermission] outer layer already plaintext: '
'"$resourceUrl"');

// Even when the outer encrypted-TTL wrapper is gone, the
// host app may still have an inner encryption layer that
// needs unwrapping (e.g. NotePod's per-note noteContent).
// Invoke the public-share decrypted hook directly so that
// a file left in this mixed state by an older version of
// solidpod is still made fully readable.
await applyPublicShareDecryptedHookInPlace(resourceUrl);
}
}

Expand Down
65 changes: 65 additions & 0 deletions lib/src/solid/public_sharing_hooks.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/// Application-level hooks for the public/authenticated-user sharing lifecycle.
///
/// Copyright (C) 2026, Software Innovation Institute, ANU.
///
/// Licensed under the MIT License (the "License").
///
/// License: https://choosealicense.com/licenses/mit/.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
///
/// Authors: Tony Chen

library;

/// Signature of a transformer that rewrites the (plaintext) content of a
/// resource as part of the public/authenticated-user sharing lifecycle.

typedef PublicSharingContentTransformer = Future<String> Function(
String resourceUrl,
String content,
);

/// Hook points that let host applications layer additional content
/// transformations on top of solidpod's own encrypted-TTL wrapper when
/// files are shared with the Public or Authenticated User classes.

class PublicSharingHooks {
PublicSharingHooks._();

/// Invoked by [decryptFileInPlace] after solidpod has unwrapped the
/// outer encrypted-TTL layer, immediately before writing the result
/// back to the server. The returned value is what gets persisted.

static PublicSharingContentTransformer? onPublicShareDecrypted;

/// Invoked by [encryptFileInPlace] before solidpod re-wraps a file
/// in its outer encrypted-TTL layer (e.g. after public/auth-user
/// access is revoked). The returned value is what then gets fed
/// into [getEncTTLStr] to produce the encrypted payload at rest.

static PublicSharingContentTransformer? onPublicShareRevoked;

/// Reset all registered hooks. Primarily intended for tests.

static void clear() {
onPublicShareDecrypted = null;
onPublicShareRevoked = null;
}
}
39 changes: 29 additions & 10 deletions lib/src/solid/revoke_permission.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ library;

import 'dart:core';

import 'package:flutter/foundation.dart' show debugPrint;

import 'package:solidpod/src/solid/api/common_permission.dart';
import 'package:solidpod/src/solid/api/rest_api.dart';
import 'package:solidpod/src/solid/api/revoke_permission_api.dart';
Expand Down Expand Up @@ -106,18 +108,21 @@ Future<SolidFunctionCallStatus> revokePermission({
recipientWebIdList.add(recipientIndOrGroupWebId);
}

// Check if the file is encrypted
final fileIsEncrypted = await checkFileEnc(
// Whether the resource has an associated individual encryption key.
// This is the legacy "is encrypted" signal: it reflects the user's
// original intent but does not tell us whether the bytes on the
// server are encrypted at this moment (a file shared with the
// Public or Authenticated User class is decrypted in place by
// `grantPermission`).
final fileHasIndKey = await checkFileEnc(
resourceUrl,
isExternalRes: isExternalRes,
);

// If the file is encrypted then remove the individual key from relavant
// users/ user classes
if (fileIsEncrypted) {
// If access revoked for specific recipients, remove key
// from recipient's POD
if (fileHasIndKey) {
if (specificRecipientTypeList.contains(recipientType)) {
// Remove the per-recipient copy of the individual key from each
// recipient's POD when access is revoked from specific recipients.
for (final recipientWebId in recipientWebIdList) {
// Check if POD file structure is still there
if (await checkPodInitialised(recipientWebId as String)) {
Expand All @@ -129,9 +134,11 @@ Future<SolidFunctionCallStatus> revokePermission({
}
}
} else {
// if the recipient type is either public or authenticated agent
// Remove the key from the publicly available or authenticated user
// accessible file
// Best-effort cleanup of the legacy public/authenticated-user
// shared key file. Newer versions of solidpod no longer write to
// these files when granting public/auth-user access (the resource
// is decrypted in place instead), but PODs initialised by older
// versions may still contain stale entries.
await removeSharedKeyUserClass(resourceUrl, recipientType);
}
}
Expand All @@ -145,6 +152,18 @@ Future<SolidFunctionCallStatus> revokePermission({
isFile: isFile,
);

// When revoking access from the Public or Authenticated User class,
// re-encrypt the resource if it had previously been decrypted in
// place for sharing. The presence of an individual key indicates
// the user originally intended the file to be encrypted at rest;
// `encryptFileInPlace` is a no-op when the file is already in the
// encrypted TTL format or when no individual key is available.
if (fileHasIndKey && !specificRecipientTypeList.contains(recipientType)) {
debugPrint('[revokePermission] re-encrypting "$resourceUrl" after '
'revoking $recipientType access');
await encryptFileInPlace(resourceUrl, isExternalRes: isExternalRes);
}

// Add log entry to owner, granter, and receiver permission log files

for (final recipientWebId in recipientWebIdList) {
Expand Down
Loading
Loading