fix(privacy): privacy is a signed fact the instance can't take back (ADR 0019)

The reading of 22 Oct, against an instance that alters data. A data key's
ID now says what it is for (a studyset's key naming it, the person's key,
or a record that a studyset was made readable), and the ID is sealed with
the key and signed by the account, so the instance can't relabel a key or
make one up; the row's own columns are no longer trusted. A studyset is
private while the latest signed record about it says so, and the device
keeps the latest it saw in a ledger in the person's folder (privacy.json),
so a record withheld or a row replayed changes nothing; a tree's key is
the nearest studyset's with such a record, itself or above. An item keeps
the key it was last seen sealed with unless this device moves it or a
signed record makes its tree readable.

A readable row a device holding the keys writes carries the account's
signature in place of a nonce; one nobody signed is locked wherever
privacy holds (in private trees, for items once sealed, and for tags and
settings once sealed), so the instance can't plant content, confirm a
guessed tag name by folding, or switch Count me on. Signed readable rows
from a device that hadn't heard are shown and sealed anew.

Rewrites are kept as under way until done, write the top studyset last,
hold writes into their tree meanwhile, and are finished as the data opens
and after each merge; a sweep seals what a stale device wrote. Cards moved
into a private tree are sealed with their files first; cards of a private
tree are never moved into a readable one. A private topic moved out of
its tree keeps its privacy with a key of its own. Settings carry a counter
inside their sealed payload, and a lower one than seen is refused. The
person's readable tags and settings are sealed once. A device without the
keys of an account that has them writes nothing that would be private and
reports nothing for the learner count; an anonymous person's keys kept but
unreadable are never replaced. Publishing sends only the names of the
tags the release carries. Key refusals are remembered per row.

Tests on the real gateways over a local database: a replayed readable top
row, a withheld key record, a moved parent, a planted revision, a signed
stale revision, card and topic moves, an interrupted rewrite, a settings
rollback, a device without keys, planted tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-22 09:12:35 +02:00
commit 88e79155bc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 2622 additions and 444 deletions

View file

@ -49,9 +49,16 @@ abstract interface class AccountKeyStore() {
/// Keeps [keys] as [person]'s.
Future<void> keep(String person, UnlockedAccountKeys keys);
/// [person]'s keys; null where none are kept.
/// [person]'s keys; null where none are kept, or what is kept can't be
/// read as keys.
Future<UnlockedAccountKeys?> read(String person);
/// Whether anything is kept for [person], readable or not: an anonymous
/// person's device keys are made only where nothing is, so an entry that
/// can't be read now is never replaced, locking their private studysets
/// for good (the reading of 22 Oct).
Future<bool> holds(String person);
/// Forgets every person's keys but [person]'s (everyone's with null).
Future<void> forgetAllBut(String? person);
}
@ -89,6 +96,10 @@ class SecureAccountKeyStore(
);
}
@override
Future<bool> holds(String person) =>
_storage.containsKey(key: _keyOf(person));
@override
Future<UnlockedAccountKeys?> read(String person) async {
final kept = await _storage.read(key: _keyOf(person));

View file

@ -59,6 +59,16 @@ abstract interface class ContentCipher() {
/// account's, it was changed, or it belongs to another row.
Uint8List? unseal(SealedPayload sealed, {required List<int> context});
/// The account's signature (64 bytes) over [message]: a readable row's,
/// which tells a row a device of the account wrote from one the instance
/// made up (ADR 0019, the reading). Throws a [StateError] before
/// [unlock].
Uint8List sign(List<int> message);
/// Whether [signature] is the account's over [message]; false before
/// [unlock].
bool verify(List<int> message, List<int> signature);
/// Wipes every key kept open here.
void dispose();
}
@ -176,6 +186,33 @@ class SodiumContentCipher() implements ContentCipher {
}
}
@override
Uint8List sign(List<int> message) {
final (sodium, account) = _unlocked();
return Uint8List.fromList(
sodium.crypto.sign.detached(
message: Uint8List.fromList(message),
secretKey: account.signing.secretKey,
),
);
}
@override
bool verify(List<int> message, List<int> signature) {
final sodium = _sodium;
final account = _account;
if (sodium == null ||
account == null ||
signature.length != sodium.crypto.sign.bytes) {
return false;
}
return sodium.crypto.sign.verifyDetached(
message: Uint8List.fromList(message),
signature: Uint8List.fromList(signature),
publicKey: account.signingPublicKey,
);
}
@override
void dispose() {
for (final key in _keys.values) {

View file

@ -78,6 +78,24 @@ class FakeContentCipher() implements ContentCipher {
]);
}
@override
Uint8List sign(List<int> message) {
final account = _unlocked();
final tag = utf8.encode('sig|$account|${base64Encode(message)}');
// 64 bytes, as a real signature: a digest of what it signs, folded.
final signature = Uint8List(64);
for (var i = 0; i < tag.length; i++) {
signature[i % 64] = (signature[i % 64] * 31 + tag[i]) & 0xff;
}
return signature;
}
@override
bool verify(List<int> message, List<int> signature) =>
_account != null &&
signature.length == 64 &&
_same(Uint8List.fromList(signature), sign(message));
@override
void dispose() => opened.clear();

View file

@ -13,6 +13,13 @@ class MemoryAccountKeyStore() implements AccountKeyStore {
@override
Future<UnlockedAccountKeys?> read(String person) async => kept[person];
/// The people whose keys are kept but can't be read.
final unreadable = <String>{};
@override
Future<bool> holds(String person) async =>
kept.containsKey(person) || unreadable.contains(person);
@override
Future<void> forgetAllBut(String? person) async =>
kept.removeWhere((each, _) => each != person);