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
1 changed files with 476 additions and 9 deletions
|
|
@ -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