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>
This commit is contained in:
parent
92b87e218d
commit
5faf3fce58
8 changed files with 336 additions and 68 deletions
|
|
@ -81,9 +81,16 @@ abstract interface class ContentCipher() {
|
|||
|
||||
/// What [sealed] holds, for the [context] it is stored in; null where it
|
||||
/// doesn't open: its key isn't open here, its signature isn't the
|
||||
/// account's, it was changed, or it belongs to another row.
|
||||
/// account's (or, for a key with signers trusted, none of theirs), it was
|
||||
/// changed, or it belongs to another row.
|
||||
Uint8List? unseal(SealedPayload sealed, {required List<int> context});
|
||||
|
||||
/// Takes content sealed with the key [keyId] as genuine when any of
|
||||
/// [signers] (signing public keys) signed it, in place of the account
|
||||
/// alone (Halkyon's ADR 0044, D20): a shared space's key, with which
|
||||
/// every member writes. Replaces what was trusted for it before.
|
||||
void trustSigners(String keyId, List<Uint8List> signers);
|
||||
|
||||
/// The account's signature (64 bytes) over [message]: a readable row's,
|
||||
/// which tells a row a device of the account wrote from one the instance
|
||||
/// made up (ADR 0019, the reading). Throws a [StateError] before
|
||||
|
|
@ -112,6 +119,9 @@ class SodiumContentCipher() implements ContentCipher {
|
|||
AccountKeys? _account;
|
||||
final _keys = <String, DataKey>{};
|
||||
|
||||
/// The signers trusted for each shared key, by its ID ([trustSigners]).
|
||||
final _signers = <String, List<Uint8List>>{};
|
||||
|
||||
@override
|
||||
Future<void> unlock(UnlockedAccountKeys account) async {
|
||||
final sodium = _sodium ??= await SodiumSumoInit.init();
|
||||
|
|
@ -251,24 +261,32 @@ class SodiumContentCipher() implements ContentCipher {
|
|||
if (sealed.payload.length < signatureBytes) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return openContent(
|
||||
sodium,
|
||||
SealedContent(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: Uint8List.sublistView(sealed.payload, signatureBytes),
|
||||
signature: Uint8List.sublistView(sealed.payload, 0, signatureBytes),
|
||||
),
|
||||
dataKey: key,
|
||||
authorSigningPublicKey: account.signingPublicKey,
|
||||
context: context,
|
||||
);
|
||||
} on KeyRefused {
|
||||
return null;
|
||||
final content = SealedContent(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: Uint8List.sublistView(sealed.payload, signatureBytes),
|
||||
signature: Uint8List.sublistView(sealed.payload, 0, signatureBytes),
|
||||
);
|
||||
for (final signer in _signers[sealed.keyId] ?? [account.signingPublicKey]) {
|
||||
try {
|
||||
return openContent(
|
||||
sodium,
|
||||
content,
|
||||
dataKey: key,
|
||||
authorSigningPublicKey: signer,
|
||||
context: context,
|
||||
);
|
||||
} on KeyRefused {
|
||||
// Another signer may have signed it.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void trustSigners(String keyId, List<Uint8List> signers) => _signers[keyId] =
|
||||
[for (final signer in signers) Uint8List.fromList(signer)];
|
||||
|
||||
@override
|
||||
Uint8List sign(List<int> message) {
|
||||
final (sodium, account) = _unlocked();
|
||||
|
|
@ -308,6 +326,7 @@ class SodiumContentCipher() implements ContentCipher {
|
|||
key.dispose();
|
||||
}
|
||||
_keys.clear();
|
||||
_signers.clear();
|
||||
_account?.dispose();
|
||||
_account = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,16 @@ 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).
|
||||
/// The public keys a device first saw for a person, whether their safety
|
||||
/// code was compared, and the signing keys pinned for them before (Halkyon's
|
||||
/// ADR 0044, D18, D20).
|
||||
final class const PinnedKeys({
|
||||
required final PublicKeys keys,
|
||||
final bool verified = false,
|
||||
|
||||
/// The signing keys pinned for the person before [keys], each trusted
|
||||
/// still for what it signed then: a reset leaves what they wrote readable.
|
||||
final List<Uint8List> earlierSigning = const [],
|
||||
}) {
|
||||
/// From what [toJson] wrote; null for anything else.
|
||||
static PinnedKeys? fromJson(Object? json) => switch (json) {
|
||||
|
|
@ -16,8 +21,9 @@ final class const PinnedKeys({
|
|||
'encryption': final String encryption,
|
||||
'signing': final String signing,
|
||||
'verified': final bool verified,
|
||||
'earlierSigning': final List<Object?> earlier,
|
||||
} =>
|
||||
_decoded(encryption, signing, verified),
|
||||
_decoded(encryption, signing, verified, earlier),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
|
@ -25,6 +31,7 @@ final class const PinnedKeys({
|
|||
String encryption,
|
||||
String signing,
|
||||
bool verified,
|
||||
List<Object?> earlier,
|
||||
) {
|
||||
try {
|
||||
return PinnedKeys(
|
||||
|
|
@ -33,6 +40,13 @@ final class const PinnedKeys({
|
|||
signing: base64Decode(signing),
|
||||
),
|
||||
verified: verified,
|
||||
earlierSigning: [
|
||||
for (final key in earlier)
|
||||
if (key is String)
|
||||
base64Decode(key)
|
||||
else
|
||||
throw const FormatException(),
|
||||
],
|
||||
);
|
||||
} on FormatException {
|
||||
return null;
|
||||
|
|
@ -43,46 +57,72 @@ final class const PinnedKeys({
|
|||
'encryption': base64Encode(keys.encryption),
|
||||
'signing': base64Encode(keys.signing),
|
||||
'verified': verified,
|
||||
'earlierSigning': [for (final key in earlierSigning) base64Encode(key)],
|
||||
};
|
||||
}
|
||||
|
||||
/// Where a device keeps the keys it pinned, by person (an account's ID).
|
||||
abstract interface class KeyPinStore() {
|
||||
Future<Map<String, PinnedKeys>> read();
|
||||
/// What a device keeps of other people's keys: the keys pinned for each
|
||||
/// person (an account's ID), and who it saw in each space (its ID).
|
||||
typedef PinBook = ({
|
||||
Map<String, PinnedKeys> pins,
|
||||
Map<String, Set<String>> spaces,
|
||||
});
|
||||
|
||||
Future<void> write(Map<String, PinnedKeys> pins);
|
||||
/// Where a device keeps its [PinBook].
|
||||
abstract interface class KeyPinStore() {
|
||||
Future<PinBook> read();
|
||||
|
||||
Future<void> write(PinBook book);
|
||||
}
|
||||
|
||||
/// [KeyPinStore] in a JSON file in the person's folder (`key_pins.json`).
|
||||
/// [KeyPinStore] in a JSON file in the person's folder (`key_pins.json`); a
|
||||
/// file that doesn't hold one counts as none.
|
||||
class FileKeyPinStore(final File _file) implements KeyPinStore {
|
||||
@override
|
||||
Future<Map<String, PinnedKeys>> read() async {
|
||||
Future<PinBook> 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),
|
||||
};
|
||||
return (
|
||||
pins: <String, PinnedKeys>{
|
||||
if (json case {'pins': final Map<String, Object?> pins})
|
||||
for (final MapEntry(:key, :value) in pins.entries)
|
||||
key: ?PinnedKeys.fromJson(value),
|
||||
},
|
||||
spaces: <String, Set<String>>{
|
||||
if (json case {'spaces': final Map<String, Object?> spaces})
|
||||
for (final MapEntry(:key, :value) in spaces.entries)
|
||||
if (value case final List<Object?> people)
|
||||
key: {...people.whereType<String>()},
|
||||
},
|
||||
);
|
||||
} on PathNotFoundException {
|
||||
return {};
|
||||
return _empty();
|
||||
} on FormatException {
|
||||
return {};
|
||||
return _empty();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, PinnedKeys> pins) async {
|
||||
Future<void> write(PinBook book) 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(),
|
||||
'pins': {
|
||||
for (final MapEntry(:key, :value) in book.pins.entries)
|
||||
key: value.toJson(),
|
||||
},
|
||||
'spaces': {
|
||||
for (final MapEntry(:key, :value) in book.spaces.entries)
|
||||
key: value.toList()..sort(),
|
||||
},
|
||||
}),
|
||||
flush: true,
|
||||
);
|
||||
await temporary.rename(_file.path);
|
||||
}
|
||||
|
||||
static PinBook _empty() => (pins: {}, spaces: {});
|
||||
}
|
||||
|
||||
/// How a person's keys compare with those pinned for them.
|
||||
|
|
@ -101,43 +141,80 @@ enum PinCheck() {
|
|||
|
||||
/// 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.
|
||||
/// anew; and who it saw in each space, whose signatures it takes there
|
||||
/// (D20).
|
||||
class KeyPins(final KeyPinStore _store) {
|
||||
Map<String, PinnedKeys>? _pins;
|
||||
PinBook? _book;
|
||||
|
||||
Future<Map<String, PinnedKeys>> _all() async => _pins ??= await _store.read();
|
||||
Future<PinBook> _read() async => _book ??= 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];
|
||||
final book = await _read();
|
||||
final pinned = book.pins[person];
|
||||
if (pinned == null) {
|
||||
pins[person] = PinnedKeys(keys: keys);
|
||||
await _store.write(pins);
|
||||
book.pins[person] = PinnedKeys(keys: keys);
|
||||
await _store.write(book);
|
||||
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];
|
||||
Future<PinnedKeys?> of(String person) async => (await _read()).pins[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);
|
||||
final book = await _read();
|
||||
if (book.pins[person] case final pinned?) {
|
||||
book.pins[person] = PinnedKeys(
|
||||
keys: pinned.keys,
|
||||
verified: true,
|
||||
earlierSigning: pinned.earlierSigning,
|
||||
);
|
||||
await _store.write(book);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins [keys] for [person] in place of those before, not compared: the
|
||||
/// person said their keys changed for a reason of their own.
|
||||
/// person said their keys changed for a reason of their own. The signing
|
||||
/// key before stays trusted for what it signed.
|
||||
Future<void> trustAnew(String person, PublicKeys keys) async {
|
||||
final pins = await _all();
|
||||
pins[person] = PinnedKeys(keys: keys);
|
||||
await _store.write(pins);
|
||||
final book = await _read();
|
||||
final before = book.pins[person];
|
||||
book.pins[person] = PinnedKeys(
|
||||
keys: keys,
|
||||
earlierSigning: [
|
||||
...?before?.earlierSigning,
|
||||
if (before != null && !_bytesEqual(before.keys.signing, keys.signing))
|
||||
before.keys.signing,
|
||||
],
|
||||
);
|
||||
await _store.write(book);
|
||||
}
|
||||
|
||||
/// Keeps that [people] were seen as members of [space].
|
||||
Future<void> sawIn(String space, Iterable<String> people) async {
|
||||
final book = await _read();
|
||||
final seen = book.spaces[space] ??= {};
|
||||
if (!seen.containsAll(people)) {
|
||||
seen.addAll(people);
|
||||
await _store.write(book);
|
||||
}
|
||||
}
|
||||
|
||||
/// The signing keys whose signatures count in [space] (D20): every key
|
||||
/// pinned, now or before, for anyone ever seen there.
|
||||
Future<List<Uint8List>> signersIn(String space) async {
|
||||
final book = await _read();
|
||||
return [
|
||||
for (final person in book.spaces[space] ?? const <String>{})
|
||||
if (book.pins[person] case final pinned?) ...[
|
||||
pinned.keys.signing,
|
||||
...pinned.earlierSigning,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class SharedKeys(
|
|||
(keyId: keyId, person: person, sealed: _cipher.sealKeyTo(keyId, keys)),
|
||||
];
|
||||
await _gateway.seal(space, sealed);
|
||||
_cipher.trustSigners(keyId, await _pins.signersIn(space));
|
||||
return (
|
||||
sealedTo: {for (final key in sealed) key.person},
|
||||
changed: members.changed,
|
||||
|
|
@ -96,7 +97,9 @@ class SharedKeys(
|
|||
|
||||
/// 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.
|
||||
/// against the keys pinned for them, if any. Content sealed with them is
|
||||
/// taken from anyone ever seen in the space, by the keys pinned for them
|
||||
/// (D20).
|
||||
Future<OpenOutcome> openMine(String space) async {
|
||||
final mine = await _gateway.mine(space);
|
||||
if (mine.isEmpty) {
|
||||
|
|
@ -123,6 +126,14 @@ class SharedKeys(
|
|||
);
|
||||
(opens ? opened : refused).add(key.keyId);
|
||||
}
|
||||
// What is sealed with the space's keys is taken from anyone seen in it
|
||||
// (D20), the person's own key among them.
|
||||
final signers = await _pins.signersIn(space);
|
||||
for (final key in mine) {
|
||||
if (_cipher.has(key.keyId)) {
|
||||
_cipher.trustSigners(key.keyId, signers);
|
||||
}
|
||||
}
|
||||
return (opened: opened, refused: refused, changed: changed);
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +156,12 @@ class SharedKeys(
|
|||
if (!sameKeys(member.keys, _cipher.ownKeys)) {
|
||||
throw OwnKeysDiffer();
|
||||
}
|
||||
// The device's own keys are the person's, so they are pinned as
|
||||
// they are, an earlier key of theirs trusted still for what it
|
||||
// signed.
|
||||
if (await _pins.check(me, member.keys) == PinCheck.changed) {
|
||||
await _pins.trustAnew(me, member.keys);
|
||||
}
|
||||
trusted[me] = member.keys;
|
||||
} else if (await _pins.check(member.person, member.keys) ==
|
||||
PinCheck.changed) {
|
||||
|
|
@ -153,6 +170,7 @@ class SharedKeys(
|
|||
trusted[member.person] = member.keys;
|
||||
}
|
||||
}
|
||||
await _pins.sawIn(space, [...trusted.keys, ...changed]);
|
||||
return (trusted: trusted, changed: changed);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ class FakeContentCipher() implements ContentCipher {
|
|||
/// The keys open here, by their ID, with the account they belong to.
|
||||
final Map<String, String> opened = {};
|
||||
|
||||
/// The signers trusted for each shared key, by its ID ([trustSigners]),
|
||||
/// as their signing keys in base64.
|
||||
final _signers = <String, Set<String>>{};
|
||||
|
||||
@override
|
||||
Future<void> unlock(UnlockedAccountKeys account) async {
|
||||
_account = base64Encode(account.signingPublicKey);
|
||||
|
|
@ -91,7 +95,7 @@ class FakeContentCipher() implements ContentCipher {
|
|||
if (!has(keyId)) {
|
||||
throw StateError('The data key $keyId is not open');
|
||||
}
|
||||
final tag = _tag(keyId, context);
|
||||
final tag = _tag(keyId, _unlocked(), context);
|
||||
return SealedPayload(
|
||||
keyId: keyId,
|
||||
nonce: Uint8List(24),
|
||||
|
|
@ -111,10 +115,15 @@ class FakeContentCipher() implements ContentCipher {
|
|||
return null;
|
||||
}
|
||||
final length = payload[0] << 8 | payload[1];
|
||||
final tag = _tag(sealed.keyId, context);
|
||||
if (length != tag.length ||
|
||||
payload.length < 2 + length ||
|
||||
!_same(Uint8List.sublistView(payload, 2, 2 + length), tag)) {
|
||||
if (payload.length < 2 + length) {
|
||||
return null;
|
||||
}
|
||||
final found = Uint8List.sublistView(payload, 2, 2 + length);
|
||||
final signed = (_signers[sealed.keyId] ?? {_unlocked()}).any((signer) {
|
||||
final tag = _tag(sealed.keyId, signer, context);
|
||||
return length == tag.length && _same(found, tag);
|
||||
});
|
||||
if (!signed) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList([
|
||||
|
|
@ -122,6 +131,10 @@ class FakeContentCipher() implements ContentCipher {
|
|||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
void trustSigners(String keyId, List<Uint8List> signers) =>
|
||||
_signers[keyId] = {for (final signer in signers) base64Encode(signer)};
|
||||
|
||||
@override
|
||||
Uint8List sign(List<int> message) {
|
||||
final account = _unlocked();
|
||||
|
|
@ -160,13 +173,17 @@ class FakeContentCipher() implements ContentCipher {
|
|||
}
|
||||
|
||||
@override
|
||||
void dispose() => opened.clear();
|
||||
void dispose() {
|
||||
opened.clear();
|
||||
_signers.clear();
|
||||
}
|
||||
|
||||
String _unlocked() => _account ?? (throw StateError('Not unlocked'));
|
||||
|
||||
/// What binds a payload to its key and context.
|
||||
Uint8List _tag(String keyId, List<int> context) =>
|
||||
utf8.encode('$keyId|${base64Encode(context)}');
|
||||
/// What binds a payload to its key, the account that sealed it (its
|
||||
/// signing key in base64) and its context.
|
||||
Uint8List _tag(String keyId, String author, List<int> context) =>
|
||||
utf8.encode('$keyId|$author|${base64Encode(context)}');
|
||||
|
||||
static bool _same(Uint8List a, Uint8List b) {
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import 'package:halkyon_app/src/shared_space/key_pins.dart';
|
||||
|
||||
/// [KeyPinStore] in memory; [pins] is what a file would hold.
|
||||
/// [KeyPinStore] in memory; [book] is what a file would hold.
|
||||
class MemoryKeyPinStore() implements KeyPinStore {
|
||||
Map<String, PinnedKeys> pins = {};
|
||||
PinBook book = (pins: {}, spaces: {});
|
||||
|
||||
@override
|
||||
Future<Map<String, PinnedKeys>> read() async => {...pins};
|
||||
Future<PinBook> read() async => _copy(book);
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, PinnedKeys> pins) async =>
|
||||
this.pins = {...pins};
|
||||
Future<void> write(PinBook book) async => this.book = _copy(book);
|
||||
|
||||
static PinBook _copy(PinBook book) => (
|
||||
pins: {...book.pins},
|
||||
spaces: {
|
||||
for (final MapEntry(:key, :value) in book.spaces.entries) key: {...value},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,4 +125,63 @@ void main() {
|
|||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
final carol = (await crypto.newAccount('carol password')).keys;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,11 +54,52 @@ void main() {
|
|||
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);
|
||||
var book = await FileKeyPinStore(file).read();
|
||||
expect(book.pins, isEmpty);
|
||||
expect(book.spaces, isEmpty);
|
||||
await file.writeAsString(
|
||||
'{"bob": {"encryption": "%%", "signing": "", '
|
||||
'"verified": false}, "carol": 3}',
|
||||
'{"pins": {"bob": {"encryption": "%%", "signing": "", '
|
||||
'"verified": false, "earlierSigning": []}, "carol": 3}, '
|
||||
'"spaces": {"s": 4}}',
|
||||
);
|
||||
expect(await FileKeyPinStore(file).read(), isEmpty);
|
||||
book = await FileKeyPinStore(file).read();
|
||||
expect(book.pins, isEmpty);
|
||||
expect(book.spaces, isEmpty);
|
||||
});
|
||||
|
||||
test('signatures count in a space from everyone seen there, by every '
|
||||
'signing key pinned for them, earlier ones too (D20)', () async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
await pins.check('bob', keys(1));
|
||||
await pins.check('carol', keys(5));
|
||||
await pins.check('mallory', keys(9));
|
||||
await pins.sawIn('space', ['bob', 'carol']);
|
||||
await pins.trustAnew('bob', keys(3));
|
||||
|
||||
final later = KeyPins(FileKeyPinStore(file));
|
||||
final signers = await later.signersIn('space');
|
||||
expect(
|
||||
signers,
|
||||
unorderedEquals([keys(3).signing, keys(1).signing, keys(5).signing]),
|
||||
);
|
||||
expect(signers, isNot(contains(keys(9).signing)));
|
||||
expect(await later.signersIn('elsewhere'), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'someone gone from a space still counts for what they signed there',
|
||||
() async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
await pins.check('bob', keys(1));
|
||||
await pins.sawIn('space', ['bob']);
|
||||
await pins.sawIn('space', <String>[]);
|
||||
expect(await pins.signersIn('space'), [keys(1).signing]);
|
||||
},
|
||||
);
|
||||
|
||||
test('a seen person without pinned keys counts for nothing', () async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
await pins.sawIn('space', ['bob']);
|
||||
expect(await pins.signersIn('space'), isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
|
@ -84,6 +86,35 @@ void main() {
|
|||
expect(await bob.safetyCodeWith('alice'), code);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('keys swapped after they were pinned are neither sealed to nor '
|
||||
'trusted, and named until trusted anew', () async {
|
||||
aliceCipher.newKey('k1');
|
||||
|
|
|
|||
Loading…
Reference in a new issue