fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an instance a person was lured into adding could pass A, M1 and the code on to the real register and get a session there, and pass a phrase's proof on with a password of its own in place of the person's (the second reading's M6). SRP's identity is now a label, the challenge's ID and the host the device addressed (passwordIdentity, in halkyon_base_client and halkyon_base_server alike), and the phrase's proof signs that host, what it is for (an authenticator's reset or a new password) and the new password's digest (key proof v2). Every proving call carries the host; in production it must be the server's apiServer.publicHost (otherwise otherInstance, which the app shows as an instance it can't trust), in development any; a member forwards its own. The tests prove that a relayed sign-in, a proof for another host or purpose, and a swapped password are refused, nothing changing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
009970faab
commit
dab2c80524
24 changed files with 966 additions and 137 deletions
|
|
@ -194,7 +194,13 @@ Built on the three parts above, on the register (members forward every step thro
|
|||
session. A setup never finished (never a confirmed authenticator) signs in with a code
|
||||
mailed to the address it began with in its place (`request`, `code`), which calls off
|
||||
a reset of the account waiting; any other account takes no code by mail there and
|
||||
leaves it unused. SRP's identity I is the challenge's ID. An address nobody has (or whose
|
||||
leaves it unused. SRP's identity I is `passwordIdentity`: the challenge's ID and the
|
||||
host the device addressed (`host` on every proving call: in production it must be the
|
||||
server's `apiServer.publicHost`, else `otherInstance`; in development any, as an
|
||||
emulator reaches the machine as 10.0.2.2), so a hostile instance relaying a sign-in or
|
||||
a phrase's proof here gets nothing taken; a member forwards its own host, and the
|
||||
phrase's proof for a new password signs that password's digest
|
||||
(`newPasswordDigest`). An address nobody has (or whose
|
||||
account has no password) gets a salt and verifier from `signInDecoyKey` and the
|
||||
address's hash, the same queries and arithmetic, and the same refusal: a wrong
|
||||
password, an unknown address and a wrong, used or missing code are all
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export 'src/password/authenticators.dart'
|
|||
forgetUnconfirmedAuthenticators,
|
||||
maxAuthenticators,
|
||||
unconfirmedAuthenticatorLifetime;
|
||||
export 'src/password/key_proof.dart' show keyProofMessage;
|
||||
export 'src/password/key_proof.dart'
|
||||
show KeyProofPurpose, keyProofMessage, newPasswordDigest, passwordIdentity;
|
||||
export 'src/password/password_config.dart';
|
||||
export 'src/password/passwords.dart'
|
||||
show
|
||||
|
|
|
|||
|
|
@ -339,6 +339,11 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<_idt.ByteData?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'host': _is.ParameterDescription(
|
||||
name: 'host',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
|
|
@ -358,6 +363,7 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
code: params['code'],
|
||||
keyChallenge: params['keyChallenge'],
|
||||
signature: params['signature'],
|
||||
host: params['host'],
|
||||
),
|
||||
),
|
||||
'beginKeyProof': _is.MethodConnector(
|
||||
|
|
@ -409,6 +415,11 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'host': _is.ParameterDescription(
|
||||
name: 'host',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
|
|
@ -424,6 +435,7 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
password: params['password'],
|
||||
totpCode: params['totpCode'],
|
||||
recoveryCode: params['recoveryCode'],
|
||||
host: params['host'],
|
||||
),
|
||||
),
|
||||
'startAccountReset': _is.MethodConnector(
|
||||
|
|
@ -710,6 +722,11 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<_ijye8mcv.NewPassword>(),
|
||||
nullable: false,
|
||||
),
|
||||
'host': _is.ParameterDescription(
|
||||
name: 'host',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
|
|
@ -724,6 +741,7 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
totpCode: params['totpCode'],
|
||||
recoveryCode: params['recoveryCode'],
|
||||
password: params['password'],
|
||||
host: params['host'],
|
||||
),
|
||||
),
|
||||
'setRecoveryKeys': _is.MethodConnector(
|
||||
|
|
@ -764,6 +782,11 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<_idt.ByteData>(),
|
||||
nullable: false,
|
||||
),
|
||||
'host': _is.ParameterDescription(
|
||||
name: 'host',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
|
|
@ -779,6 +802,7 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
recoveryCode: params['recoveryCode'],
|
||||
recoveryKeys: params['recoveryKeys'],
|
||||
recoveryProofKey: params['recoveryProofKey'],
|
||||
host: params['host'],
|
||||
),
|
||||
),
|
||||
},
|
||||
|
|
@ -846,6 +870,11 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'host': _is.ParameterDescription(
|
||||
name: 'host',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
|
|
@ -863,6 +892,7 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
recoveryCode: params['recoveryCode'],
|
||||
request: params['request'],
|
||||
code: params['code'],
|
||||
host: params['host'],
|
||||
),
|
||||
),
|
||||
'signUp': _is.MethodConnector(
|
||||
|
|
|
|||
|
|
@ -73,7 +73,12 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
/// Adding an authenticator to an account that has one confirmed takes a
|
||||
/// code from one of those, which is missing or isn't right: a session
|
||||
/// alone adds no second factor.
|
||||
wrongExistingCode;
|
||||
wrongExistingCode,
|
||||
|
||||
/// The device addressed this instance by another name than its own
|
||||
/// (`apiServer.publicHost`): what it proves is bound to the name it
|
||||
/// addressed, so the operator must configure the name devices use.
|
||||
otherInstance;
|
||||
|
||||
static PasswordRefusal fromJson(String name) {
|
||||
switch (name) {
|
||||
|
|
@ -111,6 +116,8 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
return PasswordRefusal.noReset;
|
||||
case 'wrongExistingCode':
|
||||
return PasswordRefusal.wrongExistingCode;
|
||||
case 'otherInstance':
|
||||
return PasswordRefusal.otherInstance;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "PasswordRefusal"',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/src/auth/email.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart';
|
||||
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -50,6 +51,7 @@ Future<ResetPending> resetAuthenticator(
|
|||
String? code,
|
||||
UuidValue? keyChallenge,
|
||||
ByteData? signature,
|
||||
required String host,
|
||||
}) async {
|
||||
final pod = session.serverpod;
|
||||
final keys = requirePasswordKeys(pod);
|
||||
|
|
@ -59,6 +61,7 @@ Future<ResetPending> resetAuthenticator(
|
|||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
host: host,
|
||||
);
|
||||
final to = normalizedAddress(address);
|
||||
if (to == null || addressHash(pod, to) != proved.addressHash) {
|
||||
|
|
@ -80,6 +83,8 @@ Future<ResetPending> resetAuthenticator(
|
|||
keys,
|
||||
challenge: keyChallenge,
|
||||
signature: signature,
|
||||
host: host,
|
||||
purpose: KeyProofPurpose.authenticatorReset,
|
||||
);
|
||||
if (owner.authUserId != account) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
|
|
@ -117,6 +122,7 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
|
|||
required NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) async {
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
if (!isNewPassword(password)) {
|
||||
|
|
@ -127,6 +133,9 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
|
|||
keys,
|
||||
challenge: challenge,
|
||||
signature: signature,
|
||||
host: host,
|
||||
purpose: KeyProofPurpose.passwordReset,
|
||||
digest: newPasswordDigest(password),
|
||||
);
|
||||
final account = row.authUserId;
|
||||
await secondFactor(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_base_server/src/common/network.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:halkyon_base_server/src/register/people.dart';
|
||||
|
|
@ -30,6 +31,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
|||
String? code,
|
||||
UuidValue? keyChallenge,
|
||||
ByteData? signature,
|
||||
required String host,
|
||||
}) => AccountRegister.of(session.serverpod).resetAuthenticator(
|
||||
session,
|
||||
challenge: challenge,
|
||||
|
|
@ -41,6 +43,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
|||
code: code,
|
||||
keyChallenge: keyChallenge,
|
||||
signature: signature,
|
||||
host: deviceHostOn(session.serverpod, host),
|
||||
);
|
||||
|
||||
/// The first step of proving the recovery phrase for [address]: the
|
||||
|
|
@ -64,6 +67,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
|||
required NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) async {
|
||||
final ended = await AccountRegister.of(session.serverpod)
|
||||
.resetPasswordWithPhrase(
|
||||
|
|
@ -73,6 +77,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
|||
password: password,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: deviceHostOn(session.serverpod, host),
|
||||
);
|
||||
return await issueSessionAfter(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,125 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as hashes;
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// Proving the recovery phrase (ADR 0017): the device opens the account's
|
||||
// private keys with the phrase and signs, with the Ed25519 key so opened,
|
||||
// what [keyProofMessage] makes of the register's challenge; the register
|
||||
// checks the signature against the account's public signing key. Only the
|
||||
// account's own key signs so: the phrase never leaves the device.
|
||||
// What a device proves and signs, bound to the instance it addresses (ADR
|
||||
// 0017; the second reading's M6): SRP's identity and the recovery phrase's
|
||||
// proof carry the host the device addressed, so a hostile instance a person
|
||||
// was lured into adding can't relay them to the real one, which takes them
|
||||
// only for its own host; and a phrase's proof for a new password signs that
|
||||
// password, so nobody between can swap in another. The phrase's proof key
|
||||
// is the phrase's own (`recoveryProofKey`), never the account's signing key.
|
||||
// halkyon_base_client's `key_proof.dart` makes the same bytes.
|
||||
|
||||
/// What the device signs to prove the recovery phrase: a label naming the
|
||||
/// purpose and its version, the challenge's ID (its 16 bytes) and its nonce,
|
||||
/// so a signature made for one challenge, or for anything else the account
|
||||
/// signs, never counts for another.
|
||||
Uint8List keyProofMessage(UuidValue challenge, List<int> nonce) =>
|
||||
Uint8List.fromList([
|
||||
...utf8.encode('halkyon key proof v1|'),
|
||||
...challenge.toBytes(),
|
||||
...nonce,
|
||||
]);
|
||||
/// SRP's identity I for the sign-in [challenge] on [host] (in lower case):
|
||||
/// x comes from Argon2id over the password and salt alone, so I only binds
|
||||
/// the proof, to this sign-in and this instance.
|
||||
Uint8List passwordIdentity(UuidValue challenge, String host) => _framed([
|
||||
utf8.encode('halkyon srp identity v1'),
|
||||
challenge.toBytes(),
|
||||
utf8.encode(host.toLowerCase()),
|
||||
]);
|
||||
|
||||
/// What proving the recovery phrase is for.
|
||||
enum KeyProofPurpose {
|
||||
/// Asking for the reset of a lost authenticator.
|
||||
authenticatorReset('authenticator reset'),
|
||||
|
||||
/// Setting a new password, the keys staying.
|
||||
passwordReset('password reset');
|
||||
|
||||
KeyProofPurpose(this.label);
|
||||
|
||||
/// The words the message carries.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// What the device signs with the phrase's proof key: a label naming the
|
||||
/// format, the challenge's ID (its 16 bytes), its nonce, the [host] the
|
||||
/// device addressed, the [purpose] and the [digest] of what it sets
|
||||
/// ([newPasswordDigest]), each framed by its length; so a signature made
|
||||
/// for one challenge, instance, purpose or password never counts for
|
||||
/// another.
|
||||
Uint8List keyProofMessage(
|
||||
UuidValue challenge,
|
||||
List<int> nonce, {
|
||||
required String host,
|
||||
required KeyProofPurpose purpose,
|
||||
List<int> digest = const [],
|
||||
}) => _framed([
|
||||
utf8.encode('halkyon key proof v2'),
|
||||
challenge.toBytes(),
|
||||
nonce,
|
||||
utf8.encode(host.toLowerCase()),
|
||||
utf8.encode(purpose.label),
|
||||
digest,
|
||||
]);
|
||||
|
||||
/// The SHA-256 of [password] as the device sent it, framed: what a
|
||||
/// phrase's proof for it signs.
|
||||
Uint8List newPasswordDigest(NewPassword password) => Uint8List.fromList(
|
||||
hashes.sha256
|
||||
.convert(
|
||||
_framed([
|
||||
utf8.encode('halkyon new password v1'),
|
||||
Uint8List.sublistView(password.salt),
|
||||
utf8.encode(password.costs.algorithm),
|
||||
utf8.encode('${password.costs.opsLimit}'),
|
||||
utf8.encode('${password.costs.memLimit}'),
|
||||
Uint8List.sublistView(password.verifier),
|
||||
Uint8List.sublistView(password.passwordKeys.nonce),
|
||||
Uint8List.sublistView(password.passwordKeys.cipherText),
|
||||
]),
|
||||
)
|
||||
.bytes,
|
||||
);
|
||||
|
||||
/// [parts], each after its length in four bytes, big-endian.
|
||||
Uint8List _framed(List<List<int>> parts) {
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
for (final part in parts) {
|
||||
bytes
|
||||
..addByte((part.length >> 24) & 0xff)
|
||||
..addByte((part.length >> 16) & 0xff)
|
||||
..addByte((part.length >> 8) & 0xff)
|
||||
..addByte(part.length & 0xff)
|
||||
..add(part);
|
||||
}
|
||||
return bytes.takeBytes();
|
||||
}
|
||||
|
||||
/// The host a device addressed this server by ([claimed]), which its proofs
|
||||
/// are bound to: where the server runs for real, only its own public host
|
||||
/// ([publicHost], `apiServer.publicHost`), refused otherwise
|
||||
/// ([PasswordRefusal.otherInstance]: the instance is configured under
|
||||
/// another name than the device reaches it by); in development whatever
|
||||
/// the device names, as an emulator reaches the developer's machine as
|
||||
/// 10.0.2.2 and a phone on the network by its address. A relaying
|
||||
/// instance gains nothing either way: the device's proof names the host it
|
||||
/// addressed, and the real one checks it for its own.
|
||||
String deviceHost({
|
||||
required String runMode,
|
||||
required String publicHost,
|
||||
required String claimed,
|
||||
}) {
|
||||
final host = claimed.trim().toLowerCase();
|
||||
if (runMode != ServerpodRunMode.development &&
|
||||
host != publicHost.trim().toLowerCase()) {
|
||||
throw PasswordRefusedException(reason: PasswordRefusal.otherInstance);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/// [deviceHost] on [pod].
|
||||
String deviceHostOn(Serverpod pod, String claimed) => deviceHost(
|
||||
runMode: pod.runMode,
|
||||
publicHost: pod.config.apiServer.publicHost,
|
||||
claimed: claimed,
|
||||
);
|
||||
|
||||
/// Whether [signature] is the account's Ed25519 signature ([publicKey], 32
|
||||
/// bytes) over [message]; false for anything malformed.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ Future<void> changePassword(
|
|||
required ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
required NewPassword password,
|
||||
UuidValue? keepSession,
|
||||
}) async {
|
||||
|
|
@ -52,6 +53,7 @@ Future<void> changePassword(
|
|||
clientProof: clientProof,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: host,
|
||||
);
|
||||
await session.db.transaction((transaction) async {
|
||||
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
|
||||
|
|
@ -101,6 +103,7 @@ Future<void> setRecoveryKeys(
|
|||
required ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
required WrappedKeys recoveryKeys,
|
||||
required ByteData recoveryProofKey,
|
||||
}) async {
|
||||
|
|
@ -117,6 +120,7 @@ Future<void> setRecoveryKeys(
|
|||
clientProof: clientProof,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: host,
|
||||
);
|
||||
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
|
||||
// "recoveryKeysCipherText" = $2, "recoveryProofKey" = $3,
|
||||
|
|
@ -150,6 +154,7 @@ Future<AccountPassword> _proveOwner(
|
|||
required ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) async {
|
||||
final proved = await provePassword(
|
||||
session,
|
||||
|
|
@ -157,6 +162,7 @@ Future<AccountPassword> _proveOwner(
|
|||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
host: host,
|
||||
);
|
||||
final row = proved.password;
|
||||
if (row.authUserId != person) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_base_server/src/auth/membership.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:halkyon_base_server/src/register/people.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -60,6 +61,7 @@ class PasswordEndpoint extends Endpoint {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required NewPassword password,
|
||||
required String host,
|
||||
}) {
|
||||
final authenticated = session.authenticated!;
|
||||
return AccountRegister.of(session.serverpod).changePassword(
|
||||
|
|
@ -75,6 +77,7 @@ class PasswordEndpoint extends Endpoint {
|
|||
keepSession: Uuid.isValidUUID(fromString: authenticated.authId)
|
||||
? UuidValue.fromString(authenticated.authId)
|
||||
: null,
|
||||
host: deviceHostOn(session.serverpod, host),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +95,7 @@ class PasswordEndpoint extends Endpoint {
|
|||
String? recoveryCode,
|
||||
required WrappedKeys recoveryKeys,
|
||||
required ByteData recoveryProofKey,
|
||||
required String host,
|
||||
}) => AccountRegister.of(session.serverpod).setRecoveryKeys(
|
||||
session,
|
||||
person: session.authenticated!.authUserId,
|
||||
|
|
@ -102,5 +106,6 @@ class PasswordEndpoint extends Endpoint {
|
|||
recoveryCode: recoveryCode,
|
||||
recoveryKeys: recoveryKeys,
|
||||
recoveryProofKey: recoveryProofKey,
|
||||
host: deviceHostOn(session.serverpod, host),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,3 +46,7 @@ values:
|
|||
### code from one of those, which is missing or isn't right: a session
|
||||
### alone adds no second factor.
|
||||
- wrongExistingCode
|
||||
### The device addressed this instance by another name than its own
|
||||
### (`apiServer.publicHost`): what it proves is bound to the name it
|
||||
### addressed, so the operator must configure the name devices use.
|
||||
- otherInstance
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_base_server/src/common/network.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:halkyon_base_server/src/register/people.dart';
|
||||
|
|
@ -41,6 +42,7 @@ class PasswordSignInEndpoint extends Endpoint {
|
|||
String? recoveryCode,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
}) async {
|
||||
final proof = await AccountRegister.of(session.serverpod)
|
||||
.completePasswordSignIn(
|
||||
|
|
@ -52,6 +54,7 @@ class PasswordSignInEndpoint extends Endpoint {
|
|||
recoveryCode: recoveryCode,
|
||||
request: request,
|
||||
code: code,
|
||||
host: deviceHostOn(session.serverpod, host),
|
||||
);
|
||||
final signedIn = await issueSession(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -551,6 +551,7 @@ Future<PasswordProof> completePasswordSignIn(
|
|||
String? recoveryCode,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
}) async {
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
final proved = await provePassword(
|
||||
|
|
@ -559,6 +560,7 @@ Future<PasswordProof> completePasswordSignIn(
|
|||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
host: host,
|
||||
);
|
||||
final account = proved.password.authUserId;
|
||||
final bool needsAuthenticator;
|
||||
|
|
@ -659,15 +661,18 @@ typedef PasswordProved = ({
|
|||
|
||||
/// Takes [challenge] (once) and checks the device's proof against it, as
|
||||
/// [completePasswordSignIn] and the reset of a lost authenticator do; SRP's
|
||||
/// identity is the challenge's ID (16 bytes), so nobody learns an account's
|
||||
/// ID from its address. Refusals as [completePasswordSignIn]'s, but for the
|
||||
/// second factor.
|
||||
/// identity is [passwordIdentity] of the challenge's ID (so nobody learns an
|
||||
/// account's ID from its address) and of the [host] the device addressed
|
||||
/// (`deviceHost`, or the host a member names), so a proof a hostile
|
||||
/// instance relays here never counts. Refusals as
|
||||
/// [completePasswordSignIn]'s, but for the second factor.
|
||||
Future<PasswordProved> provePassword(
|
||||
Session session,
|
||||
PasswordKeys keys, {
|
||||
required UuidValue challenge,
|
||||
required ByteData clientValue,
|
||||
required ByteData clientProof,
|
||||
required String host,
|
||||
}) async {
|
||||
final kept = await takeChallenge(
|
||||
session,
|
||||
|
|
@ -679,7 +684,7 @@ Future<PasswordProved> provePassword(
|
|||
final clientBytes = Uint8List.sublistView(clientValue);
|
||||
final verification = srpVerify(
|
||||
passwordGroup,
|
||||
identity: challenge.toBytes(),
|
||||
identity: passwordIdentity(challenge, host),
|
||||
salt: password == null ? decoy.salt : Uint8List.sublistView(password.salt),
|
||||
verifier: password == null
|
||||
? decoy.verifier
|
||||
|
|
@ -972,7 +977,9 @@ Future<KeyProofChallenge> beginKeyProof(
|
|||
}
|
||||
|
||||
/// Takes the key proof [challenge] (once) and checks [signature], the
|
||||
/// signature over [keyProofMessage] by the recovery phrase's own proof key
|
||||
/// signature over [keyProofMessage] (for the [host] the device addressed,
|
||||
/// the [purpose] and the [digest] of what it sets) by the recovery
|
||||
/// phrase's own proof key
|
||||
/// (`recoveryProofKey`: derived from the phrase alone, so the account's
|
||||
/// signing key, which every signed-in device keeps, proves nothing); the
|
||||
/// account's password row. Refused ([PasswordRefusedException]) for a
|
||||
|
|
@ -984,6 +991,9 @@ Future<AccountPassword> proveRecoveryPhrase(
|
|||
PasswordKeys keys, {
|
||||
required UuidValue challenge,
|
||||
required ByteData signature,
|
||||
required String host,
|
||||
required KeyProofPurpose purpose,
|
||||
List<int> digest = const [],
|
||||
}) async {
|
||||
final kept = await takeChallenge(
|
||||
session,
|
||||
|
|
@ -996,7 +1006,13 @@ Future<AccountPassword> proveRecoveryPhrase(
|
|||
final password = await _passwordOf(session, kept.authUserId);
|
||||
final proofKey = password?.recoveryProofKey;
|
||||
final signed = await isAccountSignature(
|
||||
keyProofMessage(challenge, _unhex(kept.secret)),
|
||||
keyProofMessage(
|
||||
challenge,
|
||||
_unhex(kept.secret),
|
||||
host: host,
|
||||
purpose: purpose,
|
||||
digest: digest,
|
||||
),
|
||||
signature: Uint8List.sublistView(signature),
|
||||
publicKey: proofKey == null
|
||||
? decoy.recoveryProofKey
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ abstract interface class AccountRegister {
|
|||
String? recoveryCode,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
});
|
||||
|
||||
/// Signs up with the address [code] proves for [request] and the password
|
||||
|
|
@ -236,6 +237,7 @@ abstract interface class AccountRegister {
|
|||
String? code,
|
||||
UuidValue? keyChallenge,
|
||||
ByteData? signature,
|
||||
required String host,
|
||||
});
|
||||
|
||||
/// The first step of proving the recovery phrase for [address], the
|
||||
|
|
@ -256,6 +258,7 @@ abstract interface class AccountRegister {
|
|||
required NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
});
|
||||
|
||||
/// Asks for the reset of a whole account. Refusals as
|
||||
|
|
@ -298,6 +301,7 @@ abstract interface class AccountRegister {
|
|||
String? recoveryCode,
|
||||
required NewPassword password,
|
||||
UuidValue? keepSession,
|
||||
required String host,
|
||||
});
|
||||
|
||||
/// Replaces the wrapping of [person]'s keys under their recovery phrase
|
||||
|
|
@ -313,6 +317,7 @@ abstract interface class AccountRegister {
|
|||
String? recoveryCode,
|
||||
required WrappedKeys recoveryKeys,
|
||||
required ByteData recoveryProofKey,
|
||||
required String host,
|
||||
});
|
||||
|
||||
/// Deletes the account [person] with everything the register's server has
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||
request: arguments.optionalId('request'),
|
||||
code: arguments.optionalString('code'),
|
||||
host: arguments.string('host'),
|
||||
);
|
||||
return {'proof': proof.toJson()};
|
||||
case RegisterOperation.signUpWithCode:
|
||||
|
|
@ -300,6 +301,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
code: arguments.optionalString('code'),
|
||||
keyChallenge: arguments.optionalId('keyChallenge'),
|
||||
signature: arguments.optionalBytes('signature'),
|
||||
host: arguments.string('host'),
|
||||
);
|
||||
return {'pending': pending.toJson()};
|
||||
case RegisterOperation.beginKeyProof:
|
||||
|
|
@ -317,6 +319,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
password: arguments.model('password', NewPassword.fromJson),
|
||||
totpCode: arguments.optionalString('totpCode'),
|
||||
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||
host: arguments.string('host'),
|
||||
);
|
||||
return _sessionsEnded(ended);
|
||||
case RegisterOperation.startAccountReset:
|
||||
|
|
@ -354,6 +357,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||
password: arguments.model('password', NewPassword.fromJson),
|
||||
keepSession: arguments.optionalId('keepSession'),
|
||||
host: arguments.string('host'),
|
||||
);
|
||||
return const {};
|
||||
case RegisterOperation.setRecoveryKeys:
|
||||
|
|
@ -367,6 +371,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
||||
recoveryProofKey: arguments.bytes('recoveryProofKey'),
|
||||
host: arguments.string('host'),
|
||||
);
|
||||
return const {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,6 +430,7 @@ final class LocalRegister implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
}) => passwords.completePasswordSignIn(
|
||||
session,
|
||||
challenge: challenge,
|
||||
|
|
@ -439,6 +440,7 @@ final class LocalRegister implements AccountRegister {
|
|||
recoveryCode: recoveryCode,
|
||||
request: request,
|
||||
code: code,
|
||||
host: host,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -520,6 +522,7 @@ final class LocalRegister implements AccountRegister {
|
|||
String? code,
|
||||
UuidValue? keyChallenge,
|
||||
ByteData? signature,
|
||||
required String host,
|
||||
}) => recovery.resetAuthenticator(
|
||||
session,
|
||||
challenge: challenge,
|
||||
|
|
@ -531,6 +534,7 @@ final class LocalRegister implements AccountRegister {
|
|||
code: code,
|
||||
keyChallenge: keyChallenge,
|
||||
signature: signature,
|
||||
host: host,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -548,6 +552,7 @@ final class LocalRegister implements AccountRegister {
|
|||
required NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) => recovery.resetPasswordWithPhrase(
|
||||
session,
|
||||
challenge: challenge,
|
||||
|
|
@ -555,6 +560,7 @@ final class LocalRegister implements AccountRegister {
|
|||
password: password,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: host,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -604,6 +610,7 @@ final class LocalRegister implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
required NewPassword password,
|
||||
UuidValue? keepSession,
|
||||
required String host,
|
||||
}) => change.changePassword(
|
||||
session,
|
||||
person: person,
|
||||
|
|
@ -614,6 +621,7 @@ final class LocalRegister implements AccountRegister {
|
|||
recoveryCode: recoveryCode,
|
||||
password: password,
|
||||
keepSession: keepSession,
|
||||
host: host,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -627,6 +635,7 @@ final class LocalRegister implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
required WrappedKeys recoveryKeys,
|
||||
required ByteData recoveryProofKey,
|
||||
required String host,
|
||||
}) => change.setRecoveryKeys(
|
||||
session,
|
||||
person: person,
|
||||
|
|
@ -637,6 +646,7 @@ final class LocalRegister implements AccountRegister {
|
|||
recoveryCode: recoveryCode,
|
||||
recoveryKeys: recoveryKeys,
|
||||
recoveryProofKey: recoveryProofKey,
|
||||
host: host,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -321,9 +321,11 @@ final class RemoteRegister implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
}) async => _model<PasswordProof>(
|
||||
RegisterOperation.completePasswordSignIn,
|
||||
await _ask(RegisterOperation.completePasswordSignIn, {
|
||||
'host': host,
|
||||
'challenge': challenge.uuid,
|
||||
'clientValue': _bytes(clientValue),
|
||||
'clientProof': _bytes(clientProof),
|
||||
|
|
@ -442,9 +444,11 @@ final class RemoteRegister implements AccountRegister {
|
|||
String? code,
|
||||
UuidValue? keyChallenge,
|
||||
ByteData? signature,
|
||||
required String host,
|
||||
}) async => _model<ResetPending>(
|
||||
RegisterOperation.resetAuthenticator,
|
||||
await _ask(RegisterOperation.resetAuthenticator, {
|
||||
'host': host,
|
||||
'challenge': challenge.uuid,
|
||||
'clientValue': _bytes(clientValue),
|
||||
'clientProof': _bytes(clientProof),
|
||||
|
|
@ -482,9 +486,11 @@ final class RemoteRegister implements AccountRegister {
|
|||
required NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) async => _sessionsEnded(
|
||||
RegisterOperation.resetPasswordWithPhrase,
|
||||
await _ask(RegisterOperation.resetPasswordWithPhrase, {
|
||||
'host': host,
|
||||
'challenge': challenge.uuid,
|
||||
'signature': _bytes(signature),
|
||||
'password': password.toJson(),
|
||||
|
|
@ -569,7 +575,9 @@ final class RemoteRegister implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
required NewPassword password,
|
||||
UuidValue? keepSession,
|
||||
required String host,
|
||||
}) => _ask(RegisterOperation.changePassword, {
|
||||
'host': host,
|
||||
'person': person.uuid,
|
||||
'challenge': challenge.uuid,
|
||||
'clientValue': _bytes(clientValue),
|
||||
|
|
@ -591,7 +599,9 @@ final class RemoteRegister implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
required WrappedKeys recoveryKeys,
|
||||
required ByteData recoveryProofKey,
|
||||
required String host,
|
||||
}) => _ask(RegisterOperation.setRecoveryKeys, {
|
||||
'host': host,
|
||||
'person': person.uuid,
|
||||
'challenge': challenge.uuid,
|
||||
'clientValue': _bytes(clientValue),
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ void main() {
|
|||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: totpCode ?? codeOf(enrolment, at: now),
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -118,6 +119,7 @@ void main() {
|
|||
clientProof: answer.clientProof,
|
||||
totpCode: withoutCode ? null : totpCode ?? codeOf(enrolment, at: now),
|
||||
password: password ?? device.newPassword(),
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -357,6 +359,7 @@ void main() {
|
|||
: totpCode ?? codeOf(enrolment, at: now),
|
||||
recoveryKeys: recoveryKeys ?? fresh,
|
||||
recoveryProofKey: recoveryProofKey ?? freshProofKey,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -374,15 +377,21 @@ void main() {
|
|||
address: 'anna@example.org',
|
||||
);
|
||||
nextStep();
|
||||
final signature = await device.sign(proof, keys: keys);
|
||||
final newPassword = device.newPassword();
|
||||
final signature = await device.sign(
|
||||
proof,
|
||||
keys: keys,
|
||||
password: newPassword,
|
||||
);
|
||||
try {
|
||||
await atNow(
|
||||
() => endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: proof.challenge,
|
||||
signature: signature,
|
||||
password: device.newPassword(),
|
||||
password: newPassword,
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
|
|
@ -467,14 +476,16 @@ void main() {
|
|||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final signature = await device.sign(proof);
|
||||
final newPassword = device.newPassword();
|
||||
final signature = await device.sign(proof, password: newPassword);
|
||||
final signedIn = await atNow(
|
||||
() => endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: proof.challenge,
|
||||
signature: signature,
|
||||
password: device.newPassword(),
|
||||
password: newPassword,
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ void main() {
|
|||
clientProof: answer.clientProof,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -383,6 +384,7 @@ void main() {
|
|||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -521,6 +523,7 @@ void main() {
|
|||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -547,6 +550,7 @@ void main() {
|
|||
clientValue: lateAnswer.clientValue,
|
||||
clientProof: lateAnswer.clientProof,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.challengeExpired),
|
||||
|
|
@ -558,6 +562,7 @@ void main() {
|
|||
challenge: const Uuid().v7obj(),
|
||||
clientValue: lateAnswer.clientValue,
|
||||
clientProof: lateAnswer.clientProof,
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.challengeExpired),
|
||||
|
|
@ -583,6 +588,7 @@ void main() {
|
|||
),
|
||||
clientProof: ByteData(32),
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
|
|
@ -590,6 +596,65 @@ void main() {
|
|||
}
|
||||
});
|
||||
|
||||
test('refuses a proof made for another host, as an instance the '
|
||||
'device was lured into adding would relay it, alike; and tells a '
|
||||
'device naming another host that the instance has another '
|
||||
'name', () async {
|
||||
final complete = await annaComplete();
|
||||
|
||||
// The device proved its password to learn.evil.example, which
|
||||
// passes A and M1 on as they are.
|
||||
await expectLater(
|
||||
atNow(() async {
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final answer = device.answer(
|
||||
challenge,
|
||||
host: 'learn.evil.example',
|
||||
);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
);
|
||||
}),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(() async {
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final answer = device.answer(
|
||||
challenge,
|
||||
host: 'learn.evil.example',
|
||||
);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: 'learn.evil.example',
|
||||
);
|
||||
}),
|
||||
refused(PasswordRefusal.otherInstance),
|
||||
);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('says setup is incomplete only to the right password', () async {
|
||||
await annaSetsPassword();
|
||||
|
||||
|
|
@ -1136,6 +1201,7 @@ void main() {
|
|||
language: 'de',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1276,6 +1342,7 @@ void main() {
|
|||
language: 'en',
|
||||
keyChallenge: keyChallenge.challenge,
|
||||
signature: await device.sign(keyChallenge),
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1336,6 +1403,7 @@ void main() {
|
|||
keyChallenge,
|
||||
keys: await otherSigningKeys(),
|
||||
),
|
||||
host: testHost,
|
||||
);
|
||||
}),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
|
|
@ -1354,6 +1422,7 @@ void main() {
|
|||
clientProof: answer.clientProof,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
host: testHost,
|
||||
);
|
||||
}),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
|
|
@ -1386,6 +1455,7 @@ void main() {
|
|||
language: 'en',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refusedAsEmail(EmailSignInRefusal.mailFailed),
|
||||
|
|
@ -1406,6 +1476,7 @@ void main() {
|
|||
notSignedIn(),
|
||||
address: address,
|
||||
);
|
||||
final newPassword = password ?? device.newPassword();
|
||||
return await endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
|
|
@ -1416,9 +1487,11 @@ void main() {
|
|||
: accountsKey
|
||||
? device.signingKeys
|
||||
: null,
|
||||
password: newPassword,
|
||||
),
|
||||
password: password ?? device.newPassword(),
|
||||
password: newPassword,
|
||||
totpCode: totpCode,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1551,14 +1624,16 @@ void main() {
|
|||
address: 'anna@example.org',
|
||||
),
|
||||
);
|
||||
final signature = await device.sign(challenge);
|
||||
final newPassword = device.newPassword();
|
||||
final signature = await device.sign(challenge, password: newPassword);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
signature: signature,
|
||||
password: device.newPassword(),
|
||||
password: newPassword,
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
|
|
@ -1569,8 +1644,9 @@ void main() {
|
|||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
signature: signature,
|
||||
password: device.newPassword(),
|
||||
password: newPassword,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.challengeExpired),
|
||||
|
|
@ -1588,8 +1664,9 @@ void main() {
|
|||
notSignedIn(),
|
||||
challenge: passwordChallenge.challenge,
|
||||
signature: signature,
|
||||
password: device.newPassword(),
|
||||
password: newPassword,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.challengeExpired),
|
||||
|
|
@ -1640,6 +1717,60 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('takes no proof made for another host, for an authenticator '
|
||||
'reset, or for another password than the one sent: whoever '
|
||||
'stands between swaps in nothing', () async {
|
||||
final complete = await annaComplete();
|
||||
final kept = (await AccountPassword.db.findFirstRow(session))!;
|
||||
Future<AuthSuccess> reset(
|
||||
Future<ByteData> Function(KeyProofChallenge, NewPassword) sign,
|
||||
) => atNow(() async {
|
||||
final challenge = await endpoints.accountRecovery.beginKeyProof(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final newPassword = device.newPassword();
|
||||
return await endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
signature: await sign(challenge, newPassword),
|
||||
password: newPassword,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
await expectLater(
|
||||
reset(
|
||||
(challenge, password) => device.sign(
|
||||
challenge,
|
||||
password: password,
|
||||
host: 'learn.evil.example',
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
reset((challenge, password) => device.sign(challenge)),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
reset(
|
||||
(challenge, password) => device.sign(
|
||||
challenge,
|
||||
password: password.copyWith(
|
||||
verifier: device.newPassword().verifier,
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(
|
||||
(await AccountPassword.db.findFirstRow(session))!.toJson(),
|
||||
kept.toJson(),
|
||||
);
|
||||
});
|
||||
|
||||
test('counts at most 5 proofs begun for an address an hour', () async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await atNow(
|
||||
|
|
@ -1768,6 +1899,7 @@ void main() {
|
|||
language: 'en',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
// An hour on, whoever reads her mail asks for the account's reset.
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ void main() {
|
|||
totpCode: totpCode,
|
||||
request: mailed?.request,
|
||||
code: wrongMailedCode ?? mailed?.code,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ final class _Register implements AccountRegister {
|
|||
/// Thrown by every call while set: the register refusing, or down.
|
||||
Exception? failing;
|
||||
|
||||
/// The hosts sign-ins were bound to, as members named them.
|
||||
final hosts = <String>[];
|
||||
|
||||
Future<T> _answer<T>(String call, T Function() answer) async {
|
||||
asked.add(call);
|
||||
if (failing case final error?) {
|
||||
|
|
@ -236,7 +239,11 @@ final class _Register implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
}) => _answer('completePasswordSignIn', _proven);
|
||||
required String host,
|
||||
}) {
|
||||
hosts.add(host);
|
||||
return _answer('completePasswordSignIn', _proven);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UuidValue> signUpWithCode(
|
||||
|
|
@ -310,6 +317,7 @@ final class _Register implements AccountRegister {
|
|||
String? code,
|
||||
UuidValue? keyChallenge,
|
||||
ByteData? signature,
|
||||
required String host,
|
||||
}) => _answer(
|
||||
'resetAuthenticator',
|
||||
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
|
||||
|
|
@ -342,6 +350,7 @@ final class _Register implements AccountRegister {
|
|||
required NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) => _answer(
|
||||
'resetPasswordWithPhrase',
|
||||
() => (account: _proven().account, endedBefore: endedBefore),
|
||||
|
|
@ -399,6 +408,7 @@ final class _Register implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
required NewPassword password,
|
||||
UuidValue? keepSession,
|
||||
required String host,
|
||||
}) => _answer('changePassword', () {
|
||||
changed[person] = keepSession;
|
||||
});
|
||||
|
|
@ -414,6 +424,7 @@ final class _Register implements AccountRegister {
|
|||
String? recoveryCode,
|
||||
required WrappedKeys recoveryKeys,
|
||||
required ByteData recoveryProofKey,
|
||||
required String host,
|
||||
}) => _answer('setRecoveryKeys', () {});
|
||||
|
||||
@override
|
||||
|
|
@ -633,6 +644,7 @@ void main() {
|
|||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
totpCode: '123456',
|
||||
host: testHost,
|
||||
);
|
||||
|
||||
expect(register.asked, [
|
||||
|
|
@ -640,6 +652,8 @@ void main() {
|
|||
'completePasswordSignIn',
|
||||
]);
|
||||
expect(register.networks, hasLength(1));
|
||||
// Bound to the host the device addressed: this member's.
|
||||
expect(register.hosts, [testHost]);
|
||||
expect(signedIn.authUserId, carla);
|
||||
expect(await scopesOf(carla), {identified.name});
|
||||
expect(
|
||||
|
|
@ -683,6 +697,7 @@ void main() {
|
|||
challenge: const Uuid().v7obj(),
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
host: testHost,
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordRefusedException>().having(
|
||||
|
|
@ -1002,6 +1017,7 @@ void main() {
|
|||
cipherText: ByteData(112),
|
||||
),
|
||||
),
|
||||
host: testHost,
|
||||
);
|
||||
|
||||
expect(register.asked, ['beginPasswordChange', 'changePassword']);
|
||||
|
|
@ -1038,6 +1054,7 @@ void main() {
|
|||
),
|
||||
),
|
||||
totpCode: '123456',
|
||||
host: testHost,
|
||||
);
|
||||
|
||||
expect(signedIn.authUserId, ben);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue