feat(privacy): every synced payload sealed and opened through one envelope (ADR 0019)

- `ContentKeys` opens the person's data keys (`content_key`) with their
  account's keys on the device, an anonymous person's device keys made as
  their data first opens; it makes a private studyset's key and the
  personal key. Without the account's keys nothing opens and nothing new
  is private (D8).
- `PayloadEnvelope` is the one place payloads are sealed and opened, each
  for its table, row and column, so a payload moved to another row never
  opens; one that doesn't open is `Locked`, never half-read.
- `Privacy` gives each gateway the key of the row's tree (its top-level
  studyset's), and rewrites a tree under another key, or readable, in
  place (`rekeyTree`).
- The studyset, card, note, learning path, path answer and PDF gateways
  seal with their tree's key and open what they read: a new top-level
  studyset is private where the device holds the keys, a new one inside a
  tree takes its privacy, and one moved into another tree is rewritten
  under that tree's key. Tags and settings are sealed with the personal
  key; a tag or settings row that doesn't open here is left out, so
  nothing folds or deletes it. A studyset whose key doesn't open shows as
  locked (`Studyset.isLocked`); other rows that don't open read as blank.
- Every payload codec round-trips sealed, and refuses another row.

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-21 21:21:31 +02:00
commit 64fdfa091d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1693 additions and 151 deletions

View file

@ -52,6 +52,8 @@ class FakeContentCipher() implements ContentCipher {
keyId: keyId,
nonce: Uint8List(24),
payload: Uint8List.fromList([
tag.length >> 8,
tag.length & 0xff,
...tag,
for (final byte in plain) byte ^ 0xff,
]),
@ -60,16 +62,19 @@ class FakeContentCipher() implements ContentCipher {
@override
Uint8List? unseal(SealedPayload sealed, {required List<int> context}) {
if (!has(sealed.keyId)) {
final payload = sealed.payload;
if (!has(sealed.keyId) || payload.length < 2) {
return null;
}
final length = payload[0] << 8 | payload[1];
final tag = _tag(sealed.keyId, context);
if (sealed.payload.length < tag.length ||
!_same(Uint8List.sublistView(sealed.payload, 0, tag.length), tag)) {
if (length != tag.length ||
payload.length < 2 + length ||
!_same(Uint8List.sublistView(payload, 2, 2 + length), tag)) {
return null;
}
return Uint8List.fromList([
for (final byte in sealed.payload.skip(tag.length)) byte ^ 0xff,
for (final byte in payload.skip(2 + length)) byte ^ 0xff,
]);
}
@ -78,12 +83,9 @@ class FakeContentCipher() implements ContentCipher {
String _unlocked() => _account ?? (throw StateError('Not unlocked'));
/// What binds a payload to its key and context: 64 bytes, as long as a
/// signature is.
Uint8List _tag(String keyId, List<int> context) {
final text = utf8.encode('$keyId|${base64Encode(context)}');
return Uint8List(64)..setAll(0, text.take(64));
}
/// What binds a payload to its key and context.
Uint8List _tag(String keyId, List<int> context) =>
utf8.encode('$keyId|${base64Encode(context)}');
static bool _same(Uint8List a, Uint8List b) {
for (var i = 0; i < a.length; i++) {

View file

@ -41,7 +41,7 @@ void main() {
expect(other.has('studyset-1'), isTrue);
expect(other.unseal(sealed, context: context), plain);
expect(sealed.keyId, 'studyset-1');
expect(sealed.payload, isNot(contains(plain.first)));
expect(String.fromCharCodes(sealed.payload), isNot(contains('front')));
});
test("another account's device opens neither the key nor what it "