fix(app): the short reading's nits on the device

- Deleting an identified account whose security can't be read (the
  instance out of reach) says so and deletes nothing; it went on to a
  deletion by the session alone, refused for an account with a password,
  and the person saw a spinner, then nothing. A refusal still leaves the
  session's own deletion.
- InstanceChoice's sharesStorage is required, so no app leaves it out and
  removes a folder another instance uses.
- A PasswordUnlock forgotten is spent: its secrets read again fail
  (StateError) rather than make a proof or a wrapping of zeros.

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 18:15:14 +02:00
commit 012f2375a5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 106 additions and 21 deletions

View file

@ -60,6 +60,7 @@ void main() {
store,
builtIn: builtIn,
forget: (instance) async => forgotten.add(instance),
sharesStorage: (a, b) => false,
);
await choice.load(usedBefore: usedBefore);
await tester.pumpWidget(
@ -322,6 +323,7 @@ void main() {
store,
builtIn: builtIn,
forget: (instance) async {},
sharesStorage: (a, b) => false,
);
await choice.load(usedBefore: false);
var attempts = 0;

View file

@ -95,6 +95,7 @@ void main() {
MemoryInstanceStore((current: instance, known: [instance])),
builtIn: instance,
forget: (instance) async {},
sharesStorage: (a, b) => false,
);
await choice.load(usedBefore: false);
await start(tester, choice: choice);
@ -174,6 +175,31 @@ void main() {
expect(find.text('Signed out'), findsOneWidget);
});
testWidgets('deleting an identified account while the instance is out of '
'reach says so, and deletes nothing (the short reading)', (tester) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
await start(tester);
dependencies.passwordGateway.failing = const PasswordFailed(
PasswordProblem.failed,
);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
expect(
find.text(
"Couldn't reach the instance. Check the connection and try again.",
),
findsOneWidget,
);
expect(gateway.accountDeletions, 0);
expect(find.text('App of github-1'), findsOneWidget);
});
testWidgets('deleting an account whose setup never finished asks for the '
'password and a code by mail', (tester) async {
// The first person the fake password gateway makes.

View file

@ -93,6 +93,7 @@ void main() {
store,
builtIn: insecure,
forget: (instance) async {},
sharesStorage: (a, b) => false,
);
expect(development.allows(builtIn), isTrue);
expect(
@ -110,6 +111,7 @@ void main() {
builtIn: insecure,
forget: (instance) async {},
allowsInsecureBuiltIn: false,
sharesStorage: (a, b) => false,
);
expect(release.allows(insecure), isFalse);
});

View file

@ -53,9 +53,8 @@ void main() {
finished: finished,
);
bool wiped(PasswordUnlock unlock) =>
unlock.srpSecret.every((byte) => byte == 0) &&
unlock.keyEncryptionKey.every((byte) => byte == 0);
/// Whether [unlock] was wiped, and so spent (the short reading).
bool wiped(PasswordUnlock unlock) => unlock.isForgotten;
test(
'wipes what the password unlocked once signed in, once another '

View file

@ -16,9 +16,34 @@ void main() {
),
srpSecret: Uint8List.fromList(List.filled(32, 7)),
keyEncryptionKey: Uint8List.fromList(List.filled(32, 9)),
)..forget();
);
final x = unlock.srpSecret;
final key = unlock.keyEncryptionKey;
expect(unlock.srpSecret, everyElement(0));
expect(unlock.keyEncryptionKey, everyElement(0));
unlock.forget();
expect(x, everyElement(0));
expect(key, everyElement(0));
});
test('a forgotten unlock is spent: its secrets are read no more, so no '
'proof is made of zeros (the short reading)', () {
final unlock = PasswordUnlock(
salt: Uint8List(16),
costs: protocol.PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
srpSecret: Uint8List.fromList(List.filled(32, 7)),
keyEncryptionKey: Uint8List.fromList(List.filled(32, 9)),
);
expect(unlock.isForgotten, isFalse);
unlock.forget();
expect(unlock.isForgotten, isTrue);
expect(() => unlock.srpSecret, throwsStateError);
expect(() => unlock.keyEncryptionKey, throwsStateError);
});
}