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:
parent
8ea2f04384
commit
88e79155bc
4 changed files with 2622 additions and 444 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue