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
|
|
|
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:halkyon_content/testing.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.
- halkyon_keys: the safety code of two accounts' public keys
(`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
(`public-keys-of`, local and remote); the table `shared_space_key`
and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
mine). Only the Owner and Editors seal, the first key sealed to a
member stays, and a member's keys go with their membership. Every
host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
opens one sealed by another; `SharedKeys` shares a space's key with
its members, seals keys to newcomers and opens the person's own,
holding each member to the public keys `KeyPins` pinned on first
sight (`key_pins.json` in the person's folder), a change refused
until trusted anew.
Learn takes them into its `Privacy` in step 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 11:28:13 +01:00
|
|
|
|
|
|
|
|
/// A shared space's data keys sealed to each member on a device (Halkyon's
|
|
|
|
|
/// ADR 0044, D3), held to the public keys each device pinned on first sight
|
|
|
|
|
/// (D18), with libsodium as its build hook compiles it.
|
|
|
|
|
void main() {
|
|
|
|
|
const space = 'space-1';
|
|
|
|
|
late UnlockedAccountKeys aliceAccount;
|
|
|
|
|
late UnlockedAccountKeys bobAccount;
|
|
|
|
|
late UnlockedAccountKeys malloryAccount;
|
|
|
|
|
|
|
|
|
|
late FakeSharedKeysServer server;
|
|
|
|
|
late SodiumContentCipher aliceCipher;
|
|
|
|
|
late SodiumContentCipher bobCipher;
|
|
|
|
|
late MemoryKeyPinStore alicePins;
|
|
|
|
|
late KeyPins aliceKeyPins;
|
|
|
|
|
late MemoryKeyPinStore bobPins;
|
|
|
|
|
late SharedKeys alice;
|
|
|
|
|
late SharedKeys bob;
|
|
|
|
|
|
|
|
|
|
PublicKeys keysOf(UnlockedAccountKeys account) => (
|
|
|
|
|
encryption: account.encryptionPublicKey,
|
|
|
|
|
signing: account.signingPublicKey,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Future<SodiumContentCipher> cipherOf(UnlockedAccountKeys account) async {
|
|
|
|
|
final cipher = SodiumContentCipher();
|
|
|
|
|
await cipher.unlock(account);
|
|
|
|
|
addTearDown(cipher.dispose);
|
|
|
|
|
return cipher;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Matcher sealedTo(Set<String> people, {Set<String> changed = const {}}) =>
|
|
|
|
|
isA<SealOutcome>()
|
|
|
|
|
.having((o) => o.sealedTo, 'sealedTo', people)
|
|
|
|
|
.having((o) => o.changed, 'changed', changed);
|
|
|
|
|
|
|
|
|
|
Matcher openedThese(
|
|
|
|
|
Set<String> keys, {
|
|
|
|
|
Set<String> refused = const {},
|
|
|
|
|
Set<String> changed = const {},
|
|
|
|
|
}) => isA<OpenOutcome>()
|
|
|
|
|
.having((o) => o.opened, 'opened', keys)
|
|
|
|
|
.having((o) => o.refused, 'refused', refused)
|
|
|
|
|
.having((o) => o.changed, 'changed', changed);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
aliceAccount = await newAccountKeys();
|
|
|
|
|
bobAccount = await newAccountKeys();
|
|
|
|
|
malloryAccount = await newAccountKeys();
|
feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.
- halkyon_keys: the safety code of two accounts' public keys
(`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
(`public-keys-of`, local and remote); the table `shared_space_key`
and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
mine). Only the Owner and Editors seal, the first key sealed to a
member stays, and a member's keys go with their membership. Every
host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
opens one sealed by another; `SharedKeys` shares a space's key with
its members, seals keys to newcomers and opens the person's own,
holding each member to the public keys `KeyPins` pinned on first
sight (`key_pins.json` in the person's folder), a change refused
until trusted anew.
Learn takes them into its `Privacy` in step 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 11:28:13 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
server = FakeSharedKeysServer()
|
|
|
|
|
..addMember(space, 'alice', keys: keysOf(aliceAccount))
|
|
|
|
|
..addMember(space, 'bob', keys: keysOf(bobAccount));
|
|
|
|
|
aliceCipher = await cipherOf(aliceAccount);
|
|
|
|
|
bobCipher = await cipherOf(bobAccount);
|
|
|
|
|
alicePins = MemoryKeyPinStore();
|
|
|
|
|
bobPins = MemoryKeyPinStore();
|
|
|
|
|
aliceKeyPins = KeyPins(alicePins);
|
|
|
|
|
alice = SharedKeys(
|
|
|
|
|
server.as('alice'),
|
|
|
|
|
aliceKeyPins,
|
|
|
|
|
aliceCipher,
|
|
|
|
|
me: 'alice',
|
|
|
|
|
);
|
|
|
|
|
bob = SharedKeys(server.as('bob'), KeyPins(bobPins), bobCipher, me: 'bob');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a key shared with the members opens on their devices, and the '
|
|
|
|
|
'safety codes on both sides agree', () async {
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
|
|
|
|
|
expect(await alice.share(space, 'k1'), sealedTo({'alice', 'bob'}));
|
|
|
|
|
expect(await bob.openMine(space), openedThese({'k1'}));
|
|
|
|
|
|
|
|
|
|
expect(bobCipher.has('k1'), isTrue);
|
|
|
|
|
final code = await alice.safetyCodeWith('bob');
|
|
|
|
|
expect(code, isNotNull);
|
|
|
|
|
expect(await bob.safetyCodeWith('alice'), code);
|
|
|
|
|
});
|
|
|
|
|
|
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('content sealed with a space key opens from any member seen in the '
|
|
|
|
|
'space, the person included, and from no one else (D20)', () async {
|
|
|
|
|
const context = [1, 2, 3];
|
|
|
|
|
final plain = Uint8List.fromList([4, 5, 6]);
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
await bob.openMine(space);
|
|
|
|
|
// Mallory, never a member, holds the key all the same.
|
|
|
|
|
final mallory = await cipherOf(malloryAccount);
|
|
|
|
|
expect(
|
|
|
|
|
mallory.openSharedKey(
|
|
|
|
|
'k1',
|
|
|
|
|
aliceCipher.sealKeyTo('k1', keysOf(malloryAccount)),
|
|
|
|
|
sealedBy: aliceAccount.signingPublicKey,
|
|
|
|
|
),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final bobs = bobCipher.seal(plain, keyId: 'k1', context: context);
|
|
|
|
|
final alices = aliceCipher.seal(plain, keyId: 'k1', context: context);
|
|
|
|
|
final mallorys = mallory.seal(plain, keyId: 'k1', context: context);
|
|
|
|
|
|
|
|
|
|
expect(aliceCipher.unseal(bobs, context: context), plain);
|
|
|
|
|
expect(aliceCipher.unseal(alices, context: context), plain);
|
|
|
|
|
expect(bobCipher.unseal(alices, context: context), plain);
|
|
|
|
|
expect(aliceCipher.unseal(mallorys, context: context), isNull);
|
|
|
|
|
expect(bobCipher.unseal(mallorys, context: context), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.
- halkyon_keys: the safety code of two accounts' public keys
(`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
(`public-keys-of`, local and remote); the table `shared_space_key`
and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
mine). Only the Owner and Editors seal, the first key sealed to a
member stays, and a member's keys go with their membership. Every
host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
opens one sealed by another; `SharedKeys` shares a space's key with
its members, seals keys to newcomers and opens the person's own,
holding each member to the public keys `KeyPins` pinned on first
sight (`key_pins.json` in the person's folder), a change refused
until trusted anew.
Learn takes them into its `Privacy` in step 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 11:28:13 +01:00
|
|
|
test('keys swapped after they were pinned are neither sealed to nor '
|
|
|
|
|
'trusted, and named until trusted anew', () async {
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
await bob.openMine(space);
|
|
|
|
|
final mallorys = keysOf(malloryAccount);
|
|
|
|
|
|
|
|
|
|
server.handOutKeys('bob', mallorys);
|
|
|
|
|
aliceCipher.newKey('k2');
|
|
|
|
|
expect(
|
|
|
|
|
await alice.share(space, 'k2'),
|
|
|
|
|
sealedTo({'alice'}, changed: {'bob'}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server
|
|
|
|
|
..handOutKeys('bob', keysOf(bobAccount))
|
|
|
|
|
..handOutKeys('alice', mallorys);
|
|
|
|
|
expect(
|
|
|
|
|
await bob.openMine(space),
|
|
|
|
|
openedThese({}, refused: {'k1'}, changed: {'alice'}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Bob's keys changed for a reason Alice trusts.
|
|
|
|
|
server
|
|
|
|
|
..handOutKeys('alice', keysOf(aliceAccount))
|
|
|
|
|
..handOutKeys('bob', mallorys);
|
|
|
|
|
await aliceKeyPins.trustAnew('bob', mallorys);
|
|
|
|
|
expect((await alice.share(space, 'k2')).sealedTo, contains('bob'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keys swapped before they were first seen open nothing, and the '
|
|
|
|
|
'safety codes differ', () async {
|
|
|
|
|
final mallory = await cipherOf(malloryAccount);
|
|
|
|
|
server.handOutKeys('alice', keysOf(malloryAccount));
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
// Alice's own device holds to its own keys, whatever the instance says.
|
|
|
|
|
await expectLater(alice.share(space, 'k1'), throwsA(isA<OwnKeysDiffer>()));
|
|
|
|
|
|
|
|
|
|
// Sealed by the true Alice, checked by Bob against Mallory's keys.
|
|
|
|
|
server.addMember(space, 'alice', keys: keysOf(aliceAccount));
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
server.handOutKeys('alice', keysOf(malloryAccount));
|
|
|
|
|
expect(await bob.openMine(space), openedThese({}, refused: {'k1'}));
|
|
|
|
|
expect(bobCipher.has('k1'), isFalse);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await bob.safetyCodeWith('alice'),
|
|
|
|
|
isNot(await alice.safetyCodeWith('bob')),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
await bob.safetyCodeWith('alice'),
|
|
|
|
|
mallory.safetyCodeWith(keysOf(bobAccount)),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a newcomer gets the keys a writer holds, and a Viewer seals '
|
|
|
|
|
'nothing', () 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 carolAccount = await newAccountKeys();
|
feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.
- halkyon_keys: the safety code of two accounts' public keys
(`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
(`public-keys-of`, local and remote); the table `shared_space_key`
and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
mine). Only the Owner and Editors seal, the first key sealed to a
member stays, and a member's keys go with their membership. Every
host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
opens one sealed by another; `SharedKeys` shares a space's key with
its members, seals keys to newcomers and opens the person's own,
holding each member to the public keys `KeyPins` pinned on first
sight (`key_pins.json` in the person's folder), a change refused
until trusted anew.
Learn takes them into its `Privacy` in step 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 11:28:13 +01:00
|
|
|
final carolCipher = await cipherOf(carolAccount);
|
|
|
|
|
final carol = SharedKeys(
|
|
|
|
|
server.as('carol'),
|
|
|
|
|
KeyPins(MemoryKeyPinStore()),
|
|
|
|
|
carolCipher,
|
|
|
|
|
me: 'carol',
|
|
|
|
|
);
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
server.addMember(space, 'carol', keys: keysOf(carolAccount), writes: false);
|
|
|
|
|
|
|
|
|
|
expect((await carol.sealForNewcomers(space)).sealedTo, isEmpty);
|
|
|
|
|
expect((await carol.openMine(space)).opened, isEmpty);
|
|
|
|
|
|
|
|
|
|
await bob.openMine(space);
|
|
|
|
|
expect((await bob.sealForNewcomers(space)).sealedTo, {'carol'});
|
|
|
|
|
expect((await carol.openMine(space)).opened, {'k1'});
|
|
|
|
|
expect((await alice.sealForNewcomers(space)).sealedTo, isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base,app,learn): a shared space's key rolls once a member goes
What is written in a shared space after a member went takes a key they
never get (Halkyon's ADR 0044, D22). The instance marks the space as one
of its members goes, removed, leaving or deleted (`keyRollDue` on
`shared_space`, set in `removeMember`, through which each of them
passes); an invitation taken back never had a key and marks nothing, nor
does a new role. The Owner's or an Editor's device that next keeps its
keys up after a sync is told so (`sharedSpaceKeys.rollDue`, never true
for a Viewer, who seals no key) and makes a new key, sealed to those who
stay (D33). The first seal bringing a key ID the space never had clears
the mark in the same transaction (D34); members the rolling device
missed get it later as newcomers do. Earlier rows keep the key they had,
which whoever went had read already.
The migration, the same in the module and in each server including it
(Learn, Meditate, Todo, Train):
ALTER TABLE "shared_space"
ADD COLUMN "keyRollDue" boolean NOT NULL DEFAULT false;
It adds the column with false for every space there is, so no space is
due a roll from it; PostgreSQL adds a column with a constant default
without rewriting the table. The server before the switch reads and
writes the table naming its own columns, so it keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 16:15:33 +01:00
|
|
|
test('a member going makes the space due a new key, for writers alone, '
|
|
|
|
|
'which the first key it never had ends (D22, D33, D34)', () async {
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
server.addMember(space, 'carol', writes: false);
|
|
|
|
|
expect(await alice.rollDue(space), isFalse);
|
|
|
|
|
|
|
|
|
|
server.removeMember(space, 'bob');
|
|
|
|
|
expect(await alice.rollDue(space), isTrue);
|
|
|
|
|
expect(
|
|
|
|
|
await SharedKeys(
|
|
|
|
|
server.as('carol'),
|
|
|
|
|
KeyPins(MemoryKeyPinStore()),
|
|
|
|
|
FakeContentCipher(),
|
|
|
|
|
me: 'carol',
|
|
|
|
|
).rollDue(space),
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
expect(await alice.rollDue(space), isTrue, reason: 'not a new key');
|
|
|
|
|
aliceCipher.newKey('k2');
|
|
|
|
|
await alice.share(space, 'k2');
|
|
|
|
|
expect(await alice.rollDue(space), isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.
- halkyon_keys: the safety code of two accounts' public keys
(`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
(`public-keys-of`, local and remote); the table `shared_space_key`
and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
mine). Only the Owner and Editors seal, the first key sealed to a
member stays, and a member's keys go with their membership. Every
host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
opens one sealed by another; `SharedKeys` shares a space's key with
its members, seals keys to newcomers and opens the person's own,
holding each member to the public keys `KeyPins` pinned on first
sight (`key_pins.json` in the person's folder), a change refused
until trusted anew.
Learn takes them into its `Privacy` in step 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 11:28:13 +01:00
|
|
|
test("a key of a member's who left opens with the keys pinned for "
|
|
|
|
|
'them', () async {
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
await bob.openMine(space);
|
|
|
|
|
final other = await cipherOf(bobAccount);
|
|
|
|
|
final again = SharedKeys(
|
|
|
|
|
server.as('bob'),
|
|
|
|
|
KeyPins(bobPins),
|
|
|
|
|
other,
|
|
|
|
|
me: 'bob',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.removeMember(space, 'alice');
|
|
|
|
|
|
|
|
|
|
expect((await again.openMine(space)).opened, {'k1'});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a key sealed by someone never pinned, and no longer a member, '
|
|
|
|
|
"doesn't open", () async {
|
|
|
|
|
aliceCipher.newKey('k1');
|
|
|
|
|
await alice.share(space, 'k1');
|
|
|
|
|
server.removeMember(space, 'alice');
|
|
|
|
|
|
|
|
|
|
expect(await bob.openMine(space), openedThese({}, refused: {'k1'}));
|
|
|
|
|
});
|
feat(app,learn): shared spaces' members on the device
Step 4 (e) of ADR 0044, its first part, for every app's screens.
- halkyon_app: `SharedSpaceGateway` on the module's `sharedSpace`
endpoint (the spaces, their members and roles, inviting, handing on,
leaving), refusals as `SpaceRefused`; `SharedKeys.trustIn` gives
each member's safety code and whether it was compared or their keys
changed, `markCompared`, and `trustNewKeys`, which pins the keys
whose code was compared and never those the instance hands next.
`FakeSharedSpaceServer` keeps the module's rules for tests, in step
with `FakeSharedKeysServer`, which now knows members' handles.
- Learn's sharing makes its space through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 14:39:12 +01:00
|
|
|
|
|
|
|
|
test("a member's safety code is the same on both sides, and is marked "
|
|
|
|
|
'compared once the person says so (D32)', () async {
|
|
|
|
|
final seen = (await alice.trustIn(space)).single;
|
|
|
|
|
expect(seen.person, 'bob');
|
|
|
|
|
expect(seen.changed, isFalse);
|
|
|
|
|
expect(seen.compared, isFalse);
|
|
|
|
|
expect(seen.code, (await bob.trustIn(space)).single.code);
|
|
|
|
|
|
|
|
|
|
await alice.markCompared('bob');
|
|
|
|
|
expect((await alice.trustIn(space)).single.compared, isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keys that changed are flagged with the code of the new ones, and '
|
|
|
|
|
'only the keys whose code was compared are trusted anew', () async {
|
|
|
|
|
await alice.trustIn(space);
|
|
|
|
|
await alice.markCompared('bob');
|
|
|
|
|
final mallorys = keysOf(malloryAccount);
|
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 eves = keysOf(await newAccountKeys());
|
feat(app,learn): shared spaces' members on the device
Step 4 (e) of ADR 0044, its first part, for every app's screens.
- halkyon_app: `SharedSpaceGateway` on the module's `sharedSpace`
endpoint (the spaces, their members and roles, inviting, handing on,
leaving), refusals as `SpaceRefused`; `SharedKeys.trustIn` gives
each member's safety code and whether it was compared or their keys
changed, `markCompared`, and `trustNewKeys`, which pins the keys
whose code was compared and never those the instance hands next.
`FakeSharedSpaceServer` keeps the module's rules for tests, in step
with `FakeSharedKeysServer`, which now knows members' handles.
- Learn's sharing makes its space through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 14:39:12 +01:00
|
|
|
server.handOutKeys('bob', mallorys);
|
|
|
|
|
|
|
|
|
|
final changed = (await alice.trustIn(space)).single;
|
|
|
|
|
expect(changed.changed, isTrue);
|
|
|
|
|
expect(changed.compared, isFalse);
|
|
|
|
|
expect(changed.code, aliceCipher.safetyCodeWith(mallorys));
|
|
|
|
|
|
|
|
|
|
// The instance hands out other keys again before the person trusts the
|
|
|
|
|
// ones they compared: those compared are pinned, and these differ.
|
|
|
|
|
server.handOutKeys('bob', eves);
|
|
|
|
|
await alice.trustNewKeys('bob', changed.keys);
|
|
|
|
|
final after = (await alice.trustIn(space)).single;
|
|
|
|
|
expect(after.changed, isTrue);
|
|
|
|
|
expect(after.code, aliceCipher.safetyCodeWith(eves));
|
|
|
|
|
|
|
|
|
|
server.handOutKeys('bob', mallorys);
|
|
|
|
|
final trusted = (await alice.trustIn(space)).single;
|
|
|
|
|
expect(trusted.changed, isFalse);
|
|
|
|
|
expect(trusted.compared, isTrue);
|
|
|
|
|
});
|
feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.
- halkyon_keys: the safety code of two accounts' public keys
(`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
(`public-keys-of`, local and remote); the table `shared_space_key`
and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
mine). Only the Owner and Editors seal, the first key sealed to a
member stays, and a member's keys go with their membership. Every
host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
opens one sealed by another; `SharedKeys` shares a space's key with
its members, seals keys to newcomers and opens the person's own,
holding each member to the public keys `KeyPins` pinned on first
sight (`key_pins.json` in the person's folder), a change refused
until trusted anew.
Learn takes them into its `Privacy` in step 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 11:28:13 +01:00
|
|
|
}
|