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>
257 lines
6.8 KiB
Dart
257 lines
6.8 KiB
Dart
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() {
|
|
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, 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,
|
|
'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, '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 '
|
|
"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,
|
|
instanceUrl: 'https://learn.halkyon.eu/',
|
|
purpose: KeyProofPurpose.passwordReset,
|
|
digest: digest,
|
|
),
|
|
_framed([
|
|
utf8.encode('halkyon key proof v3'),
|
|
challenge,
|
|
nonce,
|
|
utf8.encode('https://learn.halkyon.eu/'),
|
|
utf8.encode('password reset'),
|
|
digest,
|
|
]),
|
|
);
|
|
expect(
|
|
keyProofMessage(
|
|
challenge,
|
|
nonce,
|
|
instanceUrl: 'https://learn.halkyon.eu/',
|
|
purpose: KeyProofPurpose.authenticatorReset,
|
|
),
|
|
_framed([
|
|
utf8.encode('halkyon key proof v3'),
|
|
challenge,
|
|
nonce,
|
|
utf8.encode('https://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),
|
|
);
|
|
});
|
|
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)),
|
|
);
|
|
});
|
|
}
|