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:
Tizian 「ティツィアーン」 2026-10-21 20:58:19 +02:00
commit 905bb9abf2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 476 additions and 9 deletions

View file

@ -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';

View file

@ -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(

View file

@ -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

View file

@ -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, {

View 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'),
};
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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();

View file

@ -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,
);
});
}