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/secret_screen.dart';
|
||||
export 'src/person/person_folders.dart';
|
||||
export 'src/privacy/content_cipher.dart';
|
||||
export 'src/sync/app_compatibility.dart';
|
||||
export 'src/sync/bloc/sync_status_bloc.dart';
|
||||
export 'src/sync/pending_changes_store.dart';
|
||||
|
|
|
|||
|
|
@ -107,13 +107,20 @@ abstract interface class AccountCrypto() {
|
|||
Future<NewAccountMaterial> newAccount(String password);
|
||||
|
||||
/// 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(
|
||||
String password,
|
||||
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]:
|
||||
/// Argon2id, about a second on a phone.
|
||||
Future<PasswordUnlock> unlock(
|
||||
|
|
|
|||
|
|
@ -240,9 +240,17 @@ class PasswordRepository(
|
|||
// Signing up, and a password for someone signed in.
|
||||
|
||||
/// A new account's password: new keys and a new recovery phrase, wrapped
|
||||
/// on the device (Argon2id, about a second).
|
||||
Future<NewAccountMaterial> makePassword(String password) =>
|
||||
_crypto.newAccount(password);
|
||||
/// on the device (Argon2id, about a second). An anonymous person's
|
||||
/// device keys, which encrypt their private studysets (ADR 0017, 9; ADR
|
||||
/// 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]
|
||||
/// proving it: a new account, or the account with that address and no
|
||||
|
|
|
|||
|
|
@ -43,19 +43,36 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
Future<NewAccountMaterial> keepingKeys(
|
||||
String password,
|
||||
UnlockedAccountKeys keys, {
|
||||
required String phrase,
|
||||
String? phrase,
|
||||
}) => Isolate.run(
|
||||
() => _guarded(() async {
|
||||
final sodium = await SodiumSumoInit.init();
|
||||
final opened = _keysOf(sodium, keys);
|
||||
try {
|
||||
return _material(sodium, password, opened, phrase: phrase);
|
||||
return _material(
|
||||
sodium,
|
||||
password,
|
||||
opened,
|
||||
phrase: phrase ?? newRecoveryPhrase(sodium),
|
||||
);
|
||||
} finally {
|
||||
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
|
||||
Future<PasswordUnlock> unlock(
|
||||
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);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UnlockedAccountKeys> newDeviceKeys() async =>
|
||||
keysNumbered(10 * ++_accounts);
|
||||
|
||||
@override
|
||||
Future<NewAccountMaterial> keepingKeys(
|
||||
String password,
|
||||
UnlockedAccountKeys keys, {
|
||||
required String phrase,
|
||||
String? phrase,
|
||||
}) async {
|
||||
_checkNew(password);
|
||||
return _material(password, keys, phrase);
|
||||
return _material(password, keys, phrase ?? fakePhrase);
|
||||
}
|
||||
|
||||
@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_auth_gateway.dart';
|
||||
export 'src/testing/fake_content_cipher.dart';
|
||||
export 'src/testing/fake_instance_checker.dart';
|
||||
export 'src/testing/fake_password_gateway.dart';
|
||||
export 'src/testing/fake_screen_guard.dart';
|
||||
|
|
|
|||
|
|
@ -325,6 +325,38 @@ void main() {
|
|||
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 "
|
||||
'signing out, takes the others away', () async {
|
||||
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