fix(privacy): a device without the keys writes no content where the account has keys (ADR 0019)

Such a device opens no record, so every tree looks readable to it: it
wrote cards, notes, paths, steps, answers, PDFs and files into private
trees readable. Every content write now asks for its key through one place
that refuses there (keyToWriteIn, and each item's write key), before any
row is inserted; reading is unchanged. That the account has keys is kept
once this device saw key rows, so withholding them later and answering no
changes nothing.

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-22 11:48:10 +02:00
commit bb44a7d65d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 114 additions and 9 deletions

View file

@ -99,7 +99,7 @@ class AssetRepository(
bytes, bytes,
fileName: fileName, fileName: fileName,
mimeType: mimeType, mimeType: mimeType,
keyId: studysetId == null ? null : await _sealing.keyOfStudyset(studysetId), keyId: studysetId == null ? null : await _sealing.keyToWriteIn(studysetId),
); );
/// Adds [bytes] as [add] does, for the item [itemId] of [kind]: sealed /// Adds [bytes] as [add] does, for the item [itemId] of [kind]: sealed

View file

@ -14,6 +14,11 @@ abstract interface class AssetSealing() {
/// The key [studysetId]'s tree is encrypted with; null while readable. /// The key [studysetId]'s tree is encrypted with; null while readable.
Future<String?> keyOfStudyset(String studysetId); Future<String?> keyOfStudyset(String studysetId);
/// [keyOfStudyset] for a file about to be added there; throws where this
/// device may write nothing (it lacks the keys of an account that has
/// them, the final reading of 22 Oct).
Future<String?> keyToWriteIn(String studysetId);
/// The key of the tree the item [id] of [kind] is in; null while /// The key of the tree the item [id] of [kind] is in; null while
/// readable, or for an item that isn't there. /// readable, or for an item that isn't there.
Future<String?> keyOfItem(ItemKind kind, String id); Future<String?> keyOfItem(ItemKind kind, String id);

View file

@ -80,6 +80,7 @@ class ContentKeys(
_unlocked || _unlocked ||
ledger.registerHasKeys == true || ledger.registerHasKeys == true ||
_rows.isNotEmpty || _rows.isNotEmpty ||
ledger.keysSeen ||
ledger.studysetsWithFacts.isNotEmpty || ledger.studysetsWithFacts.isNotEmpty ||
ledger.personalSealed; ledger.personalSealed;
@ -110,6 +111,10 @@ class ContentKeys(
} }
} }
_rows = await _gateway.all(); _rows = await _gateway.all();
// Remembered, so withholding them later changes nothing here.
if (_rows.isNotEmpty) {
ledger.keysSeen = true;
}
if (!_unlocked) { if (!_unlocked) {
return; return;
} }

View file

@ -163,6 +163,25 @@ class Privacy(
return treeKeyIn(await _structure(), studysetId); return treeKeyIn(await _structure(), studysetId);
} }
/// [keyOfStudyset] for something about to be written into
/// [studysetId]'s tree: refused on a device without the keys of an
/// account that has them ([_refuseKeyless]).
@override
Future<String?> keyToWriteIn(String studysetId) async {
_refuseKeyless();
return await keyOfStudyset(studysetId);
}
/// Refuses every write of content on a device without the account's keys
/// where the account has keys (the final reading of 22 Oct): it can open
/// no record, so it can't tell which trees are private, and every tree
/// would look readable to it.
void _refuseKeyless() {
if (!keys.canEncrypt && keys.accountHasKeys) {
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
}
}
/// [keyOfStudyset] on [parents] already read, for reading many rows. /// [keyOfStudyset] on [parents] already read, for reading many rows.
String? treeKeyIn(Map<String, String?> parents, String studysetId) => String? treeKeyIn(Map<String, String?> parents, String studysetId) =>
_nearestPrivate(parents, studysetId); _nearestPrivate(parents, studysetId);
@ -282,8 +301,12 @@ class Privacy(
} }
/// The key [item], in a tree with [treeKey], is written with: the key the /// The key [item], in a tree with [treeKey], is written with: the key the
/// item has while it still holds, else the tree's. /// item has while it still holds, else the tree's. Refused on a device
String? _writeKey(String item, String? treeKey) => _itemKey(item) ?? treeKey; /// without the keys of an account that has them.
String? _writeKey(String item, String? treeKey) {
_refuseKeyless();
return _itemKey(item) ?? treeKey;
}
@override @override
Future<String?> keyOfItem(ItemKind kind, String id) => switch (kind) { Future<String?> keyOfItem(ItemKind kind, String id) => switch (kind) {

View file

@ -98,6 +98,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
final _foldedSettings = <String>{}; final _foldedSettings = <String>{};
String? _deviceId; String? _deviceId;
bool? _registerHasKeys; bool? _registerHasKeys;
var _keysSeen = false;
/// Whether what was kept couldn't be read: until the keys are opened /// Whether what was kept couldn't be read: until the keys are opened
/// again, which brings the signed records back, this device writes /// again, which brings the signed records back, this device writes
@ -165,6 +166,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
_deviceId = kept['device'] as String?; _deviceId = kept['device'] as String?;
// A no kept by an earlier version is asked again. // A no kept by an earlier version is asked again.
_registerHasKeys = kept['registerHasKeys'] == true ? true : null; _registerHasKeys = kept['registerHasKeys'] == true ? true : null;
_keysSeen = kept['keysSeen'] == true;
} }
/// The latest signed fact seen about [studysetId]'s privacy. /// The latest signed fact seen about [studysetId]'s privacy.
@ -296,6 +298,18 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
} }
} }
/// Whether this device ever saw the person's key rows: from then on the
/// account has keys here, whatever the instance withholds later (the
/// final reading of 22 Oct).
bool get keysSeen => _keysSeen;
set keysSeen(bool seen) {
if (seen && !_keysSeen) {
_keysSeen = true;
_changed();
}
}
/// The rewrites still to finish here, oldest first. /// The rewrites still to finish here, oldest first.
List<PendingRewrite> get pending => List.unmodifiable(_pending); List<PendingRewrite> get pending => List.unmodifiable(_pending);
@ -351,6 +365,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
'foldedSettings': [..._foldedSettings], 'foldedSettings': [..._foldedSettings],
'device': ?_deviceId, 'device': ?_deviceId,
'registerHasKeys': ?_registerHasKeys, 'registerHasKeys': ?_registerHasKeys,
'keysSeen': _keysSeen,
}); });
} on Object catch (error) { } on Object catch (error) {
log( log(

View file

@ -144,6 +144,9 @@ class SyncCardGateway(
required String position, required String position,
required ByteData payload, required ByteData payload,
}) async { }) async {
// Refused before anything is written where this device may write
// nothing (the final reading of 22 Oct).
await _privacy.keyToWriteIn(studysetId);
final card = await protocol.Card.db.insertRow( final card = await protocol.Card.db.insertRow(
_db, _db,
protocol.Card( protocol.Card(
@ -167,6 +170,7 @@ class SyncCardGateway(
if (cards.isEmpty) { if (cards.isEmpty) {
return const []; return const [];
} }
final keyId = await _privacy.keyToWriteIn(studysetId);
final rows = await protocol.Card.db.insert(_db, [ final rows = await protocol.Card.db.insert(_db, [
for (final card in cards) for (final card in cards)
protocol.Card( protocol.Card(
@ -177,7 +181,6 @@ class SyncCardGateway(
// The batch's positions are distinct, so they tell the stored rows apart // The batch's positions are distinct, so they tell the stored rows apart
// whatever order they come back in. // whatever order they come back in.
final idOf = {for (final row in rows) row.position: row.id!}; final idOf = {for (final row in rows) row.position: row.id!};
final keyId = await _privacy.keyOfStudyset(studysetId);
await protocol.CardRevision.db.insert(_db, [ await protocol.CardRevision.db.insert(_db, [
for (final card in cards) for (final card in cards)
_newRevision(idOf[card.position]!, const [], card.payload, keyId), _newRevision(idOf[card.position]!, const [], card.payload, keyId),
@ -241,7 +244,7 @@ class SyncCardGateway(
// out of a private tree into a readable one, which the person is asked // out of a private tree into a readable one, which the person is asked
// to make private first (the reading of 22 Oct). // to make private first (the reading of 22 Oct).
final from = await _privacy.keyOfCard(cardId); final from = await _privacy.keyOfCard(cardId);
final into = await _privacy.keyOfStudyset(studysetId); final into = await _privacy.keyToWriteIn(studysetId);
if (from != null && into == null) { if (from != null && into == null) {
throw const PrivacyRefused(PrivacyRefusal.movedIntoReadable); throw const PrivacyRefused(PrivacyRefusal.movedIntoReadable);
} }

View file

@ -77,7 +77,7 @@ class SyncLearningPathGateway(
final id = Privacy.newId(); final id = Privacy.newId();
final sealed = _privacy.seal( final sealed = _privacy.seal(
payload, payload,
keyId: await _privacy.keyOfStudyset(studysetId), keyId: await _privacy.keyToWriteIn(studysetId),
table: 'learning_path', table: 'learning_path',
id: id.uuid, id: id.uuid,
bound: 'studyset:$studysetId', bound: 'studyset:$studysetId',
@ -230,6 +230,9 @@ class SyncLearningPathGateway(
required String kind, required String kind,
required ByteData payload, required ByteData payload,
}) async { }) async {
// Refused before anything is written where this device may write
// nothing (the final reading of 22 Oct).
await _privacy.keyOfPath(pathId);
final step = await protocol.LearningPathStep.db.insertRow( final step = await protocol.LearningPathStep.db.insertRow(
_db, _db,
protocol.LearningPathStep( protocol.LearningPathStep(
@ -254,6 +257,7 @@ class SyncLearningPathGateway(
if (steps.isEmpty) { if (steps.isEmpty) {
return const []; return const [];
} }
final keyId = await _privacy.keyOfPath(pathId);
final rows = await protocol.LearningPathStep.db.insert(_db, [ final rows = await protocol.LearningPathStep.db.insert(_db, [
for (final step in steps) for (final step in steps)
protocol.LearningPathStep( protocol.LearningPathStep(
@ -265,7 +269,6 @@ class SyncLearningPathGateway(
// The batch's positions are distinct, so they tell the stored rows apart // The batch's positions are distinct, so they tell the stored rows apart
// whatever order they come back in. // whatever order they come back in.
final idOf = {for (final row in rows) row.position: row.id!}; final idOf = {for (final row in rows) row.position: row.id!};
final keyId = await _privacy.keyOfPath(pathId);
await protocol.LearningPathStepRevision.db.insert(_db, [ await protocol.LearningPathStepRevision.db.insert(_db, [
for (final step in steps) for (final step in steps)
_newRevision(idOf[step.position]!, const [], step.payload, keyId), _newRevision(idOf[step.position]!, const [], step.payload, keyId),

View file

@ -90,6 +90,9 @@ class SyncNoteGateway(
required String position, required String position,
required ByteData payload, required ByteData payload,
}) async { }) async {
// Refused before anything is written where this device may write
// nothing (the final reading of 22 Oct).
await _privacy.keyToWriteIn(studysetId);
final note = await protocol.Note.db.insertRow( final note = await protocol.Note.db.insertRow(
_db, _db,
protocol.Note( protocol.Note(

View file

@ -59,7 +59,7 @@ class SyncPdfSourceGateway(
final id = Privacy.newId(); final id = Privacy.newId();
final sealed = _privacy.seal( final sealed = _privacy.seal(
payload, payload,
keyId: await _privacy.keyOfStudyset(studysetId), keyId: await _privacy.keyToWriteIn(studysetId),
table: 'pdf_source', table: 'pdf_source',
id: id.uuid, id: id.uuid,
bound: 'studyset:$studysetId', bound: 'studyset:$studysetId',

View file

@ -79,7 +79,7 @@ class SyncStudysetGateway(
// device without the keys of an account that has them makes none // device without the keys of an account that has them makes none
// (PrivacyRefused). // (PrivacyRefused).
final keyId = parentId != null final keyId = parentId != null
? await _privacy.keyOfStudyset(parentId) ? await _privacy.keyToWriteIn(parentId)
: await _privacy.keyOfNewTopLevel(id.uuid); : await _privacy.keyOfNewTopLevel(id.uuid);
final sealed = _privacy.seal( final sealed = _privacy.seal(
payload, payload,

View file

@ -949,6 +949,51 @@ void main() {
); );
}); });
test("a device without the keys writes no card into a tree it can't "
'tell is private', () async {
final laptop = await device(account: alice);
final id = await privateStudyset(laptop, 'Diary');
final keyless = await device();
await expectLater(
keyless.cards.insertCard(
studysetId: id,
position: 'a0',
payload: cardPayload('SECRET from the keyless device'),
),
throwsA(isA<PrivacyRefused>()),
);
});
test('a device without the keys that once saw key rows keeps refusing '
'when the instance withholds them all and answers no', () async {
final laptop = await device(account: alice);
await privateStudyset(laptop, 'Diary');
final seen = MemoryPrivacyLedgerStore();
final keyless = await device(
seen: seen,
registerHasKeys: () async => false,
);
await keyless.privacy.ledger.flush();
await protocol.ContentKey.db.deleteWhere(
db,
where: (t) => t.id.notEquals(null),
);
final again = await device(
seen: seen,
registerHasKeys: () async => false,
);
await expectLater(
again.studysets.insert(
studysetPayload('After'),
DateTime.utc(2026, 10, 22),
),
throwsA(isA<PrivacyRefused>()),
);
});
test('a sweep left owed by a file never takes in edits nobody signed ' test('a sweep left owed by a file never takes in edits nobody signed '
'when it is finished', () async { 'when it is finished', () async {
final files = _FailingFiles(); final files = _FailingFiles();

View file

@ -34,6 +34,9 @@ class FakeAssetSealing({
@override @override
Future<String?> keyOfStudyset(String studysetId) async => keys[studysetId]; Future<String?> keyOfStudyset(String studysetId) async => keys[studysetId];
@override
Future<String?> keyToWriteIn(String studysetId) async => keys[studysetId];
@override @override
Future<String?> keyOfItem(ItemKind kind, String id) async => itemKeys[id]; Future<String?> keyOfItem(ItemKind kind, String id) async => itemKeys[id];