feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and signed by it, opens them, and seals and opens a row's content with them for the row it is stored in (`SodiumContentCipher`, on the isolate it is used on, as rows are read and written one by one; `FakeContentCipher` for tests). A sealed payload holds the signature, then the ciphertext. `AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's device encrypts their private studysets with (ADR 0017, point 9, not built before); setting a password keeps them as the account's (`keepingKeys` with a new phrase), so nothing they made private is lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a1c5ac3f86
commit
905bb9abf2
10 changed files with 476 additions and 9 deletions
|
|
@ -92,6 +92,7 @@ export 'src/password/widget/password_texts.dart';
|
||||||
export 'src/password/widget/password_widgets.dart';
|
export 'src/password/widget/password_widgets.dart';
|
||||||
export 'src/password/widget/secret_screen.dart';
|
export 'src/password/widget/secret_screen.dart';
|
||||||
export 'src/person/person_folders.dart';
|
export 'src/person/person_folders.dart';
|
||||||
|
export 'src/privacy/content_cipher.dart';
|
||||||
export 'src/sync/app_compatibility.dart';
|
export 'src/sync/app_compatibility.dart';
|
||||||
export 'src/sync/bloc/sync_status_bloc.dart';
|
export 'src/sync/bloc/sync_status_bloc.dart';
|
||||||
export 'src/sync/pending_changes_store.dart';
|
export 'src/sync/pending_changes_store.dart';
|
||||||
|
|
|
||||||
|
|
@ -107,13 +107,20 @@ abstract interface class AccountCrypto() {
|
||||||
Future<NewAccountMaterial> newAccount(String password);
|
Future<NewAccountMaterial> newAccount(String password);
|
||||||
|
|
||||||
/// A new password for [keys] (and the recovery phrase [phrase], which
|
/// A new password for [keys] (and the recovery phrase [phrase], which
|
||||||
/// stays), as a whole setup: an account reset that keeps the keys.
|
/// stays; a new one without), as a whole setup: an account reset that
|
||||||
|
/// keeps the keys, or an anonymous person's device keys (ADR 0017, 9)
|
||||||
|
/// becoming their account's.
|
||||||
Future<NewAccountMaterial> keepingKeys(
|
Future<NewAccountMaterial> keepingKeys(
|
||||||
String password,
|
String password,
|
||||||
UnlockedAccountKeys keys, {
|
UnlockedAccountKeys keys, {
|
||||||
required String phrase,
|
String? phrase,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// New key pairs for an anonymous person's device (ADR 0017, 9; ADR
|
||||||
|
/// 0019): they encrypt the person's private studysets until a password
|
||||||
|
/// wraps them as the account's.
|
||||||
|
Future<UnlockedAccountKeys> newDeviceKeys();
|
||||||
|
|
||||||
/// What [password] unlocks for an account with [salt] and [costs]:
|
/// What [password] unlocks for an account with [salt] and [costs]:
|
||||||
/// Argon2id, about a second on a phone.
|
/// Argon2id, about a second on a phone.
|
||||||
Future<PasswordUnlock> unlock(
|
Future<PasswordUnlock> unlock(
|
||||||
|
|
|
||||||
|
|
@ -240,9 +240,17 @@ class PasswordRepository(
|
||||||
// Signing up, and a password for someone signed in.
|
// Signing up, and a password for someone signed in.
|
||||||
|
|
||||||
/// A new account's password: new keys and a new recovery phrase, wrapped
|
/// A new account's password: new keys and a new recovery phrase, wrapped
|
||||||
/// on the device (Argon2id, about a second).
|
/// on the device (Argon2id, about a second). An anonymous person's
|
||||||
Future<NewAccountMaterial> makePassword(String password) =>
|
/// device keys, which encrypt their private studysets (ADR 0017, 9; ADR
|
||||||
_crypto.newAccount(password);
|
/// 0019), become their account's instead of new ones.
|
||||||
|
Future<NewAccountMaterial> makePassword(String password) async {
|
||||||
|
if (_auth.status case SignedIn(:final authUserId, isIdentified: false)) {
|
||||||
|
if (await _keys.read(authUserId) case final kept?) {
|
||||||
|
return await _crypto.keepingKeys(password, kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await _crypto.newAccount(password);
|
||||||
|
}
|
||||||
|
|
||||||
/// Signs up at [address] with [material], a [code] mailed for [request]
|
/// Signs up at [address] with [material], a [code] mailed for [request]
|
||||||
/// proving it: a new account, or the account with that address and no
|
/// proving it: a new account, or the account with that address and no
|
||||||
|
|
|
||||||
|
|
@ -43,19 +43,36 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
||||||
Future<NewAccountMaterial> keepingKeys(
|
Future<NewAccountMaterial> keepingKeys(
|
||||||
String password,
|
String password,
|
||||||
UnlockedAccountKeys keys, {
|
UnlockedAccountKeys keys, {
|
||||||
required String phrase,
|
String? phrase,
|
||||||
}) => Isolate.run(
|
}) => Isolate.run(
|
||||||
() => _guarded(() async {
|
() => _guarded(() async {
|
||||||
final sodium = await SodiumSumoInit.init();
|
final sodium = await SodiumSumoInit.init();
|
||||||
final opened = _keysOf(sodium, keys);
|
final opened = _keysOf(sodium, keys);
|
||||||
try {
|
try {
|
||||||
return _material(sodium, password, opened, phrase: phrase);
|
return _material(
|
||||||
|
sodium,
|
||||||
|
password,
|
||||||
|
opened,
|
||||||
|
phrase: phrase ?? newRecoveryPhrase(sodium),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
opened.dispose();
|
opened.dispose();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UnlockedAccountKeys> newDeviceKeys() => Isolate.run(
|
||||||
|
() => _guarded(() async {
|
||||||
|
final keys = AccountKeys.generate(await SodiumSumoInit.init());
|
||||||
|
try {
|
||||||
|
return _unlockedOf(keys);
|
||||||
|
} finally {
|
||||||
|
keys.dispose();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PasswordUnlock> unlock(
|
Future<PasswordUnlock> unlock(
|
||||||
String password, {
|
String password, {
|
||||||
|
|
|
||||||
193
packages/halkyon_app/lib/src/privacy/content_cipher.dart
Normal file
193
packages/halkyon_app/lib/src/privacy/content_cipher.dart
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
|
||||||
|
import 'package:halkyon_keys/halkyon_keys.dart';
|
||||||
|
import 'package:sodium/sodium_sumo.dart';
|
||||||
|
|
||||||
|
/// A row's content sealed with a data key (ADR 0019): what goes into ADR
|
||||||
|
/// 0002's envelope, `keyId`, `nonce` and `payload`, the payload holding the
|
||||||
|
/// author's signature (64 bytes), then the ciphertext.
|
||||||
|
final class const SealedPayload({
|
||||||
|
/// The data key's ID.
|
||||||
|
required final String keyId,
|
||||||
|
|
||||||
|
/// XChaCha20-Poly1305's nonce, random for every sealing.
|
||||||
|
required final Uint8List nonce,
|
||||||
|
|
||||||
|
/// The signature, then the ciphertext.
|
||||||
|
required final Uint8List payload,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [keyId, nonce, payload];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals and opens content with the person's data keys (ADR 0019), each
|
||||||
|
/// sealed to their account and kept open here, in libsodium's guarded
|
||||||
|
/// memory, while their data is open. Every key is the account's own, so
|
||||||
|
/// content is signed by the account and checked against it. A fake in
|
||||||
|
/// tests.
|
||||||
|
abstract interface class ContentCipher() {
|
||||||
|
/// Gets ready to work for the account whose keys are [account]; once,
|
||||||
|
/// before anything else.
|
||||||
|
Future<void> unlock(UnlockedAccountKeys account);
|
||||||
|
|
||||||
|
/// A new data key named [keyId], kept open here, and sealed to the
|
||||||
|
/// account (signed by it), as its row keeps it.
|
||||||
|
Uint8List newKey(String keyId);
|
||||||
|
|
||||||
|
/// Opens [sealed] as the data key [keyId], kept open here from then on;
|
||||||
|
/// false where it doesn't open (another account's, sealed under another
|
||||||
|
/// ID, changed). Throws a [StateError] before [unlock].
|
||||||
|
bool openKey(String keyId, Uint8List sealed);
|
||||||
|
|
||||||
|
/// Whether the key [keyId] is open here.
|
||||||
|
bool has(String keyId);
|
||||||
|
|
||||||
|
/// [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.
|
||||||
|
SealedPayload seal(
|
||||||
|
Uint8List plain, {
|
||||||
|
required String keyId,
|
||||||
|
required List<int> context,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
Uint8List? unseal(SealedPayload sealed, {required List<int> context});
|
||||||
|
|
||||||
|
/// Wipes every key kept open here.
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ContentCipher] with libsodium (halkyon_keys), on the isolate it is used
|
||||||
|
/// on: rows are sealed and opened one by one as they are read and written,
|
||||||
|
/// too many for an isolate each.
|
||||||
|
class SodiumContentCipher() implements ContentCipher {
|
||||||
|
SodiumSumo? _sodium;
|
||||||
|
AccountKeys? _account;
|
||||||
|
final _keys = <String, DataKey>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unlock(UnlockedAccountKeys account) async {
|
||||||
|
final sodium = _sodium ??= await SodiumSumoInit.init();
|
||||||
|
_account?.dispose();
|
||||||
|
_account = AccountKeys.fromSecretKeys(
|
||||||
|
sodium,
|
||||||
|
encryptionSecretKey: account.encryptionSecretKey,
|
||||||
|
signingSecretKey: account.signingSecretKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Uint8List newKey(String keyId) {
|
||||||
|
final (sodium, account) = _unlocked();
|
||||||
|
final key = DataKey.generate(sodium, keyId);
|
||||||
|
_keys.remove(keyId)?.dispose();
|
||||||
|
_keys[keyId] = key;
|
||||||
|
return sealDataKey(
|
||||||
|
sodium,
|
||||||
|
key,
|
||||||
|
to: account.encryptionPublicKey,
|
||||||
|
sealer: account,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool openKey(String keyId, Uint8List sealed) {
|
||||||
|
final (sodium, account) = _unlocked();
|
||||||
|
try {
|
||||||
|
final key = openDataKey(
|
||||||
|
sodium,
|
||||||
|
sealed,
|
||||||
|
keyId: keyId,
|
||||||
|
keys: account,
|
||||||
|
sealedBy: account.signingPublicKey,
|
||||||
|
);
|
||||||
|
_keys.remove(keyId)?.dispose();
|
||||||
|
_keys[keyId] = key;
|
||||||
|
return true;
|
||||||
|
} on KeyRefused catch (refused) {
|
||||||
|
log('A data key not opened: ${refused.reason.name}', name: 'Privacy');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool has(String keyId) => _keys.containsKey(keyId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
SealedPayload seal(
|
||||||
|
Uint8List plain, {
|
||||||
|
required String keyId,
|
||||||
|
required List<int> context,
|
||||||
|
}) {
|
||||||
|
final (sodium, account) = _unlocked();
|
||||||
|
final key = _keys[keyId];
|
||||||
|
if (key == null) {
|
||||||
|
throw StateError('The data key $keyId is not open');
|
||||||
|
}
|
||||||
|
final sealed = sealContent(
|
||||||
|
sodium,
|
||||||
|
plainText: plain,
|
||||||
|
dataKey: key,
|
||||||
|
author: account,
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
return SealedPayload(
|
||||||
|
keyId: keyId,
|
||||||
|
nonce: Uint8List.fromList(sealed.nonce),
|
||||||
|
payload: Uint8List(sealed.signature.length + sealed.payload.length)
|
||||||
|
..setAll(0, sealed.signature)
|
||||||
|
..setAll(sealed.signature.length, sealed.payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Uint8List? unseal(SealedPayload sealed, {required List<int> context}) {
|
||||||
|
final sodium = _sodium;
|
||||||
|
final account = _account;
|
||||||
|
final key = _keys[sealed.keyId];
|
||||||
|
if (sodium == null || account == null || key == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final signatureBytes = sodium.crypto.sign.bytes;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final key in _keys.values) {
|
||||||
|
key.dispose();
|
||||||
|
}
|
||||||
|
_keys.clear();
|
||||||
|
_account?.dispose();
|
||||||
|
_account = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
(SodiumSumo, AccountKeys) _unlocked() => switch ((_sodium, _account)) {
|
||||||
|
(final sodium?, final account?) => (sodium, account),
|
||||||
|
_ => throw StateError('Not unlocked'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -130,14 +130,18 @@ class FakeAccountCrypto() implements AccountCrypto {
|
||||||
return _material(password, keysNumbered(10 * ++_accounts), fakePhrase);
|
return _material(password, keysNumbered(10 * ++_accounts), fakePhrase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UnlockedAccountKeys> newDeviceKeys() async =>
|
||||||
|
keysNumbered(10 * ++_accounts);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<NewAccountMaterial> keepingKeys(
|
Future<NewAccountMaterial> keepingKeys(
|
||||||
String password,
|
String password,
|
||||||
UnlockedAccountKeys keys, {
|
UnlockedAccountKeys keys, {
|
||||||
required String phrase,
|
String? phrase,
|
||||||
}) async {
|
}) async {
|
||||||
_checkNew(password);
|
_checkNew(password);
|
||||||
return _material(password, keys, phrase);
|
return _material(password, keys, phrase ?? fakePhrase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
|
||||||
|
import 'package:halkyon_app/src/privacy/content_cipher.dart';
|
||||||
|
|
||||||
|
/// [ContentCipher] without libsodium, for tests: a key opens only on a
|
||||||
|
/// device unlocked for the account it was made for, and content only under
|
||||||
|
/// its key and for its context, as the real one; but nothing here keeps it
|
||||||
|
/// secret (each byte is flipped, so the plain text doesn't show).
|
||||||
|
class FakeContentCipher() implements ContentCipher {
|
||||||
|
String? _account;
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Uint8List newKey(String keyId) {
|
||||||
|
final account = _unlocked();
|
||||||
|
opened[keyId] = account;
|
||||||
|
return Uint8List.fromList(utf8.encode('key|$account|$keyId'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool openKey(String keyId, Uint8List sealed) {
|
||||||
|
final account = _unlocked();
|
||||||
|
if (utf8.decode(sealed, allowMalformed: true) != 'key|$account|$keyId') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
opened[keyId] = account;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool has(String keyId) => opened.containsKey(keyId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
SealedPayload seal(
|
||||||
|
Uint8List plain, {
|
||||||
|
required String keyId,
|
||||||
|
required List<int> context,
|
||||||
|
}) {
|
||||||
|
if (!has(keyId)) {
|
||||||
|
throw StateError('The data key $keyId is not open');
|
||||||
|
}
|
||||||
|
final tag = _tag(keyId, context);
|
||||||
|
return SealedPayload(
|
||||||
|
keyId: keyId,
|
||||||
|
nonce: Uint8List(24),
|
||||||
|
payload: Uint8List.fromList([
|
||||||
|
...tag,
|
||||||
|
for (final byte in plain) byte ^ 0xff,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Uint8List? unseal(SealedPayload sealed, {required List<int> context}) {
|
||||||
|
if (!has(sealed.keyId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final tag = _tag(sealed.keyId, context);
|
||||||
|
if (sealed.payload.length < tag.length ||
|
||||||
|
!_same(Uint8List.sublistView(sealed.payload, 0, tag.length), tag)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Uint8List.fromList([
|
||||||
|
for (final byte in sealed.payload.skip(tag.length)) byte ^ 0xff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => opened.clear();
|
||||||
|
|
||||||
|
String _unlocked() => _account ?? (throw StateError('Not unlocked'));
|
||||||
|
|
||||||
|
/// What binds a payload to its key and context: 64 bytes, as long as a
|
||||||
|
/// signature is.
|
||||||
|
Uint8List _tag(String keyId, List<int> context) {
|
||||||
|
final text = utf8.encode('$keyId|${base64Encode(context)}');
|
||||||
|
return Uint8List(64)..setAll(0, text.take(64));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _same(Uint8List a, Uint8List b) {
|
||||||
|
for (var i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ library;
|
||||||
|
|
||||||
export 'src/testing/fake_account_crypto.dart';
|
export 'src/testing/fake_account_crypto.dart';
|
||||||
export 'src/testing/fake_auth_gateway.dart';
|
export 'src/testing/fake_auth_gateway.dart';
|
||||||
|
export 'src/testing/fake_content_cipher.dart';
|
||||||
export 'src/testing/fake_instance_checker.dart';
|
export 'src/testing/fake_instance_checker.dart';
|
||||||
export 'src/testing/fake_password_gateway.dart';
|
export 'src/testing/fake_password_gateway.dart';
|
||||||
export 'src/testing/fake_screen_guard.dart';
|
export 'src/testing/fake_screen_guard.dart';
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,38 @@ void main() {
|
||||||
expect(addresses.addresses['anonymous-1'], 'dana@example.org');
|
expect(addresses.addresses['anonymous-1'], 'dana@example.org');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an anonymous person's device keys become their account's as they "
|
||||||
|
'set a password, so their private studysets stay readable', () async {
|
||||||
|
await auth.signInAnonymously();
|
||||||
|
final device = await crypto.newDeviceKeys();
|
||||||
|
await keys.keep('anonymous-1', device);
|
||||||
|
final request = await passwords.sendCode(
|
||||||
|
'dana@example.org',
|
||||||
|
language: 'en',
|
||||||
|
purpose: MailCodePurpose.signUp,
|
||||||
|
);
|
||||||
|
|
||||||
|
final material = await passwords.makePassword('her password');
|
||||||
|
await passwords.setPassword(
|
||||||
|
material,
|
||||||
|
address: 'dana@example.org',
|
||||||
|
request: request,
|
||||||
|
code: gateway.codeFor('dana@example.org'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(material.keys, device);
|
||||||
|
expect(keys.kept['anonymous-1'], device);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('someone signed up anew gets new keys, never the keys kept on the '
|
||||||
|
'device for another', () async {
|
||||||
|
await keys.keep('anonymous-1', crypto.keysNumbered(1));
|
||||||
|
|
||||||
|
final material = await passwords.makePassword('a new password');
|
||||||
|
|
||||||
|
expect(material.keys, isNot(crypto.keysNumbered(1)));
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps only the signed-in person's keys: another signing in, or "
|
test("keeps only the signed-in person's keys: another signing in, or "
|
||||||
'signing out, takes the others away', () async {
|
'signing out, takes the others away', () async {
|
||||||
await auth.signInAnonymously();
|
await auth.signInAnonymously();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
|
||||||
|
/// Content sealed with a data key (ADR 0019), with libsodium as its build
|
||||||
|
/// hook compiles it.
|
||||||
|
void main() {
|
||||||
|
final crypto = SodiumAccountCrypto();
|
||||||
|
late UnlockedAccountKeys alice;
|
||||||
|
late UnlockedAccountKeys bob;
|
||||||
|
late SodiumContentCipher device;
|
||||||
|
|
||||||
|
final context = utf8.encode('card_revision/0192/payload');
|
||||||
|
final plain = Uint8List.fromList(utf8.encode('{"front":"火"}'));
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
alice = await crypto.newDeviceKeys();
|
||||||
|
bob = await crypto.newDeviceKeys();
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
device = SodiumContentCipher();
|
||||||
|
await device.unlock(alice);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => device.dispose());
|
||||||
|
|
||||||
|
test("a key made on one device opens on another of the account's, and "
|
||||||
|
'what it seals opens there', () async {
|
||||||
|
final sealedKey = device.newKey('studyset-1');
|
||||||
|
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
||||||
|
|
||||||
|
final other = SodiumContentCipher();
|
||||||
|
await other.unlock(alice);
|
||||||
|
addTearDown(other.dispose);
|
||||||
|
expect(other.has('studyset-1'), isFalse);
|
||||||
|
expect(other.openKey('studyset-1', sealedKey), isTrue);
|
||||||
|
|
||||||
|
expect(other.has('studyset-1'), isTrue);
|
||||||
|
expect(other.unseal(sealed, context: context), plain);
|
||||||
|
expect(sealed.keyId, 'studyset-1');
|
||||||
|
expect(sealed.payload, isNot(contains(plain.first)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another account's device opens neither the key nor what it "
|
||||||
|
'sealed', () async {
|
||||||
|
final sealedKey = device.newKey('studyset-1');
|
||||||
|
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
||||||
|
|
||||||
|
final bobs = SodiumContentCipher();
|
||||||
|
await bobs.unlock(bob);
|
||||||
|
addTearDown(bobs.dispose);
|
||||||
|
|
||||||
|
expect(bobs.openKey('studyset-1', sealedKey), isFalse);
|
||||||
|
expect(bobs.unseal(sealed, context: context), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a key opens only under the ID it was sealed with, and only once '
|
||||||
|
'the account is unlocked', () async {
|
||||||
|
final sealedKey = device.newKey('studyset-1');
|
||||||
|
final other = SodiumContentCipher();
|
||||||
|
addTearDown(other.dispose);
|
||||||
|
|
||||||
|
expect(() => other.openKey('studyset-1', sealedKey), throwsStateError);
|
||||||
|
await other.unlock(alice);
|
||||||
|
expect(other.openKey('studyset-2', sealedKey), isFalse);
|
||||||
|
expect(other.openKey('studyset-1', sealedKey), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a payload moved to another row, or changed, opens no more', () {
|
||||||
|
device.newKey('studyset-1');
|
||||||
|
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
||||||
|
final changed = Uint8List.fromList(sealed.payload)..last ^= 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
device.unseal(sealed, context: utf8.encode('card_revision/0193/payload')),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
device.unseal(
|
||||||
|
SealedPayload(
|
||||||
|
keyId: sealed.keyId,
|
||||||
|
nonce: sealed.nonce,
|
||||||
|
payload: changed,
|
||||||
|
),
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(device.unseal(sealed, context: context), plain);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without its key nothing opens, nor is sealed', () {
|
||||||
|
final sealed = SealedPayload(
|
||||||
|
keyId: 'unknown',
|
||||||
|
nonce: Uint8List(24),
|
||||||
|
payload: Uint8List(100),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(device.unseal(sealed, context: context), isNull);
|
||||||
|
expect(
|
||||||
|
() => device.seal(plain, keyId: 'unknown', context: context),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue