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>
2026-10-21 20:58:19 +02:00
|
|
|
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 {
|
feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)
Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.
What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.
The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 14:50:55 +02:00
|
|
|
alice = (await crypto.newAccount('alice password')).keys;
|
|
|
|
|
bob = (await crypto.newAccount('bob password')).keys;
|
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>
2026-10-21 20:58:19 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
feat(privacy): every synced payload sealed and opened through one envelope (ADR 0019)
- `ContentKeys` opens the person's data keys (`content_key`) with their
account's keys on the device, an anonymous person's device keys made as
their data first opens; it makes a private studyset's key and the
personal key. Without the account's keys nothing opens and nothing new
is private (D8).
- `PayloadEnvelope` is the one place payloads are sealed and opened, each
for its table, row and column, so a payload moved to another row never
opens; one that doesn't open is `Locked`, never half-read.
- `Privacy` gives each gateway the key of the row's tree (its top-level
studyset's), and rewrites a tree under another key, or readable, in
place (`rekeyTree`).
- The studyset, card, note, learning path, path answer and PDF gateways
seal with their tree's key and open what they read: a new top-level
studyset is private where the device holds the keys, a new one inside a
tree takes its privacy, and one moved into another tree is rewritten
under that tree's key. Tags and settings are sealed with the personal
key; a tag or settings row that doesn't open here is left out, so
nothing folds or deletes it. A studyset whose key doesn't open shows as
locked (`Studyset.isLocked`); other rows that don't open read as blank.
- Every payload codec round-trips sealed, and refuses another row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 21:21:31 +02:00
|
|
|
expect(String.fromCharCodes(sealed.payload), isNot(contains('front')));
|
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>
2026-10-21 20:58:19 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-30 16:12:00 +01:00
|
|
|
test(
|
|
|
|
|
"a row's keyed ID (ADR 0030, D57) is the same on every device of the "
|
|
|
|
|
"account, another on another account's, and none before unlocking",
|
|
|
|
|
() async {
|
|
|
|
|
final other = SodiumContentCipher();
|
|
|
|
|
await other.unlock(alice);
|
|
|
|
|
addTearDown(other.dispose);
|
|
|
|
|
final bobs = SodiumContentCipher();
|
|
|
|
|
await bobs.unlock(bob);
|
|
|
|
|
addTearDown(bobs.dispose);
|
|
|
|
|
final locked = SodiumContentCipher();
|
|
|
|
|
|
|
|
|
|
final id = device.rowId('train/day/2026-10-30');
|
|
|
|
|
expect(other.rowId('train/day/2026-10-30'), id);
|
|
|
|
|
expect(bobs.rowId('train/day/2026-10-30'), isNot(id));
|
|
|
|
|
expect(device.rowId('train/day/2026-10-31'), isNot(id));
|
|
|
|
|
expect(() => locked.rowId('train/day/2026-10-30'), throwsStateError);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
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>
2026-10-21 20:58:19 +02:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|