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:
parent
1ad5d84110
commit
92d2df4695
6 changed files with 467 additions and 77 deletions
|
|
@ -16,7 +16,10 @@ import 'package:serverpod/serverpod.dart' as _is;
|
|||
/// What a signed-in owner proves to change how their account is secured
|
||||
/// (ADR 0017; the third reading, as Proton has it): the password's SRP
|
||||
/// proof for a challenge `password.beginChange` gave, bound to the base URL
|
||||
/// the device addressed, and a TOTP code from one of the account's confirmed
|
||||
/// the device addressed and to what it approves (SRP's identity carries a
|
||||
/// purpose and the digest of what is set, which the register works out from
|
||||
/// the call that brings the proof, so a proof made for one change counts for
|
||||
/// no other), and a TOTP code from one of the account's confirmed
|
||||
/// authenticators or one of its recovery codes. Adding an authenticator once
|
||||
/// one is confirmed, removing a confirmed one, new recovery codes, a new
|
||||
/// recovery phrase and a new password take it: a session alone never gets a
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ Future<ResetPending> resetAuthenticator(
|
|||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
instanceUrl: instanceUrl,
|
||||
proofPurpose: PasswordProofPurpose.authenticatorReset,
|
||||
);
|
||||
final to = normalizedAddress(address);
|
||||
if (to == null || addressHash(pod, to) != proved.addressHash) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:clock/clock.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/owner_proof_tries.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_change.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_config.dart';
|
||||
|
|
@ -166,11 +167,25 @@ Future<List<String>> confirmAuthenticator(
|
|||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
if (await hasConfirmedAuthenticator(session, person)) {
|
||||
await proveOwner(session, keys, person: person, proof: proof);
|
||||
await proveOwner(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
proof: proof,
|
||||
purpose: PasswordProofPurpose.addAuthenticator,
|
||||
digest: authenticatorDigest(authenticator),
|
||||
);
|
||||
} else {
|
||||
// The first: the password alone (the fourth reading), as no confirmed
|
||||
// authenticator gives a code yet.
|
||||
await proveOwnersPassword(session, keys, person: person, proof: proof);
|
||||
await proveOwnersPassword(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
proof: proof,
|
||||
purpose: PasswordProofPurpose.firstAuthenticator,
|
||||
digest: authenticatorDigest(authenticator),
|
||||
);
|
||||
}
|
||||
return await session.db.transaction((transaction) async {
|
||||
// UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2
|
||||
|
|
@ -277,7 +292,14 @@ Future<void> removeAuthenticator(
|
|||
if (proof == null) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
await proveOwner(session, keys, person: person, proof: proof);
|
||||
await proveOwner(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
proof: proof,
|
||||
purpose: PasswordProofPurpose.removeAuthenticator,
|
||||
digest: authenticatorDigest(authenticator),
|
||||
);
|
||||
// Serializable: two removals at once must not take both of the last two.
|
||||
await session.db.transaction(
|
||||
(transaction) async {
|
||||
|
|
@ -317,7 +339,13 @@ Future<List<String>> newRecoveryCodes(
|
|||
required OwnerProof proof,
|
||||
}) async {
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
await proveOwner(session, keys, person: person, proof: proof);
|
||||
await proveOwner(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
proof: proof,
|
||||
purpose: PasswordProofPurpose.newRecoveryCodes,
|
||||
);
|
||||
return await session.db.transaction(
|
||||
(transaction) =>
|
||||
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
### What a signed-in owner proves to change how their account is secured
|
||||
### (ADR 0017; the third reading, as Proton has it): the password's SRP
|
||||
### proof for a challenge `password.beginChange` gave, bound to the base URL
|
||||
### the device addressed, and a TOTP code from one of the account's confirmed
|
||||
### the device addressed and to what it approves (SRP's identity carries a
|
||||
### purpose and the digest of what is set, which the register works out from
|
||||
### the call that brings the proof, so a proof made for one change counts for
|
||||
### no other), and a TOTP code from one of the account's confirmed
|
||||
### authenticators or one of its recovery codes. Adding an authenticator once
|
||||
### one is confirmed, removing a confirmed one, new recovery codes, a new
|
||||
### recovery phrase and a new password take it: a session alone never gets a
|
||||
|
|
|
|||
|
|
@ -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/password_config.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart';
|
||||
import 'package:halkyon_base_server/src/password/second_factor_waits.dart';
|
||||
|
|
@ -42,7 +43,14 @@ Future<void> changePassword(
|
|||
if (!isNewPassword(password)) {
|
||||
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||
}
|
||||
final row = await proveOwner(session, keys, person: person, proof: proof);
|
||||
final row = await proveOwner(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
proof: proof,
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: newPasswordDigest(password),
|
||||
);
|
||||
await session.db.transaction((transaction) async {
|
||||
await writeNewPassword(session, row, password, transaction: transaction);
|
||||
await endSessionsEverywhere(
|
||||
|
|
@ -68,7 +76,13 @@ Future<void> callOffResets(
|
|||
}) async {
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
// The owner's code, accepted, calls every reset off (`ownersCode`).
|
||||
await proveOwner(session, keys, person: person, proof: proof);
|
||||
await proveOwner(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
proof: proof,
|
||||
purpose: PasswordProofPurpose.callOffResets,
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaces the wrapping of [person]'s keys under their recovery phrase
|
||||
|
|
@ -91,7 +105,14 @@ Future<void> setRecoveryKeys(
|
|||
if (!isWrappedKeys(recoveryKeys) || !isPublicKey(recoveryProofKey)) {
|
||||
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||
}
|
||||
final row = await proveOwner(session, keys, person: person, proof: proof);
|
||||
final row = await proveOwner(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
proof: proof,
|
||||
purpose: PasswordProofPurpose.newPhrase,
|
||||
digest: newPhraseDigest(recoveryKeys, recoveryProofKey),
|
||||
);
|
||||
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
|
||||
// "recoveryKeysCipherText" = $2, "recoveryProofKey" = $3,
|
||||
// "updatedAt" = $4 WHERE "id" = $5
|
||||
|
|
@ -158,6 +179,7 @@ Future<void> requireOwnerToDelete(
|
|||
clientValue: proof.clientValue,
|
||||
clientProof: proof.clientProof,
|
||||
instanceUrl: proof.instanceUrl,
|
||||
purpose: PasswordProofPurpose.deleteAccount,
|
||||
);
|
||||
final totp = proof.totpCode?.trim() ?? '';
|
||||
final recovery = proof.recoveryCode?.trim() ?? '';
|
||||
|
|
@ -190,7 +212,8 @@ Future<void> requireOwnerToDelete(
|
|||
}
|
||||
|
||||
/// [person]'s password row, once [proof] (`OwnerProof`) proved their
|
||||
/// password alone, for a change's challenge of theirs, without a code: what
|
||||
/// password alone, for a change's challenge of theirs, made for [purpose]
|
||||
/// with the [digest] of what it sets, without a code: what
|
||||
/// confirming an account's first authenticator takes (the fourth reading:
|
||||
/// a session alone claims none; there is no confirmed authenticator yet to
|
||||
/// take a code from). Refused ([PasswordRefusedException]) for a proof
|
||||
|
|
@ -201,6 +224,8 @@ Future<AccountPassword> proveOwnersPassword(
|
|||
PasswordKeys keys, {
|
||||
required UuidValue person,
|
||||
required OwnerProof proof,
|
||||
required PasswordProofPurpose purpose,
|
||||
List<int> digest = const [],
|
||||
}) async {
|
||||
if ((proof.totpCode?.trim() ?? '').isNotEmpty ||
|
||||
(proof.recoveryCode?.trim() ?? '').isNotEmpty) {
|
||||
|
|
@ -214,13 +239,19 @@ Future<AccountPassword> proveOwnersPassword(
|
|||
clientValue: proof.clientValue,
|
||||
clientProof: proof.clientProof,
|
||||
instanceUrl: proof.instanceUrl,
|
||||
purpose: purpose,
|
||||
digest: digest,
|
||||
);
|
||||
return proved.password;
|
||||
}
|
||||
|
||||
/// [person]'s password row, once they proved owning the account with
|
||||
/// [proof] (`OwnerProof`): the password's proof for a change's challenge,
|
||||
/// [person]'s own, then a code ([ownersCode], which also calls off a reset
|
||||
/// [person]'s own, made for [purpose] with the [digest] of what it sets
|
||||
/// (the full reading's device medium 1: whoever stands between the device
|
||||
/// and the register can neither swap in a password, a phrase or an
|
||||
/// authenticator of their own, nor take a proof made for one change to
|
||||
/// make another), then a code ([ownersCode], which also calls off a reset
|
||||
/// waiting). Never counted toward the account's waits, nor refused while
|
||||
/// they run: the signed-in session's wrong proofs count against it where
|
||||
/// it is (`asOwnerProofTry`).
|
||||
|
|
@ -236,6 +267,8 @@ Future<AccountPassword> proveOwner(
|
|||
PasswordKeys keys, {
|
||||
required UuidValue person,
|
||||
required OwnerProof proof,
|
||||
required PasswordProofPurpose purpose,
|
||||
List<int> digest = const [],
|
||||
}) async {
|
||||
final proved = await _provePassword(
|
||||
session,
|
||||
|
|
@ -245,6 +278,8 @@ Future<AccountPassword> proveOwner(
|
|||
clientValue: proof.clientValue,
|
||||
clientProof: proof.clientProof,
|
||||
instanceUrl: proof.instanceUrl,
|
||||
purpose: purpose,
|
||||
digest: digest,
|
||||
);
|
||||
await ownersCode(
|
||||
session,
|
||||
|
|
@ -256,8 +291,8 @@ Future<AccountPassword> proveOwner(
|
|||
return proved.password;
|
||||
}
|
||||
|
||||
/// The password's proof for [challenge], [person]'s own and a change's;
|
||||
/// refused ([PasswordRefusal.wrongCredentials]) for a wrong password or a
|
||||
/// The password's proof for [challenge], [person]'s own and a change's,
|
||||
/// made for [purpose] with the [digest] of what it sets; refused ([PasswordRefusal.wrongCredentials]) for a wrong password or a
|
||||
/// challenge that isn't [person]'s.
|
||||
Future<PasswordProved> _provePassword(
|
||||
Session session,
|
||||
|
|
@ -267,6 +302,8 @@ Future<PasswordProved> _provePassword(
|
|||
required ByteData clientValue,
|
||||
required ByteData clientProof,
|
||||
required String instanceUrl,
|
||||
required PasswordProofPurpose purpose,
|
||||
List<int> digest = const [],
|
||||
}) async {
|
||||
final proved = await provePassword(
|
||||
session,
|
||||
|
|
@ -276,6 +313,8 @@ Future<PasswordProved> _provePassword(
|
|||
clientProof: clientProof,
|
||||
instanceUrl: instanceUrl,
|
||||
purpose: SignInChallengePurpose.change,
|
||||
proofPurpose: purpose,
|
||||
digest: digest,
|
||||
);
|
||||
if (proved.password.authUserId != person) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
|
|
|
|||
|
|
@ -890,9 +890,12 @@ typedef PasswordProved = ({
|
|||
/// [completePasswordSignIn] and the reset of a lost authenticator do; SRP's
|
||||
/// identity is [passwordIdentity] of the challenge's ID (so nobody learns an
|
||||
/// account's ID from its address) and of the [instanceUrl] the device
|
||||
/// addressed (`deviceInstanceUrl`, or the one a member names), so a proof a hostile
|
||||
/// instance relays here never counts. Refusals as
|
||||
/// [completePasswordSignIn]'s, but for the second factor.
|
||||
/// addressed (`deviceInstanceUrl`, or the one a member names), so a proof
|
||||
/// a hostile instance relays here never counts; and of what the proof is
|
||||
/// for ([proofPurpose]) with the [digest] of what it sets, which the caller
|
||||
/// works out from its own call, so a proof made for one purpose or one
|
||||
/// change never counts for another. Refusals as [completePasswordSignIn]'s,
|
||||
/// but for the second factor.
|
||||
Future<PasswordProved> provePassword(
|
||||
Session session,
|
||||
PasswordKeys keys, {
|
||||
|
|
@ -901,6 +904,8 @@ Future<PasswordProved> provePassword(
|
|||
required ByteData clientProof,
|
||||
required String instanceUrl,
|
||||
SignInChallengePurpose purpose = SignInChallengePurpose.password,
|
||||
PasswordProofPurpose proofPurpose = PasswordProofPurpose.signIn,
|
||||
List<int> digest = const [],
|
||||
}) async {
|
||||
final kept = await takeChallenge(session, challenge, purpose);
|
||||
// Counted before it is checked, whatever its outcome (a change's per
|
||||
|
|
@ -913,7 +918,12 @@ Future<PasswordProved> provePassword(
|
|||
final clientBytes = Uint8List.sublistView(clientValue);
|
||||
final verification = srpVerify(
|
||||
passwordGroup,
|
||||
identity: passwordIdentity(challenge, instanceUrl),
|
||||
identity: passwordIdentity(
|
||||
challenge,
|
||||
instanceUrl,
|
||||
purpose: proofPurpose,
|
||||
digest: digest,
|
||||
),
|
||||
salt: password == null ? decoy.salt : Uint8List.sublistView(password.salt),
|
||||
verifier: password == null
|
||||
? decoy.verifier
|
||||
|
|
|
|||
Loading…
Reference in a new issue