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
4 changed files with 966 additions and 137 deletions
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
final challenge = List.generate(16, (i) => i);
|
||||
final nonce = List.generate(32, (i) => 200 + i % 50);
|
||||
|
||||
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')),
|
||||
);
|
||||
});
|
||||
|
||||
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 challenge = List.generate(16, (i) => i);
|
||||
final nonce = List.generate(32, (i) => 200 + i % 50);
|
||||
final digest = List.generate(32, (i) => i * 3);
|
||||
|
||||
final message = keyProofMessage(challenge, nonce);
|
||||
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>[],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(message, [
|
||||
...utf8.encode('halkyon key proof v1|'),
|
||||
...challenge,
|
||||
...nonce,
|
||||
]);
|
||||
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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue