feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and
signed by it, opens them, and seals and opens a row's content with them for
the row it is stored in (`SodiumContentCipher`, on the isolate it is used
on, as rows are read and written one by one; `FakeContentCipher` for
tests). A sealed payload holds the signature, then the ciphertext.
`AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's
device encrypts their private studysets with (ADR 0017, point 9, not built
before); setting a password keeps them as the account's
(`keepingKeys` with a new phrase), so nothing they made private is lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 20:58:19 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's
content moves out of the Flutter packages, so Learn's agent, which runs
without Flutter, shares it rather than repeating the privacy rules.
packages/halkyon_content (new, pure Dart) takes from halkyon_app the
envelope and its cipher, SealedFile, UnlockedAccountKeys, shared
spaces' gateway with SharedKeys and KeyPins, FileCache and
FileServerGateway, with their fakes and tests; halkyon_app exports it,
so no app changes. Its rich_text.dart takes RichContent from
halkyon_editor, which exports it. newAccountKeys makes an agent's key
pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a
synced gateway asks of the sync, which SyncService implements.
apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy,
sync, payloads and the models, codecs, gateways and repositories of
studysets, cards, notes, tags and feedback, with what they reach of
files, learning paths, citations and study options; the app imports them
from there. A subject's icon, colour and name stay in the app
(SubjectLook); what only the app shows (progress, exam coverage, the
card browser, deletions' waits) stays too. Nothing changes for people
using the apps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 21:24:30 +01:00
|
|
|
import 'package:halkyon_content/halkyon_content.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and
signed by it, opens them, and seals and opens a row's content with them for
the row it is stored in (`SodiumContentCipher`, on the isolate it is used
on, as rows are read and written one by one; `FakeContentCipher` for
tests). A sealed payload holds the signature, then the ciphertext.
`AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's
device encrypts their private studysets with (ADR 0017, point 9, not built
before); setting a password keeps them as the account's
(`keepingKeys` with a new phrase), so nothing they made private is lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 20:58:19 +02:00
|
|
|
|
|
|
|
|
/// Content sealed with a data key (ADR 0019), with libsodium as its build
|
|
|
|
|
/// hook compiles it.
|
|
|
|
|
void main() {
|
|
|
|
|
late UnlockedAccountKeys alice;
|
|
|
|
|
late UnlockedAccountKeys bob;
|
|
|
|
|
late SodiumContentCipher device;
|
|
|
|
|
|
|
|
|
|
final context = utf8.encode('card_revision/0192/payload');
|
|
|
|
|
final plain = Uint8List.fromList(utf8.encode('{"front":"火"}'));
|
|
|
|
|
|
|
|
|
|
setUpAll(() async {
|
refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's
content moves out of the Flutter packages, so Learn's agent, which runs
without Flutter, shares it rather than repeating the privacy rules.
packages/halkyon_content (new, pure Dart) takes from halkyon_app the
envelope and its cipher, SealedFile, UnlockedAccountKeys, shared
spaces' gateway with SharedKeys and KeyPins, FileCache and
FileServerGateway, with their fakes and tests; halkyon_app exports it,
so no app changes. Its rich_text.dart takes RichContent from
halkyon_editor, which exports it. newAccountKeys makes an agent's key
pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a
synced gateway asks of the sync, which SyncService implements.
apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy,
sync, payloads and the models, codecs, gateways and repositories of
studysets, cards, notes, tags and feedback, with what they reach of
files, learning paths, citations and study options; the app imports them
from there. A subject's icon, colour and name stay in the app
(SubjectLook); what only the app shows (progress, exam coverage, the
card browser, deletions' waits) stays too. Nothing changes for people
using the apps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 21:24:30 +01:00
|
|
|
alice = await newAccountKeys();
|
|
|
|
|
bob = await newAccountKeys();
|
feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and
signed by it, opens them, and seals and opens a row's content with them for
the row it is stored in (`SodiumContentCipher`, on the isolate it is used
on, as rows are read and written one by one; `FakeContentCipher` for
tests). A sealed payload holds the signature, then the ciphertext.
`AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's
device encrypts their private studysets with (ADR 0017, point 9, not built
before); setting a password keeps them as the account's
(`keepingKeys` with a new phrase), so nothing they made private is lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 20:58:19 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
device = SodiumContentCipher();
|
|
|
|
|
await device.unlock(alice);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tearDown(() => device.dispose());
|
|
|
|
|
|
|
|
|
|
test("a key made on one device opens on another of the account's, and "
|
|
|
|
|
'what it seals opens there', () async {
|
|
|
|
|
final sealedKey = device.newKey('studyset-1');
|
|
|
|
|
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
|
|
|
|
|
|
|
|
|
final other = SodiumContentCipher();
|
|
|
|
|
await other.unlock(alice);
|
|
|
|
|
addTearDown(other.dispose);
|
|
|
|
|
expect(other.has('studyset-1'), isFalse);
|
|
|
|
|
expect(other.openKey('studyset-1', sealedKey), isTrue);
|
|
|
|
|
|
|
|
|
|
expect(other.has('studyset-1'), isTrue);
|
|
|
|
|
expect(other.unseal(sealed, context: context), plain);
|
|
|
|
|
expect(sealed.keyId, 'studyset-1');
|
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>
2026-10-21 21:21:31 +02:00
|
|
|
expect(String.fromCharCodes(sealed.payload), isNot(contains('front')));
|
feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and
signed by it, opens them, and seals and opens a row's content with them for
the row it is stored in (`SodiumContentCipher`, on the isolate it is used
on, as rows are read and written one by one; `FakeContentCipher` for
tests). A sealed payload holds the signature, then the ciphertext.
`AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's
device encrypts their private studysets with (ADR 0017, point 9, not built
before); setting a password keeps them as the account's
(`keepingKeys` with a new phrase), so nothing they made private is lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 20:58:19 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("another account's device opens neither the key nor what it "
|
|
|
|
|
'sealed', () async {
|
|
|
|
|
final sealedKey = device.newKey('studyset-1');
|
|
|
|
|
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
|
|
|
|
|
|
|
|
|
final bobs = SodiumContentCipher();
|
|
|
|
|
await bobs.unlock(bob);
|
|
|
|
|
addTearDown(bobs.dispose);
|
|
|
|
|
|
|
|
|
|
expect(bobs.openKey('studyset-1', sealedKey), isFalse);
|
|
|
|
|
expect(bobs.unseal(sealed, context: context), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a key opens only under the ID it was sealed with, and only once '
|
|
|
|
|
'the account is unlocked', () async {
|
|
|
|
|
final sealedKey = device.newKey('studyset-1');
|
|
|
|
|
final other = SodiumContentCipher();
|
|
|
|
|
addTearDown(other.dispose);
|
|
|
|
|
|
|
|
|
|
expect(() => other.openKey('studyset-1', sealedKey), throwsStateError);
|
|
|
|
|
await other.unlock(alice);
|
|
|
|
|
expect(other.openKey('studyset-2', sealedKey), isFalse);
|
|
|
|
|
expect(other.openKey('studyset-1', sealedKey), isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a payload moved to another row, or changed, opens no more', () {
|
|
|
|
|
device.newKey('studyset-1');
|
|
|
|
|
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
|
|
|
|
final changed = Uint8List.fromList(sealed.payload)..last ^= 1;
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
device.unseal(sealed, context: utf8.encode('card_revision/0193/payload')),
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
device.unseal(
|
|
|
|
|
SealedPayload(
|
|
|
|
|
keyId: sealed.keyId,
|
|
|
|
|
nonce: sealed.nonce,
|
|
|
|
|
payload: changed,
|
|
|
|
|
),
|
|
|
|
|
context: context,
|
|
|
|
|
),
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
|
|
|
|
expect(device.unseal(sealed, context: context), plain);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-30 16:12:00 +01:00
|
|
|
test(
|
|
|
|
|
"a row's keyed ID (ADR 0030, D57) is the same on every device of the "
|
|
|
|
|
"account, another on another account's, and none before unlocking",
|
|
|
|
|
() async {
|
|
|
|
|
final other = SodiumContentCipher();
|
|
|
|
|
await other.unlock(alice);
|
|
|
|
|
addTearDown(other.dispose);
|
|
|
|
|
final bobs = SodiumContentCipher();
|
|
|
|
|
await bobs.unlock(bob);
|
|
|
|
|
addTearDown(bobs.dispose);
|
|
|
|
|
final locked = SodiumContentCipher();
|
|
|
|
|
|
|
|
|
|
final id = device.rowId('train/day/2026-10-30');
|
|
|
|
|
expect(other.rowId('train/day/2026-10-30'), id);
|
|
|
|
|
expect(bobs.rowId('train/day/2026-10-30'), isNot(id));
|
|
|
|
|
expect(device.rowId('train/day/2026-10-31'), isNot(id));
|
|
|
|
|
expect(() => locked.rowId('train/day/2026-10-30'), throwsStateError);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and
signed by it, opens them, and seals and opens a row's content with them for
the row it is stored in (`SodiumContentCipher`, on the isolate it is used
on, as rows are read and written one by one; `FakeContentCipher` for
tests). A sealed payload holds the signature, then the ciphertext.
`AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's
device encrypts their private studysets with (ADR 0017, point 9, not built
before); setting a password keeps them as the account's
(`keepingKeys` with a new phrase), so nothing they made private is lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 20:58:19 +02:00
|
|
|
test('without its key nothing opens, nor is sealed', () {
|
|
|
|
|
final sealed = SealedPayload(
|
|
|
|
|
keyId: 'unknown',
|
|
|
|
|
nonce: Uint8List(24),
|
|
|
|
|
payload: Uint8List(100),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(device.unseal(sealed, context: context), isNull);
|
|
|
|
|
expect(
|
|
|
|
|
() => device.seal(plain, keyId: 'unknown', context: context),
|
|
|
|
|
throwsStateError,
|
|
|
|
|
);
|
|
|
|
|
});
|
feat(app): a shared space's content taken from its members' signatures
ADR 0044, D20: a row in a shared space counts as genuine when any
member this device pinned signed it, current or former, never by the
author column the row names.
- `ContentCipher.trustSigners`: content sealed with a key is checked
against the signing keys given for it, in place of the account's
alone; `FakeContentCipher` tells authors apart likewise.
- `KeyPins` keeps who it saw in each space (`sawIn`) and the signing
keys pinned for someone before a reset (`earlierSigning`), so what
they wrote stays readable; `signersIn` gives a space's signers.
`key_pins.json` holds both (`pins`, `spaces`); a file written by
step 3, on no released app, counts as none.
- `SharedKeys` pins the person's own keys too, records the members it
checks, and trusts the space's signers for each key it shares or
opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 13:22:30 +01:00
|
|
|
|
|
|
|
|
test("a shared key's content opens when a member it trusts signed it, "
|
|
|
|
|
"and only for that key (Halkyon's ADR 0044, D20)", () async {
|
refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's
content moves out of the Flutter packages, so Learn's agent, which runs
without Flutter, shares it rather than repeating the privacy rules.
packages/halkyon_content (new, pure Dart) takes from halkyon_app the
envelope and its cipher, SealedFile, UnlockedAccountKeys, shared
spaces' gateway with SharedKeys and KeyPins, FileCache and
FileServerGateway, with their fakes and tests; halkyon_app exports it,
so no app changes. Its rich_text.dart takes RichContent from
halkyon_editor, which exports it. newAccountKeys makes an agent's key
pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a
synced gateway asks of the sync, which SyncService implements.
apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy,
sync, payloads and the models, codecs, gateways and repositories of
studysets, cards, notes, tags and feedback, with what they reach of
files, learning paths, citations and study options; the app imports them
from there. A subject's icon, colour and name stay in the app
(SubjectLook); what only the app shows (progress, exam coverage, the
card browser, deletions' waits) stays too. Nothing changes for people
using the apps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 21:24:30 +01:00
|
|
|
final carol = await newAccountKeys();
|
feat(app): a shared space's content taken from its members' signatures
ADR 0044, D20: a row in a shared space counts as genuine when any
member this device pinned signed it, current or former, never by the
author column the row names.
- `ContentCipher.trustSigners`: content sealed with a key is checked
against the signing keys given for it, in place of the account's
alone; `FakeContentCipher` tells authors apart likewise.
- `KeyPins` keeps who it saw in each space (`sawIn`) and the signing
keys pinned for someone before a reset (`earlierSigning`), so what
they wrote stays readable; `signersIn` gives a space's signers.
`key_pins.json` holds both (`pins`, `spaces`); a file written by
step 3, on no released app, counts as none.
- `SharedKeys` pins the person's own keys too, records the members it
checks, and trusts the space's signers for each key it shares or
opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 13:22:30 +01:00
|
|
|
PublicKeys keysOf(UnlockedAccountKeys account) => (
|
|
|
|
|
encryption: account.encryptionPublicKey,
|
|
|
|
|
signing: account.signingPublicKey,
|
|
|
|
|
);
|
|
|
|
|
Future<SodiumContentCipher> deviceOf(UnlockedAccountKeys account) async {
|
|
|
|
|
final cipher = SodiumContentCipher();
|
|
|
|
|
await cipher.unlock(account);
|
|
|
|
|
addTearDown(cipher.dispose);
|
|
|
|
|
return cipher;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final bobs = await deviceOf(bob);
|
|
|
|
|
final carols = await deviceOf(carol);
|
|
|
|
|
for (final keyId in ['shared', 'other']) {
|
|
|
|
|
device.newKey(keyId);
|
|
|
|
|
for (final (cipher, keys) in [(bobs, bob), (carols, carol)]) {
|
|
|
|
|
expect(
|
|
|
|
|
cipher.openSharedKey(
|
|
|
|
|
keyId,
|
|
|
|
|
device.sealKeyTo(keyId, keysOf(keys)),
|
|
|
|
|
sealedBy: alice.signingPublicKey,
|
|
|
|
|
),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
final byBob = bobs.seal(plain, keyId: 'shared', context: context);
|
|
|
|
|
final byCarol = carols.seal(plain, keyId: 'shared', context: context);
|
|
|
|
|
final byAlice = device.seal(plain, keyId: 'shared', context: context);
|
|
|
|
|
|
|
|
|
|
// Only the account's own, until told whom to trust.
|
|
|
|
|
expect(device.unseal(byBob, context: context), isNull);
|
|
|
|
|
expect(device.unseal(byAlice, context: context), plain);
|
|
|
|
|
|
|
|
|
|
device.trustSigners('shared', [
|
|
|
|
|
alice.signingPublicKey,
|
|
|
|
|
bob.signingPublicKey,
|
|
|
|
|
]);
|
|
|
|
|
expect(device.unseal(byBob, context: context), plain);
|
|
|
|
|
expect(device.unseal(byAlice, context: context), plain);
|
|
|
|
|
expect(device.unseal(byCarol, context: context), isNull);
|
|
|
|
|
expect(
|
|
|
|
|
device.unseal(
|
|
|
|
|
bobs.seal(plain, keyId: 'other', context: context),
|
|
|
|
|
context: context,
|
|
|
|
|
),
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// What it trusts is replaced, not added to.
|
|
|
|
|
device.trustSigners('shared', [carol.signingPublicKey]);
|
|
|
|
|
expect(device.unseal(byCarol, context: context), plain);
|
|
|
|
|
expect(device.unseal(byBob, context: context), isNull);
|
|
|
|
|
expect(device.unseal(byAlice, context: context), isNull);
|
|
|
|
|
});
|
feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and
signed by it, opens them, and seals and opens a row's content with them for
the row it is stored in (`SodiumContentCipher`, on the isolate it is used
on, as rows are read and written one by one; `FakeContentCipher` for
tests). A sealed payload holds the signature, then the ciphertext.
`AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's
device encrypts their private studysets with (ADR 0017, point 9, not built
before); setting a password keeps them as the account's
(`keepingKeys` with a new phrase), so nothing they made private is lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 20:58:19 +02:00
|
|
|
}
|