fix(app): no state logs an authenticator's key, codes or the address
Equatable's own text lists every field, and a debug build's bloc observer logs every state, so an authenticator's otpauth URI with its secret, the recovery codes and the address went into the log. The password states (signing in, getting back in, adding an authenticator, a step of the account section) and AuthenticatorSetup now say only where they stand, as the setup and new-phrase states did; Learn's observer takes a writer so its test holds what it logs to that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
001ab37d09
commit
5067d11c18
7 changed files with 150 additions and 2 deletions
|
|
@ -2,13 +2,25 @@ import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
/// Writes [message] under [name]: `dart:developer`'s [log], or what a test
|
||||||
|
/// collects.
|
||||||
|
typedef BlocLogWriter = void Function(String message, {required String name});
|
||||||
|
|
||||||
|
void _log(String message, {required String name}) => log(message, name: name);
|
||||||
|
|
||||||
/// The one observer of all blocs: logs every state change and error, so bloc
|
/// The one observer of all blocs: logs every state change and error, so bloc
|
||||||
/// behaviour can be followed without adding logging to each bloc.
|
/// behaviour can be followed without adding logging to each bloc.
|
||||||
class const AppBlocObserver() extends BlocObserver {
|
///
|
||||||
|
/// A state's text is what it says of itself: states holding secrets (an
|
||||||
|
/// authenticator's key, recovery codes, a recovery phrase, an address) say
|
||||||
|
/// only where they stand (halkyon_app's password states, ADR 0017), which
|
||||||
|
/// `app_bloc_observer_test.dart` holds them to.
|
||||||
|
class const AppBlocObserver({final BlocLogWriter write = _log})
|
||||||
|
extends BlocObserver {
|
||||||
@override
|
@override
|
||||||
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
|
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
|
||||||
super.onChange(bloc, change);
|
super.onChange(bloc, change);
|
||||||
log(
|
write(
|
||||||
'${change.currentState} → ${change.nextState}',
|
'${change.currentState} → ${change.nextState}',
|
||||||
name: '${bloc.runtimeType}',
|
name: '${bloc.runtimeType}',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/bloc/app_bloc_observer.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('what the observer logs of the password sheets never holds an '
|
||||||
|
"authenticator's secret, recovery codes or the address", () {
|
||||||
|
final logged = <String>[];
|
||||||
|
final observer = AppBlocObserver(
|
||||||
|
write: (message, {required name}) => logged.add('$name: $message'),
|
||||||
|
);
|
||||||
|
const secret = 'JBSWY3DPEHPK3PXP';
|
||||||
|
const codes = ['K7QX-M2PD', 'AAAA-0001'];
|
||||||
|
const authenticator = AuthenticatorSetup(
|
||||||
|
id: 'a1',
|
||||||
|
uri: 'otpauth://totp/learn.halkyon.eu:anna?secret=$secret',
|
||||||
|
);
|
||||||
|
final cubit = SecurityStepCubit<List<String>>();
|
||||||
|
addTearDown(cubit.close);
|
||||||
|
|
||||||
|
observer
|
||||||
|
..onChange(
|
||||||
|
cubit,
|
||||||
|
const Change(
|
||||||
|
currentState: PasswordSignInState(address: 'anna@example.org'),
|
||||||
|
nextState: PasswordSignInState(
|
||||||
|
step: PasswordSignInStep.recoveryCodes,
|
||||||
|
address: 'anna@example.org',
|
||||||
|
authenticator: authenticator,
|
||||||
|
recoveryCodes: codes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..onChange(
|
||||||
|
cubit,
|
||||||
|
const Change(
|
||||||
|
currentState: SecurityStepState<List<String>>(busy: true),
|
||||||
|
nextState: SecurityStepState<List<String>>(result: codes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logged, hasLength(2));
|
||||||
|
for (final line in logged) {
|
||||||
|
for (final kept in [secret, ...codes, 'anna@example.org']) {
|
||||||
|
expect(line, isNot(contains(kept)), reason: line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,11 @@ final class const AccountRecoveryState({
|
||||||
address,
|
address,
|
||||||
resetAt,
|
resetAt,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Never the address (Equatable's own text lists every field, which a
|
||||||
|
// debug build's bloc observer logs).
|
||||||
|
@override
|
||||||
|
String toString() => 'AccountRecoveryState($step, busy: $busy, $problem)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Getting back into an account without its password (ADR 0017): with the
|
/// Getting back into an account without its password (ADR 0017): with the
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,14 @@ final class const SecurityStepState<T>({
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [busy, problem, result, mismatch];
|
List<Object?> get props => [busy, problem, result, mismatch];
|
||||||
|
|
||||||
|
// Never the result, which may be recovery codes or an authenticator's
|
||||||
|
// secret (Equatable's own text lists every field, which a debug build's
|
||||||
|
// bloc observer logs).
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SecurityStepState(busy: $busy, $problem, '
|
||||||
|
'result: ${result == null ? 'none' : 'kept'}, mismatch: $mismatch)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A step of the account section ([run]), busy meanwhile, its refusal or
|
/// A step of the account section ([run]), busy meanwhile, its refusal or
|
||||||
|
|
@ -121,6 +129,11 @@ final class const AddAuthenticatorState({
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [step, busy, problem, setup, recoveryCodes];
|
List<Object?> get props => [step, busy, problem, setup, recoveryCodes];
|
||||||
|
|
||||||
|
// Never the authenticator's secret or the recovery codes (Equatable's own
|
||||||
|
// text lists every field, which a debug build's bloc observer logs).
|
||||||
|
@override
|
||||||
|
String toString() => 'AddAuthenticatorState($step, busy: $busy, $problem)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds an authenticator to the signed-in person's account: enrolled as it
|
/// Adds an authenticator to the signed-in person's account: enrolled as it
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,12 @@ final class const PasswordSignInState({
|
||||||
resetAt,
|
resetAt,
|
||||||
sessionEndedAt,
|
sessionEndedAt,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Never the authenticator's secret, the recovery codes or the address
|
||||||
|
// (Equatable's own text lists every field, which a debug build's bloc
|
||||||
|
// observer logs).
|
||||||
|
@override
|
||||||
|
String toString() => 'PasswordSignInState($step, busy: $busy, $problem)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signing in with an address, a password and a code from an authenticator
|
/// Signing in with an address, a password and a code from an authenticator
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,9 @@ final class const AuthenticatorSetup({
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, uri];
|
List<Object?> get props => [id, uri];
|
||||||
|
|
||||||
|
// Never the URI, which holds the secret (Equatable's own text lists every
|
||||||
|
// field, which a debug build's bloc observer logs).
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthenticatorSetup($id)';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
|
||||||
|
/// What a debug build's bloc observer logs of every state must never hold a
|
||||||
|
/// secret: Equatable's own text lists every field.
|
||||||
|
void main() {
|
||||||
|
const secret = 'JBSWY3DPEHPK3PXP';
|
||||||
|
const authenticator = AuthenticatorSetup(
|
||||||
|
id: 'a1',
|
||||||
|
uri: 'otpauth://totp/learn.halkyon.eu:anna?secret=$secret&issuer=x',
|
||||||
|
);
|
||||||
|
const codes = ['K7QX-M2PD', 'AAAA-0001'];
|
||||||
|
const address = 'anna@example.org';
|
||||||
|
|
||||||
|
void expectNoSecret(Object state) {
|
||||||
|
final text = '$state';
|
||||||
|
for (final kept in [secret, ...codes, address]) {
|
||||||
|
expect(text, isNot(contains(kept)), reason: text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no state of signing in, setting up, getting back in or the account '
|
||||||
|
'section says a secret, a code or the address', () {
|
||||||
|
expectNoSecret(authenticator);
|
||||||
|
expectNoSecret(
|
||||||
|
const PasswordSignInState(
|
||||||
|
step: PasswordSignInStep.recoveryCodes,
|
||||||
|
address: address,
|
||||||
|
authenticator: authenticator,
|
||||||
|
recoveryCodes: codes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expectNoSecret(const AccountRecoveryState(address: address));
|
||||||
|
expectNoSecret(
|
||||||
|
const AddAuthenticatorState(
|
||||||
|
step: AddAuthenticatorStep.recoveryCodes,
|
||||||
|
setup: authenticator,
|
||||||
|
recoveryCodes: codes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expectNoSecret(const SecurityStepState<List<String>>(result: codes));
|
||||||
|
expectNoSecret(
|
||||||
|
const SecurityStepState<AuthenticatorSetup>(result: authenticator),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('they still say where they stand', () {
|
||||||
|
expect(
|
||||||
|
'${const PasswordSignInState(step: PasswordSignInStep.secondFactor, busy: true)}',
|
||||||
|
contains('secondFactor'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
'${const SecurityStepState<List<String>>(result: codes)}',
|
||||||
|
contains('result'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue