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:
parent
074d05ab4e
commit
a1c5ac3f86
1 changed files with 35261 additions and 198 deletions
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue