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:
parent
ee44ca2a44
commit
bb44a7d65d
12 changed files with 114 additions and 9 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue