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

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),
),
);