fix(auth)!: the owner's proof is bound to what it approves

A signed-in change's proof covered only its challenge and the instance,
so whoever could read and change the traffic (an http:// instance, a
TLS-intercepting proxy, TLS broken: what SRP is for) could keep the owner's
proof and code and swap in a new password, a recovery phrase or an
authenticator of their own, or turn a proof made for new recovery codes
into a deletion (the full reading's device medium 1).

SRP's identity now also carries what the proof is for and the digest of
what it sets, which the register works out from the call that brings it,
never from the device: a new password (its digest), a new phrase (its
wrapping and proof key), confirming the first or another authenticator and
removing one (its ID), new recovery codes, calling resets off, deleting;
the sessionless reset of an authenticator its own, apart from signing in.
A proof made for one of them counts for no other. Tests made for each
refusal fail with the binding taken out on both sides.

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 14:12:11 +02:00
commit 92d2df4695
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 467 additions and 77 deletions

View file

@ -123,30 +123,40 @@ void main() {
);
});
/// Changes Anna's password from her phone (or [from]), the old one
/// proved by [privateValue] (hers by default) with [totpCode] (her
/// authenticator's now by default); sent as [as] where given.
/// Changes Anna's password from her phone (or [from]) to [password]
/// (a new one by default), the old one proved by [privateValue] (hers
/// by default) with [totpCode] (her authenticator's now by default),
/// the proof made for [purpose] and for [provedFor] (the password
/// sent, by default); sent as [as] where given.
Future<void> change({
BigInt? privateValue,
String? totpCode,
bool withoutCode = false,
NewPassword? password,
NewPassword? provedFor,
PasswordProofPurpose purpose = PasswordProofPurpose.newPassword,
TestSessionBuilder? from,
TestSessionBuilder? as,
}) => atNow(() async {
final challenge = await endpoints.password.beginChange(
from ?? onPhone(),
);
// The old password proves it: the device takes the new one's x as
// it makes it.
final old = privateValue ?? device.privateValue;
final sent = password ?? device.newPassword();
await endpoints.password.change(
as ?? from ?? onPhone(),
proof: device.ownerProof(
challenge,
privateValue: privateValue,
purpose: purpose,
digest: newPasswordDigest(provedFor ?? sent),
privateValue: old,
totpCode: withoutCode
? null
: totpCode ?? codeOf(enrolment, at: now),
),
password: password ?? device.newPassword(),
password: sent,
);
});
@ -196,6 +206,8 @@ void main() {
code: codeOf(enrolment, at: now),
proof: device.ownerProof(
await endpoints.password.beginChange(onPhone()),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
),
);
@ -299,6 +311,20 @@ void main() {
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
// Her password and a right code, made for another new password
// than the one sent, or for another change: whoever stands between
// swaps in a password of their own nowhere.
final another = (await PasswordDevice.make(seed: 51)).newPassword();
await expectLater(
change(provedFor: another),
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
await expectLater(
change(purpose: PasswordProofPurpose.newRecoveryCodes),
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
await expectLater(
change(
password: device.newPassword().copyWith(salt: ByteData(3)),
@ -373,6 +399,7 @@ void main() {
onPhone(),
proof: device.ownerProof(
signInChallenge,
purpose: PasswordProofPurpose.newPassword,
totpCode: codeOf(enrolment, at: now),
),
password: device.newPassword(),
@ -649,7 +676,9 @@ void main() {
/// Gives Anna's keys a new phrase's wrapping ([fresh]) from her
/// phone, the old password proved by [privateValue] (hers by
/// default) or a made-up proof ([withoutProof]), with [totpCode] (her
/// authenticator's now by default).
/// authenticator's now by default), the proof made for [purpose]
/// and for [provedFor] (the wrapping and proof key sent, by
/// default).
Future<void> newPhrase({
BigInt? privateValue,
bool withoutProof = false,
@ -657,11 +686,20 @@ void main() {
bool withoutCode = false,
WrappedKeys? recoveryKeys,
ByteData? recoveryProofKey,
({WrappedKeys keys, ByteData proofKey})? provedFor,
PasswordProofPurpose purpose = PasswordProofPurpose.newPhrase,
TestSessionBuilder? as,
}) => atNow(() async {
final challenge = await endpoints.password.beginChange(onPhone());
final keys = recoveryKeys ?? fresh;
final proofKey = recoveryProofKey ?? freshProofKey;
final proof = device.ownerProof(
challenge,
purpose: purpose,
digest: newPhraseDigest(
provedFor?.keys ?? keys,
provedFor?.proofKey ?? proofKey,
),
privateValue: privateValue,
totpCode: withoutCode
? null
@ -672,8 +710,8 @@ void main() {
proof: withoutProof
? proof.copyWith(clientProof: ByteData(32))
: proof,
recoveryKeys: recoveryKeys ?? fresh,
recoveryProofKey: recoveryProofKey ?? freshProofKey,
recoveryKeys: keys,
recoveryProofKey: proofKey,
);
});
@ -783,6 +821,29 @@ void main() {
newPhrase(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
// Her password and a right code, made for another wrapping or
// proof key than the ones sent, or for another change: whoever
// stands between swaps in a phrase of their own nowhere.
await expectLater(
newPhrase(
provedFor: (
keys: WrappedKeys(
nonce: ByteData.sublistView(Uint8List(24)..[0] = 1),
cipherText: ByteData(wrappedKeysLength),
),
proofKey: freshProofKey,
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
newPhrase(provedFor: (keys: fresh, proofKey: ByteData(32))),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
newPhrase(purpose: PasswordProofPurpose.newPassword),
refused(PasswordRefusal.wrongCredentials),
);
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
@ -822,13 +883,14 @@ void main() {
/// Deletes Anna's account from her phone, with the password's proof
/// (hers by default, by [privateValue] otherwise, none [withoutProof])
/// and [totpCode] (her authenticator's now by default), bound to
/// [instanceUrl].
/// [instanceUrl] and made for [purpose].
Future<void> delete({
BigInt? privateValue,
String? totpCode,
bool withoutCode = false,
bool withoutProof = false,
String instanceUrl = testInstanceUrl,
PasswordProofPurpose purpose = PasswordProofPurpose.deleteAccount,
}) => atNow(() async {
if (withoutProof) {
return await endpoints.account.delete(onPhone());
@ -838,6 +900,7 @@ void main() {
challenge,
privateValue: privateValue,
instanceUrl: instanceUrl,
purpose: purpose,
);
await endpoints.account.delete(
onPhone(),
@ -877,6 +940,19 @@ void main() {
delete(instanceUrl: 'https://learn.evil.example/'),
refused(PasswordRefusal.wrongCredentials),
);
// Her password and a right code, made for new recovery codes
// (or any other change): nobody between turns it into a
// deletion.
for (final purpose in [
PasswordProofPurpose.newRecoveryCodes,
PasswordProofPurpose.newPassword,
]) {
await expectLater(
delete(purpose: purpose),
refused(PasswordRefusal.wrongCredentials),
reason: purpose.name,
);
}
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(await AccountPassword.db.count(session), 1);
@ -918,7 +994,10 @@ void main() {
? null
: await mailedCode('ben@example.org', purpose: mailedFor);
final challenge = await endpoints.password.beginChange(asBen());
final answer = bens.answer(challenge);
final answer = bens.answer(
challenge,
purpose: PasswordProofPurpose.deleteAccount,
);
await endpoints.account.delete(
asBen(),
proof: DeletionProof(
@ -963,7 +1042,10 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.authenticatorReset,
@ -996,7 +1078,10 @@ void main() {
final challenge = await endpoints.password.beginChange(
onPhone(),
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.deleteAccount,
);
await endpoints.account.delete(
onPhone(),
proof: DeletionProof(

View file

@ -76,15 +76,20 @@ void main() {
);
/// Anna's proof from her phone (`OwnerProof`): her password (or
/// [privateValue]'s) for a change's challenge, and [totpCode] or
/// [privateValue]'s) for a change's challenge, made for [purpose]
/// with the [digest] of what it sets, and [totpCode] or
/// [recoveryCode].
Future<OwnerProof> ownerProof({
required PasswordProofPurpose purpose,
List<int> digest = const [],
String? totpCode,
String? recoveryCode,
BigInt? privateValue,
}) => atNow(
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: purpose,
digest: digest,
totpCode: totpCode,
recoveryCode: recoveryCode,
privateValue: privateValue,
@ -92,14 +97,17 @@ void main() {
);
/// Anna's password alone, from [from] (her phone unless given), for a
/// change's challenge: what her first authenticator takes (the fourth
/// reading).
Future<OwnerProof> passwordAlone({
/// change's challenge, made for confirming [authenticator] as her
/// first: what her first authenticator takes (the fourth reading).
Future<OwnerProof> passwordAlone(
UuidValue authenticator, {
TestSessionBuilder? from,
PasswordDevice? with_,
}) => atNow(
() async => (with_ ?? device).ownerProof(
await endpoints.password.beginChange(from ?? asAnna()),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(authenticator),
),
);
@ -162,7 +170,7 @@ void main() {
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: await passwordAlone(),
proof: await passwordAlone(enrolment.authenticator),
);
nextStep();
return (enrolment: enrolment, recoveryCodes: codes);
@ -580,7 +588,10 @@ void main() {
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: await passwordAlone(with_: other),
proof: await passwordAlone(
enrolment.authenticator,
with_: other,
),
);
});
@ -1230,7 +1241,10 @@ void main() {
'holds nobody out of their codes', () async {
final complete = await annaComplete();
for (var i = 0; i < 10; i++) {
final proof = await ownerProof(totpCode: '000000');
final proof = await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: '000000',
);
await expectLater(
atNow(
() => endpoints.authenticator.newRecoveryCodes(
@ -1296,7 +1310,7 @@ void main() {
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: await passwordAlone(),
proof: await passwordAlone(enrolment.authenticator),
);
});
@ -1397,6 +1411,8 @@ void main() {
proof: await atNow(
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
privateValue: BigInt.from(12345),
),
),
@ -1409,6 +1425,8 @@ void main() {
proof: await atNow(
() async => bensDevice.ownerProof(
await endpoints.password.beginChange(asBen),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
),
),
@ -1420,6 +1438,8 @@ void main() {
proof: await atNow(
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
totpCode: codeOf(enrolment, at: now),
),
),
@ -1442,7 +1462,9 @@ void main() {
2,
);
final codes = await confirm(proof: await passwordAlone());
final codes = await confirm(
proof: await passwordAlone(enrolment.authenticator),
);
expect(codes, hasLength(recoveryCodeCount));
expect(
await SessionCodeFailures.db.count(
@ -1466,7 +1488,10 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.authenticatorReset,
@ -1572,6 +1597,8 @@ void main() {
refused(PasswordRefusal.wrongCode),
);
final proof = await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(second.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
);
final codes = await atNow(
@ -1615,30 +1642,67 @@ void main() {
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
confirm(proof: await ownerProof(totpCode: '000000')),
confirm(
proof: await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(another.authenticator),
totpCode: '000000',
),
),
refused(PasswordRefusal.wrongCredentials),
);
// Its own code is no code from one confirmed before.
await expectLater(
confirm(
proof: await ownerProof(totpCode: codeOf(another, at: now)),
proof: await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(another.authenticator),
totpCode: codeOf(another, at: now),
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
confirm(
proof: await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(another.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
privateValue: BigInt.from(4242),
),
),
refused(PasswordRefusal.wrongCredentials),
);
// Her password and a right code, made for adding another
// authenticator, or for removing this one: whoever stands between
// adds one of their own nowhere.
await expectLater(
confirm(
proof: await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(complete.enrolment.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
confirm(
proof: await ownerProof(
purpose: PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(another.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
),
),
refused(PasswordRefusal.wrongCredentials),
);
// A wrong code from the new one takes nothing of the proof's.
await expectLater(
confirm(
code: '000000',
proof: await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(another.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
),
),
@ -1654,6 +1718,8 @@ void main() {
expect(
await confirm(
proof: await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(another.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
),
),
@ -1715,7 +1781,10 @@ void main() {
authenticator: bens.authenticator,
code: codeOf(bens, at: now),
// Anna's password, for a challenge of hers.
proof: await ownerProof(),
proof: await ownerProof(
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(bens.authenticator),
),
),
),
refused(PasswordRefusal.wrongCredentials),
@ -1771,6 +1840,8 @@ void main() {
asAnna(),
authenticator: complete.enrolment.authenticator,
proof: await ownerProof(
purpose: PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(complete.enrolment.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
),
),
@ -1784,6 +1855,8 @@ void main() {
label: '',
);
final proof = await ownerProof(
purpose: PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(second.authenticator),
totpCode: codeOf(complete.enrolment, at: now),
);
await atNow(
@ -1807,7 +1880,11 @@ void main() {
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
proof: await ownerProof(totpCode: '123456'),
proof: await ownerProof(
purpose: PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(complete.enrolment.authenticator),
totpCode: '123456',
),
),
),
refused(PasswordRefusal.wrongCredentials),
@ -1818,6 +1895,8 @@ void main() {
asAnna(),
authenticator: complete.enrolment.authenticator,
proof: await ownerProof(
purpose: PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(complete.enrolment.authenticator),
totpCode: codeOf(second, at: now),
privateValue: BigInt.from(4242),
),
@ -1829,7 +1908,11 @@ void main() {
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
proof: await ownerProof(totpCode: codeOf(second, at: now)),
proof: await ownerProof(
purpose: PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(complete.enrolment.authenticator),
totpCode: codeOf(second, at: now),
),
),
);
@ -1846,7 +1929,10 @@ void main() {
atNow(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: await ownerProof(totpCode: '000000'),
proof: await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: '000000',
),
),
),
refused(PasswordRefusal.wrongCredentials),
@ -1856,6 +1942,7 @@ void main() {
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: codeOf(complete.enrolment, at: now),
privateValue: BigInt.from(4242),
),
@ -1868,6 +1955,7 @@ void main() {
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: codeOf(complete.enrolment, at: now),
),
),
@ -1890,7 +1978,10 @@ void main() {
Future<List<String>> newCodes(String code) => atNow(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: await ownerProof(totpCode: code),
proof: await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: code,
),
),
);
for (var i = 0; i < 9; i++) {
@ -1937,7 +2028,10 @@ void main() {
// the session's 30 changes begun an hour.
final proofs = [
for (var i = 0; i < 29; i++)
await ownerProof(totpCode: '${100000 + i}'),
await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: '${100000 + i}',
),
];
final reasons = await Future.wait([
for (final proof in proofs)
@ -1974,7 +2068,10 @@ void main() {
atNow(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: await ownerProof(totpCode: '000000'),
proof: await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: '000000',
),
),
),
refused(PasswordRefusal.wrongCredentials),
@ -1986,7 +2083,11 @@ void main() {
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
proof: await ownerProof(totpCode: '000000'),
proof: await ownerProof(
purpose: PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(complete.enrolment.authenticator),
totpCode: '000000',
),
),
),
refused(PasswordRefusal.lastAuthenticator),
@ -2006,7 +2107,10 @@ void main() {
await annaComplete();
for (var round = 0; round < 2; round++) {
for (var i = 0; i < (round == 0 ? 9 : 1); i++) {
final proof = await ownerProof(totpCode: '000000');
final proof = await ownerProof(
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: '000000',
);
await expectLater(
atNow(
() => endpoints.authenticator.newRecoveryCodes(
@ -2034,7 +2138,11 @@ void main() {
endpoints.authenticator.remove(
asAnna(),
authenticator: own.authenticator,
proof: await ownerProof(totpCode: '000000'),
proof: await ownerProof(
purpose: PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(own.authenticator),
totpCode: '000000',
),
),
refused(PasswordRefusal.invalidSetup),
);
@ -2135,11 +2243,14 @@ void main() {
});
group('a lost authenticator', () {
/// Asks for the reset with the password and a code by mail.
/// Asks for the reset with the password (its proof made for
/// [purpose]) and a code by mail.
Future<ResetPending> resetByMail({
String address = 'anna@example.org',
String codeFor = 'anna@example.org',
BigInt? privateValue,
PasswordProofPurpose purpose =
PasswordProofPurpose.authenticatorReset,
}) => atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
@ -2148,6 +2259,7 @@ void main() {
final answer = device.answer(
challenge,
privateValue: privateValue,
purpose: purpose,
);
final code = await mailedCode(
codeFor,
@ -2166,6 +2278,40 @@ void main() {
);
});
test('is asked for only with a proof made for it: one made for '
'signing in, which a hostile instance could have kept, asks '
'nothing; nor does one made for it sign in', () async {
final complete = await annaComplete();
await expectLater(
resetByMail(purpose: PasswordProofPurpose.signIn),
refused(PasswordRefusal.wrongCredentials),
);
expect(await AccountReset.db.count(session), 0);
await expectLater(
atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
return await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(complete.enrolment, at: now),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
);
}),
refused(PasswordRefusal.wrongCredentials),
);
});
test('carried out, forgets the codes refused before: the new '
"authenticator's codes wait for nothing of the old", () async {
await annaComplete();
@ -2245,7 +2391,10 @@ void main() {
asAnnaAgain,
authenticator: fresh.authenticator,
code: codeOf(fresh, at: now),
proof: await passwordAlone(from: asAnnaAgain),
proof: await passwordAlone(
fresh.authenticator,
from: asAnnaAgain,
),
),
);
expect(codes, hasLength(recoveryCodeCount));
@ -2323,7 +2472,10 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
final keyChallenge = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
@ -2383,7 +2535,10 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
final keyChallenge = await endpoints.accountRecovery
.beginKeyProof(notSignedIn(), address: 'anna@example.org');
return await endpoints.accountRecovery.resetAuthenticator(
@ -2409,7 +2564,10 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
return await endpoints.accountRecovery.resetAuthenticator(
notSignedIn(),
challenge: challenge.challenge,
@ -2435,7 +2593,10 @@ void main() {
address: 'anna@example.org',
),
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
final code = await atNow(
() => mailedCode(
'anna@example.org',
@ -3096,7 +3257,10 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.authenticatorReset,

View file

@ -85,10 +85,13 @@ void main() {
return (request: request, code: mailbox.codeFor(address));
});
/// Anna's password alone, for a change's challenge.
Future<OwnerProof> passwordAlone() => atNow(
/// Anna's password alone, for a change's challenge, made for
/// confirming [authenticator] as her first.
Future<OwnerProof> passwordAlone(UuidValue authenticator) => atNow(
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(authenticator),
),
);
@ -110,7 +113,7 @@ void main() {
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: await passwordAlone(),
proof: await passwordAlone(enrolment.authenticator),
);
nextStep();
return enrolment;
@ -152,7 +155,10 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final answer = device.answer(
challenge,
purpose: PasswordProofPurpose.authenticatorReset,
);
return await endpoints.accountRecovery.resetAuthenticator(
notSignedIn(),
challenge: challenge.challenge,
@ -398,6 +404,7 @@ void main() {
asAnna(),
proof: device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.callOffResets,
totpCode: codeOf(enrolment, at: now),
),
),
@ -415,15 +422,24 @@ void main() {
for (final proof in <Future<OwnerProof> Function()>[
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.callOffResets,
),
() async => wrong.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.callOffResets,
totpCode: codeOf(enrolment, at: now),
),
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.callOffResets,
totpCode: '000000',
),
// Her password and a right code, for another change.
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
purpose: PasswordProofPurpose.newRecoveryCodes,
totpCode: codeOf(enrolment, at: now),
),
]) {
await expectLater(
atNow(

View file

@ -163,6 +163,8 @@ void main() {
// The first takes the password too (the fourth reading).
proof: device.ownerProof(
await endpoints.password.beginChange(as(anna)),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
),
);
@ -342,6 +344,8 @@ void main() {
// The first takes the password too (the fourth reading).
proof: device.ownerProof(
await endpoints.password.beginChange(as(anna)),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
),
);

View file

@ -533,6 +533,8 @@ void main() {
code: codeOf(enrolment),
proof: device.ownerProof(
await remote.beginPasswordChange(session, person: annaOnTodo),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
);
expect(codes, hasLength(recoveryCodeCount));
@ -556,8 +558,13 @@ void main() {
refused(PasswordRefusal.wrongCredentials),
);
now = now.add(totpPeriod);
Future<OwnerProof> ownerProof() async => device.ownerProof(
Future<OwnerProof> ownerProof(
PasswordProofPurpose purpose, {
List<int> digest = const [],
}) async => device.ownerProof(
await remote.beginPasswordChange(session, person: annaOnTodo),
purpose: purpose,
digest: digest,
totpCode: codeOf(enrolment),
);
expect(
@ -566,7 +573,10 @@ void main() {
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
proof: await ownerProof(),
proof: await ownerProof(
PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(spare.authenticator),
),
),
isEmpty,
);
@ -575,7 +585,10 @@ void main() {
session,
person: annaOnTodo,
authenticator: spare.authenticator,
proof: await ownerProof(),
proof: await ownerProof(
PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(spare.authenticator),
),
);
final ways = await remote.signInWaysOf(session, annaOnTodo);
expect(ways.password && ways.authenticator, isTrue);
@ -660,7 +673,7 @@ void main() {
await remote.newRecoveryCodes(
session,
person: annaOnTodo,
proof: await ownerProof(),
proof: await ownerProof(PasswordProofPurpose.newRecoveryCodes),
),
hasLength(recoveryCodeCount),
);
@ -701,7 +714,10 @@ void main() {
address: 'anna@example.org',
network: '203.0.113.7',
);
final resetAnswer = device.answer(forReset);
final resetAnswer = device.answer(
forReset,
purpose: PasswordProofPurpose.authenticatorReset,
);
final resetRequest = await remote.sendSignInCode(
session,
address: 'anna@example.org',
@ -808,6 +824,8 @@ void main() {
session,
person: annaOnTodo,
),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
);
now = now.add(totpPeriod);
@ -816,14 +834,21 @@ void main() {
session,
person: annaOnTodo,
);
// The old password proves it: the device takes the new one's
// x as it makes it.
final old = device.privateValue;
final newPassword = device.newPassword();
await remote.changePassword(
session,
person: annaOnTodo,
proof: device.ownerProof(
challenge,
purpose: PasswordProofPurpose.newPassword,
digest: newPasswordDigest(newPassword),
privateValue: old,
totpCode: codeOf(enrolment),
),
password: device.newPassword(),
password: newPassword,
keepSession: annasPhone,
);
await sessionNoticesDelivered();
@ -844,17 +869,18 @@ void main() {
session,
person: annaOnTodo,
);
final proofKey = ByteData.sublistView(device.recoveryProofKey);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
proof: device.ownerProof(
challenge,
purpose: PasswordProofPurpose.newPhrase,
digest: newPhraseDigest(fresh, proofKey),
totpCode: codeOf(enrolment),
),
recoveryKeys: fresh,
recoveryProofKey: ByteData.sublistView(
device.recoveryProofKey,
),
recoveryProofKey: proofKey,
);
}
@ -918,6 +944,7 @@ void main() {
session,
person: annaOnTodo,
),
purpose: PasswordProofPurpose.callOffResets,
totpCode: codeOf(enrolment),
),
);

View file

@ -137,7 +137,7 @@ final class PasswordDevice {
PasswordChallenge challenge, {
BigInt? privateValue,
String instanceUrl = testInstanceUrl,
srp.PasswordProofPurpose purpose = srp.PasswordProofPurpose.signIn,
PasswordProofPurpose purpose = PasswordProofPurpose.signIn,
List<int> digest = const [],
}) {
final client = srp.SrpClient(_clientGroup, random: _random);
@ -145,7 +145,9 @@ final class PasswordDevice {
identity: srp.passwordIdentity(
challenge.challenge.toBytes(),
instanceUrl,
purpose: purpose,
// As the device names it: the register's and the device's purposes
// are one list (key_proof_test).
purpose: srp.PasswordProofPurpose.values.byName(purpose.name),
digest: digest,
),
salt: Uint8List.sublistView(challenge.salt),
@ -163,11 +165,14 @@ final class PasswordDevice {
);
}
/// The owner's proof for [challenge] (a change's): the device's answer
/// with its password, or the [privateValue] of another, and a
/// [totpCode] or [recoveryCode].
/// The owner's proof for [challenge] (a change's), made for [purpose]
/// with the [digest] of what it sets: the device's answer with its
/// password, or the [privateValue] of another, and a [totpCode] or
/// [recoveryCode].
OwnerProof ownerProof(
PasswordChallenge challenge, {
required PasswordProofPurpose purpose,
List<int> digest = const [],
String? totpCode,
String? recoveryCode,
BigInt? privateValue,
@ -177,6 +182,8 @@ final class PasswordDevice {
challenge,
privateValue: privateValue,
instanceUrl: instanceUrl,
purpose: purpose,
digest: digest,
);
return OwnerProof(
challenge: challenge.challenge,