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:
Tizian 「ティツィアーン」 2026-10-20 21:20:12 +02:00
commit dab2c80524
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
36 changed files with 966 additions and 137 deletions

View file

@ -96,11 +96,13 @@ abstract interface class AccountCrypto() {
});
/// The answer to [challenge] (SRP's B and the account's salt) with what
/// [unlock] holds.
/// [unlock] holds, its identity bound to [host], the instance as the
/// device addresses it.
Future<SrpAnswer> answer(
PasswordUnlock unlock,
protocol.PasswordChallenge challenge,
);
protocol.PasswordChallenge challenge, {
required String host,
});
/// The account's keys [wrapped] under the password, opened with
/// [unlock]'s key and checked against its public keys.
@ -127,15 +129,18 @@ abstract interface class AccountCrypto() {
required Uint8List signingPublicKey,
});
/// The signature proving the recovery [phrase] for [challenge]: by the
/// phrase's own proof key, derived from it alone while it is typed and
/// kept nowhere, never the account's signing key, which every signed-in
/// device keeps. Refused (`PasswordProblem.notAPhrase`) for what is no
/// recovery phrase.
/// The signature proving the recovery [phrase] for [challenge] on
/// [host], for the new [password] it sets (or, without one, for an
/// authenticator's reset): by the phrase's own proof key, derived from it
/// alone while it is typed and kept nowhere, never the account's signing
/// key, which every signed-in device keeps. Refused
/// (`PasswordProblem.notAPhrase`) for what is no recovery phrase.
Future<ByteData> signKeyProof(
String phrase,
protocol.KeyProofChallenge challenge,
);
protocol.KeyProofChallenge challenge, {
required String host,
protocol.NewPassword? password,
});
/// A new recovery phrase for [keys]: the keys wrapped under it, and the
/// public half of its proof key.

View file

@ -28,6 +28,12 @@ typedef PasswordWays = ({
/// up before a new sign-in is put in place). Every refusal is a
/// `PasswordFailed`. Tests replace it with a fake.
abstract interface class PasswordGateway() {
/// The host the device addresses the instance by, which SRP's identity
/// and the recovery phrase's proofs are bound to (ADR 0017): a hostile
/// instance relaying them to the real one gets nothing the real one
/// takes.
String get host;
/// Whether the instance offers password sign-in, and until when GitHub
/// sign-in lasts there (null for no end set).
Future<({bool offered, DateTime? gitHubEnds})> offers();

View file

@ -146,7 +146,11 @@ class PasswordRepository(
String? code,
}) async {
final challenge = await _challengeOf(unlocked);
final answer = await _crypto.answer(unlocked._unlock, challenge);
final answer = await _crypto.answer(
unlocked._unlock,
challenge,
host: _gateway.host,
);
final signedIn = await _gateway.complete(
challenge,
answer,
@ -326,7 +330,7 @@ class PasswordRepository(
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
final answer = await _crypto.answer(unlock, challenge);
final answer = await _crypto.answer(unlock, challenge, host: _gateway.host);
await _gateway.change(
challenge,
answer,
@ -362,7 +366,7 @@ class PasswordRepository(
);
await _gateway.setRecoveryKeys(
challenge,
await _crypto.answer(unlock, challenge),
await _crypto.answer(unlock, challenge, host: _gateway.host),
totpCode: code.replaceAll(' ', ''),
keys: phrase._recoveryKeys,
proofKey: phrase._recoveryProofKey,
@ -390,10 +394,18 @@ class PasswordRepository(
// Opened only to say here, rather than on the instance, that the
// phrase doesn't fit.
await _openWithPhrase(keyChallenge, phrase);
signature = await _crypto.signKeyProof(phrase, keyChallenge);
signature = await _crypto.signKeyProof(
phrase,
keyChallenge,
host: _gateway.host,
);
}
final challenge = await _challengeOf(unlocked);
final answer = await _crypto.answer(unlocked._unlock, challenge);
final answer = await _crypto.answer(
unlocked._unlock,
challenge,
host: _gateway.host,
);
return await _gateway.resetAuthenticator(
challenge,
answer,
@ -419,11 +431,19 @@ class PasswordRepository(
final typed = address.trim();
final challenge = await _gateway.beginKeyProof(typed);
final keys = await _openWithPhrase(challenge, phrase);
final signature = await _crypto.signKeyProof(phrase, challenge);
final password = await _crypto.newPassword(newPassword, keys);
// The proof signs this password and this instance's host: nobody
// between swaps in another, or relays it elsewhere.
final signature = await _crypto.signKeyProof(
phrase,
challenge,
host: _gateway.host,
password: password,
);
final grant = await _gateway.resetPassword(
challenge,
signature,
password: await _crypto.newPassword(newPassword, keys),
password: password,
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
);

View file

@ -24,6 +24,9 @@ class ServerpodPasswordGateway(
/// What the clients it makes for itself send through; tests give a fake.
final http.Client Function()? _httpClient,
}) implements PasswordGateway {
@override
String get host => Uri.parse(_client.host).host;
@override
Future<({bool offered, DateTime? gitHubEnds})> offers() => _failing(() async {
final basics = await _base.instance.basics();
@ -66,6 +69,7 @@ class ServerpodPasswordGateway(
recoveryCode: recoveryCode,
request: _idOf(request),
code: code,
host: host,
);
return PasswordSignedIn(
grant: _grantOf(
@ -186,6 +190,7 @@ class ServerpodPasswordGateway(
totpCode: totpCode,
recoveryCode: recoveryCode,
password: password,
host: host,
),
);
@ -206,6 +211,7 @@ class ServerpodPasswordGateway(
recoveryCode: recoveryCode,
recoveryKeys: keys,
recoveryProofKey: proofKey,
host: host,
),
);
@ -230,6 +236,7 @@ class ServerpodPasswordGateway(
code: code,
keyChallenge: keyChallenge?.challenge,
signature: signature,
host: host,
)).takesEffectAt,
);
@ -254,6 +261,7 @@ class ServerpodPasswordGateway(
password: password,
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
),
),
);
@ -390,6 +398,10 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
protocol.PasswordRefusal.noReset => PasswordProblem.noReset,
protocol.PasswordRefusal.wrongExistingCode =>
PasswordProblem.wrongExistingCode,
// The instance answers by another name than the device addressed it by:
// what the device proved is bound to that name, so nothing it says can
// be taken as the instance's.
protocol.PasswordRefusal.otherInstance => PasswordProblem.instanceUnproven,
};
/// Keeps one session in memory, for a client used once.

View file

@ -85,13 +85,14 @@ class SodiumAccountCrypto() implements AccountCrypto {
@override
Future<SrpAnswer> answer(
PasswordUnlock unlock,
protocol.PasswordChallenge challenge,
) async {
protocol.PasswordChallenge challenge, {
required String host,
}) async {
final group = srp.SrpGroup.rfc5054Group3072;
final (:clientValue, :proof) = await Isolate.run(() {
final client = srp.SrpClient(group);
final step = client.respond(
identity: challenge.challenge.toBytes(),
identity: srp.passwordIdentity(challenge.challenge.toBytes(), host),
salt: Uint8List.sublistView(challenge.salt),
privateValue: _numberOf(unlock.srpSecret),
serverValue: _numberOf(Uint8List.sublistView(challenge.serverValue)),
@ -205,8 +206,10 @@ class SodiumAccountCrypto() implements AccountCrypto {
@override
Future<ByteData> signKeyProof(
String phrase,
protocol.KeyProofChallenge challenge,
) => Isolate.run(
protocol.KeyProofChallenge challenge, {
required String host,
protocol.NewPassword? password,
}) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final KeyPair proof;
@ -222,6 +225,11 @@ class SodiumAccountCrypto() implements AccountCrypto {
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce),
host: host,
purpose: password == null
? srp.KeyProofPurpose.authenticatorReset
: srp.KeyProofPurpose.passwordReset,
digest: password == null ? const [] : _digestOf(password),
),
secretKey: proof.secretKey,
),
@ -256,6 +264,17 @@ class SodiumAccountCrypto() implements AccountCrypto {
);
}
/// [password]'s digest, which a phrase's proof for it signs.
Uint8List _digestOf(protocol.NewPassword password) => srp.newPasswordDigest(
salt: Uint8List.sublistView(password.salt),
algorithm: password.costs.algorithm,
opsLimit: password.costs.opsLimit,
memLimit: password.costs.memLimit,
verifier: Uint8List.sublistView(password.verifier),
keysNonce: Uint8List.sublistView(password.passwordKeys.nonce),
keysCipherText: Uint8List.sublistView(password.passwordKeys.cipherText),
);
/// Argon2id's [costs] as an instance named them, refused
/// ([PasswordProblem.unsafeCosts]) outside the bounds every account is
/// within, before anything is derived: below them guessing the password

View file

@ -145,8 +145,9 @@ class FakeAccountCrypto() implements AccountCrypto {
@override
Future<SrpAnswer> answer(
PasswordUnlock unlock,
protocol.PasswordChallenge challenge,
) async => SrpAnswer(
protocol.PasswordChallenge challenge, {
required String host,
}) async => SrpAnswer(
clientValue: ByteData(384),
clientProof: ByteData.sublistView(unlock.srpSecret),
verifies: (serverProof) =>
@ -200,8 +201,10 @@ class FakeAccountCrypto() implements AccountCrypto {
@override
Future<ByteData> signKeyProof(
String phrase,
protocol.KeyProofChallenge challenge,
) async {
protocol.KeyProofChallenge challenge, {
required String host,
protocol.NewPassword? password,
}) async {
if (phrase.trim().split(RegExp(r'\s+')).length != 24) {
throw const PasswordFailed(PasswordProblem.notAPhrase);
}

View file

@ -56,6 +56,9 @@ final class FakePasswordAccount(
/// passwords proved by [FakeAccountCrypto]'s answers. The signed-in person
/// is [_auth]'s. [failing] makes every call fail as without a connection.
class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
@override
String get host => 'halkyon.example';
/// Whether the instance offers password sign-in.
bool offered = true;

View file

@ -161,9 +161,11 @@ void main() {
);
});
test("signs the key proof with the phrase's own proof key, whose public "
"half the setup carries: never the account's key, which a signed-in "
'device keeps', () async {
test(
"signs the key proof with the phrase's own proof key, whose public "
"half the setup carries, for the instance's host and the password it "
"sets: never the account's key, which a signed-in device keeps",
() async {
final challenge = protocol.KeyProofChallenge(
challenge: protocol.UuidValue.fromString(
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
@ -176,24 +178,56 @@ void main() {
recoveryKeys: account.setup.recoveryKeys,
);
final signature = await crypto.signKeyProof(account.phrase, challenge);
final password = await crypto.newPassword(
'another battery staple',
account.keys,
);
final signature = await crypto.signKeyProof(
account.phrase,
challenge,
host: 'learn.halkyon.eu',
password: password,
);
final sodium = await SodiumSumoInit.init();
bool verifies(Uint8List publicKey) => sodium.crypto.sign.verifyDetached(
bool verifies(
Uint8List publicKey, {
String host = 'learn.halkyon.eu',
List<int>? digest,
}) => sodium.crypto.sign.verifyDetached(
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
List.filled(32, 9),
host: host,
purpose: srp.KeyProofPurpose.passwordReset,
digest:
digest ??
srp.newPasswordDigest(
salt: Uint8List.sublistView(password.salt),
algorithm: password.costs.algorithm,
opsLimit: password.costs.opsLimit,
memLimit: password.costs.memLimit,
verifier: Uint8List.sublistView(password.verifier),
keysNonce: Uint8List.sublistView(password.passwordKeys.nonce),
keysCipherText: Uint8List.sublistView(
password.passwordKeys.cipherText,
),
),
),
signature: Uint8List.sublistView(signature),
publicKey: publicKey,
);
expect(
verifies(Uint8List.sublistView(account.setup.recoveryProofKey)),
isTrue,
);
final proofKey = Uint8List.sublistView(account.setup.recoveryProofKey);
expect(verifies(proofKey), isTrue);
expect(verifies(account.keys.signingPublicKey), isFalse);
expect(verifies(proofKey, host: 'learn.evil.example'), isFalse);
expect(verifies(proofKey, digest: List.filled(32, 0)), isFalse);
await expectLater(
crypto.signKeyProof('not a phrase', challenge),
crypto.signKeyProof(
'not a phrase',
challenge,
host: 'learn.halkyon.eu',
),
throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
@ -202,7 +236,8 @@ void main() {
),
),
);
});
},
);
test("a new phrase carries its proof key's public half, which its "
'signatures verify under', () async {
@ -237,6 +272,7 @@ void main() {
]),
),
),
host: 'learn.halkyon.eu',
);
expect(answer.clientValue.lengthInBytes, group.length);

View file

@ -229,6 +229,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
String? code,
_isc.UuidValue? keyChallenge,
_idt.ByteData? signature,
required String host,
}) => caller.callServerEndpoint<_ippuf33k.ResetPending>(
'halkyon_base.accountRecovery',
'resetAuthenticator',
@ -242,6 +243,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
'code': code,
'keyChallenge': keyChallenge,
'signature': signature,
'host': host,
},
);
@ -266,6 +268,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
required _i95l60wq.NewPassword password,
String? totpCode,
String? recoveryCode,
required String host,
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
'halkyon_base.accountRecovery',
'resetPassword',
@ -275,6 +278,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
'password': password,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'host': host,
},
);
@ -445,6 +449,7 @@ class EndpointPassword extends _isc.EndpointRef {
String? totpCode,
String? recoveryCode,
required _i95l60wq.NewPassword password,
required String host,
}) => caller.callServerEndpoint<void>(
'halkyon_base.password',
'change',
@ -455,6 +460,7 @@ class EndpointPassword extends _isc.EndpointRef {
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'password': password,
'host': host,
},
);
@ -471,6 +477,7 @@ class EndpointPassword extends _isc.EndpointRef {
String? recoveryCode,
required _i3so7e0p.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
required String host,
}) => caller.callServerEndpoint<void>(
'halkyon_base.password',
'setRecoveryKeys',
@ -482,6 +489,7 @@ class EndpointPassword extends _isc.EndpointRef {
'recoveryCode': recoveryCode,
'recoveryKeys': recoveryKeys,
'recoveryProofKey': recoveryProofKey,
'host': host,
},
);
}
@ -522,6 +530,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
String? recoveryCode,
_isc.UuidValue? request,
String? code,
required String host,
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
'halkyon_base.passwordSignIn',
'complete',
@ -533,6 +542,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
'recoveryCode': recoveryCode,
'request': request,
'code': code,
'host': host,
},
);

View file

@ -73,7 +73,12 @@ enum PasswordRefusal implements _isc.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 _isc.SerializableModel {
return PasswordRefusal.noReset;
case 'wrongExistingCode':
return PasswordRefusal.wrongExistingCode;
case 'otherInstance':
return PasswordRefusal.otherInstance;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"',

View file

@ -1,14 +1,95 @@
import 'dart:convert';
import 'dart:typed_data';
/// What the device signs with the account's Ed25519 key, opened with the
/// recovery phrase, to prove the phrase (ADR 0017): a label naming the
/// purpose and its version, the challenge's ID (its 16 bytes) and the
/// register's nonce. The register checks the signature over the same bytes
/// (halkyon_base_server's `key_proof.dart`).
Uint8List keyProofMessage(List<int> challenge, List<int> nonce) =>
Uint8List.fromList([
...utf8.encode('halkyon key proof v1|'),
...challenge,
...nonce,
import 'package:crypto/crypto.dart';
// What the device proves and signs with, bound to the instance it addresses
// (ADR 0017; the second reading's M6): a hostile instance a person was lured
// into adding can't relay a sign-in or a phrase's proof to the real one,
// which takes them only for its own host, nor swap in a password of its
// own. halkyon_base_server's `key_proof.dart` makes the same bytes.
/// SRP's identity I for the sign-in [challenge] (its 16 bytes) on [host],
/// the instance as the device addresses it, in lower case. SRP's x comes
/// from Argon2id over the password and salt alone, so I only binds the
/// proof: to this sign-in and to this instance.
Uint8List passwordIdentity(List<int> challenge, String host) => _framed([
utf8.encode('halkyon srp identity v1'),
challenge,
utf8.encode(host.toLowerCase()),
]);
/// What proving the recovery phrase is for, by the words the message
/// carries ([label]).
enum KeyProofPurpose(final String label) {
/// Asking for the reset of a lost authenticator.
authenticatorReset('authenticator reset'),
/// Setting a new password, the keys staying.
passwordReset('password reset'),
}
/// What the device signs with the recovery phrase's proof key to prove the
/// phrase: a label naming the format, the challenge's ID (its 16 bytes),
/// the register's nonce, the [host] the device addresses, the [purpose],
/// and the [digest] of what it sets ([newPasswordDigest] for a new
/// password; none for a reset of the authenticators), each framed by its
/// length.
Uint8List keyProofMessage(
List<int> challenge,
List<int> nonce, {
required String host,
required KeyProofPurpose purpose,
List<int> digest = const [],
}) => _framed([
utf8.encode('halkyon key proof v2'),
challenge,
nonce,
utf8.encode(host.toLowerCase()),
utf8.encode(purpose.label),
digest,
]);
/// The SHA-256 of a new password as the device sends it (the salt,
/// Argon2id's costs, SRP's verifier, the keys wrapped under it), framed:
/// what a phrase's proof for a new password signs, so nobody between the
/// device and the register can swap in another.
Uint8List newPasswordDigest({
required List<int> salt,
required String algorithm,
required int opsLimit,
required int memLimit,
required List<int> verifier,
required List<int> keysNonce,
required List<int> keysCipherText,
}) => Uint8List.fromList(
sha256
.convert(
_framed([
utf8.encode('halkyon new password v1'),
salt,
utf8.encode(algorithm),
utf8.encode('$opsLimit'),
utf8.encode('$memLimit'),
verifier,
keysNonce,
keysCipherText,
]),
)
.bytes,
);
/// [parts], each after its length in four bytes, big-endian, so no part
/// can run into the next.
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();
}

View file

@ -1,20 +1,120 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_client/srp.dart';
import 'package:test/test.dart';
/// [parts], each after its length in four bytes, big-endian.
List<int> _framed(List<List<int>> parts) => [
for (final part in parts) ...[
(part.length >> 24) & 0xff,
(part.length >> 16) & 0xff,
(part.length >> 8) & 0xff,
part.length & 0xff,
...part,
],
];
void main() {
test('the key proof message is the label, the challenge and the nonce, as '
'the register checks it', () {
final challenge = List.generate(16, (i) => i);
final nonce = List.generate(32, (i) => 200 + i % 50);
final message = keyProofMessage(challenge, nonce);
test("SRP's identity is the label, the challenge and the host the device "
'addresses, each framed by its length, the host in lower case', () {
expect(
passwordIdentity(challenge, 'Learn.Halkyon.EU'),
_framed([
utf8.encode('halkyon srp identity v1'),
challenge,
utf8.encode('learn.halkyon.eu'),
]),
);
expect(
passwordIdentity(challenge, 'learn.halkyon.eu'),
isNot(passwordIdentity(challenge, 'learn.halkyon.eu.evil.example')),
);
});
expect(message, [
...utf8.encode('halkyon key proof v1|'),
...challenge,
...nonce,
]);
test('the key proof message is the label, the challenge, the nonce, the '
'host, what it proves the phrase for and what that sets, framed, as '
'the register checks it', () {
final digest = List.generate(32, (i) => i * 3);
expect(
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
_framed([
utf8.encode('halkyon key proof v2'),
challenge,
nonce,
utf8.encode('learn.halkyon.eu'),
utf8.encode('password reset'),
digest,
]),
);
expect(
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
purpose: KeyProofPurpose.authenticatorReset,
),
_framed([
utf8.encode('halkyon key proof v2'),
challenge,
nonce,
utf8.encode('learn.halkyon.eu'),
utf8.encode('authenticator reset'),
<int>[],
]),
);
});
test("a new password's digest is SHA-256 over its parts, framed: the "
'salt, the costs, the verifier and the wrapping', () {
final digest = newPasswordDigest(
salt: [1, 2],
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 67108864,
verifier: [3, 4, 5],
keysNonce: [6],
keysCipherText: [7, 8],
);
expect(
digest,
sha256
.convert(
_framed([
utf8.encode('halkyon new password v1'),
[1, 2],
utf8.encode('argon2id13'),
utf8.encode('3'),
utf8.encode('67108864'),
[3, 4, 5],
[6],
[7, 8],
]),
)
.bytes,
);
expect(
newPasswordDigest(
salt: [1, 2],
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 67108864,
verifier: [3, 4, 5],
keysNonce: [6],
keysCipherText: [7, 9],
),
isNot(digest),
);
});
}

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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"',

View file

@ -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(

View file

@ -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,

View file

@ -1,26 +1,126 @@
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.
Future<bool> isAccountSignature(

View file

@ -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) {

Some files were not shown because too many files have changed in this diff Show more