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

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)));