feat(privacy): the instance's side of private studysets (ADR 0019)

- `content_key`, a synced table in the person's space, keeps their data keys
  sealed to their account (a private studyset's, their personal one);
  `learning_path_answer` gains `keyId` and `nonce` for answers in private
  studysets. Additive only, on the server and the device.
- Publishing refuses a private studyset, and a tree holding a private row
  (`PublishRefusal.private`); a private studyset elsewhere no longer breaks
  publishing a readable one. Encrypted tags are named as the device names
  them (`tagNames`): a name for a tag that isn't the person's, or an
  encrypted tag of the release without one, refuses it
  (`tagNamesMismatch`); a readable tag keeps its own name.
- The connector leaves private studysets out and says how many there are.
- The learner count reads no payload any more: Count me is kept by the
  instance (`counted_learner`) and the device reports which publications
  its copies come from (`learning_copy`, accepted only while counted, each
  report replacing the last); only the reporter's own studysets count.
  Deleting a person deletes both.
- An upload says whether its file is encrypted: only a readable one over
  20 MB is checked to be a PDF, and an encrypted one may be 4 KiB larger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 20:52:35 +02:00
commit a1c5ac3f86
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 35261 additions and 198 deletions

View file

@ -75,12 +75,14 @@ void main() {
List<int> bytes, {
String? as,
Set<Scope> scopes = const {},
bool? encrypted,
}) async {
final hash = as ?? sha256.convert(bytes).toString();
final description = await endpoints.asset.startUpload(
signedIn(person, scopes: scopes),
hash,
bytes.length,
encrypted: encrypted,
);
if (description != null) {
await _upload(session, description, bytes);
@ -88,6 +90,7 @@ void main() {
return await endpoints.asset.finishUpload(
signedIn(person, scopes: scopes),
hash,
encrypted: encrypted,
);
}
@ -183,6 +186,48 @@ void main() {
);
});
test(
'an encrypted file (ADR 0019) over 20 MB is kept, as nothing can '
'look into it, a little larger than 50 MB too, and no larger',
() async {
const size = AssetEndpoint.maxOtherBytes + 1;
final sealed = Uint8List(size)..fillRange(0, size, 7);
final readable = Uint8List(size)..fillRange(0, size, 8);
const most =
AssetEndpoint.maxBytes + AssetEndpoint.encryptionOverhead;
expect(await add(alice, sealed, encrypted: true), isTrue);
// A readable file is still checked to be a PDF.
expect(await add(alice, readable, encrypted: false), isFalse);
expect(
await endpoints.asset.startUpload(
signedIn(alice),
imageHash,
most,
encrypted: true,
),
isNotNull,
);
await expectLater(
endpoints.asset.startUpload(
signedIn(alice),
imageHash,
most + 1,
encrypted: true,
),
throwsArgumentError,
);
await expectLater(
endpoints.asset.startUpload(
signedIn(alice),
imageHash,
AssetEndpoint.maxBytes + 1,
),
throwsArgumentError,
);
},
);
test('finishes no upload that never came', () async {
expect(
await endpoints.asset.finishUpload(signedIn(alice), imageHash),

View file

@ -549,7 +549,10 @@ void main() {
final second = jsonDecode(renewed.body) as Map;
expect(second['access_token'], isNot(first['access_token']));
expect(reused.status, 400);
expect(await call(second['access_token'], 'list_studysets'), '[]');
expect(
jsonDecode(await call(second['access_token'], 'list_studysets')),
{'studysets': <Object?>[]},
);
expect(
(await mcp(first['access_token'], {
'jsonrpc': '2.0',
@ -609,17 +612,23 @@ void main() {
List<(String, String)> cards = const [],
UuidValue? parent,
DateTime? deletedAt,
bool private = false,
}) => asPerson(person, (transaction) async {
final id = (await Studyset.db.insertRow(
session,
Studyset(
parentId: parent,
deletedAt: deletedAt,
payload: _json({
'schemaVersion': 4,
'title': title,
'description': _rich('About $title'),
}),
// A private studyset's payload is ciphertext (ADR 0019).
keyId: private ? 'studyset-key' : null,
nonce: private ? ByteData(24) : null,
payload: private
? ByteData.sublistView(Uint8List.fromList([0xff, 0xfe, 7]))
: _json({
'schemaVersion': 4,
'title': title,
'description': _rich('About $title'),
}),
),
transaction: transaction,
)).id!;
@ -740,6 +749,35 @@ void main() {
}
});
test('leaves out private studysets, saying how many, and reads '
'nothing in them', () async {
final diary = await studyset(
alice,
'Diary',
cards: [('secret', 'kept')],
private: true,
);
await studyset(alice, 'Inside', parent: diary, private: true);
await studyset(alice, 'Greek');
final token = (await tokensOf(alice))['access_token'];
final listed = jsonDecode(await call(token, 'list_studysets')) as Map;
expect(
[for (final s in listed['studysets'] as List) (s as Map)['title']],
['Greek'],
);
expect(listed['private_studysets'], 1);
expect(listed['private_note'], contains('end-to-end encrypted'));
expect(
await call(token, 'read_studyset', {'studyset_id': diary.uuid}),
contains('end-to-end encrypted'),
);
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'kept'}),
) as List;
expect(found, isEmpty);
});
test('leaves out a studyset waiting in Recently deleted, and what '
'is inside it', () async {
final japanese = await studyset(
@ -753,7 +791,9 @@ void main() {
final token = (await tokensOf(alice))['access_token'];
final listed =
jsonDecode(await call(token, 'list_studysets')) as List;
(jsonDecode(await call(token, 'list_studysets'))
as Map)['studysets']
as List;
expect([for (final s in listed) (s as Map)['title']], ['Greek']);
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'fire'}),
@ -773,7 +813,9 @@ void main() {
final token = (await tokensOf(alice))['access_token'];
final listed =
jsonDecode(await call(token, 'list_studysets')) as List;
(jsonDecode(await call(token, 'list_studysets'))
as Map)['studysets']
as List;
expect(
[for (final s in listed) (s as Map)['title']],
[

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
@ -71,50 +70,40 @@ void main() {
await PublishedStudyset.db.deleteRow(session, japanese);
});
ByteData json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
TestSessionBuilder signedIn(UuidValue person, {bool anonymous = false}) =>
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
anonymous ? const {} : {identified},
),
);
/// A person (with an identity unless [anonymous]), counted if
/// [counted], who studied a copy of Japanese on [daysAgo] (a review
/// in [mode] each), and since put it in Recently deleted if
/// [deleted].
Future<void> learner({
/// [counted], who studied a private copy of Japanese on [daysAgo] (a
/// review in [mode] each), which their device [reported], and since
/// put it in Recently deleted if [deleted]. Returns the copy.
Future<UuidValue> learner({
bool counted = true,
bool anonymous = false,
List<int> daysAgo = const [1, 2, 3],
String mode = 'review',
bool deleted = false,
bool reported = true,
}) async {
final person = (await AuthServices.instance.authUsers.create(
session,
scopes: anonymous ? const {} : {identified},
)).id;
people.add(person);
await asPerson(session, person, (transaction) async {
await PersonSettings.db.insertRow(
session,
PersonSettings(
payload: json({
'schemaVersion': 8,
'studyTags': <String, Object?>{},
'countLearning': ?(counted ? true : null),
}),
),
transaction: transaction,
);
final studyset = await asPerson(session, person, (transaction) async {
// A private copy: its origin is encrypted (ADR 0019).
final studyset = (await Studyset.db.insertRow(
session,
Studyset(
deletedAt: deleted ? now : null,
payload: json({
'schemaVersion': 5,
'title': 'Japanese',
'published': {
'publication': japanese.publicId.uuid,
'item': 'root',
'version': 1,
},
}),
keyId: 'studyset-key',
nonce: ByteData(24),
payload: ByteData.sublistView(Uint8List.fromList([0xff, 1, 2])),
),
transaction: transaction,
)).id!;
@ -136,7 +125,35 @@ void main() {
transaction: transaction,
);
}
return studyset;
});
if (counted && !anonymous) {
await endpoints.learningCount.setCounted(signedIn(person), true);
} else if (counted) {
// Only the endpoint is refused an anonymous person; their row is
// made as a modified server would, and still never counts.
await CountedLearner.db.insertRow(
session,
CountedLearner(authUserId: person, since: now),
);
await LearningCopy.db.insertRow(
session,
LearningCopy(
authUserId: person,
publicationId: japanese.publicId.uuid,
studysetId: studyset,
),
);
}
if (reported && !anonymous) {
await endpoints.learningCount.reportCopies(signedIn(person), [
CopyReport(
publicationId: japanese.publicId.uuid,
studysetIds: [studyset],
),
]);
}
return studyset;
}
Future<int> counted() async {
@ -148,7 +165,7 @@ void main() {
}
test('counts the people with an identity who chose to be, studying a '
'copy on 3 days of the last 30', () async {
'copy their device reported on 3 days of the last 30', () async {
await learner();
await learner(daysAgo: [1, 1, 1, 2, 29]);
@ -156,24 +173,108 @@ void main() {
});
test('leaves out who chose not to be counted, anonymous people, too '
'little or too long ago, and a copy deleted', () async {
'little or too long ago, a copy deleted, and one never '
'reported', () async {
await learner(counted: false);
await learner(anonymous: true);
await learner(daysAgo: [1, 2]);
await learner(daysAgo: [31, 32, 40]);
await learner(mode: 'reset');
await learner(deleted: true);
await learner(reported: false);
expect(await counted(), 0);
});
test('a count goes down when learning stops', () async {
test("a studyset reported that isn't the reporter's own never "
'counts', () async {
final alices = await learner(reported: false);
await learner(daysAgo: const []);
final mallory = people.last;
await endpoints.learningCount.reportCopies(signedIn(mallory), [
CopyReport(
publicationId: japanese.publicId.uuid,
studysetIds: [alices],
),
]);
expect(await counted(), 0);
});
test('a count goes down when learning stops, or the person no longer '
'wants to be counted', () async {
await learner();
expect(await counted(), 1);
await deletePerson(session, people.removeLast());
await endpoints.learningCount.setCounted(
signedIn(people.last),
false,
);
expect(await counted(), 0);
expect(
await LearningCopy.db.count(
session,
where: (t) => t.authUserId.equals(people.last),
),
0,
);
await learner();
expect(await counted(), 1);
await deletePerson(session, people.removeLast());
expect(await counted(), 0);
});
test('reports are kept only while the person chose to be counted, '
'each replacing the last', () async {
final copy = await learner(counted: false);
final person = people.last;
final report = [
CopyReport(
publicationId: japanese.publicId.uuid,
studysetIds: [copy],
),
];
expect(
await endpoints.learningCount.reportCopies(signedIn(person), report),
isFalse,
);
expect(
await endpoints.learningCount.counted(signedIn(person)),
isFalse,
);
await endpoints.learningCount.setCounted(signedIn(person), true);
expect(await endpoints.learningCount.counted(signedIn(person)), isTrue);
expect(
await endpoints.learningCount.reportCopies(signedIn(person), report),
isTrue,
);
expect(await counted(), 1);
await endpoints.learningCount.reportCopies(signedIn(person), []);
expect(await counted(), 0);
});
test('anonymous people are refused', () async {
final person = const Uuid().v7obj();
await expectLater(
endpoints.learningCount.setCounted(
signedIn(person, anonymous: true),
true,
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
await expectLater(
endpoints.learningCount.reportCopies(
signedIn(person, anonymous: true),
[],
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
expect(await CountedLearner.db.count(session), 0);
});
},
databaseInterceptor: syncDatabaseInterceptor,

View file

@ -86,11 +86,13 @@ void main() {
UuidValue studyset, {
String slug = 'japanese',
String notes = '',
Map<String, String>? tagNames,
}) => endpoints.publish.publish(
signedIn(person),
studyset,
slug,
notes,
tagNames: tagNames,
);
Matcher refused(PublishRefusal reason, [List<UuidValue>? items]) =>
@ -644,6 +646,152 @@ void main() {
expect(await PublishedStudyset.db.count(session), 0);
});
test('a private studyset is never published, nor one holding a '
'private row', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
final diary = await asPerson(
alice,
(transaction) async => (await Studyset.db.insertRow(
session,
Studyset(
keyId: 'studyset-key',
nonce: ByteData(24),
payload: _ciphertext,
),
transaction: transaction,
)).id!,
);
await expectLater(
publish(alice, diary),
refused(PublishRefusal.private),
);
// A private topic inside a readable tree makes it no release either.
await asPerson(
alice,
(transaction) => Studyset.db.updateRow(
session,
Studyset(
id: diary,
parentId: content.root,
keyId: 'studyset-key',
nonce: ByteData(24),
payload: _ciphertext,
),
columns: (t) => [t.parentId],
transaction: transaction,
),
);
await expectLater(
publish(alice, content.root),
refused(PublishRefusal.private),
);
expect(await PublishedStudyset.db.count(session), 0);
});
test("another private studyset of the person's doesn't keep a readable "
'one from being published', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
await asPerson(
alice,
(transaction) => Studyset.db.insertRow(
session,
Studyset(
keyId: 'studyset-key',
nonce: ByteData(24),
payload: _ciphertext,
),
transaction: transaction,
),
);
final publication = await publish(alice, content.root);
expect(publication.releases, hasLength(1));
});
test("an encrypted tag is named as the person's device names it, and "
'without a name the release is refused', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
final tag = (await CardTag.db.findFirstRow(
session,
where: (t) => t.cardId.equals(content.word),
))!.tagId;
// The person's tags are encrypted with their personal key (ADR 0019).
await asPerson(
alice,
(transaction) => Tag.db.updateRow(
session,
Tag(
id: tag,
keyId: 'personal-key',
nonce: ByteData(24),
payload: _ciphertext,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
),
);
await expectLater(
publish(alice, content.root),
refused(PublishRefusal.tagNamesMismatch),
);
final publication = await publish(
alice,
content.root,
tagNames: {tag.uuid: 'JLPT-N4'},
);
final word = await fileAt(
publication,
'v1',
'cards/${content.word.uuid}.json',
);
expect(word['tags'], ['JLPT-N4']);
});
test("a name sent for a tag that isn't the person's is refused, and "
'a readable tag keeps its own name', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
final bobs = await asPerson(
bob,
(transaction) async => (await Tag.db.insertRow(
session,
Tag(payload: _json({'schemaVersion': 1, 'name': 'Bob'})),
transaction: transaction,
)).id!,
);
final tag = (await CardTag.db.findFirstRow(
session,
where: (t) => t.cardId.equals(content.word),
))!.tagId;
await expectLater(
publish(alice, content.root, tagNames: {bobs.uuid: 'Bob'}),
refused(PublishRefusal.tagNamesMismatch),
);
await expectLater(
publish(alice, content.root, tagNames: {'not an id': 'x'}),
refused(PublishRefusal.tagNamesMismatch),
);
final publication = await publish(
alice,
content.root,
tagNames: {tag.uuid: 'Renamed'},
);
final word = await fileAt(
publication,
'v1',
'cards/${content.word.uuid}.json',
);
expect(word['tags'], ['JLPT-N5']);
});
test('files the content names must have reached the instance', () async {
await profile(alice, 'alice');
final content = await japanese(alice, stored: false);
@ -1018,6 +1166,11 @@ void main() {
final _syncDatabases = Expando<OfflineSyncDatabase>();
/// [json] as a synced payload.
/// A payload encrypted on a device (ADR 0019): not JSON, nor UTF-8.
final _ciphertext = ByteData.sublistView(
Uint8List.fromList([0xff, 0xfe, 0x00, 0x81, 0x9c]),
);
ByteData _json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));

View file

@ -38,6 +38,8 @@ import 'package:halkyon_learn_server/src/generated/discussion/discussion_target.
as _ike2gc23;
import 'package:halkyon_learn_server/src/generated/discussion/discussion_view.dart'
as _ikk5e562;
import 'package:halkyon_learn_server/src/generated/explore/copy_report.dart'
as _ipludy4j;
import 'package:halkyon_learn_server/src/generated/explore/explore_entry.dart'
as _iscq690j;
import 'package:halkyon_learn_server/src/generated/explore/explore_query.dart'
@ -205,6 +207,8 @@ class TestEndpoints {
late final _ExploreEndpoint explore;
late final _LearningCountEndpoint learningCount;
late final _FeedbackEndpoint feedback;
late final _InboxEndpoint inbox;
@ -249,6 +253,10 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
learningCount = _LearningCountEndpoint(
endpoints,
serializationManager,
);
feedback = _FeedbackEndpoint(
endpoints,
serializationManager,
@ -291,8 +299,9 @@ class _AssetEndpoint {
_ida.Future<String?> startUpload(
_ist.TestSessionBuilder sessionBuilder,
String sha256,
int byteSize,
) async {
int byteSize, {
bool? encrypted,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
@ -307,6 +316,7 @@ class _AssetEndpoint {
parameters: _ist.testObjectToJson({
'sha256': sha256,
'byteSize': byteSize,
'encrypted': encrypted,
}),
serializationManager: _serializationManager,
);
@ -325,8 +335,9 @@ class _AssetEndpoint {
_ida.Future<bool> finishUpload(
_ist.TestSessionBuilder sessionBuilder,
String sha256,
) async {
String sha256, {
bool? encrypted,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
@ -338,7 +349,10 @@ class _AssetEndpoint {
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'asset',
methodName: 'finishUpload',
parameters: _ist.testObjectToJson({'sha256': sha256}),
parameters: _ist.testObjectToJson({
'sha256': sha256,
'encrypted': encrypted,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
@ -1535,6 +1549,107 @@ class _ExploreEndpoint {
}
}
class _LearningCountEndpoint {
_LearningCountEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<bool> counted(_ist.TestSessionBuilder sessionBuilder) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'learningCount',
method: 'counted',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'learningCount',
methodName: 'counted',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<bool>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> setCounted(
_ist.TestSessionBuilder sessionBuilder,
bool counted,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'learningCount',
method: 'setCounted',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'learningCount',
methodName: 'setCounted',
parameters: _ist.testObjectToJson({'counted': counted}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<bool> reportCopies(
_ist.TestSessionBuilder sessionBuilder,
List<_ipludy4j.CopyReport> copies,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'learningCount',
method: 'reportCopies',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'learningCount',
methodName: 'reportCopies',
parameters: _ist.testObjectToJson({'copies': copies}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<bool>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _FeedbackEndpoint {
_FeedbackEndpoint(
this._endpointDispatch,
@ -1922,6 +2037,7 @@ class _PublishEndpoint {
String slug,
String notes, {
String? description,
Map<String, String>? tagNames,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1939,6 +2055,7 @@ class _PublishEndpoint {
'slug': slug,
'notes': notes,
'description': description,
'tagNames': tagNames,
}),
serializationManager: _serializationManager,
);