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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 11:28:13 +01:00
commit b72cea1fe0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
51 changed files with 105813 additions and 5 deletions

View file

@ -42,11 +42,18 @@
/// - **Sync**: `SyncService` on triggers, the changes still waiting, and
/// where it stands (`SyncStatusBloc`, `SyncStatusCard`, `AppMismatchBanner`,
/// `SyncTroubleBadge`).
/// - **Shared spaces' keys** (Halkyon's ADR 0044, D3, D18): `SharedKeys`
/// seals a space's data keys to its members and opens those sealed to
/// the person, holding each member to the public keys `KeyPins` pinned
/// on first sight, with the safety code to compare them by.
///
/// An app gives its name, look and own words as an `AppFrame`, and installs
/// `HalkyonAppLocalizations.delegate` beside its own.
library;
// A member's public keys, as `SharedKeys` and `KeyPins` take them.
export 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
export 'l10n/halkyon_app_localizations.dart';
export 'src/app/about.dart';
export 'src/app/app_frame.dart' show AppFrame, AppFrameScope, LookSettings;
@ -177,6 +184,10 @@ export 'src/profile/model/public_profile.dart';
export 'src/profile/widget/halkyon_profile.dart';
export 'src/profile/widget/profile_sheet.dart';
export 'src/profile/widget/public_profile_section.dart';
export 'src/shared_space/key_pins.dart';
export 'src/shared_space/serverpod_shared_keys_gateway.dart';
export 'src/shared_space/shared_keys.dart';
export 'src/shared_space/shared_keys_gateway.dart';
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
export 'src/simplelogin/bloc/simplelogin_standing_cubit.dart';
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';

View file

@ -45,6 +45,31 @@ abstract interface class ContentCipher() {
/// Whether the key [keyId] is open here.
bool has(String keyId);
/// The account's own public keys. Throws a [StateError] before [unlock].
PublicKeys get ownKeys;
/// The open key [keyId] sealed to another account, whose public keys are
/// [to], and signed by this one (Halkyon's ADR 0044, D3): for a member of
/// a space shared with them. Throws a [StateError] for a key not open
/// here.
Uint8List sealKeyTo(String keyId, PublicKeys to);
/// Opens [sealed] as the data key [keyId] another account sealed to this
/// one, checked against that account's signing key [sealedBy], kept open
/// here from then on; false where it doesn't open (not sealed to this
/// account, sealed or signed by another, changed). Throws a [StateError]
/// before [unlock].
bool openSharedKey(
String keyId,
Uint8List sealed, {
required Uint8List sealedBy,
});
/// The safety code of this account and the one whose public keys are
/// [other] (halkyon_keys' `safetyCode`, ADR 0044, D18), the same on both
/// sides. Throws a [StateError] before [unlock].
String safetyCodeWith(PublicKeys other);
/// [plain] sealed with the key [keyId] for the [context] it is stored in
/// (its table, row and column), so it opens nowhere else. Throws a
/// [StateError] for a key not open here.
@ -135,6 +160,58 @@ class SodiumContentCipher() implements ContentCipher {
@override
bool has(String keyId) => _keys.containsKey(keyId);
@override
PublicKeys get ownKeys {
final (_, account) = _unlocked();
return (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
}
@override
Uint8List sealKeyTo(String keyId, PublicKeys to) {
final (sodium, account) = _unlocked();
final key = _keys[keyId];
if (key == null) {
throw StateError('The data key $keyId is not open');
}
return sealDataKey(sodium, key, to: to.encryption, sealer: account);
}
@override
bool openSharedKey(
String keyId,
Uint8List sealed, {
required Uint8List sealedBy,
}) {
final (sodium, account) = _unlocked();
try {
final key = openDataKey(
sodium,
sealed,
keyId: keyId,
keys: account,
sealedBy: sealedBy,
);
_keys.remove(keyId)?.dispose();
_keys[keyId] = key;
return true;
} on KeyRefused catch (refused) {
log(
'A shared data key not opened: ${refused.reason.name}',
name: 'Privacy',
);
return false;
}
}
@override
String safetyCodeWith(PublicKeys other) {
final (sodium, _) = _unlocked();
return safetyCode(sodium, ownKeys, other);
}
@override
SealedPayload seal(
Uint8List plain, {

View file

@ -0,0 +1,158 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// The public keys a device first saw for a person, and whether their
/// safety code was compared (Halkyon's ADR 0044, D18).
final class const PinnedKeys({
required final PublicKeys keys,
final bool verified = false,
}) {
/// From what [toJson] wrote; null for anything else.
static PinnedKeys? fromJson(Object? json) => switch (json) {
{
'encryption': final String encryption,
'signing': final String signing,
'verified': final bool verified,
} =>
_decoded(encryption, signing, verified),
_ => null,
};
static PinnedKeys? _decoded(
String encryption,
String signing,
bool verified,
) {
try {
return PinnedKeys(
keys: (
encryption: base64Decode(encryption),
signing: base64Decode(signing),
),
verified: verified,
);
} on FormatException {
return null;
}
}
Map<String, Object?> toJson() => {
'encryption': base64Encode(keys.encryption),
'signing': base64Encode(keys.signing),
'verified': verified,
};
}
/// Where a device keeps the keys it pinned, by person (an account's ID).
abstract interface class KeyPinStore() {
Future<Map<String, PinnedKeys>> read();
Future<void> write(Map<String, PinnedKeys> pins);
}
/// [KeyPinStore] in a JSON file in the person's folder (`key_pins.json`).
class FileKeyPinStore(final File _file) implements KeyPinStore {
@override
Future<Map<String, PinnedKeys>> read() async {
try {
final json = jsonDecode(await _file.readAsString());
return {
if (json case final Map<String, Object?> pins)
for (final MapEntry(:key, :value) in pins.entries)
key: ?PinnedKeys.fromJson(value),
};
} on PathNotFoundException {
return {};
} on FormatException {
return {};
}
}
@override
Future<void> write(Map<String, PinnedKeys> pins) async {
await _file.parent.create(recursive: true);
final temporary = File('${_file.path}.tmp');
await temporary.writeAsString(
jsonEncode({
for (final MapEntry(:key, :value) in pins.entries) key: value.toJson(),
}),
flush: true,
);
await temporary.rename(_file.path);
}
}
/// How a person's keys compare with those pinned for them.
enum PinCheck() {
/// Seen for the first time, and pinned now.
firstSeen,
/// The same as pinned.
same,
/// Other than pinned: the instance swapped them, or the person's keys
/// were reset. Nothing is sealed to them or taken from them until the
/// person trusts the new keys ([KeyPins.trustAnew]).
changed,
}
/// The keys a device pinned for each person (ADR 0044, D18), as Signal and
/// SSH pin them: the first seen are kept, a change refused until trusted
/// anew.
class KeyPins(final KeyPinStore _store) {
Map<String, PinnedKeys>? _pins;
Future<Map<String, PinnedKeys>> _all() async => _pins ??= await _store.read();
/// How [keys] compare with those pinned for [person], pinning them when
/// none are.
Future<PinCheck> check(String person, PublicKeys keys) async {
final pins = await _all();
final pinned = pins[person];
if (pinned == null) {
pins[person] = PinnedKeys(keys: keys);
await _store.write(pins);
return PinCheck.firstSeen;
}
return sameKeys(pinned.keys, keys) ? PinCheck.same : PinCheck.changed;
}
/// The keys pinned for [person]; null for none.
Future<PinnedKeys?> of(String person) async => (await _all())[person];
/// Marks [person]'s pinned keys compared, by their safety code.
Future<void> markVerified(String person) async {
final pins = await _all();
if (pins[person] case final pinned?) {
pins[person] = PinnedKeys(keys: pinned.keys, verified: true);
await _store.write(pins);
}
}
/// Pins [keys] for [person] in place of those before, not compared: the
/// person said their keys changed for a reason of their own.
Future<void> trustAnew(String person, PublicKeys keys) async {
final pins = await _all();
pins[person] = PinnedKeys(keys: keys);
await _store.write(pins);
}
}
/// Whether [a] and [b] are the same keys, compared in constant time.
bool sameKeys(PublicKeys a, PublicKeys b) =>
_bytesEqual(a.encryption, b.encryption) &&
_bytesEqual(a.signing, b.signing);
bool _bytesEqual(Uint8List a, Uint8List b) {
if (a.length != b.length) {
return false;
}
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a[i] ^ b[i];
}
return difference == 0;
}

View file

@ -0,0 +1,50 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/shared_space/shared_keys_gateway.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
/// [SharedKeysGateway] on halkyon_base's `sharedSpaceKeys` endpoint.
class ServerpodSharedKeysGateway(final protocol.Caller _base)
implements SharedKeysGateway {
@override
Future<List<MemberKeys>> memberKeys(String space) async => [
for (final member in await _base.sharedSpaceKeys.memberKeys(_id(space)))
(
person: member.person.uuid,
handle: member.handle,
keys: (
encryption: Uint8List.sublistView(member.encryption),
signing: Uint8List.sublistView(member.signing),
),
),
];
@override
Future<List<KeyNeed>> keysNeeded(String space) async => [
for (final need in await _base.sharedSpaceKeys.keysNeeded(_id(space)))
(keyId: need.keyId, person: need.person.uuid),
];
@override
Future<void> seal(String space, List<SealedKey> keys) =>
_base.sharedSpaceKeys.seal(_id(space), [
for (final key in keys)
protocol.SharedSpaceSealedKey(
keyId: key.keyId,
person: _id(key.person),
sealedKey: ByteData.sublistView(key.sealed),
),
]);
@override
Future<List<SealedKey>> mine(String space) async => [
for (final key in await _base.sharedSpaceKeys.mine(_id(space)))
(
keyId: key.keyId,
person: key.person.uuid,
sealed: Uint8List.sublistView(key.sealedKey),
),
];
protocol.UuidValue _id(String id) => protocol.UuidValue.withValidation(id);
}

View file

@ -0,0 +1,158 @@
import 'package:halkyon_app/src/privacy/content_cipher.dart';
import 'package:halkyon_app/src/shared_space/key_pins.dart';
import 'package:halkyon_app/src/shared_space/shared_keys_gateway.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// The instance hands other public keys for the person than their own on
/// this device: nothing is sealed to keys it chose.
final class OwnKeysDiffer() implements Exception {
@override
String toString() =>
"OwnKeysDiffer: the instance's public keys for this account aren't "
"the device's";
}
/// What sealing a space's keys to its members did.
typedef SealOutcome = ({
/// The people a key was sealed to now.
Set<String> sealedTo,
/// Members whose keys changed since this device pinned them, left out
/// until trusted anew ([KeyPins.trustAnew]).
Set<String> changed,
});
/// What opening the keys sealed to the person did.
typedef OpenOutcome = ({
/// The keys opened, kept open in the cipher.
Set<String> opened,
/// Keys that didn't open: sealed or signed by keys other than those
/// pinned for their sealer, changed, or from a sealer unknown here.
Set<String> refused,
/// Sealers whose keys changed since this device pinned them; their keys
/// stay unopened until trusted anew.
Set<String> changed,
});
/// A shared space's data keys on the device (Halkyon's ADR 0044, D3, D18):
/// each sealed to every member's public keys, which this device pins on
/// first sight and holds to from then on, as Signal and SSH do; the
/// instance only carries what devices sealed.
class SharedKeys(
final SharedKeysGateway _gateway,
final KeyPins _pins,
final ContentCipher _cipher, {
required final String me,
}) {
/// Seals the open key [keyId] to every member of [space] who has keys,
/// the person among them: for a space's first key, or the one replacing
/// it after someone left. Throws [OwnKeysDiffer] where the instance's
/// keys for the person aren't the device's.
Future<SealOutcome> share(String space, String keyId) async {
final members = await _checkedMembers(space);
final sealed = [
for (final MapEntry(key: person, value: keys) in members.trusted.entries)
(keyId: keyId, person: person, sealed: _cipher.sealKeyTo(keyId, keys)),
];
await _gateway.seal(space, sealed);
return (
sealedTo: {for (final key in sealed) key.person},
changed: members.changed,
);
}
/// Seals the keys of [space] that this device holds to the members who
/// lack them: newcomers, and those who have keys only now. Nothing for a
/// Viewer, whom the instance asks for none.
Future<SealOutcome> sealForNewcomers(String space) async {
final needed = await _gateway.keysNeeded(space);
if (needed.isEmpty) {
return (sealedTo: <String>{}, changed: <String>{});
}
final members = await _checkedMembers(space);
final sealed = [
for (final need in needed)
if (_cipher.has(need.keyId))
if (members.trusted[need.person] case final keys?)
(
keyId: need.keyId,
person: need.person,
sealed: _cipher.sealKeyTo(need.keyId, keys),
),
];
if (sealed.isNotEmpty) {
await _gateway.seal(space, sealed);
}
return (
sealedTo: {for (final key in sealed) key.person},
changed: {
for (final need in needed)
if (members.changed.contains(need.person)) need.person,
},
);
}
/// Opens the keys of [space] sealed to the person, each checked against
/// its sealer's pinned keys; one a former member sealed is checked
/// against the keys pinned for them, if any.
Future<OpenOutcome> openMine(String space) async {
final mine = await _gateway.mine(space);
if (mine.isEmpty) {
return (opened: <String>{}, refused: <String>{}, changed: <String>{});
}
final members = await _checkedMembers(space);
final opened = <String>{};
final refused = <String>{};
final changed = <String>{};
for (final key in mine) {
if (members.changed.contains(key.person)) {
changed.add(key.person);
refused.add(key.keyId);
continue;
}
final sealer =
members.trusted[key.person] ?? (await _pins.of(key.person))?.keys;
final opens =
sealer != null &&
_cipher.openSharedKey(
key.keyId,
key.sealed,
sealedBy: sealer.signing,
);
(opens ? opened : refused).add(key.keyId);
}
return (opened: opened, refused: refused, changed: changed);
}
/// The safety code of the person and [person], over the keys pinned for
/// them; null where none are.
Future<String?> safetyCodeWith(String person) async {
final pinned = await _pins.of(person);
return pinned == null ? null : _cipher.safetyCodeWith(pinned.keys);
}
/// [space]'s members with keys, checked against their pins: those whose
/// keys are pinned (or now first seen) and those whose keys changed. The
/// person's own must be the cipher's.
Future<({Map<String, PublicKeys> trusted, Set<String> changed})>
_checkedMembers(String space) async {
final trusted = <String, PublicKeys>{};
final changed = <String>{};
for (final member in await _gateway.memberKeys(space)) {
if (member.person == me) {
if (!sameKeys(member.keys, _cipher.ownKeys)) {
throw OwnKeysDiffer();
}
trusted[me] = member.keys;
} else if (await _pins.check(member.person, member.keys) ==
PinCheck.changed) {
changed.add(member.person);
} else {
trusted[member.person] = member.keys;
}
}
return (trusted: trusted, changed: changed);
}
}

View file

@ -0,0 +1,30 @@
import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// A member of a shared space with their public keys, as the instance hands
/// them.
typedef MemberKeys = ({String person, String? handle, PublicKeys keys});
/// A key the device holds that `person` lacks.
typedef KeyNeed = ({String keyId, String person});
/// A key sealed to one person: to `person` on the way to the instance,
/// from `person` (its sealer) on the way back.
typedef SealedKey = ({String keyId, String person, Uint8List sealed});
/// The boundary to the keys of spaces shared on the instance (halkyon_base's
/// `sharedSpaceKeys`, Halkyon's ADR 0044, D3). Tests replace it with a fake.
abstract interface class SharedKeysGateway() {
/// The public keys of [space]'s members who have keys.
Future<List<MemberKeys>> memberKeys(String space);
/// The keys of [space] the person holds that members lack.
Future<List<KeyNeed>> keysNeeded(String space);
/// Keeps [keys], each sealed to the member it names.
Future<void> seal(String space, List<SealedKey> keys);
/// The keys of [space] sealed to the person, each naming its sealer.
Future<List<SealedKey>> mine(String space);
}

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
import 'package:halkyon_app/src/privacy/content_cipher.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// [ContentCipher] without libsodium, for tests: a key opens only on a
/// device unlocked for the account it was made for, and content only under
@ -10,13 +11,19 @@ import 'package:halkyon_app/src/privacy/content_cipher.dart';
/// secret (each byte is flipped, so the plain text doesn't show).
class FakeContentCipher() implements ContentCipher {
String? _account;
PublicKeys? _ownKeys;
/// The keys open here, by their ID, with the account they belong to.
final Map<String, String> opened = {};
@override
Future<void> unlock(UnlockedAccountKeys account) async =>
_account = base64Encode(account.signingPublicKey);
Future<void> unlock(UnlockedAccountKeys account) async {
_account = base64Encode(account.signingPublicKey);
_ownKeys = (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
}
@override
Uint8List newKey(String keyId) {
@ -38,6 +45,43 @@ class FakeContentCipher() implements ContentCipher {
@override
bool has(String keyId) => opened.containsKey(keyId);
@override
PublicKeys get ownKeys => _ownKeys ?? (throw StateError('Not unlocked'));
@override
Uint8List sealKeyTo(String keyId, PublicKeys to) {
final account = _unlocked();
if (!has(keyId)) {
throw StateError('The data key $keyId is not open');
}
return Uint8List.fromList(
utf8.encode('shared|${base64Encode(to.encryption)}|$keyId|$account'),
);
}
@override
bool openSharedKey(
String keyId,
Uint8List sealed, {
required Uint8List sealedBy,
}) {
if (utf8.decode(sealed, allowMalformed: true) !=
'shared|${base64Encode(ownKeys.encryption)}|$keyId|'
'${base64Encode(sealedBy)}') {
return false;
}
opened[keyId] = _unlocked();
return true;
}
/// Both accounts' signing keys, in one order: alike on both sides, but
/// not the real code's digits.
@override
String safetyCodeWith(PublicKeys other) => ([
base64Encode(ownKeys.signing),
base64Encode(other.signing),
]..sort()).join(' ');
@override
SealedPayload seal(
Uint8List plain, {

View file

@ -0,0 +1,123 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/shared_space/shared_keys_gateway.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
typedef _Sealed = ({String keyId, String to, String by, Uint8List sealed});
/// The instance's shared spaces' keys in memory, as halkyon_base keeps
/// them: members with their public keys (which a test may swap, as a
/// hostile instance would), the keys sealed to each, the first staying.
/// Each person reaches it through their own [as].
class FakeSharedKeysServer() {
final _members = <String, Map<String, ({PublicKeys? keys, bool writes})>>{};
final _sealed =
<
String,
List<({String keyId, String to, String by, Uint8List sealed})>
>{};
/// Makes [person] a member of [space], writing where [writes], with the
/// public keys [keys] the register hands out for them (none where null).
void addMember(
String space,
String person, {
PublicKeys? keys,
bool writes = true,
}) => (_members[space] ??= {})[person] = (keys: keys, writes: writes);
/// Ends [person]'s membership of [space], with the keys sealed to them.
void removeMember(String space, String person) {
_members[space]?.remove(person);
_sealed[space]?.removeWhere((key) => key.to == person);
}
/// The keys handed out for [person] from now on, in every space.
void handOutKeys(String person, PublicKeys keys) {
for (final members in _members.values) {
if (members[person] case final member?) {
members[person] = (keys: keys, writes: member.writes);
}
}
}
/// How many keys are sealed in [space].
int sealedCount(String space) => _sealed[space]?.length ?? 0;
/// The gateway as [person] reaches it.
SharedKeysGateway as(String person) => _View(this, person);
Map<String, ({PublicKeys? keys, bool writes})> _membersAs(
String space,
String person,
) {
final members = _members[space];
if (members == null || !members.containsKey(person)) {
throw StateError('No such space');
}
return members;
}
}
class _View(final FakeSharedKeysServer _server, final String _person)
implements SharedKeysGateway {
@override
Future<List<MemberKeys>> memberKeys(String space) async => [
for (final MapEntry(key: person, value: member)
in _server._membersAs(space, _person).entries)
if (member.keys case final keys?)
(person: person, handle: null, keys: keys),
];
@override
Future<List<KeyNeed>> keysNeeded(String space) async {
final members = _server._membersAs(space, _person);
if (!members[_person]!.writes) {
return const [];
}
final sealed = _server._sealed[space] ?? const <_Sealed>[];
final held = {
for (final key in sealed)
if (key.to == _person) key.keyId,
};
return [
for (final keyId in held)
for (final MapEntry(key: person, value: member) in members.entries)
if (member.keys != null &&
!sealed.any((key) => key.keyId == keyId && key.to == person))
(keyId: keyId, person: person),
];
}
@override
Future<void> seal(String space, List<SealedKey> keys) async {
final members = _server._membersAs(space, _person);
if (!members[_person]!.writes) {
throw StateError('Not allowed');
}
final sealed = _server._sealed[space] ??= [];
for (final key in keys) {
if (!members.containsKey(key.person)) {
throw StateError('No such member');
}
if (!sealed.any((k) => k.keyId == key.keyId && k.to == key.person)) {
sealed.add((
keyId: key.keyId,
to: key.person,
by: _person,
sealed: key.sealed,
));
}
}
}
@override
Future<List<SealedKey>> mine(String space) async {
_server._membersAs(space, _person);
return [
for (final key in _server._sealed[space] ?? const <_Sealed>[])
if (key.to == _person)
(keyId: key.keyId, person: key.by, sealed: key.sealed),
];
}
}

View file

@ -0,0 +1,13 @@
import 'package:halkyon_app/src/shared_space/key_pins.dart';
/// [KeyPinStore] in memory; [pins] is what a file would hold.
class MemoryKeyPinStore() implements KeyPinStore {
Map<String, PinnedKeys> pins = {};
@override
Future<Map<String, PinnedKeys>> read() async => {...pins};
@override
Future<void> write(Map<String, PinnedKeys> pins) async =>
this.pins = {...pins};
}

View file

@ -22,6 +22,7 @@ export 'src/testing/fake_oidc_browser.dart';
export 'src/testing/fake_password_gateway.dart';
export 'src/testing/fake_public_profile_gateway.dart';
export 'src/testing/fake_screen_guard.dart';
export 'src/testing/fake_shared_keys_gateway.dart';
export 'src/testing/fake_simplelogin_gateway.dart';
export 'src/testing/fake_speech_input.dart';
export 'src/testing/memory_account_key_store.dart';
@ -29,6 +30,7 @@ export 'src/testing/memory_account_wait_store.dart';
export 'src/testing/memory_file_cache.dart';
export 'src/testing/memory_file_saver.dart';
export 'src/testing/memory_instance_store.dart';
export 'src/testing/memory_key_pin_store.dart';
export 'src/testing/memory_look_store.dart';
export 'src/testing/memory_pending_changes_store.dart';
export 'src/testing/memory_sign_in_addresses.dart';

View file

@ -0,0 +1,64 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
/// The public keys a device pins for each person (Halkyon's ADR 0044, D18).
void main() {
late Directory folder;
late File file;
PublicKeys keys(int mark) => (
encryption: Uint8List.fromList(List.filled(32, mark)),
signing: Uint8List.fromList(List.filled(32, mark + 1)),
);
setUp(() async {
folder = await Directory.systemTemp.createTemp('key_pins');
file = File('${folder.path}/person/key_pins.json');
});
tearDown(() => folder.delete(recursive: true));
test('the first keys seen stay pinned, on the next start too; others '
'are a change until trusted anew', () async {
final pins = KeyPins(FileKeyPinStore(file));
expect(await pins.check('bob', keys(1)), PinCheck.firstSeen);
expect(await pins.check('bob', keys(1)), PinCheck.same);
expect(await pins.check('bob', keys(3)), PinCheck.changed);
final later = KeyPins(FileKeyPinStore(file));
expect(await later.check('bob', keys(3)), PinCheck.changed);
expect(await later.check('bob', keys(1)), PinCheck.same);
await later.trustAnew('bob', keys(3));
expect(
await KeyPins(FileKeyPinStore(file)).check('bob', keys(3)),
PinCheck.same,
);
});
test('compared keys are marked so, until they are trusted anew', () async {
final pins = KeyPins(FileKeyPinStore(file));
await pins.check('bob', keys(1));
expect((await pins.of('bob'))!.verified, isFalse);
await pins.markVerified('bob');
expect((await KeyPins(FileKeyPinStore(file)).of('bob'))!.verified, isTrue);
await pins.trustAnew('bob', keys(3));
expect((await pins.of('bob'))!.verified, isFalse);
});
test('a file not holding pins counts as none', () async {
await file.parent.create(recursive: true);
await file.writeAsString('{not json');
expect(await FileKeyPinStore(file).read(), isEmpty);
await file.writeAsString(
'{"bob": {"encryption": "%%", "signing": "", '
'"verified": false}, "carol": 3}',
);
expect(await FileKeyPinStore(file).read(), isEmpty);
});
}

View file

@ -0,0 +1,191 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
/// 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';
final crypto = SodiumAccountCrypto();
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 {
aliceAccount = (await crypto.newAccount('alice password')).keys;
bobAccount = (await crypto.newAccount('bob password')).keys;
malloryAccount = (await crypto.newAccount('mallory password')).keys;
});
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);
});
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 {
final carolAccount = (await crypto.newAccount('carol password')).keys;
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);
});
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'}));
});
}

View file

@ -80,10 +80,16 @@ import 'package:halkyon_base_client/src/protocol/profile/profile_refusal.dart'
as _ih722tf9;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_info.dart'
as _i9dv6y4h;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_key_need.dart'
as _ixm5azhv;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_member_info.dart'
as _iyon0cg8;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_member_keys.dart'
as _i6d21238;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_role.dart'
as _i1fyjuki;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_sealed_key.dart'
as _ie2by8eg;
import 'package:halkyon_base_client/src/protocol/simplelogin/simplelogin_completed.dart'
as _ianfx7qj;
import 'package:halkyon_base_client/src/protocol/simplelogin/simplelogin_return.dart'
@ -1442,6 +1448,66 @@ class EndpointSharedSpace extends _isc.EndpointRef {
);
}
/// The data keys of a space you share, each sealed on a device to one
/// member (ADR 0044, D3): the members' public keys to seal to and pin
/// (D18), the keys a member still lacks, sealing them, and your own. The
/// instance keeps what devices sealed and never opens it. Only for people
/// with an identity; a space you aren't a member of can't be told from one
/// that doesn't exist ([SharedSpaceNotFoundException]).
/// {@category Endpoint}
class EndpointSharedSpaceKeys extends _isc.EndpointRef {
EndpointSharedSpaceKeys(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'halkyon_base.sharedSpaceKeys';
/// The public keys of [space]'s members who have keys, you among them,
/// as the register keeps them.
_ida.Future<List<_i6d21238.SharedSpaceMemberKeys>> memberKeys(
_isc.UuidValue space,
) => caller.callServerEndpoint<List<_i6d21238.SharedSpaceMemberKeys>>(
'halkyon_base.sharedSpaceKeys',
'memberKeys',
{'space': space},
);
/// The keys of [space] you hold that members who have keys lack, for
/// your device to seal to them; none while you may not seal ([seal]).
_ida.Future<List<_ixm5azhv.SharedSpaceKeyNeed>> keysNeeded(
_isc.UuidValue space,
) => caller.callServerEndpoint<List<_ixm5azhv.SharedSpaceKeyNeed>>(
'halkyon_base.sharedSpaceKeys',
'keysNeeded',
{'space': space},
);
/// Keeps [keys], each sealed on your device to the member it names, as
/// sealed by you; one a member has already stays as it was, as does the
/// first of two the call names alike. Refused for a
/// Viewer: only those who write, the Owner and Editors, seal keys, so a
/// reader can't hand a newcomer a key of their own making.
_ida.Future<void> seal(
_isc.UuidValue space,
List<_ie2by8eg.SharedSpaceSealedKey> keys,
) => caller.callServerEndpoint<void>(
'halkyon_base.sharedSpaceKeys',
'seal',
{
'space': space,
'keys': keys,
},
);
/// The keys of [space] sealed to you, each naming who sealed it.
_ida.Future<List<_ie2by8eg.SharedSpaceSealedKey>> mine(
_isc.UuidValue space,
) => caller.callServerEndpoint<List<_ie2by8eg.SharedSpaceSealedKey>>(
'halkyon_base.sharedSpaceKeys',
'mine',
{'space': space},
);
}
/// Signing in with SimpleLogin (ADR 0020, 3 to 5), signed out or, to add it
/// to one's account, signed in. The instance's register keeps the attempts
/// and checks SimpleLogin's codes (ADR 0016); a session is this server's.
@ -1597,6 +1663,7 @@ class Caller extends _isc.ModuleEndpointCaller {
profile = EndpointProfile(this);
profiles = EndpointProfiles(this);
sharedSpace = EndpointSharedSpace(this);
sharedSpaceKeys = EndpointSharedSpaceKeys(this);
simpleLogin = EndpointSimpleLogin(this);
}
@ -1632,6 +1699,8 @@ class Caller extends _isc.ModuleEndpointCaller {
late final EndpointSharedSpace sharedSpace;
late final EndpointSharedSpaceKeys sharedSpaceKeys;
late final EndpointSimpleLogin simpleLogin;
@override
@ -1652,6 +1721,7 @@ class Caller extends _isc.ModuleEndpointCaller {
'halkyon_base.profile': profile,
'halkyon_base.profiles': profiles,
'halkyon_base.sharedSpace': sharedSpace,
'halkyon_base.sharedSpaceKeys': sharedSpaceKeys,
'halkyon_base.simpleLogin': simpleLogin,
};
}

View file

@ -21,8 +21,14 @@ import 'package:halkyon_base_client/src/protocol/password/authenticator.dart'
as _ivfmflch;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_info.dart'
as _i9dv6y4h;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_key_need.dart'
as _ixm5azhv;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_member_info.dart'
as _iyon0cg8;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_member_keys.dart'
as _i6d21238;
import 'package:halkyon_base_client/src/protocol/shared_space/shared_space_sealed_key.dart'
as _ie2by8eg;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -95,11 +101,14 @@ import 'profile/profile_not_found_exception.dart' as _iim3hfcf;
import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'shared_space/shared_space_info.dart' as _i1rvc02f;
import 'shared_space/shared_space_key_need.dart' as _imxqlbwp;
import 'shared_space/shared_space_member_info.dart' as _iz8z2oju;
import 'shared_space/shared_space_member_keys.dart' as _iqkqjhh4;
import 'shared_space/shared_space_not_found_exception.dart' as _ixm56fil;
import 'shared_space/shared_space_refusal.dart' as _irg3s7i7;
import 'shared_space/shared_space_refused_exception.dart' as _iooym5cy;
import 'shared_space/shared_space_role.dart' as _i2fude1g;
import 'shared_space/shared_space_sealed_key.dart' as _ifx6y5sl;
import 'simplelogin/simplelogin_completed.dart' as _i51ry5s2;
import 'simplelogin/simplelogin_outcome.dart' as _i5ar1hzl;
import 'simplelogin/simplelogin_return.dart' as _i8dcjd27;
@ -169,11 +178,14 @@ export 'profile/profile_not_found_exception.dart';
export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart';
export 'shared_space/shared_space_info.dart';
export 'shared_space/shared_space_key_need.dart';
export 'shared_space/shared_space_member_info.dart';
export 'shared_space/shared_space_member_keys.dart';
export 'shared_space/shared_space_not_found_exception.dart';
export 'shared_space/shared_space_refusal.dart';
export 'shared_space/shared_space_refused_exception.dart';
export 'shared_space/shared_space_role.dart';
export 'shared_space/shared_space_sealed_key.dart';
export 'simplelogin/simplelogin_completed.dart';
export 'simplelogin/simplelogin_outcome.dart';
export 'simplelogin/simplelogin_return.dart';
@ -422,9 +434,15 @@ class Protocol extends _isc.SerializationManager {
if (t == _i1rvc02f.SharedSpaceInfo) {
return _i1rvc02f.SharedSpaceInfo.fromJson(data) as T;
}
if (t == _imxqlbwp.SharedSpaceKeyNeed) {
return _imxqlbwp.SharedSpaceKeyNeed.fromJson(data) as T;
}
if (t == _iz8z2oju.SharedSpaceMemberInfo) {
return _iz8z2oju.SharedSpaceMemberInfo.fromJson(data) as T;
}
if (t == _iqkqjhh4.SharedSpaceMemberKeys) {
return _iqkqjhh4.SharedSpaceMemberKeys.fromJson(data) as T;
}
if (t == _ixm56fil.SharedSpaceNotFoundException) {
return _ixm56fil.SharedSpaceNotFoundException.fromJson(data) as T;
}
@ -437,6 +455,9 @@ class Protocol extends _isc.SerializationManager {
if (t == _i2fude1g.SharedSpaceRole) {
return _i2fude1g.SharedSpaceRole.fromJson(data) as T;
}
if (t == _ifx6y5sl.SharedSpaceSealedKey) {
return _ifx6y5sl.SharedSpaceSealedKey.fromJson(data) as T;
}
if (t == _i51ry5s2.SimpleLoginCompleted) {
return _i51ry5s2.SimpleLoginCompleted.fromJson(data) as T;
}
@ -722,12 +743,22 @@ class Protocol extends _isc.SerializationManager {
return (data != null ? _i1rvc02f.SharedSpaceInfo.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_imxqlbwp.SharedSpaceKeyNeed?>()) {
return (data != null ? _imxqlbwp.SharedSpaceKeyNeed.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_iz8z2oju.SharedSpaceMemberInfo?>()) {
return (data != null
? _iz8z2oju.SharedSpaceMemberInfo.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_iqkqjhh4.SharedSpaceMemberKeys?>()) {
return (data != null
? _iqkqjhh4.SharedSpaceMemberKeys.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_ixm56fil.SharedSpaceNotFoundException?>()) {
return (data != null
? _ixm56fil.SharedSpaceNotFoundException.fromJson(data)
@ -748,6 +779,12 @@ class Protocol extends _isc.SerializationManager {
return (data != null ? _i2fude1g.SharedSpaceRole.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_ifx6y5sl.SharedSpaceSealedKey?>()) {
return (data != null
? _ifx6y5sl.SharedSpaceSealedKey.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i51ry5s2.SimpleLoginCompleted?>()) {
return (data != null
? _i51ry5s2.SimpleLoginCompleted.fromJson(data)
@ -859,6 +896,24 @@ class Protocol extends _isc.SerializationManager {
.toList()
as T;
}
if (t == List<_i6d21238.SharedSpaceMemberKeys>) {
return (data as List)
.map((e) => deserialize<_i6d21238.SharedSpaceMemberKeys>(e))
.toList()
as T;
}
if (t == List<_ixm5azhv.SharedSpaceKeyNeed>) {
return (data as List)
.map((e) => deserialize<_ixm5azhv.SharedSpaceKeyNeed>(e))
.toList()
as T;
}
if (t == List<_ie2by8eg.SharedSpaceSealedKey>) {
return (data as List)
.map((e) => deserialize<_ie2by8eg.SharedSpaceSealedKey>(e))
.toList()
as T;
}
try {
return _ibkpwzgl.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -939,11 +994,14 @@ class Protocol extends _isc.SerializationManager {
_i6j6efd1.ProfileRefusal => 'ProfileRefusal',
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_i1rvc02f.SharedSpaceInfo => 'SharedSpaceInfo',
_imxqlbwp.SharedSpaceKeyNeed => 'SharedSpaceKeyNeed',
_iz8z2oju.SharedSpaceMemberInfo => 'SharedSpaceMemberInfo',
_iqkqjhh4.SharedSpaceMemberKeys => 'SharedSpaceMemberKeys',
_ixm56fil.SharedSpaceNotFoundException => 'SharedSpaceNotFoundException',
_irg3s7i7.SharedSpaceRefusal => 'SharedSpaceRefusal',
_iooym5cy.SharedSpaceRefusedException => 'SharedSpaceRefusedException',
_i2fude1g.SharedSpaceRole => 'SharedSpaceRole',
_ifx6y5sl.SharedSpaceSealedKey => 'SharedSpaceSealedKey',
_i51ry5s2.SimpleLoginCompleted => 'SimpleLoginCompleted',
_i5ar1hzl.SimpleLoginOutcome => 'SimpleLoginOutcome',
_i8dcjd27.SimpleLoginReturn => 'SimpleLoginReturn',
@ -1096,8 +1154,12 @@ class Protocol extends _isc.SerializationManager {
return 'ProfileRefusedException';
case _i1rvc02f.SharedSpaceInfo():
return 'SharedSpaceInfo';
case _imxqlbwp.SharedSpaceKeyNeed():
return 'SharedSpaceKeyNeed';
case _iz8z2oju.SharedSpaceMemberInfo():
return 'SharedSpaceMemberInfo';
case _iqkqjhh4.SharedSpaceMemberKeys():
return 'SharedSpaceMemberKeys';
case _ixm56fil.SharedSpaceNotFoundException():
return 'SharedSpaceNotFoundException';
case _irg3s7i7.SharedSpaceRefusal():
@ -1106,6 +1168,8 @@ class Protocol extends _isc.SerializationManager {
return 'SharedSpaceRefusedException';
case _i2fude1g.SharedSpaceRole():
return 'SharedSpaceRole';
case _ifx6y5sl.SharedSpaceSealedKey():
return 'SharedSpaceSealedKey';
case _i51ry5s2.SimpleLoginCompleted():
return 'SimpleLoginCompleted';
case _i5ar1hzl.SimpleLoginOutcome():
@ -1320,9 +1384,15 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'SharedSpaceInfo') {
return deserialize<_i1rvc02f.SharedSpaceInfo>(data['data']);
}
if (dataClassName == 'SharedSpaceKeyNeed') {
return deserialize<_imxqlbwp.SharedSpaceKeyNeed>(data['data']);
}
if (dataClassName == 'SharedSpaceMemberInfo') {
return deserialize<_iz8z2oju.SharedSpaceMemberInfo>(data['data']);
}
if (dataClassName == 'SharedSpaceMemberKeys') {
return deserialize<_iqkqjhh4.SharedSpaceMemberKeys>(data['data']);
}
if (dataClassName == 'SharedSpaceNotFoundException') {
return deserialize<_ixm56fil.SharedSpaceNotFoundException>(data['data']);
}
@ -1335,6 +1405,9 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'SharedSpaceRole') {
return deserialize<_i2fude1g.SharedSpaceRole>(data['data']);
}
if (dataClassName == 'SharedSpaceSealedKey') {
return deserialize<_ifx6y5sl.SharedSpaceSealedKey>(data['data']);
}
if (dataClassName == 'SimpleLoginCompleted') {
return deserialize<_i51ry5s2.SimpleLoginCompleted>(data['data']);
}

View file

@ -0,0 +1,94 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A key the caller holds that a member of the space lacks: for the
/// caller's device to seal to them (ADR 0044, D3).
abstract class SharedSpaceKeyNeed
implements _isc.SerializableModel, _isc.ProtocolSerialization {
SharedSpaceKeyNeed._({
required this.keyId,
required this.person,
});
factory SharedSpaceKeyNeed({
required String keyId,
required _isc.UuidValue person,
}) = _SharedSpaceKeyNeedImpl;
factory SharedSpaceKeyNeed.fromJson(Map<String, dynamic> jsonSerialization) {
return SharedSpaceKeyNeed(
keyId: jsonSerialization['keyId'] as String,
person: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['person']),
);
}
String keyId;
/// The member who lacks it.
_isc.UuidValue person;
/// Returns a shallow copy of this [SharedSpaceKeyNeed]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SharedSpaceKeyNeed copyWith({
String? keyId,
_isc.UuidValue? person,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SharedSpaceKeyNeed',
'keyId': keyId,
'person': person.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.SharedSpaceKeyNeed',
'keyId': keyId,
'person': person.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _SharedSpaceKeyNeedImpl extends SharedSpaceKeyNeed {
_SharedSpaceKeyNeedImpl({
required String keyId,
required _isc.UuidValue person,
}) : super._(
keyId: keyId,
person: person,
);
/// Returns a shallow copy of this [SharedSpaceKeyNeed]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SharedSpaceKeyNeed copyWith({
String? keyId,
_isc.UuidValue? person,
}) {
return SharedSpaceKeyNeed(
keyId: keyId ?? this.keyId,
person: person ?? this.person,
);
}
}

View file

@ -0,0 +1,132 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A member's public keys, as the register keeps them, which a device pins
/// the first time it sees them and seals keys to (ADR 0044, D3 and D18).
/// Named by the member's account ID, as the space's synced rows name their
/// authors: a device pins keys per person, across every space it shares
/// with them.
abstract class SharedSpaceMemberKeys
implements _isc.SerializableModel, _isc.ProtocolSerialization {
SharedSpaceMemberKeys._({
required this.person,
this.handle,
required this.encryption,
required this.signing,
});
factory SharedSpaceMemberKeys({
required _isc.UuidValue person,
String? handle,
required _idt.ByteData encryption,
required _idt.ByteData signing,
}) = _SharedSpaceMemberKeysImpl;
factory SharedSpaceMemberKeys.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return SharedSpaceMemberKeys(
person: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['person']),
handle: jsonSerialization['handle'] as String?,
encryption: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['encryption'],
),
signing: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['signing'],
),
);
}
_isc.UuidValue person;
/// Their handle; null without a public profile.
String? handle;
/// X25519's, which keys are sealed to.
_idt.ByteData encryption;
/// Ed25519's, which their signatures check against.
_idt.ByteData signing;
/// Returns a shallow copy of this [SharedSpaceMemberKeys]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SharedSpaceMemberKeys copyWith({
_isc.UuidValue? person,
String? handle,
_idt.ByteData? encryption,
_idt.ByteData? signing,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SharedSpaceMemberKeys',
'person': person.toJson(),
if (handle != null) 'handle': handle,
'encryption': encryption.toJson(),
'signing': signing.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.SharedSpaceMemberKeys',
'person': person.toJson(),
if (handle != null) 'handle': handle,
'encryption': encryption.toJson(),
'signing': signing.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SharedSpaceMemberKeysImpl extends SharedSpaceMemberKeys {
_SharedSpaceMemberKeysImpl({
required _isc.UuidValue person,
String? handle,
required _idt.ByteData encryption,
required _idt.ByteData signing,
}) : super._(
person: person,
handle: handle,
encryption: encryption,
signing: signing,
);
/// Returns a shallow copy of this [SharedSpaceMemberKeys]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SharedSpaceMemberKeys copyWith({
_isc.UuidValue? person,
Object? handle = _Undefined,
_idt.ByteData? encryption,
_idt.ByteData? signing,
}) {
return SharedSpaceMemberKeys(
person: person ?? this.person,
handle: handle is String? ? handle : this.handle,
encryption: encryption ?? this.encryption.clone(),
signing: signing ?? this.signing.clone(),
);
}
}

View file

@ -0,0 +1,112 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A key a device sealed to a member, as it sends it (ADR 0044, D3), or as
/// the member gets it back: [person] is whom it is sealed to on the way
/// in, and who sealed it on the way out.
abstract class SharedSpaceSealedKey
implements _isc.SerializableModel, _isc.ProtocolSerialization {
SharedSpaceSealedKey._({
required this.keyId,
required this.person,
required this.sealedKey,
});
factory SharedSpaceSealedKey({
required String keyId,
required _isc.UuidValue person,
required _idt.ByteData sealedKey,
}) = _SharedSpaceSealedKeyImpl;
factory SharedSpaceSealedKey.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return SharedSpaceSealedKey(
keyId: jsonSerialization['keyId'] as String,
person: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['person']),
sealedKey: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['sealedKey'],
),
);
}
String keyId;
_isc.UuidValue person;
/// The sealer's signature, then the sealed box.
_idt.ByteData sealedKey;
/// Returns a shallow copy of this [SharedSpaceSealedKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SharedSpaceSealedKey copyWith({
String? keyId,
_isc.UuidValue? person,
_idt.ByteData? sealedKey,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SharedSpaceSealedKey',
'keyId': keyId,
'person': person.toJson(),
'sealedKey': sealedKey.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.SharedSpaceSealedKey',
'keyId': keyId,
'person': person.toJson(),
'sealedKey': sealedKey.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _SharedSpaceSealedKeyImpl extends SharedSpaceSealedKey {
_SharedSpaceSealedKeyImpl({
required String keyId,
required _isc.UuidValue person,
required _idt.ByteData sealedKey,
}) : super._(
keyId: keyId,
person: person,
sealedKey: sealedKey,
);
/// Returns a shallow copy of this [SharedSpaceSealedKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SharedSpaceSealedKey copyWith({
String? keyId,
_isc.UuidValue? person,
_idt.ByteData? sealedKey,
}) {
return SharedSpaceSealedKey(
keyId: keyId ?? this.keyId,
person: person ?? this.person,
sealedKey: sealedKey ?? this.sealedKey.clone(),
);
}
}

View file

@ -30,6 +30,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove` (what it shows goes, the handle stays, private again: Halkyon Learn's ADR 0020, D27): the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
| `sharedSpace` | spaces people share (Halkyon's ADR 0044, step 2), on the host's sync engine through its `SpaceGrants` (refused, `unavailable`, without one): `create(kind)`, the caller its Owner; `mine`; `members(space)`, by membership ID and public profile, never by account, those invited and waiting last; `invite(space, handle, role)`, the Owner's (or any member's where the space lets members invite), a member at once where the person has an account on this service, else invited until they first sign in there (D13, D17), counted as an answer about a handle; `setRole`, `remove`, `handOn`, `delete`, the Owner's; `leave`, the Owner naming who takes the space on, its only member deleting it instead. A space the caller isn't in can't be told from one that doesn't exist (`SharedSpaceNotFoundException`); Owner and Editor write in the engine, a Viewer reads | `identified` |
| `sharedSpaceKeys` | a shared space's data keys, each sealed on a device to one member (Halkyon's ADR 0044, D3, step 3), which the instance keeps and never opens: `memberKeys(space)`, the public keys of its members who have keys, by account, as the register hands them out, for a device to seal to and pin (D18); `keysNeeded(space)`, the keys the caller holds that members lack, for the Owner and Editors, none for a Viewer; `seal(space, keys)`, refused for a Viewer (`notAllowed`) and for anyone outside the space, the first key sealed to a member staying; `mine(space)`, those sealed to the caller, each naming its sealer. A member's keys go with their membership. A space the caller isn't in can't be told from one that doesn't exist | `identified` |
- Email keeps only HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its
codes as Argon2id hashes (`email_code_request`). Codes are eight Crockford characters,

View file

@ -37,6 +37,8 @@ import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
as _iji0wmu8;
import 'package:halkyon_base_server/src/generated/shared_space/shared_space_role.dart'
as _i4jk9by9;
import 'package:halkyon_base_server/src/generated/shared_space/shared_space_sealed_key.dart'
as _ik1elru1;
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_return.dart'
as _igmh0yku;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
@ -60,6 +62,7 @@ import '../password/password_endpoint.dart' as _ig7fyg1b;
import '../password/password_sign_in_endpoint.dart' as _ik13m562;
import '../profile/profile_endpoint.dart' as _i6ky944g;
import '../shared_space/shared_space_endpoint.dart' as _i858tn7r;
import '../shared_space/shared_space_keys_endpoint.dart' as _izzpx5a5;
import '../simplelogin/simplelogin_endpoint.dart' as _ia8c9alv;
class Endpoints extends _is.EndpointDispatch {
@ -162,6 +165,12 @@ class Endpoints extends _is.EndpointDispatch {
'sharedSpace',
'halkyon_base',
),
'sharedSpaceKeys': _izzpx5a5.SharedSpaceKeysEndpoint()
..initialize(
server,
'sharedSpaceKeys',
'halkyon_base',
),
'simpleLogin': _ia8c9alv.SimpleLoginEndpoint()
..initialize(
server,
@ -2134,6 +2143,102 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['sharedSpaceKeys'] = _is.EndpointConnector(
name: 'sharedSpaceKeys',
endpoint: endpoints['sharedSpaceKeys']!,
methodConnectors: {
'memberKeys': _is.MethodConnector(
name: 'memberKeys',
params: {
'space': _is.ParameterDescription(
name: 'space',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['sharedSpaceKeys']
as _izzpx5a5.SharedSpaceKeysEndpoint)
.memberKeys(
session,
params['space'],
),
),
'keysNeeded': _is.MethodConnector(
name: 'keysNeeded',
params: {
'space': _is.ParameterDescription(
name: 'space',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['sharedSpaceKeys']
as _izzpx5a5.SharedSpaceKeysEndpoint)
.keysNeeded(
session,
params['space'],
),
),
'seal': _is.MethodConnector(
name: 'seal',
params: {
'space': _is.ParameterDescription(
name: 'space',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'keys': _is.ParameterDescription(
name: 'keys',
type: _is.getType<List<_ik1elru1.SharedSpaceSealedKey>>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['sharedSpaceKeys']
as _izzpx5a5.SharedSpaceKeysEndpoint)
.seal(
session,
params['space'],
params['keys'],
),
),
'mine': _is.MethodConnector(
name: 'mine',
params: {
'space': _is.ParameterDescription(
name: 'space',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['sharedSpaceKeys']
as _izzpx5a5.SharedSpaceKeysEndpoint)
.mine(
session,
params['space'],
),
),
},
);
connectors['simpleLogin'] = _is.EndpointConnector(
name: 'simpleLogin',
endpoint: endpoints['simpleLogin']!,

View file

@ -0,0 +1,94 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// An account's public keys as the register keeps them (ADR 0017): X25519's,
/// which data keys are sealed to, and Ed25519's, which its signatures check
/// against. What others seal shared keys to and check them by (Halkyon's ADR
/// 0044, D3 and D18).
abstract class AccountPublicKeys
implements _is.SerializableModel, _is.ProtocolSerialization {
AccountPublicKeys._({
required this.encryption,
required this.signing,
});
factory AccountPublicKeys({
required _idt.ByteData encryption,
required _idt.ByteData signing,
}) = _AccountPublicKeysImpl;
factory AccountPublicKeys.fromJson(Map<String, dynamic> jsonSerialization) {
return AccountPublicKeys(
encryption: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['encryption'],
),
signing: _is.ByteDataJsonExtension.fromJson(jsonSerialization['signing']),
);
}
_idt.ByteData encryption;
_idt.ByteData signing;
/// Returns a shallow copy of this [AccountPublicKeys]
/// with some or all fields replaced by the given arguments.
@_is.useResult
AccountPublicKeys copyWith({
_idt.ByteData? encryption,
_idt.ByteData? signing,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.AccountPublicKeys',
'encryption': encryption.toJson(),
'signing': signing.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _AccountPublicKeysImpl extends AccountPublicKeys {
_AccountPublicKeysImpl({
required _idt.ByteData encryption,
required _idt.ByteData signing,
}) : super._(
encryption: encryption,
signing: signing,
);
/// Returns a shallow copy of this [AccountPublicKeys]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
AccountPublicKeys copyWith({
_idt.ByteData? encryption,
_idt.ByteData? signing,
}) {
return AccountPublicKeys(
encryption: encryption ?? this.encryption.clone(),
signing: signing ?? this.signing.clone(),
);
}
}

Some files were not shown because too many files have changed in this diff Show more