fix(privacy): what decides a row's key is bound into its seal (ADR 0019)
The final reading of 22 Oct found that an item learned its first key from any sealed row pointed at it: the instance pointed a revision of private B at a card of private A, the card's next edit was sealed under B's key, and making B readable opened it. Every sealed payload and every signed readable one is now bound to the row it belongs to where that decides its key (a revision's card, note or step, an answer's step, a path's or a PDF's studyset; sealed format v2, the old dropped as nothing was released), so a re-pointed row doesn't open and reads as locked, and an item learns its key only from its own rows, before any write decides on one. A studyset's parentId isn't bound (a move and an edit on two devices merge column by column); instead each studyset on the way up keeps its own key before any above it counts. Also from the reading: - whether the account has keys fails closed: any sign counts (keys open, the register's yes, key rows, records, sealed tags and settings), the register's no is never kept and it is asked again; - a pending rewrite keeps whether it may take in unsigned rows, a sweep's never does, and a sweep never takes over the person's own rewrite; - settings carry one version vector per person, rows folded away here are never read again, a row never seen is read only if it brings news, and rows without a vector (HLKS) are dropped; - a tree stays partly readable while rows a sweep can't move remain, which are looked at again once more keys open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7b3e2f02a8
commit
037abb0cc6
14 changed files with 759 additions and 125 deletions
|
|
@ -64,6 +64,7 @@ class SyncAssetGateway(
|
||||||
payload,
|
payload,
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'asset',
|
table: 'asset',
|
||||||
|
bound: '',
|
||||||
id: id,
|
id: id,
|
||||||
item: 'asset:$id',
|
item: 'asset:$id',
|
||||||
);
|
);
|
||||||
|
|
@ -111,6 +112,7 @@ class SyncAssetGateway(
|
||||||
final opened = _privacy.openItem(
|
final opened = _privacy.openItem(
|
||||||
(keyId: asset.keyId, nonce: asset.nonce, payload: asset.payload),
|
(keyId: asset.keyId, nonce: asset.nonce, payload: asset.payload),
|
||||||
table: 'asset',
|
table: 'asset',
|
||||||
|
bound: '',
|
||||||
id: asset.id!.uuid,
|
id: asset.id!.uuid,
|
||||||
item: 'asset:${asset.id!.uuid}',
|
item: 'asset:${asset.id!.uuid}',
|
||||||
treeKey: null,
|
treeKey: null,
|
||||||
|
|
|
||||||
|
|
@ -56,29 +56,44 @@ class ContentKeys(
|
||||||
log('Opening data keys failed', name: 'Privacy', error: error),
|
log('Opening data keys failed', name: 'Privacy', error: error),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// How many keys were opened here so far: rows that didn't open may with
|
||||||
|
/// more.
|
||||||
|
int opened = 0;
|
||||||
|
|
||||||
/// Whether this device holds the account's keys, so new content can be
|
/// Whether this device holds the account's keys, so new content can be
|
||||||
/// made private; known once [refresh] ran.
|
/// made private; known once [refresh] ran.
|
||||||
bool get canEncrypt => _unlocked;
|
bool get canEncrypt => _unlocked;
|
||||||
|
|
||||||
/// Whether the person has keys at all: some are kept, or this device saw
|
/// Whether the person has keys at all. A device without the account's
|
||||||
/// some. A device without the account's keys on such an account writes
|
/// keys on such an account writes nothing that would be private (the
|
||||||
/// nothing that would be private (the reading of 22 Oct).
|
/// reading of 22 Oct).
|
||||||
///
|
///
|
||||||
/// Decided by the register where it said (the short reading of 22 Oct):
|
/// Any sign is enough, and none is taken back (the final reading of 22
|
||||||
/// keys rows the instance withholds or plants change nothing then.
|
/// Oct): the keys open here, the register saying so (once it said yes),
|
||||||
|
/// key rows kept, records or sealed tags and settings this device saw. The
|
||||||
|
/// register's answer comes through the instance unsigned, so its "no"
|
||||||
|
/// never outweighs the rest, and it is asked again until it says yes: a
|
||||||
|
/// password set on another device later counts here too. Rows the instance
|
||||||
|
/// plants on an account without keys only make this device refuse to
|
||||||
|
/// write, never write readable.
|
||||||
bool get accountHasKeys =>
|
bool get accountHasKeys =>
|
||||||
_unlocked ||
|
_unlocked ||
|
||||||
(ledger.registerHasKeys ??
|
ledger.registerHasKeys == true ||
|
||||||
(_rows.isNotEmpty || ledger.studysetsWithFacts.isNotEmpty));
|
_rows.isNotEmpty ||
|
||||||
|
ledger.studysetsWithFacts.isNotEmpty ||
|
||||||
|
ledger.personalSealed;
|
||||||
|
|
||||||
/// Opens the keys kept that aren't open yet: as the person's data opens,
|
/// Opens the keys kept that aren't open yet: as the person's data opens,
|
||||||
/// and once a sync may have brought more.
|
/// and once a sync may have brought more.
|
||||||
Future<void> refresh() => _refreshing.run();
|
Future<void> refresh() => _refreshing.run();
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
if (ledger.registerHasKeys == null && registerHasKeys != null) {
|
if (ledger.registerHasKeys != true && registerHasKeys != null) {
|
||||||
try {
|
try {
|
||||||
ledger.registerHasKeys = await registerHasKeys!();
|
// Kept only once it says yes; a no is asked again next time.
|
||||||
|
if (await registerHasKeys!() == true) {
|
||||||
|
ledger.registerHasKeys = true;
|
||||||
|
}
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
log(
|
log(
|
||||||
'Asking whether the account has keys failed',
|
'Asking whether the account has keys failed',
|
||||||
|
|
@ -108,6 +123,7 @@ class ContentKeys(
|
||||||
_refusedRows.add(row.id);
|
_refusedRows.add(row.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
opened++;
|
||||||
}
|
}
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
_learn(id);
|
_learn(id);
|
||||||
|
|
@ -192,6 +208,7 @@ class ContentKeys(
|
||||||
isUtc: true,
|
isUtc: true,
|
||||||
);
|
);
|
||||||
final sealedKey = cipher.newKey(id.value);
|
final sealedKey = cipher.newKey(id.value);
|
||||||
|
opened++;
|
||||||
final kind = switch (id.scope) {
|
final kind = switch (id.scope) {
|
||||||
ContentKeyScope.studyset => ContentKeyKind.studyset,
|
ContentKeyScope.studyset => ContentKeyKind.studyset,
|
||||||
ContentKeyScope.personal => ContentKeyKind.personal,
|
ContentKeyScope.personal => ContentKeyKind.personal,
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,14 @@ final class const Locked() extends Opened;
|
||||||
|
|
||||||
/// Seals and opens every synced payload (ADR 0019), the one place the
|
/// Seals and opens every synced payload (ADR 0019), the one place the
|
||||||
/// gateways go through: a payload is sealed for its table, row and column,
|
/// gateways go through: a payload is sealed for its table, row and column,
|
||||||
/// so the instance can't move it to another row unnoticed.
|
/// and for the row it belongs to where that decides its key (`bound`: a
|
||||||
|
/// revision's card, note or step, an answer's step, a path's or PDF's
|
||||||
|
/// studyset), so the instance can neither move it to another row nor point
|
||||||
|
/// it at another item unnoticed (the final reading of 22 Oct).
|
||||||
///
|
///
|
||||||
/// A readable payload a device holding the account's keys writes carries the
|
/// A readable payload a device holding the account's keys writes carries the
|
||||||
/// account's signature (64 bytes) in place of a nonce, for its table, row
|
/// account's signature (64 bytes) in place of a nonce, over the same: a
|
||||||
/// and column: a device can tell it from one the instance made up, and
|
/// device can tell it from one the instance made up or re-pointed, and
|
||||||
/// trusts the latter nowhere privacy holds (the reading of 22 Oct).
|
/// trusts the latter nowhere privacy holds (the reading of 22 Oct).
|
||||||
class PayloadEnvelope(
|
class PayloadEnvelope(
|
||||||
final ContentCipher _cipher, {
|
final ContentCipher _cipher, {
|
||||||
|
|
@ -47,14 +50,17 @@ class PayloadEnvelope(
|
||||||
final bool Function()? canSign,
|
final bool Function()? canSign,
|
||||||
}) {
|
}) {
|
||||||
/// [plain] as the row keeps it: sealed with the key [keyId], readable
|
/// [plain] as the row keeps it: sealed with the key [keyId], readable
|
||||||
/// without one, and then signed where this device can. A blank payload
|
/// without one, and then signed where this device can; for its [table],
|
||||||
/// stays blank. Throws a [StateError] for a key not open on this device.
|
/// row [id], [column] and the row it is [bound] to (empty for none). A
|
||||||
|
/// blank payload stays blank. Throws a [StateError] for a key not open on
|
||||||
|
/// this device.
|
||||||
Envelope seal(
|
Envelope seal(
|
||||||
ByteData plain, {
|
ByteData plain, {
|
||||||
required String? keyId,
|
required String? keyId,
|
||||||
required String table,
|
required String table,
|
||||||
required String id,
|
required String id,
|
||||||
String column = 'payload',
|
String column = 'payload',
|
||||||
|
String bound = '',
|
||||||
}) {
|
}) {
|
||||||
if (plain.lengthInBytes == 0) {
|
if (plain.lengthInBytes == 0) {
|
||||||
return (keyId: keyId, nonce: null, payload: plain);
|
return (keyId: keyId, nonce: null, payload: plain);
|
||||||
|
|
@ -65,7 +71,9 @@ class PayloadEnvelope(
|
||||||
keyId: null,
|
keyId: null,
|
||||||
nonce: signs
|
nonce: signs
|
||||||
? ByteData.sublistView(
|
? ByteData.sublistView(
|
||||||
_cipher.sign(_readableMessage(table, id, column, plain)),
|
_cipher.sign(
|
||||||
|
_readableMessage(table, id, column, bound, plain),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
payload: plain,
|
payload: plain,
|
||||||
|
|
@ -74,7 +82,7 @@ class PayloadEnvelope(
|
||||||
final sealed = _cipher.seal(
|
final sealed = _cipher.seal(
|
||||||
Uint8List.sublistView(plain),
|
Uint8List.sublistView(plain),
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
context: _context(table, id, column),
|
context: _context(table, id, column, bound),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
|
|
@ -84,12 +92,14 @@ class PayloadEnvelope(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [envelope]'s payload opened: as it is while readable or blank, opened
|
/// [envelope]'s payload opened: as it is while readable or blank, opened
|
||||||
/// with its key, or [Locked].
|
/// with its key, or [Locked]; one sealed or signed for another row, or
|
||||||
|
/// [bound] to another, doesn't open or isn't trusted.
|
||||||
Opened open(
|
Opened open(
|
||||||
Envelope envelope, {
|
Envelope envelope, {
|
||||||
required String table,
|
required String table,
|
||||||
required String id,
|
required String id,
|
||||||
String column = 'payload',
|
String column = 'payload',
|
||||||
|
String bound = '',
|
||||||
}) {
|
}) {
|
||||||
final (:keyId, :nonce, :payload) = envelope;
|
final (:keyId, :nonce, :payload) = envelope;
|
||||||
if (payload.lengthInBytes == 0) {
|
if (payload.lengthInBytes == 0) {
|
||||||
|
|
@ -101,7 +111,7 @@ class PayloadEnvelope(
|
||||||
trusted:
|
trusted:
|
||||||
nonce != null &&
|
nonce != null &&
|
||||||
_cipher.verify(
|
_cipher.verify(
|
||||||
_readableMessage(table, id, column, payload),
|
_readableMessage(table, id, column, bound, payload),
|
||||||
Uint8List.sublistView(nonce),
|
Uint8List.sublistView(nonce),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -115,26 +125,33 @@ class PayloadEnvelope(
|
||||||
nonce: Uint8List.sublistView(nonce),
|
nonce: Uint8List.sublistView(nonce),
|
||||||
payload: Uint8List.sublistView(payload),
|
payload: Uint8List.sublistView(payload),
|
||||||
),
|
),
|
||||||
context: _context(table, id, column),
|
context: _context(table, id, column, bound),
|
||||||
);
|
);
|
||||||
return plain == null
|
return plain == null
|
||||||
? const Locked()
|
? const Locked()
|
||||||
: Readable(ByteData.sublistView(plain));
|
: Readable(ByteData.sublistView(plain));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What binds a payload to where it is stored.
|
/// What binds a payload to where it is stored and what it belongs to; its
|
||||||
static List<int> _context(String table, String id, String column) =>
|
/// format's version first (2 since the final reading of 22 Oct, which
|
||||||
utf8.encode('$table|$id|$column');
|
/// added `bound`; nothing sealed before was released).
|
||||||
|
static List<int> _context(
|
||||||
|
String table,
|
||||||
|
String id,
|
||||||
|
String column,
|
||||||
|
String bound,
|
||||||
|
) => utf8.encode('v2|$table|$id|$column|$bound');
|
||||||
|
|
||||||
/// What a readable payload's signature is over: that it is readable, where
|
/// What a readable payload's signature is over: that it is readable, where
|
||||||
/// it is stored, and what it says.
|
/// it is stored, what it belongs to, and what it says.
|
||||||
static List<int> _readableMessage(
|
static List<int> _readableMessage(
|
||||||
String table,
|
String table,
|
||||||
String id,
|
String id,
|
||||||
String column,
|
String column,
|
||||||
|
String bound,
|
||||||
ByteData payload,
|
ByteData payload,
|
||||||
) => [
|
) => [
|
||||||
...utf8.encode('readable|$table|$id|$column|'),
|
...utf8.encode('readable|v2|$table|$id|$column|$bound|'),
|
||||||
...Uint8List.sublistView(payload),
|
...Uint8List.sublistView(payload),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ final class const PrivacyRefused(final PrivacyRefusal reason)
|
||||||
typedef _Row = ({
|
typedef _Row = ({
|
||||||
String table,
|
String table,
|
||||||
String id,
|
String id,
|
||||||
|
String bound,
|
||||||
String item,
|
String item,
|
||||||
Envelope current,
|
Envelope current,
|
||||||
Opened opened,
|
Opened opened,
|
||||||
|
|
@ -98,9 +99,12 @@ class Privacy(
|
||||||
Future<void>? _rewriting;
|
Future<void>? _rewriting;
|
||||||
|
|
||||||
/// Rows a sweep found it can't move (locked, or readable without a
|
/// Rows a sweep found it can't move (locked, or readable without a
|
||||||
/// signature): not looked at again until they change.
|
/// signature): not looked at again until they change, or more keys open.
|
||||||
final _unmovable = <String>{};
|
final _unmovable = <String>{};
|
||||||
|
|
||||||
|
/// How many keys were open at the last upkeep.
|
||||||
|
int? _openedBefore;
|
||||||
|
|
||||||
/// How many rows of each private tree the last sweep left unsealed (rows
|
/// How many rows of each private tree the last sweep left unsealed (rows
|
||||||
/// that don't open here, or readable ones nobody signed): its marker says
|
/// that don't open here, or readable ones nobody signed): its marker says
|
||||||
/// "not fully private" while any are.
|
/// "not fully private" while any are.
|
||||||
|
|
@ -151,7 +155,7 @@ class Privacy(
|
||||||
|
|
||||||
/// [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) ?? _itemKey('studyset:$studysetId');
|
_nearestPrivate(parents, studysetId);
|
||||||
|
|
||||||
/// Every studyset's tree key, for reading many rows at once.
|
/// Every studyset's tree key, for reading many rows at once.
|
||||||
Future<String? Function(String studysetId)> treeKeys() async {
|
Future<String? Function(String studysetId)> treeKeys() async {
|
||||||
|
|
@ -179,20 +183,30 @@ class Privacy(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The key of the nearest studyset to [studysetId], itself or above, that
|
/// The key of the nearest studyset to [studysetId], itself or above,
|
||||||
/// a signed record says is private; null where none is. In a cycle the
|
/// that has one: a signed record saying it is private, or the key its own
|
||||||
/// studysets below it come first, then its members by id, so every member
|
/// row was last seen sealed with; null where none has.
|
||||||
/// finds the same.
|
///
|
||||||
|
/// Each studyset on the way keeps its own key before any above it counts
|
||||||
|
/// (the final reading of 22 Oct): `parentId` is a column the instance can
|
||||||
|
/// change, so a topic it moves under another private studyset goes on
|
||||||
|
/// with the key it had, and making that studyset readable never opens what
|
||||||
|
/// is written in the topic. `parentId` isn't bound into a studyset's seal:
|
||||||
|
/// a move on one device and an edit on another merge column by column, so
|
||||||
|
/// the row would lock for no fault; the keys kept here guard it instead.
|
||||||
|
/// In a cycle the studysets below it come first, then its members by id,
|
||||||
|
/// so every member finds the same.
|
||||||
String? _nearestPrivate(Map<String, String?> parents, String studysetId) {
|
String? _nearestPrivate(Map<String, String?> parents, String studysetId) {
|
||||||
|
String? keyOf(String id) => _privateKeyOf(id) ?? _itemKey('studyset:$id');
|
||||||
final path = <String>[];
|
final path = <String>[];
|
||||||
final seen = <String, int>{};
|
final seen = <String, int>{};
|
||||||
String? current = studysetId;
|
String? current = studysetId;
|
||||||
while (current != null && parents.containsKey(current)) {
|
while (current != null) {
|
||||||
if (seen.containsKey(current)) {
|
if (seen.containsKey(current)) {
|
||||||
final below = path.sublist(0, seen[current]);
|
final below = path.sublist(0, seen[current]);
|
||||||
final cycle = path.sublist(seen[current]!)..sort();
|
final cycle = path.sublist(seen[current]!)..sort();
|
||||||
for (final id in [...below, ...cycle]) {
|
for (final id in [...below, ...cycle]) {
|
||||||
if (_privateKeyOf(id) case final keyId?) {
|
if (keyOf(id) case final keyId?) {
|
||||||
return keyId;
|
return keyId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,9 +214,12 @@ class Privacy(
|
||||||
}
|
}
|
||||||
seen[current] = path.length;
|
seen[current] = path.length;
|
||||||
path.add(current);
|
path.add(current);
|
||||||
if (_privateKeyOf(current) case final keyId?) {
|
if (keyOf(current) case final keyId?) {
|
||||||
return keyId;
|
return keyId;
|
||||||
}
|
}
|
||||||
|
if (!parents.containsKey(current)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
current = parents[current];
|
current = parents[current];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -271,6 +288,19 @@ class Privacy(
|
||||||
final tree = card == null
|
final tree = card == null
|
||||||
? null
|
? null
|
||||||
: await keyOfStudyset(card.studysetId.uuid);
|
: await keyOfStudyset(card.studysetId.uuid);
|
||||||
|
if (ledger.keyOfItem('card:$cardId') == null) {
|
||||||
|
_learnItem('card:$cardId', 'card_revision', 'card:$cardId', [
|
||||||
|
for (final r in await protocol.CardRevision.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) =>
|
||||||
|
t.cardId.equals(_uuid(cardId)) & t.keyId.notEquals(null),
|
||||||
|
))
|
||||||
|
(
|
||||||
|
id: r.id!.uuid,
|
||||||
|
e: (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
return _writeKey('card:$cardId', tree);
|
return _writeKey('card:$cardId', tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,6 +310,19 @@ class Privacy(
|
||||||
final tree = note == null
|
final tree = note == null
|
||||||
? null
|
? null
|
||||||
: await keyOfStudyset(note.studysetId.uuid);
|
: await keyOfStudyset(note.studysetId.uuid);
|
||||||
|
if (ledger.keyOfItem('note:$noteId') == null) {
|
||||||
|
_learnItem('note:$noteId', 'note_revision', 'note:$noteId', [
|
||||||
|
for (final r in await protocol.NoteRevision.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) =>
|
||||||
|
t.noteId.equals(_uuid(noteId)) & t.keyId.notEquals(null),
|
||||||
|
))
|
||||||
|
(
|
||||||
|
id: r.id!.uuid,
|
||||||
|
e: (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
return _writeKey('note:$noteId', tree);
|
return _writeKey('note:$noteId', tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,6 +332,19 @@ class Privacy(
|
||||||
final tree = path == null
|
final tree = path == null
|
||||||
? null
|
? null
|
||||||
: await keyOfStudyset(path.studysetId.uuid);
|
: await keyOfStudyset(path.studysetId.uuid);
|
||||||
|
if (path != null && ledger.keyOfItem('path:$pathId') == null) {
|
||||||
|
_learnItem(
|
||||||
|
'path:$pathId',
|
||||||
|
'learning_path',
|
||||||
|
'studyset:${path.studysetId.uuid}',
|
||||||
|
[
|
||||||
|
(
|
||||||
|
id: pathId,
|
||||||
|
e: (keyId: path.keyId, nonce: path.nonce, payload: path.payload),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
return _writeKey('path:$pathId', tree);
|
return _writeKey('path:$pathId', tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,6 +355,24 @@ class Privacy(
|
||||||
_uuid(stepId),
|
_uuid(stepId),
|
||||||
);
|
);
|
||||||
final tree = step == null ? null : await _treeKeyOfPath(step.pathId.uuid);
|
final tree = step == null ? null : await _treeKeyOfPath(step.pathId.uuid);
|
||||||
|
if (ledger.keyOfItem('step:$stepId') == null) {
|
||||||
|
_learnItem(
|
||||||
|
'step:$stepId',
|
||||||
|
'learning_path_step_revision',
|
||||||
|
'step:$stepId',
|
||||||
|
[
|
||||||
|
for (final r in await protocol.LearningPathStepRevision.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) =>
|
||||||
|
t.stepId.equals(_uuid(stepId)) & t.keyId.notEquals(null),
|
||||||
|
))
|
||||||
|
(
|
||||||
|
id: r.id!.uuid,
|
||||||
|
e: (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
return _writeKey('step:$stepId', tree);
|
return _writeKey('step:$stepId', tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,12 +381,65 @@ class Privacy(
|
||||||
_writeKey('answers:$stepId', await keyOfStep(stepId));
|
_writeKey('answers:$stepId', await keyOfStep(stepId));
|
||||||
|
|
||||||
/// The key the PDF [pdfId] is written with, in [studysetId]'s tree.
|
/// The key the PDF [pdfId] is written with, in [studysetId]'s tree.
|
||||||
Future<String?> keyOfPdf(String pdfId, String studysetId) async =>
|
Future<String?> keyOfPdf(String pdfId, String studysetId) async {
|
||||||
_writeKey('pdf:$pdfId', await keyOfStudyset(studysetId));
|
final tree = await keyOfStudyset(studysetId);
|
||||||
|
if (ledger.keyOfItem('pdf:$pdfId') == null) {
|
||||||
|
if (await protocol.PdfSource.db.findById(_db, _uuid(pdfId))
|
||||||
|
case final pdf?) {
|
||||||
|
_learnItem(
|
||||||
|
'pdf:$pdfId',
|
||||||
|
'pdf_source',
|
||||||
|
'studyset:${pdf.studysetId.uuid}',
|
||||||
|
[
|
||||||
|
(
|
||||||
|
id: pdfId,
|
||||||
|
e: (keyId: pdf.keyId, nonce: pdf.nonce, payload: pdf.payload),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _writeKey('pdf:$pdfId', tree);
|
||||||
|
}
|
||||||
|
|
||||||
/// The key a new or changed studyset's own row is written with.
|
/// The key a new or changed studyset's own row is written with.
|
||||||
Future<String?> keyOfStudysetRow(String studysetId) async =>
|
Future<String?> keyOfStudysetRow(String studysetId) async {
|
||||||
_writeKey('studyset:$studysetId', await keyOfStudyset(studysetId));
|
final tree = await keyOfStudyset(studysetId);
|
||||||
|
if (ledger.keyOfItem('studyset:$studysetId') == null) {
|
||||||
|
if (await protocol.Studyset.db.findById(_db, _uuid(studysetId))
|
||||||
|
case final row?) {
|
||||||
|
_learnItem('studyset:$studysetId', 'studyset', '', [
|
||||||
|
(
|
||||||
|
id: studysetId,
|
||||||
|
e: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _writeKey('studyset:$studysetId', tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Learns [item]'s key from its own sealed [rows] of [table], [bound] as
|
||||||
|
/// they are stored, before a write decides on a key (the final reading of
|
||||||
|
/// 22 Oct): a row that doesn't open for [item] (pointed at it by the
|
||||||
|
/// instance) teaches nothing.
|
||||||
|
void _learnItem(
|
||||||
|
String item,
|
||||||
|
String table,
|
||||||
|
String bound,
|
||||||
|
List<({String id, Envelope e})> rows,
|
||||||
|
) {
|
||||||
|
for (final row in rows) {
|
||||||
|
final keyId = row.e.keyId;
|
||||||
|
if (keyId == null || !keys.canOpen(keyId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (open(row.e, table: table, id: row.id, bound: bound)
|
||||||
|
case Readable(:final payload) when payload.lengthInBytes > 0) {
|
||||||
|
_sawSealed(item, keyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _treeKeyOfPath(String pathId) async {
|
Future<String?> _treeKeyOfPath(String pathId) async {
|
||||||
final path = await protocol.LearningPath.db.findById(_db, _uuid(pathId));
|
final path = await protocol.LearningPath.db.findById(_db, _uuid(pathId));
|
||||||
|
|
@ -354,20 +481,31 @@ class Privacy(
|
||||||
static final _versionMark = utf8.encode('HLKV');
|
static final _versionMark = utf8.encode('HLKV');
|
||||||
|
|
||||||
/// The sealed settings row [rowId]'s [payload] (opened) without its
|
/// The sealed settings row [rowId]'s [payload] (opened) without its
|
||||||
/// version vector; null for a rollback: one whose every count is at most
|
/// version vector; null for one that isn't read (the final reading of 22
|
||||||
/// what this device has seen of the row, and one lower, or one without a
|
/// Oct):
|
||||||
/// vector once one was seen. A row with news from any device is taken,
|
/// - a row this device folded away, whatever is handed back;
|
||||||
/// so two devices' changes at once both count. A blank one is as it is.
|
/// - a rollback of a row seen before: every count at most what was seen
|
||||||
|
/// of it, and one lower;
|
||||||
|
/// - a row never seen here that brings no news: every count at most what
|
||||||
|
/// was seen of the person's settings as a whole, so a row folded away on
|
||||||
|
/// another device can't come back;
|
||||||
|
/// - one without a vector (sealed before settings had them, or under the
|
||||||
|
/// short reading's counter, `HLKS`): dropped, as nothing sealed so was
|
||||||
|
/// released.
|
||||||
|
///
|
||||||
|
/// A row with news from any device is taken, so two devices' changes at
|
||||||
|
/// once both count. A blank one is as it is.
|
||||||
ByteData? settingsWithoutCounter(String rowId, ByteData payload) {
|
ByteData? settingsWithoutCounter(String rowId, ByteData payload) {
|
||||||
if (payload.lengthInBytes == 0) {
|
if (payload.lengthInBytes == 0) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
if (ledger.foldedSettings(rowId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final bytes = Uint8List.sublistView(payload);
|
final bytes = Uint8List.sublistView(payload);
|
||||||
final seen = ledger.settingsVersionOf(rowId);
|
|
||||||
final mark = _versionMark.length;
|
final mark = _versionMark.length;
|
||||||
if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) {
|
if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) {
|
||||||
// Sealed before settings had versions: only until one was seen.
|
return null;
|
||||||
return seen.isEmpty ? payload : null;
|
|
||||||
}
|
}
|
||||||
final length = bytes[mark] << 8 | bytes[mark + 1];
|
final length = bytes[mark] << 8 | bytes[mark + 1];
|
||||||
if (bytes.length < mark + 2 + length) {
|
if (bytes.length < mark + 2 + length) {
|
||||||
|
|
@ -386,6 +524,8 @@ class Privacy(
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (ledger.sawSettingsRow(rowId)) {
|
||||||
|
final seen = ledger.settingsVersionOf(rowId);
|
||||||
final devices = {...seen.keys, ...vector.keys};
|
final devices = {...seen.keys, ...vector.keys};
|
||||||
final older =
|
final older =
|
||||||
devices.every((d) => (vector[d] ?? 0) <= (seen[d] ?? 0)) &&
|
devices.every((d) => (vector[d] ?? 0) <= (seen[d] ?? 0)) &&
|
||||||
|
|
@ -393,18 +533,26 @@ class Privacy(
|
||||||
if (older) {
|
if (older) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
final person = ledger.settingsPersonVersion;
|
||||||
|
final news = vector.entries.any((e) => e.value > (person[e.key] ?? 0));
|
||||||
|
if (!news) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
ledger.sawSettingsVersion(rowId, vector);
|
ledger.sawSettingsVersion(rowId, vector);
|
||||||
return ByteData.sublistView(bytes, mark + 2 + length);
|
return ByteData.sublistView(bytes, mark + 2 + length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings row [rowId]'s [payload] with this device's next version in
|
/// The settings row [rowId]'s [payload] with this device's next version in
|
||||||
/// front, to be sealed.
|
/// front, to be sealed: everything seen of the person's settings, and one
|
||||||
|
/// more write of this device's, so it dominates what it replaces.
|
||||||
ByteData settingsWithCounter(String rowId, ByteData payload) {
|
ByteData settingsWithCounter(String rowId, ByteData payload) {
|
||||||
if (payload.lengthInBytes == 0) {
|
if (payload.lengthInBytes == 0) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
final device = ledger.deviceId;
|
final device = ledger.deviceId;
|
||||||
final vector = {...ledger.settingsVersionOf(rowId)};
|
final vector = {...ledger.settingsPersonVersion};
|
||||||
vector[device] = (vector[device] ?? 0) + 1;
|
vector[device] = (vector[device] ?? 0) + 1;
|
||||||
ledger.sawSettingsVersion(rowId, vector);
|
ledger.sawSettingsVersion(rowId, vector);
|
||||||
final json = utf8.encode(jsonEncode(vector));
|
final json = utf8.encode(jsonEncode(vector));
|
||||||
|
|
@ -419,32 +567,56 @@ class Privacy(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps that the settings row [rowId] was folded away here: never read
|
||||||
|
/// again.
|
||||||
|
void foldedSettings(String rowId) => ledger.foldSettings(rowId);
|
||||||
|
|
||||||
/// Whether a readable tag or settings row is no longer trusted: this
|
/// Whether a readable tag or settings row is no longer trusted: this
|
||||||
/// device sealed the person's once their personal key existed.
|
/// device sealed the person's once their personal key existed.
|
||||||
bool get personalProtected => ledger.personalSealed;
|
bool get personalProtected => ledger.personalSealed;
|
||||||
|
|
||||||
/// [envelope]'s payload opened for its [table], row [id] and [column].
|
/// [envelope]'s payload opened for its [table], row [id], [column] and
|
||||||
|
/// the row it is [bound] to: a revision's `card:`, `note:` or `step:`, an
|
||||||
|
/// answer's `step:`, a path's or a PDF's `studyset:`, empty for rows whose
|
||||||
|
/// key no other row decides (the final reading of 22 Oct). Required, so no
|
||||||
|
/// row is opened without what it belongs to.
|
||||||
Opened open(
|
Opened open(
|
||||||
Envelope envelope, {
|
Envelope envelope, {
|
||||||
required String table,
|
required String table,
|
||||||
required String id,
|
required String id,
|
||||||
|
required String bound,
|
||||||
String column = 'payload',
|
String column = 'payload',
|
||||||
}) => this.envelope.open(envelope, table: table, id: id, column: column);
|
}) => this.envelope.open(
|
||||||
|
envelope,
|
||||||
|
table: table,
|
||||||
|
id: id,
|
||||||
|
column: column,
|
||||||
|
bound: bound,
|
||||||
|
);
|
||||||
|
|
||||||
/// The row [id] of [table] of [item], in a tree with [treeKey] (for tags
|
/// The row [id] of [table] of [item], [bound] as [open] says, in a tree
|
||||||
/// and settings, [protected] once readable ones aren't trusted), opened:
|
/// with [treeKey] (for tags and settings, [protected] once readable ones
|
||||||
/// sealed and opened, its key is kept as the item's; readable without
|
/// aren't trusted), opened: sealed and opened, its key is kept as the
|
||||||
/// the account's signature where privacy holds, [Locked].
|
/// item's, which only a row bound to it can be (the final reading of 22
|
||||||
|
/// Oct); readable without the account's signature where privacy holds,
|
||||||
|
/// [Locked].
|
||||||
Opened openItem(
|
Opened openItem(
|
||||||
Envelope envelope, {
|
Envelope envelope, {
|
||||||
required String table,
|
required String table,
|
||||||
required String id,
|
required String id,
|
||||||
|
required String bound,
|
||||||
required String item,
|
required String item,
|
||||||
required String? treeKey,
|
required String? treeKey,
|
||||||
bool protected = false,
|
bool protected = false,
|
||||||
String column = 'payload',
|
String column = 'payload',
|
||||||
}) {
|
}) {
|
||||||
final opened = open(envelope, table: table, id: id, column: column);
|
final opened = open(
|
||||||
|
envelope,
|
||||||
|
table: table,
|
||||||
|
id: id,
|
||||||
|
column: column,
|
||||||
|
bound: bound,
|
||||||
|
);
|
||||||
if (opened case Readable(:final payload) when payload.lengthInBytes > 0) {
|
if (opened case Readable(:final payload) when payload.lengthInBytes > 0) {
|
||||||
if (envelope.keyId case final keyId?) {
|
if (envelope.keyId case final keyId?) {
|
||||||
_sawSealed(item, keyId);
|
_sawSealed(item, keyId);
|
||||||
|
|
@ -458,14 +630,15 @@ class Privacy(
|
||||||
return opened;
|
return opened;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [plain] sealed with [keyId] for its [table], row [id] and [column];
|
/// [plain] sealed with [keyId] for its [table], row [id], [column] and
|
||||||
/// readable without, and signed where this device can. A sealed row's key
|
/// the row it is [bound] to ([open]); readable without, and signed where
|
||||||
/// is kept as [item]'s.
|
/// this device can. A sealed row's key is kept as [item]'s.
|
||||||
Envelope seal(
|
Envelope seal(
|
||||||
ByteData plain, {
|
ByteData plain, {
|
||||||
required String? keyId,
|
required String? keyId,
|
||||||
required String table,
|
required String table,
|
||||||
required String id,
|
required String id,
|
||||||
|
required String bound,
|
||||||
String column = 'payload',
|
String column = 'payload',
|
||||||
String? item,
|
String? item,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -475,6 +648,7 @@ class Privacy(
|
||||||
table: table,
|
table: table,
|
||||||
id: id,
|
id: id,
|
||||||
column: column,
|
column: column,
|
||||||
|
bound: bound,
|
||||||
);
|
);
|
||||||
if (item != null && keyId != null) {
|
if (item != null && keyId != null) {
|
||||||
_sawSealed(item, keyId);
|
_sawSealed(item, keyId);
|
||||||
|
|
@ -515,11 +689,19 @@ class Privacy(
|
||||||
Future<Map<String, int>> unsignedIn(String studysetId) async {
|
Future<Map<String, int>> unsignedIn(String studysetId) async {
|
||||||
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
|
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
|
||||||
final counts = <String, int>{};
|
final counts = <String, int>{};
|
||||||
void count(String kind, String table, String id, Envelope e) {
|
void count(
|
||||||
|
String kind,
|
||||||
|
String table,
|
||||||
|
String id,
|
||||||
|
String bound,
|
||||||
|
Envelope e,
|
||||||
|
) {
|
||||||
if (e.keyId != null || e.payload.lengthInBytes == 0) {
|
if (e.keyId != null || e.payload.lengthInBytes == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (open(e, table: table, id: id) case Readable(trusted: false)) {
|
if (open(e, table: table, id: id, bound: bound) case Readable(
|
||||||
|
trusted: false,
|
||||||
|
)) {
|
||||||
counts[kind] = (counts[kind] ?? 0) + 1;
|
counts[kind] = (counts[kind] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -528,7 +710,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.inSet(ids) & t.keyId.equals(null),
|
where: (t) => t.id.inSet(ids) & t.keyId.equals(null),
|
||||||
)) {
|
)) {
|
||||||
count('studysets', 'studyset', r.id!.uuid, (
|
count('studysets', 'studyset', r.id!.uuid, '', (
|
||||||
keyId: r.keyId,
|
keyId: r.keyId,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
|
|
@ -545,7 +727,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.cardId.inSet(cards) & t.keyId.equals(null),
|
where: (t) => t.cardId.inSet(cards) & t.keyId.equals(null),
|
||||||
)) {
|
)) {
|
||||||
count('cards', 'card_revision', r.id!.uuid, (
|
count('cards', 'card_revision', r.id!.uuid, 'card:${r.cardId.uuid}', (
|
||||||
keyId: r.keyId,
|
keyId: r.keyId,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
|
|
@ -562,7 +744,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.noteId.inSet(notes) & t.keyId.equals(null),
|
where: (t) => t.noteId.inSet(notes) & t.keyId.equals(null),
|
||||||
)) {
|
)) {
|
||||||
count('notes', 'note_revision', r.id!.uuid, (
|
count('notes', 'note_revision', r.id!.uuid, 'note:${r.noteId.uuid}', (
|
||||||
keyId: r.keyId,
|
keyId: r.keyId,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
|
|
@ -573,11 +755,17 @@ class Privacy(
|
||||||
where: (t) => t.studysetId.inSet(ids),
|
where: (t) => t.studysetId.inSet(ids),
|
||||||
);
|
);
|
||||||
for (final r in paths) {
|
for (final r in paths) {
|
||||||
count('paths', 'learning_path', r.id!.uuid, (
|
count(
|
||||||
|
'paths',
|
||||||
|
'learning_path',
|
||||||
|
r.id!.uuid,
|
||||||
|
'studyset:${r.studysetId.uuid}',
|
||||||
|
(
|
||||||
keyId: r.keyId,
|
keyId: r.keyId,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final steps = {
|
final steps = {
|
||||||
for (final step in await protocol.LearningPathStep.db.find(
|
for (final step in await protocol.LearningPathStep.db.find(
|
||||||
|
|
@ -590,17 +778,23 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null),
|
where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null),
|
||||||
)) {
|
)) {
|
||||||
count('paths', 'learning_path_step_revision', r.id!.uuid, (
|
count(
|
||||||
|
'paths',
|
||||||
|
'learning_path_step_revision',
|
||||||
|
r.id!.uuid,
|
||||||
|
'step:${r.stepId.uuid}',
|
||||||
|
(
|
||||||
keyId: r.keyId,
|
keyId: r.keyId,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (final r in await protocol.PdfSource.db.find(
|
for (final r in await protocol.PdfSource.db.find(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.studysetId.inSet(ids) & t.keyId.equals(null),
|
where: (t) => t.studysetId.inSet(ids) & t.keyId.equals(null),
|
||||||
)) {
|
)) {
|
||||||
count('pdfs', 'pdf_source', r.id!.uuid, (
|
count('pdfs', 'pdf_source', r.id!.uuid, 'studyset:${r.studysetId.uuid}', (
|
||||||
keyId: r.keyId,
|
keyId: r.keyId,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
|
|
@ -791,7 +985,7 @@ class Privacy(
|
||||||
final done = Completer<void>();
|
final done = Completer<void>();
|
||||||
_rewriting = done.future;
|
_rewriting = done.future;
|
||||||
forgetTrees();
|
forgetTrees();
|
||||||
final rewrite = (studysetId: studysetId, keyId: keyId);
|
final rewrite = (studysetId: studysetId, keyId: keyId, adopt: adopt);
|
||||||
ledger.begin(rewrite);
|
ledger.begin(rewrite);
|
||||||
try {
|
try {
|
||||||
final scope = await scopeOf(studysetId);
|
final scope = await scopeOf(studysetId);
|
||||||
|
|
@ -979,6 +1173,7 @@ class Privacy(
|
||||||
_Row row(
|
_Row row(
|
||||||
String table,
|
String table,
|
||||||
String id,
|
String id,
|
||||||
|
String bound,
|
||||||
String item,
|
String item,
|
||||||
String? rowKey,
|
String? rowKey,
|
||||||
ByteData? nonce,
|
ByteData? nonce,
|
||||||
|
|
@ -988,9 +1183,10 @@ class Privacy(
|
||||||
return (
|
return (
|
||||||
table: table,
|
table: table,
|
||||||
id: id,
|
id: id,
|
||||||
|
bound: bound,
|
||||||
item: item,
|
item: item,
|
||||||
current: current,
|
current: current,
|
||||||
opened: open(current, table: table, id: id),
|
opened: open(current, table: table, id: id, bound: bound),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1001,6 +1197,7 @@ class Privacy(
|
||||||
'card_revision',
|
'card_revision',
|
||||||
r.id!.uuid,
|
r.id!.uuid,
|
||||||
'card:${r.cardId.uuid}',
|
'card:${r.cardId.uuid}',
|
||||||
|
'card:${r.cardId.uuid}',
|
||||||
r.keyId,
|
r.keyId,
|
||||||
r.nonce,
|
r.nonce,
|
||||||
r.payload,
|
r.payload,
|
||||||
|
|
@ -1010,6 +1207,7 @@ class Privacy(
|
||||||
'note_revision',
|
'note_revision',
|
||||||
r.id!.uuid,
|
r.id!.uuid,
|
||||||
'note:${r.noteId.uuid}',
|
'note:${r.noteId.uuid}',
|
||||||
|
'note:${r.noteId.uuid}',
|
||||||
r.keyId,
|
r.keyId,
|
||||||
r.nonce,
|
r.nonce,
|
||||||
r.payload,
|
r.payload,
|
||||||
|
|
@ -1018,6 +1216,7 @@ class Privacy(
|
||||||
row(
|
row(
|
||||||
'learning_path',
|
'learning_path',
|
||||||
r.id!.uuid,
|
r.id!.uuid,
|
||||||
|
'studyset:${r.studysetId.uuid}',
|
||||||
'path:${r.id!.uuid}',
|
'path:${r.id!.uuid}',
|
||||||
r.keyId,
|
r.keyId,
|
||||||
r.nonce,
|
r.nonce,
|
||||||
|
|
@ -1028,6 +1227,7 @@ class Privacy(
|
||||||
'learning_path_step_revision',
|
'learning_path_step_revision',
|
||||||
r.id!.uuid,
|
r.id!.uuid,
|
||||||
'step:${r.stepId.uuid}',
|
'step:${r.stepId.uuid}',
|
||||||
|
'step:${r.stepId.uuid}',
|
||||||
r.keyId,
|
r.keyId,
|
||||||
r.nonce,
|
r.nonce,
|
||||||
r.payload,
|
r.payload,
|
||||||
|
|
@ -1036,6 +1236,7 @@ class Privacy(
|
||||||
row(
|
row(
|
||||||
'pdf_source',
|
'pdf_source',
|
||||||
r.id!.uuid,
|
r.id!.uuid,
|
||||||
|
'studyset:${r.studysetId.uuid}',
|
||||||
'pdf:${r.id!.uuid}',
|
'pdf:${r.id!.uuid}',
|
||||||
r.keyId,
|
r.keyId,
|
||||||
r.nonce,
|
r.nonce,
|
||||||
|
|
@ -1046,6 +1247,7 @@ class Privacy(
|
||||||
row(
|
row(
|
||||||
'studyset',
|
'studyset',
|
||||||
r.id!.uuid,
|
r.id!.uuid,
|
||||||
|
'',
|
||||||
'studyset:${r.id!.uuid}',
|
'studyset:${r.id!.uuid}',
|
||||||
r.keyId,
|
r.keyId,
|
||||||
r.nonce,
|
r.nonce,
|
||||||
|
|
@ -1056,6 +1258,7 @@ class Privacy(
|
||||||
row(
|
row(
|
||||||
'studyset',
|
'studyset',
|
||||||
r.id!.uuid,
|
r.id!.uuid,
|
||||||
|
'',
|
||||||
'studyset:${r.id!.uuid}',
|
'studyset:${r.id!.uuid}',
|
||||||
r.keyId,
|
r.keyId,
|
||||||
r.nonce,
|
r.nonce,
|
||||||
|
|
@ -1228,7 +1431,7 @@ class Privacy(
|
||||||
}
|
}
|
||||||
await write(
|
await write(
|
||||||
r,
|
r,
|
||||||
seal(swapped, keyId: keyId, table: r.table, id: r.id),
|
seal(swapped, keyId: keyId, table: r.table, id: r.id, bound: r.bound),
|
||||||
);
|
);
|
||||||
_keepItem(r.item, keyId);
|
_keepItem(r.item, keyId);
|
||||||
}
|
}
|
||||||
|
|
@ -1247,11 +1450,13 @@ class Privacy(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final item = 'answers:${answer.stepId.uuid}';
|
final item = 'answers:${answer.stepId.uuid}';
|
||||||
|
final bound = 'step:${answer.stepId.uuid}';
|
||||||
final opened = open(
|
final opened = open(
|
||||||
current,
|
current,
|
||||||
table: 'learning_path_answer',
|
table: 'learning_path_answer',
|
||||||
id: answer.id!.uuid,
|
id: answer.id!.uuid,
|
||||||
column: 'answer',
|
column: 'answer',
|
||||||
|
bound: bound,
|
||||||
);
|
);
|
||||||
switch (opened) {
|
switch (opened) {
|
||||||
case Readable(:final payload, :final trusted)
|
case Readable(:final payload, :final trusted)
|
||||||
|
|
@ -1267,6 +1472,7 @@ class Privacy(
|
||||||
table: 'learning_path_answer',
|
table: 'learning_path_answer',
|
||||||
id: answer.id!.uuid,
|
id: answer.id!.uuid,
|
||||||
column: 'answer',
|
column: 'answer',
|
||||||
|
bound: bound,
|
||||||
);
|
);
|
||||||
written.first = true;
|
written.first = true;
|
||||||
await protocol.LearningPathAnswer.db.updateRow(
|
await protocol.LearningPathAnswer.db.updateRow(
|
||||||
|
|
@ -1340,8 +1546,13 @@ class Privacy(
|
||||||
final movedCards = {for (final id in cards) _uuid(id)};
|
final movedCards = {for (final id in cards) _uuid(id)};
|
||||||
final named = <String>{};
|
final named = <String>{};
|
||||||
var unknown = false;
|
var unknown = false;
|
||||||
void add(String table, protocol.UuidValue? id, Envelope envelope) {
|
void add(
|
||||||
switch (open(envelope, table: table, id: id!.uuid)) {
|
String table,
|
||||||
|
protocol.UuidValue? id,
|
||||||
|
String bound,
|
||||||
|
Envelope envelope,
|
||||||
|
) {
|
||||||
|
switch (open(envelope, table: table, id: id!.uuid, bound: bound)) {
|
||||||
case Readable(:final payload):
|
case Readable(:final payload):
|
||||||
named.addAll(assetsIn(payload));
|
named.addAll(assetsIn(payload));
|
||||||
case Locked():
|
case Locked():
|
||||||
|
|
@ -1353,7 +1564,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.notInSet(insideIds),
|
where: (t) => t.id.notInSet(insideIds),
|
||||||
)) {
|
)) {
|
||||||
add('studyset', row.id, (
|
add('studyset', row.id, '', (
|
||||||
keyId: row.keyId,
|
keyId: row.keyId,
|
||||||
nonce: row.nonce,
|
nonce: row.nonce,
|
||||||
payload: row.payload,
|
payload: row.payload,
|
||||||
|
|
@ -1370,7 +1581,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.cardId.inSet(outsideCards),
|
where: (t) => t.cardId.inSet(outsideCards),
|
||||||
)) {
|
)) {
|
||||||
add('card_revision', row.id, (
|
add('card_revision', row.id, 'card:${row.cardId.uuid}', (
|
||||||
keyId: row.keyId,
|
keyId: row.keyId,
|
||||||
nonce: row.nonce,
|
nonce: row.nonce,
|
||||||
payload: row.payload,
|
payload: row.payload,
|
||||||
|
|
@ -1387,7 +1598,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.noteId.inSet(notes),
|
where: (t) => t.noteId.inSet(notes),
|
||||||
)) {
|
)) {
|
||||||
add('note_revision', row.id, (
|
add('note_revision', row.id, 'note:${row.noteId.uuid}', (
|
||||||
keyId: row.keyId,
|
keyId: row.keyId,
|
||||||
nonce: row.nonce,
|
nonce: row.nonce,
|
||||||
payload: row.payload,
|
payload: row.payload,
|
||||||
|
|
@ -1398,7 +1609,7 @@ class Privacy(
|
||||||
where: (t) => t.studysetId.notInSet(insideIds),
|
where: (t) => t.studysetId.notInSet(insideIds),
|
||||||
);
|
);
|
||||||
for (final row in paths) {
|
for (final row in paths) {
|
||||||
add('learning_path', row.id, (
|
add('learning_path', row.id, 'studyset:${row.studysetId.uuid}', (
|
||||||
keyId: row.keyId,
|
keyId: row.keyId,
|
||||||
nonce: row.nonce,
|
nonce: row.nonce,
|
||||||
payload: row.payload,
|
payload: row.payload,
|
||||||
|
|
@ -1415,7 +1626,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.stepId.inSet(steps),
|
where: (t) => t.stepId.inSet(steps),
|
||||||
)) {
|
)) {
|
||||||
add('learning_path_step_revision', row.id, (
|
add('learning_path_step_revision', row.id, 'step:${row.stepId.uuid}', (
|
||||||
keyId: row.keyId,
|
keyId: row.keyId,
|
||||||
nonce: row.nonce,
|
nonce: row.nonce,
|
||||||
payload: row.payload,
|
payload: row.payload,
|
||||||
|
|
@ -1425,7 +1636,7 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.studysetId.notInSet(insideIds),
|
where: (t) => t.studysetId.notInSet(insideIds),
|
||||||
)) {
|
)) {
|
||||||
add('pdf_source', row.id, (
|
add('pdf_source', row.id, 'studyset:${row.studysetId.uuid}', (
|
||||||
keyId: row.keyId,
|
keyId: row.keyId,
|
||||||
nonce: row.nonce,
|
nonce: row.nonce,
|
||||||
payload: row.payload,
|
payload: row.payload,
|
||||||
|
|
@ -1475,6 +1686,12 @@ class Privacy(
|
||||||
if (!keys.canEncrypt || ledger.damaged) {
|
if (!keys.canEncrypt || ledger.damaged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Rows that didn't open may open with keys that came since (the final
|
||||||
|
// reading of 22 Oct).
|
||||||
|
if (keys.opened != _openedBefore) {
|
||||||
|
_openedBefore = keys.opened;
|
||||||
|
_unmovable.clear();
|
||||||
|
}
|
||||||
for (final rewrite in ledger.pending) {
|
for (final rewrite in ledger.pending) {
|
||||||
// One the latest record no longer asks for (another device made the
|
// One the latest record no longer asks for (another device made the
|
||||||
// studyset private, or readable, since) is dropped, never run over it
|
// studyset private, or readable, since) is dropped, never run over it
|
||||||
|
|
@ -1483,25 +1700,38 @@ class Privacy(
|
||||||
ledger.finish(rewrite);
|
ledger.finish(rewrite);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// As it was begun: a sweep's never takes in what nobody signed (the
|
||||||
|
// final reading of 22 Oct).
|
||||||
await _guarded(
|
await _guarded(
|
||||||
() => rekeyTree(rewrite.studysetId, rewrite.keyId, adopt: true),
|
() => rekeyTree(
|
||||||
|
rewrite.studysetId,
|
||||||
|
rewrite.keyId,
|
||||||
|
adopt: rewrite.adopt,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await _guarded(_sealPersonal);
|
await _guarded(_sealPersonal);
|
||||||
forgetTrees();
|
forgetTrees();
|
||||||
final parents = await _structure();
|
final parents = await _structure();
|
||||||
|
final owed = {for (final p in ledger.pending) p.studysetId};
|
||||||
for (final studyset in [...ledger.studysetsWithFacts]) {
|
for (final studyset in [...ledger.studysetsWithFacts]) {
|
||||||
final keyId = _privateKeyOf(studyset);
|
final keyId = _privateKeyOf(studyset);
|
||||||
if (keyId == null ||
|
if (keyId == null ||
|
||||||
!parents.containsKey(studyset) ||
|
!parents.containsKey(studyset) ||
|
||||||
!keys.canOpen(keyId)) {
|
!keys.canOpen(keyId) ||
|
||||||
|
// A rewrite still owed is finished as it was begun, never taken
|
||||||
|
// over by a sweep.
|
||||||
|
owed.contains(studyset)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _guarded(() async {
|
await _guarded(() async {
|
||||||
if (await _needsSweep(studyset, keyId)) {
|
final need = await _needsSweep(studyset, keyId);
|
||||||
|
if (need.moves) {
|
||||||
await rekeyTree(studyset, keyId);
|
await rekeyTree(studyset, keyId);
|
||||||
} else {
|
} else {
|
||||||
unsealed[studyset] = 0;
|
// What a sweep can't move still counts (the final reading of 22
|
||||||
|
// Oct): the tree stays partly readable while any is there.
|
||||||
|
unsealed[studyset] = need.stuck;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1523,14 +1753,30 @@ class Privacy(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a row in [studysetId]'s scope isn't sealed with [keyId] and a
|
/// Whether a row in [studysetId]'s scope isn't sealed with [keyId] and a
|
||||||
/// sweep could move it: readable, or under one of the studyset's own
|
/// sweep could move it (`moves`): readable, or under one of the
|
||||||
/// older keys, and not known to be unmovable. Rows are looked up by their
|
/// studyset's own older keys, and not known to be unmovable; and how many
|
||||||
/// key, so a merge costs little (the short reading of 22 Oct).
|
/// such rows are known to be unmovable (`stuck`), which keep the tree
|
||||||
Future<bool> _needsSweep(String studysetId, String keyId) async {
|
/// partly readable. Rows are looked up by their key, so a merge costs
|
||||||
|
/// little (the short reading of 22 Oct).
|
||||||
|
Future<({bool moves, int stuck})> _needsSweep(
|
||||||
|
String studysetId,
|
||||||
|
String keyId,
|
||||||
|
) async {
|
||||||
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
|
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
|
||||||
final older = 's1.$studysetId.%';
|
final older = 's1.$studysetId.%';
|
||||||
bool moves(String table, protocol.UuidValue? id, ByteData payload) =>
|
var stuck = 0;
|
||||||
payload.lengthInBytes > 0 && !_unmovable.contains('$table|${id!.uuid}');
|
// Whether the row moves; a stuck one is counted.
|
||||||
|
bool moves(String table, protocol.UuidValue? id, ByteData payload) {
|
||||||
|
if (payload.lengthInBytes == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_unmovable.contains('$table|${id!.uuid}')) {
|
||||||
|
stuck++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
for (final row in await protocol.Studyset.db.find(
|
for (final row in await protocol.Studyset.db.find(
|
||||||
_db,
|
_db,
|
||||||
where: (t) =>
|
where: (t) =>
|
||||||
|
|
@ -1539,7 +1785,7 @@ class Privacy(
|
||||||
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
||||||
)) {
|
)) {
|
||||||
if (moves('studyset', row.id, row.payload)) {
|
if (moves('studyset', row.id, row.payload)) {
|
||||||
return true;
|
return (moves: true, stuck: stuck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final cards = {
|
final cards = {
|
||||||
|
|
@ -1557,7 +1803,7 @@ class Privacy(
|
||||||
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
||||||
)) {
|
)) {
|
||||||
if (moves('card_revision', row.id, row.payload)) {
|
if (moves('card_revision', row.id, row.payload)) {
|
||||||
return true;
|
return (moves: true, stuck: stuck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final notes = {
|
final notes = {
|
||||||
|
|
@ -1575,7 +1821,7 @@ class Privacy(
|
||||||
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
||||||
)) {
|
)) {
|
||||||
if (moves('note_revision', row.id, row.payload)) {
|
if (moves('note_revision', row.id, row.payload)) {
|
||||||
return true;
|
return (moves: true, stuck: stuck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final paths = await protocol.LearningPath.db.find(
|
final paths = await protocol.LearningPath.db.find(
|
||||||
|
|
@ -1588,7 +1834,7 @@ class Privacy(
|
||||||
(rowKey != keyId &&
|
(rowKey != keyId &&
|
||||||
ContentKeyId.parse(rowKey)?.studysetId == studysetId)) &&
|
ContentKeyId.parse(rowKey)?.studysetId == studysetId)) &&
|
||||||
moves('learning_path', row.id, row.payload)) {
|
moves('learning_path', row.id, row.payload)) {
|
||||||
return true;
|
return (moves: true, stuck: stuck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final steps = {
|
final steps = {
|
||||||
|
|
@ -1606,7 +1852,7 @@ class Privacy(
|
||||||
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
||||||
)) {
|
)) {
|
||||||
if (moves('learning_path_step_revision', row.id, row.payload)) {
|
if (moves('learning_path_step_revision', row.id, row.payload)) {
|
||||||
return true;
|
return (moves: true, stuck: stuck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (final row in await protocol.PdfSource.db.find(
|
for (final row in await protocol.PdfSource.db.find(
|
||||||
|
|
@ -1617,10 +1863,10 @@ class Privacy(
|
||||||
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
|
||||||
)) {
|
)) {
|
||||||
if (moves('pdf_source', row.id, row.payload)) {
|
if (moves('pdf_source', row.id, row.payload)) {
|
||||||
return true;
|
return (moves: true, stuck: stuck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return (moves: false, stuck: stuck);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seals the person's tags and settings with their personal key: once,
|
/// Seals the person's tags and settings with their personal key: once,
|
||||||
|
|
@ -1641,10 +1887,17 @@ class Privacy(
|
||||||
(keyId: tag.keyId, nonce: tag.nonce, payload: tag.payload),
|
(keyId: tag.keyId, nonce: tag.nonce, payload: tag.payload),
|
||||||
table: 'tag',
|
table: 'tag',
|
||||||
id: tag.id!.uuid,
|
id: tag.id!.uuid,
|
||||||
|
bound: '',
|
||||||
);
|
);
|
||||||
if (opened case Readable(:final payload, :final trusted)
|
if (opened case Readable(:final payload, :final trusted)
|
||||||
when first || trusted) {
|
when first || trusted) {
|
||||||
final e = seal(payload, keyId: keyId, table: 'tag', id: tag.id!.uuid);
|
final e = seal(
|
||||||
|
payload,
|
||||||
|
keyId: keyId,
|
||||||
|
table: 'tag',
|
||||||
|
id: tag.id!.uuid,
|
||||||
|
bound: '',
|
||||||
|
);
|
||||||
wrote = true;
|
wrote = true;
|
||||||
await protocol.Tag.db.updateRow(
|
await protocol.Tag.db.updateRow(
|
||||||
_db,
|
_db,
|
||||||
|
|
@ -1665,6 +1918,7 @@ class Privacy(
|
||||||
),
|
),
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
id: settings.id!.uuid,
|
id: settings.id!.uuid,
|
||||||
|
bound: '',
|
||||||
);
|
);
|
||||||
if (opened case Readable(:final payload, :final trusted)
|
if (opened case Readable(:final payload, :final trusted)
|
||||||
when first || trusted) {
|
when first || trusted) {
|
||||||
|
|
@ -1673,6 +1927,7 @@ class Privacy(
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
id: settings.id!.uuid,
|
id: settings.id!.uuid,
|
||||||
|
bound: '',
|
||||||
);
|
);
|
||||||
wrote = true;
|
wrote = true;
|
||||||
await protocol.PersonSettings.db.updateRow(
|
await protocol.PersonSettings.db.updateRow(
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,11 @@ import 'package:halkyon_core/halkyon_core.dart' show SerialQueue;
|
||||||
typedef TreeFact = ({int at, String? keyId});
|
typedef TreeFact = ({int at, String? keyId});
|
||||||
|
|
||||||
/// A rewrite of a tree to another key that hasn't finished on this device:
|
/// A rewrite of a tree to another key that hasn't finished on this device:
|
||||||
/// the studyset at its top and the key it goes to (null: readable).
|
/// the studyset at its top, the key it goes to (null: readable), and whether
|
||||||
typedef PendingRewrite = ({String studysetId, String? keyId});
|
/// it takes in readable rows nobody signed (only where the person made a
|
||||||
|
/// readable tree private; never a sweep's, the final reading of 22 Oct), as
|
||||||
|
/// it was begun, so a resumed one does no more than it was asked.
|
||||||
|
typedef PendingRewrite = ({String studysetId, String? keyId, bool adopt});
|
||||||
|
|
||||||
/// Where the device keeps what it has seen of the person's privacy: in their
|
/// Where the device keeps what it has seen of the person's privacy: in their
|
||||||
/// folder, never synced, so the instance can't take it back by withholding
|
/// folder, never synced, so the instance can't take it back by withholding
|
||||||
|
|
@ -91,6 +94,8 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
final _pending = <PendingRewrite>[];
|
final _pending = <PendingRewrite>[];
|
||||||
var _personalSealed = false;
|
var _personalSealed = false;
|
||||||
final _settingsVersions = <String, Map<String, int>>{};
|
final _settingsVersions = <String, Map<String, int>>{};
|
||||||
|
final _settingsPerson = <String, int>{};
|
||||||
|
final _foldedSettings = <String>{};
|
||||||
String? _deviceId;
|
String? _deviceId;
|
||||||
bool? _registerHasKeys;
|
bool? _registerHasKeys;
|
||||||
|
|
||||||
|
|
@ -131,23 +136,35 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
if (kept['pending'] case final List<Object?> pending) {
|
if (kept['pending'] case final List<Object?> pending) {
|
||||||
for (final item in pending) {
|
for (final item in pending) {
|
||||||
if (item case {'studyset': final String id, 'key': final String? key}) {
|
if (item case {'studyset': final String id, 'key': final String? key}) {
|
||||||
_pending.add((studysetId: id, keyId: key));
|
_pending.add((
|
||||||
|
studysetId: id,
|
||||||
|
keyId: key,
|
||||||
|
adopt: item['adopt'] == true,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_personalSealed = kept['personalSealed'] == true;
|
_personalSealed = kept['personalSealed'] == true;
|
||||||
if (kept['settingsVersions'] case final Map<String, Object?> rows) {
|
Map<String, int> vectorOf(Map<String, Object?> vector) => {
|
||||||
for (final MapEntry(key: row, :value) in rows.entries) {
|
|
||||||
if (value case final Map<String, Object?> vector) {
|
|
||||||
_settingsVersions[row] = {
|
|
||||||
for (final MapEntry(:key, :value) in vector.entries)
|
for (final MapEntry(:key, :value) in vector.entries)
|
||||||
if (value case final int counter) key: counter,
|
if (value case final int counter) key: counter,
|
||||||
};
|
};
|
||||||
|
if (kept['settingsVersions'] case final Map<String, Object?> rows) {
|
||||||
|
for (final MapEntry(key: row, :value) in rows.entries) {
|
||||||
|
if (value case final Map<String, Object?> vector) {
|
||||||
|
_settingsVersions[row] = vectorOf(vector);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (kept['settingsPerson'] case final Map<String, Object?> vector) {
|
||||||
|
_settingsPerson.addAll(vectorOf(vector));
|
||||||
|
}
|
||||||
|
if (kept['foldedSettings'] case final List<Object?> folded) {
|
||||||
|
_foldedSettings.addAll(folded.whereType<String>());
|
||||||
|
}
|
||||||
_deviceId = kept['device'] as String?;
|
_deviceId = kept['device'] as String?;
|
||||||
_registerHasKeys = kept['registerHasKeys'] as bool?;
|
// A no kept by an earlier version is asked again.
|
||||||
|
_registerHasKeys = kept['registerHasKeys'] == true ? true : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest signed fact seen about [studysetId]'s privacy.
|
/// The latest signed fact seen about [studysetId]'s privacy.
|
||||||
|
|
@ -225,28 +242,51 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The version vector seen of the settings row [rowId] (each device's
|
/// The version vector seen of the settings row [rowId] (each device's
|
||||||
/// count of writes), empty for one never seen.
|
/// count of writes to the person's settings), empty for one never seen.
|
||||||
Map<String, int> settingsVersionOf(String rowId) =>
|
Map<String, int> settingsVersionOf(String rowId) =>
|
||||||
Map.unmodifiable(_settingsVersions[rowId] ?? const {});
|
Map.unmodifiable(_settingsVersions[rowId] ?? const {});
|
||||||
|
|
||||||
/// Keeps what [vector] adds to what was seen of the settings row [rowId]:
|
/// Whether the settings row [rowId] was ever seen here.
|
||||||
/// each device's highest count.
|
bool sawSettingsRow(String rowId) => _settingsVersions.containsKey(rowId);
|
||||||
|
|
||||||
|
/// The highest count of each device seen across all the person's settings
|
||||||
|
/// rows (the final reading of 22 Oct): one row's news is everyone's.
|
||||||
|
Map<String, int> get settingsPersonVersion =>
|
||||||
|
Map.unmodifiable(_settingsPerson);
|
||||||
|
|
||||||
|
/// Keeps what [vector] adds to what was seen of the settings row [rowId],
|
||||||
|
/// and of the person's settings as a whole: each device's highest count.
|
||||||
void sawSettingsVersion(String rowId, Map<String, int> vector) {
|
void sawSettingsVersion(String rowId, Map<String, int> vector) {
|
||||||
|
var changed = !_settingsVersions.containsKey(rowId);
|
||||||
final seen = _settingsVersions[rowId] ??= {};
|
final seen = _settingsVersions[rowId] ??= {};
|
||||||
var changed = false;
|
|
||||||
for (final MapEntry(key: device, value: count) in vector.entries) {
|
for (final MapEntry(key: device, value: count) in vector.entries) {
|
||||||
if (count > (seen[device] ?? 0)) {
|
if (count > (seen[device] ?? 0)) {
|
||||||
seen[device] = count;
|
seen[device] = count;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (count > (_settingsPerson[device] ?? 0)) {
|
||||||
|
_settingsPerson[device] = count;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (changed) {
|
if (changed) {
|
||||||
_changed();
|
_changed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the register said the account has keys, as last asked; null
|
/// Whether the settings row [rowId] was folded away here, so never read
|
||||||
/// before it was.
|
/// again, whatever the instance hands back (the final reading of 22 Oct).
|
||||||
|
bool foldedSettings(String rowId) => _foldedSettings.contains(rowId);
|
||||||
|
|
||||||
|
/// Keeps that the settings row [rowId] was folded away here.
|
||||||
|
void foldSettings(String rowId) {
|
||||||
|
if (_foldedSettings.add(rowId)) {
|
||||||
|
_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once the register said the account has keys; null until then (a
|
||||||
|
/// no is never kept, the final reading of 22 Oct).
|
||||||
bool? get registerHasKeys => _registerHasKeys;
|
bool? get registerHasKeys => _registerHasKeys;
|
||||||
|
|
||||||
set registerHasKeys(bool? has) {
|
set registerHasKeys(bool? has) {
|
||||||
|
|
@ -303,10 +343,12 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
'items': _items,
|
'items': _items,
|
||||||
'pending': [
|
'pending': [
|
||||||
for (final p in _pending)
|
for (final p in _pending)
|
||||||
{'studyset': p.studysetId, 'key': p.keyId},
|
{'studyset': p.studysetId, 'key': p.keyId, 'adopt': p.adopt},
|
||||||
],
|
],
|
||||||
'personalSealed': _personalSealed,
|
'personalSealed': _personalSealed,
|
||||||
'settingsVersions': _settingsVersions,
|
'settingsVersions': _settingsVersions,
|
||||||
|
'settingsPerson': _settingsPerson,
|
||||||
|
'foldedSettings': [..._foldedSettings],
|
||||||
'device': ?_deviceId,
|
'device': ?_deviceId,
|
||||||
'registerHasKeys': ?_registerHasKeys,
|
'registerHasKeys': ?_registerHasKeys,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ class SyncCardGateway(
|
||||||
(keyId: revision.keyId, nonce: revision.nonce, payload: revision.payload),
|
(keyId: revision.keyId, nonce: revision.nonce, payload: revision.payload),
|
||||||
table: 'card_revision',
|
table: 'card_revision',
|
||||||
id: revision.id!.uuid,
|
id: revision.id!.uuid,
|
||||||
|
bound: 'card:${revision.cardId.uuid}',
|
||||||
item: 'card:${revision.cardId.uuid}',
|
item: 'card:${revision.cardId.uuid}',
|
||||||
treeKey: treeKey,
|
treeKey: treeKey,
|
||||||
)) {
|
)) {
|
||||||
|
|
@ -121,6 +122,7 @@ class SyncCardGateway(
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'card_revision',
|
table: 'card_revision',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
bound: 'card:${cardId.uuid}',
|
||||||
item: 'card:${cardId.uuid}',
|
item: 'card:${cardId.uuid}',
|
||||||
);
|
);
|
||||||
return protocol.CardRevision(
|
return protocol.CardRevision(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class SyncLearningPathGateway(
|
||||||
'learning_path',
|
'learning_path',
|
||||||
path.id!.uuid,
|
path.id!.uuid,
|
||||||
(keyId: path.keyId, nonce: path.nonce, payload: path.payload),
|
(keyId: path.keyId, nonce: path.nonce, payload: path.payload),
|
||||||
|
bound: 'studyset:${path.studysetId.uuid}',
|
||||||
item: 'path:${path.id!.uuid}',
|
item: 'path:${path.id!.uuid}',
|
||||||
treeKey: treeKey(path.studysetId.uuid),
|
treeKey: treeKey(path.studysetId.uuid),
|
||||||
),
|
),
|
||||||
|
|
@ -45,18 +46,21 @@ class SyncLearningPathGateway(
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [envelope]'s payload opened as [item]'s in a tree with [treeKey],
|
/// [envelope]'s payload opened as [item]'s, [bound] to the row it
|
||||||
/// blank where it doesn't open here, or isn't trusted there.
|
/// belongs to, in a tree with [treeKey]; blank where it doesn't open here,
|
||||||
|
/// or isn't trusted there.
|
||||||
ByteData _opened(
|
ByteData _opened(
|
||||||
String table,
|
String table,
|
||||||
String id,
|
String id,
|
||||||
Envelope envelope, {
|
Envelope envelope, {
|
||||||
|
required String bound,
|
||||||
required String item,
|
required String item,
|
||||||
required String? treeKey,
|
required String? treeKey,
|
||||||
}) => switch (_privacy.openItem(
|
}) => switch (_privacy.openItem(
|
||||||
envelope,
|
envelope,
|
||||||
table: table,
|
table: table,
|
||||||
id: id,
|
id: id,
|
||||||
|
bound: bound,
|
||||||
item: item,
|
item: item,
|
||||||
treeKey: treeKey,
|
treeKey: treeKey,
|
||||||
)) {
|
)) {
|
||||||
|
|
@ -76,6 +80,7 @@ class SyncLearningPathGateway(
|
||||||
keyId: await _privacy.keyOfStudyset(studysetId),
|
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||||
table: 'learning_path',
|
table: 'learning_path',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
bound: 'studyset:$studysetId',
|
||||||
item: 'path:${id.uuid}',
|
item: 'path:${id.uuid}',
|
||||||
);
|
);
|
||||||
final path = await protocol.LearningPath.db.insertRow(
|
final path = await protocol.LearningPath.db.insertRow(
|
||||||
|
|
@ -102,6 +107,7 @@ class SyncLearningPathGateway(
|
||||||
keyId: await _privacy.keyOfPath(id),
|
keyId: await _privacy.keyOfPath(id),
|
||||||
table: 'learning_path',
|
table: 'learning_path',
|
||||||
id: id,
|
id: id,
|
||||||
|
bound: 'studyset:${path.studysetId.uuid}',
|
||||||
item: 'path:$id',
|
item: 'path:$id',
|
||||||
);
|
);
|
||||||
// Only the named columns change (ADR 0001, rule 1).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
|
|
@ -209,6 +215,7 @@ class SyncLearningPathGateway(
|
||||||
nonce: revision.nonce,
|
nonce: revision.nonce,
|
||||||
payload: revision.payload,
|
payload: revision.payload,
|
||||||
),
|
),
|
||||||
|
bound: 'step:${revision.stepId.uuid}',
|
||||||
item: 'step:${revision.stepId.uuid}',
|
item: 'step:${revision.stepId.uuid}',
|
||||||
treeKey: treeKeyOf(revision.stepId.uuid),
|
treeKey: treeKeyOf(revision.stepId.uuid),
|
||||||
),
|
),
|
||||||
|
|
@ -299,6 +306,7 @@ class SyncLearningPathGateway(
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'learning_path_step_revision',
|
table: 'learning_path_step_revision',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
bound: 'step:${stepId.uuid}',
|
||||||
item: 'step:${stepId.uuid}',
|
item: 'step:${stepId.uuid}',
|
||||||
);
|
);
|
||||||
return protocol.LearningPathStepRevision(
|
return protocol.LearningPathStepRevision(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class SyncPathAnswerGateway(
|
||||||
table: 'learning_path_answer',
|
table: 'learning_path_answer',
|
||||||
id: row.id!.uuid,
|
id: row.id!.uuid,
|
||||||
column: 'answer',
|
column: 'answer',
|
||||||
|
bound: 'step:${row.stepId.uuid}',
|
||||||
item: 'answers:${row.stepId.uuid}',
|
item: 'answers:${row.stepId.uuid}',
|
||||||
treeKey: treeKey,
|
treeKey: treeKey,
|
||||||
)) {
|
)) {
|
||||||
|
|
@ -96,6 +97,7 @@ class SyncPathAnswerGateway(
|
||||||
table: 'learning_path_answer',
|
table: 'learning_path_answer',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
column: 'answer',
|
column: 'answer',
|
||||||
|
bound: 'step:${answer.stepId}',
|
||||||
item: 'answers:${answer.stepId}',
|
item: 'answers:${answer.stepId}',
|
||||||
);
|
);
|
||||||
return protocol.LearningPathAnswer(
|
return protocol.LearningPathAnswer(
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ class SyncNoteGateway(
|
||||||
),
|
),
|
||||||
table: 'note_revision',
|
table: 'note_revision',
|
||||||
id: revision.id!.uuid,
|
id: revision.id!.uuid,
|
||||||
|
bound: 'note:${revision.noteId.uuid}',
|
||||||
item: 'note:${revision.noteId.uuid}',
|
item: 'note:${revision.noteId.uuid}',
|
||||||
treeKey: switch (studysetOf[revision.noteId.uuid]) {
|
treeKey: switch (studysetOf[revision.noteId.uuid]) {
|
||||||
final studyset? => treeKey(studyset),
|
final studyset? => treeKey(studyset),
|
||||||
|
|
@ -116,6 +117,7 @@ class SyncNoteGateway(
|
||||||
keyId: await _privacy.keyOfNote(noteId),
|
keyId: await _privacy.keyOfNote(noteId),
|
||||||
table: 'note_revision',
|
table: 'note_revision',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
bound: 'note:$noteId',
|
||||||
item: 'note:$noteId',
|
item: 'note:$noteId',
|
||||||
);
|
);
|
||||||
final revision = await protocol.NoteRevision.db.insertRow(
|
final revision = await protocol.NoteRevision.db.insertRow(
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class SyncPdfSourceGateway(
|
||||||
keyId: await _privacy.keyOfStudyset(studysetId),
|
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||||
table: 'pdf_source',
|
table: 'pdf_source',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
bound: 'studyset:$studysetId',
|
||||||
item: 'pdf:${id.uuid}',
|
item: 'pdf:${id.uuid}',
|
||||||
);
|
);
|
||||||
final source = await protocol.PdfSource.db.insertRow(
|
final source = await protocol.PdfSource.db.insertRow(
|
||||||
|
|
@ -88,6 +89,7 @@ class SyncPdfSourceGateway(
|
||||||
keyId: await _privacy.keyOfPdf(id, source.studysetId.uuid),
|
keyId: await _privacy.keyOfPdf(id, source.studysetId.uuid),
|
||||||
table: 'pdf_source',
|
table: 'pdf_source',
|
||||||
id: id,
|
id: id,
|
||||||
|
bound: 'studyset:${source.studysetId.uuid}',
|
||||||
item: 'pdf:$id',
|
item: 'pdf:$id',
|
||||||
);
|
);
|
||||||
// Only the named columns change (ADR 0001, rule 1).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
|
|
@ -203,6 +205,7 @@ class SyncPdfSourceGateway(
|
||||||
(keyId: source.keyId, nonce: source.nonce, payload: source.payload),
|
(keyId: source.keyId, nonce: source.nonce, payload: source.payload),
|
||||||
table: 'pdf_source',
|
table: 'pdf_source',
|
||||||
id: source.id!.uuid,
|
id: source.id!.uuid,
|
||||||
|
bound: 'studyset:${source.studysetId.uuid}',
|
||||||
item: 'pdf:${source.id!.uuid}',
|
item: 'pdf:${source.id!.uuid}',
|
||||||
treeKey: treeKey,
|
treeKey: treeKey,
|
||||||
)) {
|
)) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class SyncSettingsGateway(
|
||||||
if (_privacy.openItem(
|
if (_privacy.openItem(
|
||||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
|
bound: '',
|
||||||
id: row.id!.uuid,
|
id: row.id!.uuid,
|
||||||
item: 'settings:${row.id!.uuid}',
|
item: 'settings:${row.id!.uuid}',
|
||||||
treeKey: null,
|
treeKey: null,
|
||||||
|
|
@ -50,6 +51,7 @@ class SyncSettingsGateway(
|
||||||
_withCounter(id.uuid, payload, keyId),
|
_withCounter(id.uuid, payload, keyId),
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
|
bound: '',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
);
|
);
|
||||||
final row = await protocol.PersonSettings.db.insertRow(
|
final row = await protocol.PersonSettings.db.insertRow(
|
||||||
|
|
@ -76,6 +78,7 @@ class SyncSettingsGateway(
|
||||||
_withCounter(id, payload, keyId),
|
_withCounter(id, payload, keyId),
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
|
bound: '',
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
// Only the named columns change (ADR 0001, rule 1).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
|
|
@ -93,6 +96,9 @@ class SyncSettingsGateway(
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> delete(String id) async {
|
Future<void> delete(String id) async {
|
||||||
|
// Folded away here: never read again, whatever the instance hands back
|
||||||
|
// (the final reading of 22 Oct).
|
||||||
|
_privacy.foldedSettings(id);
|
||||||
await protocol.PersonSettings.db.deleteWhere(
|
await protocol.PersonSettings.db.deleteWhere(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.equals(_uuid(id)),
|
where: (t) => t.id.equals(_uuid(id)),
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class SyncStudysetGateway(
|
||||||
payload: studyset.payload,
|
payload: studyset.payload,
|
||||||
),
|
),
|
||||||
table: 'studyset',
|
table: 'studyset',
|
||||||
|
bound: '',
|
||||||
id: id,
|
id: id,
|
||||||
item: 'studyset:$id',
|
item: 'studyset:$id',
|
||||||
treeKey: treeKey,
|
treeKey: treeKey,
|
||||||
|
|
@ -83,6 +84,7 @@ class SyncStudysetGateway(
|
||||||
payload,
|
payload,
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'studyset',
|
table: 'studyset',
|
||||||
|
bound: '',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
item: 'studyset:${id.uuid}',
|
item: 'studyset:${id.uuid}',
|
||||||
);
|
);
|
||||||
|
|
@ -114,6 +116,7 @@ class SyncStudysetGateway(
|
||||||
payload,
|
payload,
|
||||||
keyId: await _privacy.keyOfStudysetRow(id),
|
keyId: await _privacy.keyOfStudysetRow(id),
|
||||||
table: 'studyset',
|
table: 'studyset',
|
||||||
|
bound: '',
|
||||||
id: id,
|
id: id,
|
||||||
item: 'studyset:$id',
|
item: 'studyset:$id',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class SyncTagGateway(
|
||||||
if (_privacy.openItem(
|
if (_privacy.openItem(
|
||||||
(keyId: tag.keyId, nonce: tag.nonce, payload: tag.payload),
|
(keyId: tag.keyId, nonce: tag.nonce, payload: tag.payload),
|
||||||
table: 'tag',
|
table: 'tag',
|
||||||
|
bound: '',
|
||||||
id: tag.id!.uuid,
|
id: tag.id!.uuid,
|
||||||
item: 'tag:${tag.id!.uuid}',
|
item: 'tag:${tag.id!.uuid}',
|
||||||
treeKey: null,
|
treeKey: null,
|
||||||
|
|
@ -74,6 +75,7 @@ class SyncTagGateway(
|
||||||
payload,
|
payload,
|
||||||
keyId: await _privacy.personalKey(),
|
keyId: await _privacy.personalKey(),
|
||||||
table: 'tag',
|
table: 'tag',
|
||||||
|
bound: '',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
);
|
);
|
||||||
final tag = await protocol.Tag.db.insertRow(
|
final tag = await protocol.Tag.db.insertRow(
|
||||||
|
|
@ -99,6 +101,7 @@ class SyncTagGateway(
|
||||||
payload,
|
payload,
|
||||||
keyId: await _privacy.personalKey(),
|
keyId: await _privacy.personalKey(),
|
||||||
table: 'tag',
|
table: 'tag',
|
||||||
|
bound: '',
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
// Only the named columns change (ADR 0001, rule 1).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_app/testing.dart';
|
import 'package:halkyon_app/testing.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
||||||
|
|
@ -53,6 +54,7 @@ void main() {
|
||||||
UnlockedAccountKeys? account,
|
UnlockedAccountKeys? account,
|
||||||
MemoryPrivacyLedgerStore? seen,
|
MemoryPrivacyLedgerStore? seen,
|
||||||
TreeFiles? files,
|
TreeFiles? files,
|
||||||
|
Future<bool?> Function()? registerHasKeys,
|
||||||
}) async {
|
}) async {
|
||||||
final ledger = PrivacyLedger(seen ?? MemoryPrivacyLedgerStore());
|
final ledger = PrivacyLedger(seen ?? MemoryPrivacyLedgerStore());
|
||||||
await ledger.open();
|
await ledger.open();
|
||||||
|
|
@ -61,6 +63,7 @@ void main() {
|
||||||
FakeContentCipher(),
|
FakeContentCipher(),
|
||||||
() async => account,
|
() async => account,
|
||||||
seen: ledger,
|
seen: ledger,
|
||||||
|
registerHasKeys: registerHasKeys,
|
||||||
);
|
);
|
||||||
await keys.refresh();
|
await keys.refresh();
|
||||||
final privacy = Privacy(db, keys)..files = files;
|
final privacy = Privacy(db, keys)..files = files;
|
||||||
|
|
@ -292,6 +295,7 @@ void main() {
|
||||||
keyId: null,
|
keyId: null,
|
||||||
table: 'card_revision',
|
table: 'card_revision',
|
||||||
id: stale.uuid,
|
id: stale.uuid,
|
||||||
|
bound: 'card:$card',
|
||||||
);
|
);
|
||||||
expect(signed.nonce, isNotNull, reason: 'a readable row is signed');
|
expect(signed.nonce, isNotNull, reason: 'a readable row is signed');
|
||||||
await instanceWrites(
|
await instanceWrites(
|
||||||
|
|
@ -618,7 +622,7 @@ void main() {
|
||||||
);
|
);
|
||||||
// The laptop's make-readable was cut short; the phone has made it
|
// The laptop's make-readable was cut short; the phone has made it
|
||||||
// private again since.
|
// private again since.
|
||||||
laptop.privacy.ledger.begin((studysetId: id, keyId: null));
|
laptop.privacy.ledger.begin((studysetId: id, keyId: null, adopt: false));
|
||||||
await laptop.privacy.keys.newStudysetKey(id);
|
await laptop.privacy.keys.newStudysetKey(id);
|
||||||
|
|
||||||
await laptop.privacy.maintain();
|
await laptop.privacy.maintain();
|
||||||
|
|
@ -764,6 +768,274 @@ void main() {
|
||||||
expect(await laptop.privacy.keyOfStudyset(id), isNotNull);
|
expect(await laptop.privacy.keyOfStudyset(id), isNotNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('the final reading of 22 Oct', () {
|
||||||
|
Future<String> privateStudyset(_Device d, String title) =>
|
||||||
|
d.studysets.insert(studysetPayload(title), DateTime.utc(2026, 10, 22));
|
||||||
|
|
||||||
|
Future<protocol.CardRevision> newestOf(String card) async =>
|
||||||
|
((await revisionsOf(
|
||||||
|
card,
|
||||||
|
))..sort((a, b) => a.id!.uuid.compareTo(b.id!.uuid))).last;
|
||||||
|
|
||||||
|
Future<void> instanceRepoints(
|
||||||
|
protocol.CardRevision revision,
|
||||||
|
String card,
|
||||||
|
) => protocol.CardRevision.db.updateRow(
|
||||||
|
db,
|
||||||
|
revision.copyWith(cardId: protocol.UuidValue.withValidation(card)),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a sealed revision pointed at another card never gives that card '
|
||||||
|
"its key, and making the other tree readable never opens the card's "
|
||||||
|
'edits', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final a = await privateStudyset(laptop, 'Diary');
|
||||||
|
final b = await privateStudyset(laptop, 'Published');
|
||||||
|
final bCard = await laptop.cards.insertCard(
|
||||||
|
studysetId: b,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('b'),
|
||||||
|
);
|
||||||
|
final bRevision = (await revisionsOf(bCard)).single;
|
||||||
|
// A card of the diary another device made: new to the laptop.
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final card = await phone.cards.insertCard(
|
||||||
|
studysetId: a,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('from the phone'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The instance points B's older revision at it.
|
||||||
|
await instanceRepoints(bRevision, card);
|
||||||
|
await laptop.cards.revisionsOf({card});
|
||||||
|
await laptop.cards.insertRevision(
|
||||||
|
cardId: card,
|
||||||
|
parents: const {},
|
||||||
|
payload: cardPayload('A SECRET EDIT'),
|
||||||
|
);
|
||||||
|
final edit = await newestOf(card);
|
||||||
|
expect(edit.keyId, await laptop.privacy.keyOfStudyset(a));
|
||||||
|
|
||||||
|
// Then points the edit at B's card, and the person makes B readable.
|
||||||
|
await instanceRepoints(edit, bCard);
|
||||||
|
laptop.privacy.forgetTrees();
|
||||||
|
await laptop.studysets.setPrivate(b, private: false);
|
||||||
|
|
||||||
|
final after = (await protocol.CardRevision.db.findById(db, edit.id!))!;
|
||||||
|
expect(after.keyId, isNotNull);
|
||||||
|
expect(
|
||||||
|
utf8.decode(Uint8List.sublistView(after.payload), allowMalformed: true),
|
||||||
|
isNot(contains('A SECRET EDIT')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a revision pointed at another card reads as locked', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final a = await privateStudyset(laptop, 'Diary');
|
||||||
|
final first = await laptop.cards.insertCard(
|
||||||
|
studysetId: a,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('first'),
|
||||||
|
);
|
||||||
|
final second = await laptop.cards.insertCard(
|
||||||
|
studysetId: a,
|
||||||
|
position: 'a1',
|
||||||
|
payload: cardPayload('second'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await instanceRepoints((await revisionsOf(first)).single, second);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[
|
||||||
|
for (final r in await laptop.cards.revisionsOf({second}))
|
||||||
|
if (r.payload.lengthInBytes > 0)
|
||||||
|
utf8.decode(Uint8List.sublistView(r.payload)),
|
||||||
|
],
|
||||||
|
[contains('second')],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a device without the keys asks again: once a password is set '
|
||||||
|
'elsewhere, it writes nothing readable', () async {
|
||||||
|
var registerSays = false;
|
||||||
|
final keyless = await device(registerHasKeys: () async => registerSays);
|
||||||
|
// Nothing private yet: the account has no keys, its studysets are
|
||||||
|
// readable (D8).
|
||||||
|
await keyless.studysets.insert(
|
||||||
|
studysetPayload('Before'),
|
||||||
|
DateTime.utc(2026, 10, 22),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A password set on another device, which makes keys and a private
|
||||||
|
// studyset.
|
||||||
|
registerSays = true;
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
await privateStudyset(laptop, 'Diary');
|
||||||
|
await keyless.privacy.keys.refresh();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
keyless.studysets.insert(
|
||||||
|
studysetPayload('After'),
|
||||||
|
DateTime.utc(2026, 10, 22),
|
||||||
|
),
|
||||||
|
throwsA(isA<PrivacyRefused>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an instance answering that the account has no keys, while keys '
|
||||||
|
'are kept, never makes a device write readable', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
await privateStudyset(laptop, 'Diary');
|
||||||
|
final keyless = await device(registerHasKeys: () async => false);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
keyless.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 '
|
||||||
|
'when it is finished', () async {
|
||||||
|
final files = _FailingFiles();
|
||||||
|
final laptop = await device(account: alice, files: files);
|
||||||
|
final id = await privateStudyset(laptop, 'Diary');
|
||||||
|
final card = await laptop.cards.insertCard(
|
||||||
|
studysetId: id,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('mine'),
|
||||||
|
);
|
||||||
|
// A readable revision a stale device signed names a file that won't
|
||||||
|
// move, so the sweep stays owed.
|
||||||
|
final signedId = Privacy.newId();
|
||||||
|
final signed = laptop.privacy.seal(
|
||||||
|
json({
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'type': 'basic',
|
||||||
|
'front': 'asset:${files.asset}',
|
||||||
|
'back': 'back',
|
||||||
|
}),
|
||||||
|
keyId: null,
|
||||||
|
table: 'card_revision',
|
||||||
|
id: signedId.uuid,
|
||||||
|
bound: 'card:$card',
|
||||||
|
);
|
||||||
|
await instanceWrites(
|
||||||
|
protocol.CardRevision(
|
||||||
|
id: signedId,
|
||||||
|
cardId: protocol.UuidValue.withValidation(card),
|
||||||
|
parents: const [],
|
||||||
|
authorId: const protocol.Uuid().v7obj(),
|
||||||
|
nonce: signed.nonce,
|
||||||
|
payload: signed.payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// And one nobody signed.
|
||||||
|
final planted = Privacy.newId();
|
||||||
|
await instanceWrites(
|
||||||
|
protocol.CardRevision(
|
||||||
|
id: planted,
|
||||||
|
cardId: protocol.UuidValue.withValidation(card),
|
||||||
|
parents: const [],
|
||||||
|
authorId: const protocol.Uuid().v7obj(),
|
||||||
|
payload: cardPayload('planted'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await laptop.privacy.maintain();
|
||||||
|
expect(laptop.privacy.ledger.pending, hasLength(1));
|
||||||
|
await laptop.privacy.maintain();
|
||||||
|
|
||||||
|
final row = (await protocol.CardRevision.db.findById(db, planted))!;
|
||||||
|
expect(row.keyId, isNull, reason: 'nobody asked the person');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a settings row folded away and handed back is never read again: '
|
||||||
|
'Count me stays off', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final kept = await laptop.settings.insert(
|
||||||
|
json({'countLearning': false}),
|
||||||
|
);
|
||||||
|
final folded = await phone.settings.insert(
|
||||||
|
json({'countLearning': true}),
|
||||||
|
);
|
||||||
|
final asItWas = (await protocol.PersonSettings.db.findById(
|
||||||
|
db,
|
||||||
|
protocol.UuidValue.withValidation(folded),
|
||||||
|
))!;
|
||||||
|
expect(await laptop.settings.rows(), hasLength(2));
|
||||||
|
// The laptop folds it away, as the repository does, and the person
|
||||||
|
// turns Count me off.
|
||||||
|
await laptop.settings.updatePayload(folded, redactedPayload);
|
||||||
|
await laptop.settings.delete(folded);
|
||||||
|
await laptop.settings.updatePayload(
|
||||||
|
kept,
|
||||||
|
json({'countLearning': false}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The instance hands it back as it was.
|
||||||
|
await protocol.PersonSettings.db.insertRow(db, asItWas);
|
||||||
|
|
||||||
|
expect([for (final r in await laptop.settings.rows()) r.id], [kept]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a settings row this device never saw is read only if it brings '
|
||||||
|
'news', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final kept = await laptop.settings.insert(
|
||||||
|
json({'countLearning': false}),
|
||||||
|
);
|
||||||
|
expect(await phone.settings.rows(), hasLength(1));
|
||||||
|
final other = await phone.settings.insert(
|
||||||
|
json({'countLearning': true}),
|
||||||
|
);
|
||||||
|
final asItWas = (await protocol.PersonSettings.db.findById(
|
||||||
|
db,
|
||||||
|
protocol.UuidValue.withValidation(other),
|
||||||
|
))!;
|
||||||
|
// The phone folds it away again and turns Count me off, before the
|
||||||
|
// laptop ever saw it.
|
||||||
|
expect(await phone.settings.rows(), hasLength(2));
|
||||||
|
await phone.settings.updatePayload(other, redactedPayload);
|
||||||
|
await phone.settings.delete(other);
|
||||||
|
await phone.settings.updatePayload(kept, json({'countLearning': false}));
|
||||||
|
expect(await laptop.settings.rows(), hasLength(1));
|
||||||
|
|
||||||
|
// The instance hands the laptop the row it never saw.
|
||||||
|
await protocol.PersonSettings.db.insertRow(db, asItWas);
|
||||||
|
|
||||||
|
expect([for (final r in await laptop.settings.rows()) r.id], [kept]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tree stays partly readable while rows in it stay locked', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final id = await privateStudyset(laptop, 'Diary');
|
||||||
|
final card = await laptop.cards.insertCard(
|
||||||
|
studysetId: id,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('mine'),
|
||||||
|
);
|
||||||
|
await instanceWrites(
|
||||||
|
protocol.CardRevision(
|
||||||
|
id: Privacy.newId(),
|
||||||
|
cardId: protocol.UuidValue.withValidation(card),
|
||||||
|
parents: const [],
|
||||||
|
authorId: const protocol.Uuid().v7obj(),
|
||||||
|
payload: cardPayload('planted'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await laptop.privacy.maintain();
|
||||||
|
expect(laptop.privacy.partlyReadable(id), isTrue);
|
||||||
|
await laptop.privacy.maintain();
|
||||||
|
expect(laptop.privacy.partlyReadable(id), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A device's gateways over the shared database.
|
/// A device's gateways over the shared database.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue