fix(auth)!: proofs are bound to the instance's whole base URL
SRP's identity and the recovery phrase's proof carried the host alone, so instances on one host under another port, path or scheme could relay each other's proofs (the full reading's device low 4). Both now carry the base URL the device addresses, written alike on both sides (canonicalInstanceUrl: scheme and host in lower case, the port only where it isn't the scheme's own, the path ending in /); the register takes its own public scheme, host and port (Serverpod serves at the root), a member the URL it names, development anything. The identity also gains a purpose and the digest of what a proof sets, both empty so far (signing in), with the framing for a new phrase's and an authenticator's digest beside the new password's: the owner's proofs bind them next. Cross-tests hold both sides to the same bytes. OwnerProof's and DeletionProof's host, and every endpoint's, become instanceUrl. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8ec2743bfa
commit
1ad5d84110
5 changed files with 912 additions and 448 deletions
|
|
@ -66,7 +66,7 @@ class EndpointAccount extends _isc.EndpointRef {
|
|||
/// data at once; an anonymous person is this service's alone. An account
|
||||
/// with a password deletes itself only with [proof] (the password's proof
|
||||
/// and a code, `requireOwnerToDelete`), which the register checks, its
|
||||
/// host the one the device addressed here.
|
||||
/// base URL the one the device addressed here.
|
||||
_ida.Future<void> delete({_ifxctuoq.DeletionProof? proof}) =>
|
||||
caller.callServerEndpoint<void>(
|
||||
'halkyon_base.account',
|
||||
|
|
@ -242,7 +242,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
String? code,
|
||||
_isc.UuidValue? keyChallenge,
|
||||
_idt.ByteData? signature,
|
||||
required String host,
|
||||
required String instanceUrl,
|
||||
}) => caller.callServerEndpoint<_ippuf33k.ResetPending>(
|
||||
'halkyon_base.accountRecovery',
|
||||
'resetAuthenticator',
|
||||
|
|
@ -256,7 +256,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
'code': code,
|
||||
'keyChallenge': keyChallenge,
|
||||
'signature': signature,
|
||||
'host': host,
|
||||
'instanceUrl': instanceUrl,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -274,8 +274,8 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
/// Sets a new [password] with the recovery phrase's [signature] for
|
||||
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
|
||||
/// session of the account ends, in every service of the instance, and
|
||||
/// this one signs in. [host] is the host the device addressed this
|
||||
/// instance by, which the phrase's proof signs; [address] and [language],
|
||||
/// this one signs in. [instanceUrl] is the base URL the device addressed
|
||||
/// this instance by, which the phrase's proof signs; [address] and [language],
|
||||
/// as the device typed them, serve only to tell the account's owner
|
||||
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
|
||||
_ida.Future<_iacc.AuthSuccess> resetPassword({
|
||||
|
|
@ -284,7 +284,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
required _i95l60wq.NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
required String instanceUrl,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
|
||||
|
|
@ -296,7 +296,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
'password': password,
|
||||
'totpCode': totpCode,
|
||||
'recoveryCode': recoveryCode,
|
||||
'host': host,
|
||||
'instanceUrl': instanceUrl,
|
||||
'address': address,
|
||||
'language': language,
|
||||
},
|
||||
|
|
@ -558,8 +558,8 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
|||
/// (or, for a setup never finished, a [code] mailed for [request] to the
|
||||
/// address the sign-in began with). Signed in, SRP's M2 (which the device checks before trusting anything
|
||||
/// else), the account's keys as the register keeps them, and the session.
|
||||
/// [host] is the host the device addressed this instance by, which its
|
||||
/// proof is bound to; [address] and [language], the address the sign-in
|
||||
/// [instanceUrl] is the base URL the device addressed this instance by,
|
||||
/// which its proof is bound to; [address] and [language], the address the sign-in
|
||||
/// began with as the device typed it, serve only to tell its owner should
|
||||
/// codes come to wait (the instance keeps no address). Refusals as
|
||||
/// `completePasswordSignIn`'s.
|
||||
|
|
@ -571,7 +571,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
|||
String? recoveryCode,
|
||||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
required String instanceUrl,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
|
||||
|
|
@ -585,7 +585,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
|||
'recoveryCode': recoveryCode,
|
||||
'request': request,
|
||||
'code': code,
|
||||
'host': host,
|
||||
'instanceUrl': instanceUrl,
|
||||
'address': address,
|
||||
'language': language,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
|||
|
||||
/// What deleting an account that has a password takes (ADR 0017; the second
|
||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
||||
/// gave, a TOTP or recovery code, and the host the device addressed, as a
|
||||
/// gave, a TOTP or recovery code, and the base URL the device addressed, as a
|
||||
/// change of password does (`OwnerProof`). A session alone deletes only an
|
||||
/// account without one.
|
||||
abstract class DeletionProof
|
||||
|
|
@ -28,7 +28,7 @@ abstract class DeletionProof
|
|||
this.recoveryCode,
|
||||
this.request,
|
||||
this.code,
|
||||
required this.host,
|
||||
required this.instanceUrl,
|
||||
});
|
||||
|
||||
factory DeletionProof({
|
||||
|
|
@ -39,7 +39,7 @@ abstract class DeletionProof
|
|||
String? recoveryCode,
|
||||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
required String instanceUrl,
|
||||
}) = _DeletionProofImpl;
|
||||
|
||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
|
|
@ -59,7 +59,7 @@ abstract class DeletionProof
|
|||
? null
|
||||
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
||||
code: jsonSerialization['code'] as String?,
|
||||
host: jsonSerialization['host'] as String,
|
||||
instanceUrl: jsonSerialization['instanceUrl'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -81,9 +81,10 @@ abstract class DeletionProof
|
|||
|
||||
String? code;
|
||||
|
||||
/// The host the device addressed the instance by: the server it reached
|
||||
/// checks it is its own before the register binds the proof to it.
|
||||
String host;
|
||||
/// The instance's base URL the device addressed (scheme, host, port,
|
||||
/// path): the server it reached checks it is its own before the
|
||||
/// register binds the proof to it.
|
||||
String instanceUrl;
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
|
|
@ -96,7 +97,7 @@ abstract class DeletionProof
|
|||
String? recoveryCode,
|
||||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
String? host,
|
||||
String? instanceUrl,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -109,7 +110,7 @@ abstract class DeletionProof
|
|||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
if (request != null) 'request': request?.toJson(),
|
||||
if (code != null) 'code': code,
|
||||
'host': host,
|
||||
'instanceUrl': instanceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +125,7 @@ abstract class DeletionProof
|
|||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
if (request != null) 'request': request?.toJson(),
|
||||
if (code != null) 'code': code,
|
||||
'host': host,
|
||||
'instanceUrl': instanceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +146,7 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
String? recoveryCode,
|
||||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
required String instanceUrl,
|
||||
}) : super._(
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
|
|
@ -154,7 +155,7 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
recoveryCode: recoveryCode,
|
||||
request: request,
|
||||
code: code,
|
||||
host: host,
|
||||
instanceUrl: instanceUrl,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
|
|
@ -169,7 +170,7 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
Object? recoveryCode = _Undefined,
|
||||
Object? request = _Undefined,
|
||||
Object? code = _Undefined,
|
||||
String? host,
|
||||
String? instanceUrl,
|
||||
}) {
|
||||
return DeletionProof(
|
||||
challenge: challenge ?? this.challenge,
|
||||
|
|
@ -179,7 +180,7 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||
request: request is _isc.UuidValue? ? request : this.request,
|
||||
code: code is String? ? code : this.code,
|
||||
host: host ?? this.host,
|
||||
instanceUrl: instanceUrl ?? this.instanceUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
|||
|
||||
/// 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 host the
|
||||
/// device addressed, and a TOTP code from one of the account's confirmed
|
||||
/// 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
|
||||
/// 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
|
||||
|
|
@ -27,7 +27,7 @@ abstract class OwnerProof
|
|||
required this.challenge,
|
||||
required this.clientValue,
|
||||
required this.clientProof,
|
||||
required this.host,
|
||||
required this.instanceUrl,
|
||||
this.totpCode,
|
||||
this.recoveryCode,
|
||||
});
|
||||
|
|
@ -36,7 +36,7 @@ abstract class OwnerProof
|
|||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
required String host,
|
||||
required String instanceUrl,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) = _OwnerProofImpl;
|
||||
|
|
@ -52,7 +52,7 @@ abstract class OwnerProof
|
|||
clientProof: _isc.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['clientProof'],
|
||||
),
|
||||
host: jsonSerialization['host'] as String,
|
||||
instanceUrl: jsonSerialization['instanceUrl'] as String,
|
||||
totpCode: jsonSerialization['totpCode'] as String?,
|
||||
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
||||
);
|
||||
|
|
@ -64,9 +64,10 @@ abstract class OwnerProof
|
|||
|
||||
_idt.ByteData clientProof;
|
||||
|
||||
/// The host the device addressed the instance by: the server it reached
|
||||
/// checks it is its own before the register binds the proof to it.
|
||||
String host;
|
||||
/// The instance's base URL the device addressed (scheme, host, port,
|
||||
/// path): the server it reached checks it is its own before the
|
||||
/// register binds the proof to it.
|
||||
String instanceUrl;
|
||||
|
||||
String? totpCode;
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ abstract class OwnerProof
|
|||
_isc.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
String? host,
|
||||
String? instanceUrl,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
});
|
||||
|
|
@ -90,7 +91,7 @@ abstract class OwnerProof
|
|||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
'host': host,
|
||||
'instanceUrl': instanceUrl,
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
};
|
||||
|
|
@ -103,7 +104,7 @@ abstract class OwnerProof
|
|||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
'host': host,
|
||||
'instanceUrl': instanceUrl,
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
};
|
||||
|
|
@ -122,14 +123,14 @@ class _OwnerProofImpl extends OwnerProof {
|
|||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
required String host,
|
||||
required String instanceUrl,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) : super._(
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
host: host,
|
||||
instanceUrl: instanceUrl,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
);
|
||||
|
|
@ -142,7 +143,7 @@ class _OwnerProofImpl extends OwnerProof {
|
|||
_isc.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
String? host,
|
||||
String? instanceUrl,
|
||||
Object? totpCode = _Undefined,
|
||||
Object? recoveryCode = _Undefined,
|
||||
}) {
|
||||
|
|
@ -150,7 +151,7 @@ class _OwnerProofImpl extends OwnerProof {
|
|||
challenge: challenge ?? this.challenge,
|
||||
clientValue: clientValue ?? this.clientValue.clone(),
|
||||
clientProof: clientProof ?? this.clientProof.clone(),
|
||||
host: host ?? this.host,
|
||||
instanceUrl: instanceUrl ?? this.instanceUrl,
|
||||
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,21 +4,90 @@ import 'dart:typed_data';
|
|||
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.
|
||||
// and to what the proof is for (ADR 0017; the second reading's M6, the full
|
||||
// reading's device medium 1): 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 base URL; nobody between the device and the
|
||||
// register can swap in a password, a phrase or an authenticator of their
|
||||
// own around the owner's proof, nor take a proof made for one change to
|
||||
// make another. 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'),
|
||||
/// What a proof of the password is made for: the words SRP's identity
|
||||
/// carries ([label]). The register takes a proof only for the purpose it
|
||||
/// works out from the call that brings it, so a proof made for one never
|
||||
/// counts for another.
|
||||
enum PasswordProofPurpose(final String label) {
|
||||
/// Signing in.
|
||||
signIn('sign in'),
|
||||
|
||||
/// Asking for the reset of a lost authenticator.
|
||||
authenticatorReset('authenticator reset'),
|
||||
|
||||
/// A new password while signed in.
|
||||
newPassword('new password'),
|
||||
|
||||
/// A new recovery phrase while signed in.
|
||||
newPhrase('new phrase'),
|
||||
|
||||
/// New recovery codes in place of the old.
|
||||
newRecoveryCodes('new recovery codes'),
|
||||
|
||||
/// Confirming the account's first authenticator.
|
||||
firstAuthenticator('first authenticator'),
|
||||
|
||||
/// Confirming another authenticator.
|
||||
addAuthenticator('add authenticator'),
|
||||
|
||||
/// Removing a confirmed authenticator.
|
||||
removeAuthenticator('remove authenticator'),
|
||||
|
||||
/// Calling off the account's resets waiting.
|
||||
callOffResets('call off resets'),
|
||||
|
||||
/// Deleting the account.
|
||||
deleteAccount('delete account'),
|
||||
}
|
||||
|
||||
/// SRP's identity I for the [challenge] (its 16 bytes) on [instanceUrl],
|
||||
/// the instance's base URL as the device addresses it
|
||||
/// ([canonicalInstanceUrl]), for [purpose], with the [digest] of what the
|
||||
/// proof sets (none where it sets nothing). SRP's x comes from Argon2id
|
||||
/// over the password and salt alone, so I only binds the proof: to this
|
||||
/// challenge, this instance, this purpose and this change.
|
||||
Uint8List passwordIdentity(
|
||||
List<int> challenge,
|
||||
String instanceUrl, {
|
||||
PasswordProofPurpose purpose = PasswordProofPurpose.signIn,
|
||||
List<int> digest = const [],
|
||||
}) => _framed([
|
||||
utf8.encode('halkyon srp identity v2'),
|
||||
challenge,
|
||||
utf8.encode(host.toLowerCase()),
|
||||
utf8.encode(canonicalInstanceUrl(instanceUrl)),
|
||||
utf8.encode(purpose.label),
|
||||
digest,
|
||||
]);
|
||||
|
||||
/// [url], an instance's base URL, as proofs bind it: the scheme and host in
|
||||
/// lower case, the port only where it isn't the scheme's own, the path
|
||||
/// ending in `/`, nothing after it. Refused ([FormatException]) for what is
|
||||
/// no HTTP or HTTPS address of a host, or names someone in it.
|
||||
String canonicalInstanceUrl(String url) {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
final scheme = uri?.scheme.toLowerCase();
|
||||
if (uri == null ||
|
||||
(scheme != 'https' && scheme != 'http') ||
|
||||
uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty) {
|
||||
throw FormatException("Not an instance's base URL", url);
|
||||
}
|
||||
final host = uri.host.toLowerCase();
|
||||
final shownHost = host.contains(':') ? '[$host]' : host;
|
||||
final ownPort = scheme == 'https' ? 443 : 80;
|
||||
final port = uri.hasPort && uri.port != ownPort ? ':${uri.port}' : '';
|
||||
final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/';
|
||||
return '$scheme://$shownHost$port$path';
|
||||
}
|
||||
|
||||
/// What proving the recovery phrase is for, by the words the message
|
||||
/// carries ([label]).
|
||||
enum KeyProofPurpose(final String label) {
|
||||
|
|
@ -31,29 +100,29 @@ enum KeyProofPurpose(final String label) {
|
|||
|
||||
/// 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.
|
||||
/// the register's nonce, the [instanceUrl] the device addresses
|
||||
/// ([canonicalInstanceUrl]), 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 String instanceUrl,
|
||||
required KeyProofPurpose purpose,
|
||||
List<int> digest = const [],
|
||||
}) => _framed([
|
||||
utf8.encode('halkyon key proof v2'),
|
||||
utf8.encode('halkyon key proof v3'),
|
||||
challenge,
|
||||
nonce,
|
||||
utf8.encode(host.toLowerCase()),
|
||||
utf8.encode(canonicalInstanceUrl(instanceUrl)),
|
||||
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.
|
||||
/// what a proof for a new password binds, so nobody between the device and
|
||||
/// the register can swap in another.
|
||||
Uint8List newPasswordDigest({
|
||||
required List<int> salt,
|
||||
required String algorithm,
|
||||
|
|
@ -62,22 +131,38 @@ Uint8List newPasswordDigest({
|
|||
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,
|
||||
);
|
||||
}) => _sha256([
|
||||
utf8.encode('halkyon new password v1'),
|
||||
salt,
|
||||
utf8.encode(algorithm),
|
||||
utf8.encode('$opsLimit'),
|
||||
utf8.encode('$memLimit'),
|
||||
verifier,
|
||||
keysNonce,
|
||||
keysCipherText,
|
||||
]);
|
||||
|
||||
/// The SHA-256 of a new recovery phrase as the device sends it (the keys
|
||||
/// wrapped under it and its proof key's public half), framed: what the
|
||||
/// owner's proof for it binds.
|
||||
Uint8List newPhraseDigest({
|
||||
required List<int> keysNonce,
|
||||
required List<int> keysCipherText,
|
||||
required List<int> proofKey,
|
||||
}) => _sha256([
|
||||
utf8.encode('halkyon new phrase v1'),
|
||||
keysNonce,
|
||||
keysCipherText,
|
||||
proofKey,
|
||||
]);
|
||||
|
||||
/// The SHA-256 of an authenticator's ID (its 16 bytes), framed: what the
|
||||
/// owner's proof for confirming or removing it binds.
|
||||
Uint8List authenticatorDigest(List<int> id) =>
|
||||
_sha256([utf8.encode('halkyon authenticator v1'), id]);
|
||||
|
||||
Uint8List _sha256(List<List<int>> parts) =>
|
||||
Uint8List.fromList(sha256.convert(_framed(parts)).bytes);
|
||||
|
||||
/// [parts], each after its length in four bytes, big-endian, so no part
|
||||
/// can run into the next.
|
||||
|
|
|
|||
|
|
@ -19,40 +19,132 @@ void main() {
|
|||
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', () {
|
||||
test("SRP's identity is the label, the challenge, the instance's base URL "
|
||||
'the device addresses, what the proof is for and the digest of what it '
|
||||
'sets, each framed by its length', () {
|
||||
final digest = List.generate(32, (i) => i * 5);
|
||||
|
||||
expect(
|
||||
passwordIdentity(challenge, 'Learn.Halkyon.EU'),
|
||||
_framed([
|
||||
utf8.encode('halkyon srp identity v1'),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
utf8.encode('learn.halkyon.eu'),
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: digest,
|
||||
),
|
||||
_framed([
|
||||
utf8.encode('halkyon srp identity v2'),
|
||||
challenge,
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('new password'),
|
||||
digest,
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
passwordIdentity(challenge, 'learn.halkyon.eu'),
|
||||
isNot(passwordIdentity(challenge, 'learn.halkyon.eu.evil.example')),
|
||||
passwordIdentity(challenge, 'https://learn.halkyon.eu/'),
|
||||
_framed([
|
||||
utf8.encode('halkyon srp identity v2'),
|
||||
challenge,
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('sign in'),
|
||||
<int>[],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('every purpose has a label of its own', () {
|
||||
final labels = {
|
||||
for (final purpose in PasswordProofPurpose.values) purpose.label,
|
||||
};
|
||||
|
||||
expect(labels, hasLength(PasswordProofPurpose.values.length));
|
||||
});
|
||||
|
||||
test('an identity differs for another instance, purpose or digest', () {
|
||||
final identity = passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [1],
|
||||
);
|
||||
|
||||
for (final other in [
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu:8443/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [1],
|
||||
),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/other/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [1],
|
||||
),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPhrase,
|
||||
digest: [1],
|
||||
),
|
||||
passwordIdentity(
|
||||
challenge,
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: PasswordProofPurpose.newPassword,
|
||||
digest: [2],
|
||||
),
|
||||
]) {
|
||||
expect(other, isNot(identity));
|
||||
}
|
||||
});
|
||||
|
||||
group("an instance's base URL", () {
|
||||
for (final (typed, canonical) in [
|
||||
('https://learn.halkyon.eu/', 'https://learn.halkyon.eu/'),
|
||||
('https://Learn.Halkyon.EU', 'https://learn.halkyon.eu/'),
|
||||
('HTTPS://learn.halkyon.eu:443/', 'https://learn.halkyon.eu/'),
|
||||
('http://localhost:80/', 'http://localhost/'),
|
||||
('http://localhost:8080', 'http://localhost:8080/'),
|
||||
('http://localhost:0/', 'http://localhost:0/'),
|
||||
('https://example.org:8443/learn', 'https://example.org:8443/learn/'),
|
||||
('https://example.org/learn/?q=1#top', 'https://example.org/learn/'),
|
||||
('http://[::1]:8080/', 'http://[::1]:8080/'),
|
||||
(' https://learn.halkyon.eu/ ', 'https://learn.halkyon.eu/'),
|
||||
]) {
|
||||
test('$typed is $canonical', () {
|
||||
expect(canonicalInstanceUrl(typed), canonical);
|
||||
});
|
||||
}
|
||||
|
||||
for (final typed in [
|
||||
'learn.halkyon.eu',
|
||||
'ftp://learn.halkyon.eu/',
|
||||
'https:///',
|
||||
'https://someone@learn.halkyon.eu/',
|
||||
]) {
|
||||
test('$typed is none', () {
|
||||
expect(() => canonicalInstanceUrl(typed), throwsFormatException);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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', () {
|
||||
"instance's base URL, 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',
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: KeyProofPurpose.passwordReset,
|
||||
digest: digest,
|
||||
),
|
||||
_framed([
|
||||
utf8.encode('halkyon key proof v2'),
|
||||
utf8.encode('halkyon key proof v3'),
|
||||
challenge,
|
||||
nonce,
|
||||
utf8.encode('learn.halkyon.eu'),
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('password reset'),
|
||||
digest,
|
||||
]),
|
||||
|
|
@ -61,14 +153,14 @@ void main() {
|
|||
keyProofMessage(
|
||||
challenge,
|
||||
nonce,
|
||||
host: 'learn.halkyon.eu',
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: KeyProofPurpose.authenticatorReset,
|
||||
),
|
||||
_framed([
|
||||
utf8.encode('halkyon key proof v2'),
|
||||
utf8.encode('halkyon key proof v3'),
|
||||
challenge,
|
||||
nonce,
|
||||
utf8.encode('learn.halkyon.eu'),
|
||||
utf8.encode('https://learn.halkyon.eu/'),
|
||||
utf8.encode('authenticator reset'),
|
||||
<int>[],
|
||||
]),
|
||||
|
|
@ -117,4 +209,49 @@ void main() {
|
|||
isNot(digest),
|
||||
);
|
||||
});
|
||||
test("a new phrase's digest is SHA-256 over the keys' wrapping under it "
|
||||
'and its proof key, framed', () {
|
||||
final digest = newPhraseDigest(
|
||||
keysNonce: [1, 2],
|
||||
keysCipherText: [3, 4, 5],
|
||||
proofKey: [6, 7],
|
||||
);
|
||||
|
||||
expect(
|
||||
digest,
|
||||
sha256
|
||||
.convert(
|
||||
_framed([
|
||||
utf8.encode('halkyon new phrase v1'),
|
||||
[1, 2],
|
||||
[3, 4, 5],
|
||||
[6, 7],
|
||||
]),
|
||||
)
|
||||
.bytes,
|
||||
);
|
||||
expect(
|
||||
newPhraseDigest(
|
||||
keysNonce: [1, 2],
|
||||
keysCipherText: [3, 4, 5],
|
||||
proofKey: [6, 8],
|
||||
),
|
||||
isNot(digest),
|
||||
);
|
||||
});
|
||||
|
||||
test("an authenticator's digest is SHA-256 over its ID, framed", () {
|
||||
final id = List.generate(16, (i) => 100 + i);
|
||||
|
||||
expect(
|
||||
authenticatorDigest(id),
|
||||
sha256
|
||||
.convert(_framed([utf8.encode('halkyon authenticator v1'), id]))
|
||||
.bytes,
|
||||
);
|
||||
expect(
|
||||
authenticatorDigest([...id.take(15), 0]),
|
||||
isNot(authenticatorDigest(id)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue